diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ae90e05..4982266 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ ## Theme Change Checklist - [ ] I edited source-of-truth inputs under `color-system/*` and/or generator logic under `scripts/*` (not hand-editing generated artifacts only). -- [ ] I ran `pnpm run sync` and included all generated updates (`themes`, `public/themes`, `extension/themes`, `obsidian/themes`, `obsidian/app-theme`, `src/data/tokens.ts`, `src/styles/theme-vars.css`, `docs/theme-baseline.md`, `docs/color-language-report.md`, `reports/color-language-consistency.json`). +- [ ] I ran `pnpm run sync` and included all generated updates (`themes`, `public/themes`, `extension/themes`, `obsidian/themes`, `obsidian/app-theme`, `zed/extension`, `src/data/tokens.ts`, `src/styles/theme-vars.css`, `docs/theme-baseline.md`, `docs/color-language-report.md`, `reports/color-language-consistency.json`). - [ ] I ran `pnpm run audit:generated-origin` and confirmed generated outputs are source-linked (`color-system/` or `scripts/` changed together). - [ ] I ran `pnpm run audit:all`. - [ ] I ran `pnpm run build`. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3174cc9..8e63b27 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,6 +46,17 @@ jobs: - name: Run audits run: pnpm run audit:all + - name: Validate generated Zed themes against the official schema + run: | + schema_path="$RUNNER_TEMP/zed-theme-v0.2.0.json" + curl --fail --silent --show-error --location \ + https://zed.dev/schema/themes/v0.2.0.json \ + --output "$schema_path" + pnpm dlx ajv-cli@5 validate \ + --spec=draft7 \ + -s "$schema_path" \ + -d 'zed/extension/themes/*.json' + - name: Audit dependencies run: pnpm run audit:deps @@ -506,6 +517,40 @@ jobs: --release --no-generate + publish-zed-mirror: + runs-on: ubuntu-latest + needs: verify + env: + ZED_PUBLISH_TOKEN: ${{ secrets.ZED_PUBLISH_TOKEN }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Skip publish (no Zed token) + if: ${{ env.ZED_PUBLISH_TOKEN == '' }} + run: echo "ZED_PUBLISH_TOKEN is missing. Skipping Zed mirror publish." + + # The Registry consumes a dedicated, minimal public repository as a Git + # submodule. HearthTheme remains the source; this job only mirrors the + # committed extension.toml, theme JSON, README, and license. + - name: Publish to Zed mirror + if: ${{ env.ZED_PUBLISH_TOKEN != '' }} + run: > + node scripts/pack-zed-publish.mjs + --repo hearth-code/hearthcode-zed + --no-generate + release-github: runs-on: ubuntu-latest permissions: diff --git a/AGENTS.md b/AGENTS.md index 590a8a0..70eddba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ This repository is source-of-truth driven. Treat generated artifacts as outputs - Active product: `products/active-product.json`. - Active scheme: `color-system/active-scheme.json`. - Primary source inputs live under `color-system/schemes/*` and `color-system/framework/*`. -- Generated outputs include `themes/*`, `public/themes/*`, `extension/themes/*`, `obsidian/*`, `terminal/{warp,windows-terminal,kitty,alacritty,iterm2}/*`, `src/data/tokens.ts`, `src/styles/theme-vars.css`, `docs/theme-baseline.md`, and selected `reports/*`. +- Generated outputs include `themes/*`, `public/themes/*`, `extension/themes/*`, `obsidian/*`, `terminal/{warp,windows-terminal,kitty,alacritty,iterm2}/*`, `zed/extension/*`, `src/data/tokens.ts`, `src/styles/theme-vars.css`, `docs/theme-baseline.md`, and selected `reports/*`. ## Safe Change Workflow @@ -42,3 +42,6 @@ If extension payload changes, keep these aligned: - top section of `extension/CHANGELOG.md` Never leave placeholder release notes in a publishable change. + +The Zed extension manifest version is generated from `releases/color-language.json`. +Keep `products/hearthcode/channels.zed` false until the Registry entry is live. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 428f035..09f8738 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Thanks for helping improve HearthTheme. - Generators and audits: `scripts/*` - Version source: `releases/color-language.json` -Generated outputs (`themes/*`, `public/themes/*`, `extension/themes/*`, `obsidian/*`, `terminal/{warp,windows-terminal,kitty,alacritty,iterm2}/*`, reports/docs snapshots) should be produced by scripts, not hand-maintained alone. +Generated outputs (`themes/*`, `public/themes/*`, `extension/themes/*`, `obsidian/*`, `terminal/{warp,windows-terminal,kitty,alacritty,iterm2}/*`, `zed/extension/*`, reports/docs snapshots) should be produced by scripts, not hand-maintained alone. ## Local Setup @@ -23,6 +23,9 @@ Generated outputs (`themes/*`, `public/themes/*`, `extension/themes/*`, `obsidia 1. Edit source-of-truth files (`color-system/*`) and/or generator logic (`scripts/*`). 2. Run `pnpm run sync`. 3. If previews changed, run `pnpm run preview:generate`. + If the marketing matrix, layouts, or channel assets changed, run + `pnpm run marketing:generate` instead; it synchronizes themes and force-renders + every declared size. 4. Run: - `pnpm run check:sync` - `pnpm run check:preview` @@ -31,6 +34,9 @@ Generated outputs (`themes/*`, `public/themes/*`, `extension/themes/*`, `obsidia - `pnpm run build` 5. Commit source + generated outputs in one change set. +For Zed changes, also run `pnpm run audit:zed`. Install `zed/extension` as a +development extension before publishing its generated mirror. + ## Release Notes Requirement If your PR changes extension release payload (`extension/themes/*`, `extension/package.json`, `extension/CHANGELOG.md`, `extension/icon.png`): diff --git a/README.ja.md b/README.ja.md index afa842d..c181d8a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -7,9 +7,9 @@ [![VS Code Marketplace Version](https://vsmarketplacebadges.dev/version/hearth-code.hearth-theme.svg)](https://marketplace.visualstudio.com/items?itemName=hearth-code.hearth-theme) [![Start on theme.hearthcode.dev](https://img.shields.io/badge/start%20on-theme.hearthcode.dev-8b6b4d)](https://theme.hearthcode.dev) -HearthCode はコードUI向けのテーマファミリーです。核になる方向は Ember と Moss の 2 つだけで、それぞれに Dark と Light を用意し、VS Code、Open VSX 互換エディタ、5 種類のターミナル形式で使えます。Obsidian 版は現在 Moss のみです。 +**温もりか、構造か。意味は明瞭なまま。** HearthCode は校正されたコード UI 向けテーマファミリーです。Ember は暖かくやわらかく、Moss はドライで構造的。異なる質感でも、読むリズムは同じです。どちらも Dark と Light を VS Code、Open VSX 互換エディタ、Zed、5 種類のターミナル形式で提供し、Obsidian では Moss の Dark と Light を提供します。 -![HearthCode Theme Preview](./extension/images/preview-contrast-v2.png) +![HearthCode Theme Preview](./extension/images/family-overview.png) ## まずはここから @@ -18,13 +18,15 @@ HearthCode はコードUI向けのテーマファミリーです。核になる - `Dark`: 混在照明と長時間コーディング向けの基準点。 - `Light`: 昼光や文書作業が多い日に向くライト版。 +![Ember と Moss の方向性アトラス](./docs/marketing/direction-atlas.png) + ## Moss について `Moss` は GruvDark テーマファミリーから方向的な着想を受けています。特にチャコールと紙のバランス、そして分離のはっきりした構文レーンを参照していますが、HearthCode 独自のセマンティック設計と校正ルールを通して再構成しており、1:1 の複製ではありません。 ## Obsidian -HearthCode は本格的な Obsidian テーマでもあります。同じカラーランゲージを機能的な Markdown に適用し、種類分けされたコールアウト、取り消し線付きの完了タスク、階層化されたリストマーカー、フラットなコードと引用面、タグのピルを、編集ビューと閲覧ビューで一貫して表示します。 +Obsidian では現在 Moss 方向の Dark と Light のみを提供します。同じカラーランゲージを機能的な Markdown に適用し、種類分けされたコールアウト、取り消し線付きの完了タスク、階層化されたリストマーカー、フラットなコードと引用面、タグのピルを、編集ビューと閲覧ビューで一貫して表示します。 Style Settings プラグインにも対応しています。タイポグラフィ(等幅ノート・コメントの直立表示・可読行幅)、コールアウトの濃さ、そしてコントラスト検証済みのアクセント(Moss / Amber / Slate)を調整できます。いずれも校正済みのパレットには手を加えません。 @@ -32,11 +34,13 @@ Style Settings プラグインにも対応しています。タイポグラフ ## インストール -1. VS Code Marketplace: -2. Open VSX 互換エディタ: -3. VS Code Quick Open: `ext install hearth-code.hearth-theme` -4. Obsidian: — またはアプリ内の **設定 → 外観 → テーマ → 管理** から **HearthCode** を検索。 -5. ターミナル: [Warp、Windows Terminal、Kitty、Alacritty、iTerm2 用テーマ](./terminal/README.md)。最初は `HearthCode Moss Dark` を推奨します。 +- VS Code: [Marketplace](https://marketplace.visualstudio.com/items?itemName=hearth-code.hearth-theme)、または Quick Open で `ext install hearth-code.hearth-theme`。 +- Open VSX 互換エディタ: [Open VSX](https://open-vsx.org/extension/hearth-code/hearth-theme)。 +- Zed: [Zed Extensions](https://zed.dev/extensions/hearthcode-theme)。 +- Obsidian: [Community Themes](https://community.obsidian.md/themes/hearthcode)、または **設定 → 外観 → テーマ → 管理** から **HearthCode** を検索。 +- ターミナル: [Warp、Windows Terminal、Kitty、Alacritty、iTerm2 用テーマ](./terminal/README.md)。最初は `HearthCode Moss Dark` を推奨します。 + +![HearthCode platform availability](./docs/marketing/platform-coverage.png) ## 公開中のテーマ @@ -47,6 +51,8 @@ Style Settings プラグインにも対応しています。タイポグラフ ## Theme Forge +![Theme Forge のワークフロー](./extension/images/theme-forge-workflow.png) + プライマリカラーを変えたいときは、**HearthCode: Open Theme Forge** を実行してパネルを開き、色を選ぶと、テーマ全体——構文**および**エディタのクローム(ステータスバー、サイド / アクティビティ / タイトルバー、各サーフェス)——がダーク / ライトの並列プレビューでリアルタイムに染め直されます。**Apply** は結果を theme-scoped color customizations として書き込み(即時反映、リロード不要)、アクティブな HearthCode スキームのダークとライトの 2 バリアントだけを塗り替え、もう一方のスキームには手を触れません——先に Moss か Ember のバリアントへ切り替えてください。**HearthCode: Reset Theme Forge** は Forge が書き込んだものだけを正確に取り除きます。品質は構築時に担保されます。Forge は公開テーマと同じ品質コントラクトに縛られており、構文レーンはまとめて回転して役割の分離を保ち、彩度は安全な帯域にクランプされ、クロームの色みはコントラスト検証を通してエディタ文字を AA に保ち、機能色(ターミナル・エラー・git・diff)はそれぞれの意味を保ちます。 ## イタリックを無効にしたい場合 diff --git a/README.md b/README.md index afd512a..1d401a4 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ [![VS Code Marketplace Version](https://vsmarketplacebadges.dev/version/hearth-code.hearth-theme.svg)](https://marketplace.visualstudio.com/items?itemName=hearth-code.hearth-theme) [![Start on theme.hearthcode.dev](https://img.shields.io/badge/start%20on-theme.hearthcode.dev-8b6b4d)](https://theme.hearthcode.dev) -HearthCode is a theme family for code interfaces with two design directions: Ember and Moss. Each direction ships Dark and Light for VS Code, Open VSX-compatible editors, and five terminal formats; Moss is also available for Obsidian. +**Warmth or structure. Meaning stays clear.** HearthCode is a calibrated theme family for code interfaces: Ember brings warm softness, while Moss brings dry structure. Different material, same reading rhythm. Both ship in Dark and Light for VS Code, Open VSX-compatible editors, Zed, and five terminal formats; Obsidian ships Moss in Dark and Light. -![HearthCode Theme Preview](./extension/images/preview-contrast-v2.png) +![HearthCode Theme Preview](./extension/images/family-overview.png) ## Start Here @@ -18,13 +18,15 @@ HearthCode is a theme family for code interfaces with two design directions: Emb - `Dark`: balanced default for mixed light and long coding sessions. - `Light`: bright-room and docs-heavy version. +![Ember and Moss direction atlas](./docs/marketing/direction-atlas.png) + ## Note On Moss `Moss` takes directional inspiration from the GruvDark theme family, especially its charcoal-and-paper balance and clearer split syntax lanes. It is translated through HearthCode's own semantic system and calibration rules rather than copied one-to-one. ## Obsidian -HearthCode is a first-class Obsidian theme too — the same color language applied to functional Markdown: typed callouts, task states with a struck-through done state, layered list markers, flat code and quote surfaces, and tag pills, kept consistent across edit and reading views. +Obsidian carries the Moss direction only, in Dark and Light — the same color language applied to functional Markdown: typed callouts, task states with a struck-through done state, layered list markers, flat code and quote surfaces, and tag pills, kept consistent across edit and reading views. It also integrates with the Style Settings plugin: tune typography (monospace notes, upright comment italics, readable line length), callout intensity, and a contrast-vetted accent (Moss / Amber / Slate) — all without touching the calibrated palette. @@ -32,11 +34,13 @@ It also integrates with the Style Settings plugin: tune typography (monospace no ## Install -1. VS Code Marketplace: -2. Open VSX-compatible editors: -3. VS Code Quick Open: `ext install hearth-code.hearth-theme` -4. Obsidian: — or in-app **Settings → Appearance → Themes → Manage**, then search **HearthCode**. -5. Terminal: [Warp, Windows Terminal, Kitty, Alacritty, and iTerm2 files](./terminal/README.md). Start with `HearthCode Moss Dark`. +- VS Code: [Marketplace](https://marketplace.visualstudio.com/items?itemName=hearth-code.hearth-theme) or Quick Open with `ext install hearth-code.hearth-theme`. +- Open VSX-compatible editors: [Open VSX](https://open-vsx.org/extension/hearth-code/hearth-theme). +- Zed: [Zed Extensions](https://zed.dev/extensions/hearthcode-theme). +- Obsidian: [Community Themes](https://community.obsidian.md/themes/hearthcode) — or search **HearthCode** under **Settings → Appearance → Themes → Manage**. +- Terminals: [Warp, Windows Terminal, Kitty, Alacritty, and iTerm2 files](./terminal/README.md). Start with `HearthCode Moss Dark`. + +![HearthCode platform availability](./docs/marketing/platform-coverage.png) ## Shipped Themes @@ -47,6 +51,8 @@ It also integrates with the Style Settings plugin: tune typography (monospace no ## Theme Forge +![Theme Forge workflow](./extension/images/theme-forge-workflow.png) + Want a different primary color? Run **HearthCode: Open Theme Forge** to open a panel, pick a color, and watch the whole theme — syntax **and** editor chrome (status bar, side/activity/title bars, and surfaces) — recolor live in a side-by-side dark/light preview. **Apply** writes the result as theme-scoped color customizations (live, no reload), painting only your active HearthCode scheme's dark and light variants and leaving the other scheme untouched — switch to a Moss or Ember variant first. **HearthCode: Reset Theme Forge** removes exactly what Forge wrote. Quality holds by construction: Forge is bound by the same quality contract as the shipped themes — the syntax lanes rotate together so role separation holds, saturation is clamped to a safe band, the chrome tint is contrast-checked so editor text stays at AA, and functional colors (terminal, errors, git, diff) keep their meaning. ## Prefer No Italics? diff --git a/README.zh-CN.md b/README.zh-CN.md index 26b31cb..806c56c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,9 +7,9 @@ [![VS Code 版本](https://vsmarketplacebadges.dev/version/hearth-code.hearth-theme.svg)](https://marketplace.visualstudio.com/items?itemName=hearth-code.hearth-theme) [![在 theme.hearthcode.dev 开始](https://img.shields.io/badge/start%20on-theme.hearthcode.dev-8b6b4d)](https://theme.hearthcode.dev) -HearthCode 是一套面向代码界面的主题家族,核心只有两条设计方向:Ember 和 Moss。每条方向都提供 Dark 与 Light 两个版本,覆盖 VS Code、Open VSX 兼容编辑器和五种终端格式;其中 Obsidian 目前只有 Moss。 +**温暖或秩序,语义始终清晰。** HearthCode 是一套经过校准的代码界面主题家族:Ember 更暖、更柔,Moss 更干、更有结构。不同质感,同一阅读节奏。两者均提供 Dark 与 Light,覆盖 VS Code、Open VSX 兼容编辑器、Zed 和五种终端格式;Obsidian 提供 Moss 的 Dark 与 Light。 -![HearthCode 主题预览](./extension/images/preview-contrast-v2.png) +![HearthCode 主题预览](./extension/images/family-overview.png) ## 先这样选 @@ -18,13 +18,15 @@ HearthCode 是一套面向代码界面的主题家族,核心只有两条设计 - `Dark`:适合混合光环境和长时间编码的默认起点。 - `Light`:适合白天、强光和文档偏多的工作流。 +![Ember 与 Moss 方向图谱](./docs/marketing/direction-atlas.png) + ## 关于 Moss `Moss` 的方向灵感来自 GruvDark 主题家族,主要借鉴了它的炭底纸面平衡和更清楚的分槽语法层次;但它仍然通过 HearthCode 自己的语义系统和校准规则来重新翻译,而不是做一比一复刻。 ## Obsidian -HearthCode 同样是一套完整的 Obsidian 主题——把同一套色彩语言用在功能化的 Markdown 上:分类型的 callout、带删除线的已完成任务、分层的列表标记、扁平的代码与引用面,以及标签药丸,并在编辑视图与阅读视图之间保持一致。 +Obsidian 目前只提供 Moss 方向的 Dark 与 Light——把同一套色彩语言用在功能化的 Markdown 上:分类型的 callout、带删除线的已完成任务、分层的列表标记、扁平的代码与引用面,以及标签药丸,并在编辑视图与阅读视图之间保持一致。 它也接入了 Style Settings 插件:可调排版(等宽笔记、注释正体、可读行宽)、callout 强度,以及一组经对比度校验的强调色(Moss / Amber / Slate)——而这一切都不会改动经过校准的调色板。 @@ -32,11 +34,13 @@ HearthCode 同样是一套完整的 Obsidian 主题——把同一套色彩语 ## 安装 -1. VS Code Marketplace: -2. Open VSX 兼容编辑器: -3. VS Code 快速安装:`ext install hearth-code.hearth-theme` -4. Obsidian: —— 或应用内 **设置 → 外观 → 主题 → 管理**,搜索 **HearthCode**。 -5. 终端:[Warp、Windows Terminal、Kitty、Alacritty 与 iTerm2 主题文件](./terminal/README.md)。建议从 `HearthCode Moss Dark` 开始。 +- VS Code:[Marketplace](https://marketplace.visualstudio.com/items?itemName=hearth-code.hearth-theme),或在快速安装中输入 `ext install hearth-code.hearth-theme`。 +- Open VSX 兼容编辑器:[Open VSX](https://open-vsx.org/extension/hearth-code/hearth-theme)。 +- Zed:[Zed Extensions](https://zed.dev/extensions/hearthcode-theme)。 +- Obsidian:[社区主题](https://community.obsidian.md/themes/hearthcode),或在 **设置 → 外观 → 主题 → 管理** 中搜索 **HearthCode**。 +- 终端:[Warp、Windows Terminal、Kitty、Alacritty 与 iTerm2 主题文件](./terminal/README.md)。建议从 `HearthCode Moss Dark` 开始。 + +![HearthCode 平台支持范围](./docs/marketing/platform-coverage.png) ## 当前主题 @@ -47,6 +51,8 @@ HearthCode 同样是一套完整的 Obsidian 主题——把同一套色彩语 ## Theme Forge +![Theme Forge 工作流程](./extension/images/theme-forge-workflow.png) + 想换个主色?运行 **HearthCode: Open Theme Forge** 打开面板,选一个颜色,整个主题——语法**以及**编辑器外壳(状态栏、侧边栏 / 活动栏 / 标题栏,以及各表面)——会在深色 / 浅色并排预览里实时重染。**Apply** 会把结果写成 theme-scoped color customizations(即时生效、无需重载),只影响当前 HearthCode 方案的深色与浅色两个变体,另一个方案保持不动——请先切到 Moss 或 Ember 变体。**HearthCode: Reset Theme Forge** 会精确移除 Forge 写入的内容。质量由构建保证:Forge 受与官方主题同一套质量契约约束——语法通道整体旋转以保持角色分离,饱和度被限制在安全带内,外壳染色经过对比度校验以让编辑器文本维持 AA,功能色(终端、错误、git、diff)保持各自语义。 ## 不想要斜体? diff --git a/color-system/schemes/ember/color-contract.json b/color-system/schemes/ember/color-contract.json index a5c46b3..03c5a11 100644 --- a/color-system/schemes/ember/color-contract.json +++ b/color-system/schemes/ember/color-contract.json @@ -21,11 +21,11 @@ "hueBand": [18, 24], "minSaturation": 0.24 }, - "livedInSage": { - "description": "Strings should provide a calm lived-in green reading lane.", + "warmWheatLiteral": { + "description": "Strings should occupy a baked-wheat literal lane, visibly separate from olive property structure.", "roles": ["string"], - "hueBand": [85, 105], - "minSaturation": 0.14 + "hueBand": [28, 45], + "minSaturation": 0.3 }, "dryOchreType": { "description": "Types should read as dry ochre classification signals.", @@ -53,9 +53,10 @@ "method": { "family": "leather", "tone": "base" }, "type": { "family": "ochre", "tone": "base" }, "number": { "family": "terracotta", "tone": "numeric" }, - "string": { "family": "sage", "tone": "literal" } + "string": { "family": "wheat", "tone": "literal" } }, "criticalPairs": [ + { "left": "property", "right": "string", "minDeltaE": 24 }, { "left": "keyword", "right": "string", "minDeltaE": 9 }, { "left": "function", "right": "string", "minDeltaE": 8 }, { "left": "keyword", "right": "number", "minDeltaE": 8 } diff --git a/color-system/schemes/ember/foundation.json b/color-system/schemes/ember/foundation.json index d1f0902..7cb1d13 100644 --- a/color-system/schemes/ember/foundation.json +++ b/color-system/schemes/ember/foundation.json @@ -92,15 +92,20 @@ } }, "sage": { - "description": "Calm literal band for strings and reading rhythm.", + "description": "Calm affirmative green for healthy-state feedback.", "tones": { "base": { "dark": "#98ab7d", "light": "#6a7d58" - }, + } + } + }, + "wheat": { + "description": "Warm baked-grain literal band that separates strings from olive property structure.", + "tones": { "literal": { - "dark": "#8eaa79", - "light": "#6a7f58" + "dark": "#c39b69", + "light": "#80572d" } } }, diff --git a/color-system/schemes/ember/philosophy.md b/color-system/schemes/ember/philosophy.md index 7d766ca..3e08ac9 100644 --- a/color-system/schemes/ember/philosophy.md +++ b/color-system/schemes/ember/philosophy.md @@ -21,7 +21,7 @@ The palette speaks through a small set of named families: - `ember`: control-flow pressure and release accents - `mineral`: a bounded cool anchor for structure -- `sage`: calm literal rhythm +- `wheat`: warm literal rhythm, separated from olive property structure - `ochre`: dry structural classification - `chalk`: readable carrier neutrals diff --git a/color-system/schemes/ember/scheme.json b/color-system/schemes/ember/scheme.json index f620316..2bba74e 100644 --- a/color-system/schemes/ember/scheme.json +++ b/color-system/schemes/ember/scheme.json @@ -19,7 +19,7 @@ "vocabulary": [ "ember", "mineral", - "sage", + "wheat", "ochre", "chalk" ], diff --git a/color-system/schemes/ember/semantic-rules.json b/color-system/schemes/ember/semantic-rules.json index 82e5576..1b6a51e 100644 --- a/color-system/schemes/ember/semantic-rules.json +++ b/color-system/schemes/ember/semantic-rules.json @@ -31,7 +31,7 @@ "flags": { "nearForeground": true, "contrastCritical": true, "allowEscapeHatch": false } }, "string": { - "source": { "family": "sage", "tone": "literal" }, + "source": { "family": "wheat", "tone": "literal" }, "flags": { "nearForeground": false, "contrastCritical": false, "allowEscapeHatch": false } }, "number": { diff --git a/color-system/schemes/ember/taxonomy.json b/color-system/schemes/ember/taxonomy.json index 645c489..1d32c71 100644 --- a/color-system/schemes/ember/taxonomy.json +++ b/color-system/schemes/ember/taxonomy.json @@ -15,7 +15,8 @@ "structuralWarmth": [ "brass", "leather", - "ochre" + "ochre", + "wheat" ], "coolAnchor": [ "mineral" diff --git a/docs/marketing/README.md b/docs/marketing/README.md new file mode 100644 index 0000000..4ed6832 --- /dev/null +++ b/docs/marketing/README.md @@ -0,0 +1,239 @@ +# HearthCode Marketing System + +HearthCode should be presented as a compact, calibrated theme family rather than +as a growing list of unrelated ports. + +## Product truth + +- The theme directions are **Ember** and **Moss**. +- Each direction ships in **Dark** and **Light**. +- **Amber** is an Obsidian accent preset. It is not a HearthCode theme direction. +- Theme Forge is a VS Code capability, not a separate theme family or a feature + promised on every platform. + +| Surface or channel | Ember | Moss | Platform-specific capability | +| --- | --- | --- | --- | +| VS Code | Dark + Light | Dark + Light | Theme Forge | +| Open VSX editors | Dark + Light | Dark + Light | — | +| Zed | Dark + Light | Dark + Light | — | +| Terminal packs | Dark + Light | Dark + Light | Five generated formats | +| Obsidian | — | Dark + Light | Style Settings | + +`products/hearthcode/product.json` is the source of truth for supported schemes +and channels. Marketing assets must derive availability from that manifest rather +than maintain a second hand-written platform list. + +## Positioning + +Primary message: + +> Warmth or structure. Meaning stays clear. + +Supporting message: + +> Different material. Same reading rhythm. + +`EMBER` and `MOSS` remain the product lockup. Ember brings warm softness; Moss +brings dry structure. Each direction ships in Dark and Light, with the same +semantic roles expressed through a different material character. `Four themes. +One color language.` is system proof, not the lead campaign headline. + +Proof should be shown in this order: + +1. Two visibly distinct material directions. +2. Dark and Light designed as pairs. +3. Accurate platform availability. +4. Generated and audited semantic consistency. +5. Platform-specific capabilities such as Theme Forge and Style Settings. + +Do not lead with the number of platforms, Theme Forge, or the calibration +implementation. Those are supporting proof, not the product definition. + +## Visual direction: Semantic Materials + +The system should make two material atmospheres memorable while keeping real +code, Markdown, and terminal content crisp. It should not force every channel +into the same poster shell. + +- Use charcoal and paper as the dominant fields. +- Use Ember and Moss as asymmetric material accents, never as a generic rainbow. +- Texture belongs to the substrate. Syntax and reading content remain sharp. +- Keep screenshots and generated UI previews honest. A generated representation + must not pretend to be a literal screenshot from an application. +- Use one strong statement per asset. Installation, customization, and platform + coverage belong in separate frames. +- Reserve the torn-paper rift for family-level attraction assets. Platform proof + images inherit color and spacing, not the tear or field-guide chrome. + +The visual system has three jobs: + +1. **Attraction** — family hero, OG, and social covers use one memorable material + boundary and no documentation-density labels. +2. **Product proof** — editor, Obsidian, terminal, and Forge imagery gives the + working surface at least 70% of the canvas. +3. **System proof** — direction atlases, availability matrices, and calibration + diagrams may use the denser Color Field Guide language. + +### Material treatment + +Family-level assets use a deterministic print layer: sparse paper grain, short +fibers, restrained display-type wear, and a structural torn-paper rift between +the wide Ember/Moss fields. The rift uses a jagged field boundary, +a narrow exposed paper core, a flat dark undercut, Ember-side abrasion, +projecting fibers, and detached chips rather than a decorative straight line or +drop shadow. Every layer is generated as SVG primitives; it is not a baked +raster overlay or an AI-authored palette source. + +- Texture ink must be injected from the foreground or surface tokens of the + theme underneath it. +- Most pixels remain the exact shipped surface color so color-fidelity checks + can continue to prove the source palette. +- Proof/code regions use clean theme surfaces without a decorative texture layer. +- Torn-paper geometry must stay deterministic for the same dimensions and seed; + its paper, warm edge, cool flecks, and shadow inks must be injected from real + theme tokens. +- App-like editor and Forge demonstrations remain clean UI evidence rather than + pretending to be printed screenshots. + +## Content matrix + +Every asset has one job and one source-owned sample. Do not substitute decorative +pseudocode or generic product copy at export time. + +| Asset | Message | Sample proof | +| --- | --- | --- | +| Family overview / social card | `EMBER` and `MOSS`, separated by their material fields, with `WARMTH OR STRUCTURE. MEANING STAYS CLEAR.` | One valid TypeScript object repeated across all four themes; only `direction` and `mode` change. | +| VS Code / Open VSX / Zed | `Same roles. Different material.` | The shared TypeScript theme object. | +| Obsidian | `Color as reading order.` | Markdown heading, callout, and task list. | +| Terminal packs | `Meaning survives the surface.` | The real `pnpm run verify` command and audit result labels. | +| Theme Forge | `Your color. Same safeguards.` | Choose direction, pick seed, preview both modes, apply, and restore. | + +The copy and samples live in `products/hearthcode/preview.json`. The preview +generator must consume them directly so the website, repository, and extension +exports cannot silently diverge. + +## Asset compiler + +Final marketing images are deterministic build artifacts. AI may be used to +explore a direction, but it is not a color, copy, logo, or layout source for a +shipped asset. + +The compiler has three explicit layers: + +1. `scripts/marketing/brand-system.mjs` owns typography stacks, field-guide + measurements, registration marks, and safe structural color operations. +2. `scripts/marketing/template-components.mjs` owns reusable SVG composition + primitives. Templates recompose for each aspect ratio rather than crop a + single master image. +3. `products/hearthcode/marketing-assets.json` owns formats, channels, + templates, and output paths. `schemas/marketing-assets.schema.json` guards + that source file. + +Run `pnpm run marketing:generate` after a theme, product, preview-copy, template, +or output-spec change. The command synchronizes theme outputs first, then rebuilds +the full marketing matrix. + +### Output matrix + +| Layer | Outputs | +| --- | --- | +| Family | README wide, GitHub social, site OG, square, portrait, and story/short-video cover. | +| Direction | Ember and Moss square specimens plus the combined direction atlas. | +| Editor | VS Code/Open VSX editor proof and the four-theme family overview. | +| Platform | Zed proof, terminal proof, generated availability matrix, and the separately generated real Obsidian hero. | +| Capability | Theme Forge workflow. | + +The GitHub social export is `1280×640`, matching +[GitHub's best-display recommendation](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/customizing-your-repositorys-social-media-preview). +The site OG remains `1200×630`. Social square, feed portrait, +and story layouts are `1200×1200`, `1080×1350`, and `1080×1920` respectively. + +### Aspect-ratio compositions + +The family exports share typography, theme-derived colors, and specimen +components, but they do not share one stretched layout: + +| Composition | Formats | Editorial job | +| --- | --- | --- | +| `semantic-rift-wide` | README, GitHub social, site OG | A left-to-right Ember/Moss rift with all four themes visible. | +| `editorial-square` | 1:1 social | A compact Dark/Light matrix with large, readable syntax proof. | +| `stacked-directions` | 4:5 feed | Ember and Moss become two stacked direction specimens; each pairs Dark and Light. | +| `campaign-story` | 9:16 story / short-video cover | A poster hierarchy with one large dark proof, one light proof, and no full-height rift. | + +The selected composition is declared beside each family asset in +`products/hearthcode/marketing-assets.json`. Do not infer layout from width or +height in the renderer. + +The wide composition also enforces semantic layout invariants: `EMBER` and its +code proof remain on the Ember field, while `MOSS` aligns with the Moss proof +column instead of hugging the torn edge. The material split is the separator, so +the display lockup does not add a slash glyph. The four code samples are large +enough to function as product evidence at README scale and remain legible in a +240px-wide thumbnail. The supporting sentence is split across the two fields so +the torn boundary never crosses live text. + +Platform proofs do not reuse the family poster wrapper. The editor proof is a +large Moss Dark/Light code comparison with minimal chrome and exact role rails. +The Obsidian proof contains only Moss and gives one oversized functional Markdown +frame the full canvas; a diagonal mode cut supplies the Dark/Light contrast with +only small `DARK` and `LIGHT` labels. Zed and terminal continue to show both +directions because both actually ship there, but their later template revisions +should follow the same product-first rule. + +## Color fidelity contract + +Marketing composition may be expressive, but its palette is not a separate +design surface. + +- Theme surfaces, foregrounds, syntax samples, and palette swatches must use + exact values read from `themes/ember-dark.json`, `themes/ember-light.json`, + `themes/moss-dark.json`, and `themes/moss-light.json`. +- A color shown as a Theme Forge seed must be an existing shipped theme token. +- Alpha and mixes of shipped tokens are allowed only for layout scaffolding such + as grids, borders, and muted labels. They must never be presented as theme + swatches or syntax colors. +- `reports/preview-manifest.json` records the source path, SHA-256, and exact + colors used for every theme. Generation fails when a required token is absent. +- Automated tests inspect generated PNG pixels and require every canonical + surface and syntax color to be present. Source and manifest values must match + exactly; PNG checks allow at most one 8-bit RGB unit for SVG rasterizer + rounding. A plausible-looking substitute is therefore a failing build, not an + acceptable approximation. + +## Canonical assets + +All channel-specific exports should derive from a small set of master assets: + +- `extension/images/family-overview.png`: Ember/Moss × Dark/Light family overview. +- `extension/images/editor-moss-dark-light.png`: product-first Moss Dark/Light + editor proof with enlarged syntax and exact semantic role rails. +- `extension/images/theme-forge-workflow.png`: VS Code-only Forge capability. +- `docs/marketing/direction-atlas.png`: material and syntax distinction between + Ember and Moss. +- `docs/marketing/platform-coverage.png`: accurate availability matrix. +- `docs/marketing/moss-surfaces.png`: generated semantic examples for code, + notes, and terminal. It is a system diagram, not an app screenshot. +- `docs/marketing/obsidian-hero.png`: full-canvas Moss Dark/Light functional + Markdown proof with no family-poster wrapper. +- `public/og-hearth.png`: current family-level social card. +- `docs/marketing/exports/github-social.png`: GitHub repository social preview. +- `docs/marketing/exports/family-{square,portrait,story}.png`: responsive social + compositions; these are recomposed layouts, not crops. +- `docs/marketing/exports/{ember,moss}-square.png`: single-direction campaign + cards with real Dark and Light syntax. +- `zed/images/hearthcode-zed.png`: generated Zed mirror README proof. +- `terminal/hearthcode-terminal.png`: generated terminal README proof. + +The preview generator owns these files. Update product, theme, preview, or +generator sources and run `pnpm run marketing:generate`; do not edit the PNGs by +hand. + +## Motion and sound + +Motion assets should be derived only after the still system is approved. + +- A family film shows Ember/Moss and Dark/Light. It does not explain Forge. +- Forge, Obsidian, and each direction receive separate short demonstrations. +- Use real theme switching and UI interaction instead of zooming static images. +- The video must work muted. Prefer silence or restrained interaction sounds over + an ambient music bed until HearthCode has a deliberate audio identity. diff --git a/docs/marketing/direction-atlas.png b/docs/marketing/direction-atlas.png new file mode 100644 index 0000000..813497a Binary files /dev/null and b/docs/marketing/direction-atlas.png differ diff --git a/docs/marketing/exports/ember-square.png b/docs/marketing/exports/ember-square.png new file mode 100644 index 0000000..ce3704d Binary files /dev/null and b/docs/marketing/exports/ember-square.png differ diff --git a/docs/marketing/exports/family-portrait.png b/docs/marketing/exports/family-portrait.png new file mode 100644 index 0000000..87837f2 Binary files /dev/null and b/docs/marketing/exports/family-portrait.png differ diff --git a/docs/marketing/exports/family-square.png b/docs/marketing/exports/family-square.png new file mode 100644 index 0000000..7b14f0d Binary files /dev/null and b/docs/marketing/exports/family-square.png differ diff --git a/docs/marketing/exports/family-story.png b/docs/marketing/exports/family-story.png new file mode 100644 index 0000000..9de85e3 Binary files /dev/null and b/docs/marketing/exports/family-story.png differ diff --git a/docs/marketing/exports/github-social.png b/docs/marketing/exports/github-social.png new file mode 100644 index 0000000..634477b Binary files /dev/null and b/docs/marketing/exports/github-social.png differ diff --git a/docs/marketing/exports/moss-square.png b/docs/marketing/exports/moss-square.png new file mode 100644 index 0000000..3115227 Binary files /dev/null and b/docs/marketing/exports/moss-square.png differ diff --git a/docs/marketing/moss-surfaces.png b/docs/marketing/moss-surfaces.png new file mode 100644 index 0000000..c92e9bf Binary files /dev/null and b/docs/marketing/moss-surfaces.png differ diff --git a/docs/marketing/obsidian-hero.png b/docs/marketing/obsidian-hero.png index 959a717..7224f3a 100644 Binary files a/docs/marketing/obsidian-hero.png and b/docs/marketing/obsidian-hero.png differ diff --git a/docs/marketing/platform-coverage.png b/docs/marketing/platform-coverage.png new file mode 100644 index 0000000..cbd0924 Binary files /dev/null and b/docs/marketing/platform-coverage.png differ diff --git a/docs/theme-iteration-playbook.md b/docs/theme-iteration-playbook.md index dc45680..83b903e 100644 --- a/docs/theme-iteration-playbook.md +++ b/docs/theme-iteration-playbook.md @@ -116,7 +116,7 @@ It gives the reviewer the source files, generated artifacts, commands, and manua Useful review files: - `color-system/schemes//color-contract.json` -- `public/previews/preview-contrast-v2.png` +- `public/previews/family-overview.png` - `obsidian/app-theme/screenshot.png` - `themes/-dark.json` - `themes/-light.json` diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 8de0c89..75c934c 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -1,3 +1,19 @@ +## 3.7.7 + +- Add a source-driven marketing asset compiler with shared Color Field Guide tokens, reusable SVG composition primitives, and a schema-validated platform/size matrix. +- Generate dedicated GitHub social, square, portrait, story, Ember, Moss, Zed, and terminal artwork from the shipped theme JSON instead of independent or AI-authored colors. +- Refresh the multilingual repository READMEs and generated distribution mirrors so each channel presents the same Ember/Moss story with channel-accurate proof. + +## 3.7.6 + +- Move Ember strings from the overlapping sage lane into a dedicated warm-wheat literal lane, so object properties remain olive while string values read as a clearly separate role in both Dark and Light. +- Add generated property/string hue and DeltaE guards, then refresh the VS Code, Open VSX, Zed, website, and marketing preview artifacts from the same source palette. + +## 3.7.5 + +- Add a generated Zed theme extension with Moss and Ember dark/light variants, schema validation, and source-linked release tooling. +- Keep Zed theme-picker selected rows visually distinct from hover, including Moss and Ember Light. + ## 3.7.4 - Fixed Theme Forge getting stuck on **Loading engine…** on some Windows installations by loading its bundled UI, worker, and theme source in parallel through the extension host instead of the webview resource service. Startup now reports four explicit stages, fails with a clear timeout instead of spinning forever, and offers a full retry. diff --git a/extension/README.md b/extension/README.md index 3cb2405..af4ba22 100644 --- a/extension/README.md +++ b/extension/README.md @@ -1,8 +1,8 @@ # HearthCode -Low-glare themes for long coding sessions, built to keep syntax structure clear without turning the editor into a light show. Choose warm **Ember** or dry, editorial **Moss** in Dark and Light, then use the built-in **Theme Forge** when you want your own primary color. +**Warmth or structure. Meaning stays clear.** Choose warm **Ember** or dry, editorial **Moss** in Dark and Light. Both keep the same reading rhythm through syntax and editor chrome; the built-in **Theme Forge** carries that system into your own primary color. -![HearthCode shown in a real editor layout with TypeScript syntax and editor chrome](images/preview-editor-hero.png) +![HearthCode shown in a real editor layout with TypeScript syntax and editor chrome](images/editor-moss-dark-light.png) ## Try it or install @@ -22,7 +22,7 @@ Low-glare themes for long coding sessions, built to keep syntax structure clear - **Dark:** balanced for mixed light and long coding sessions. - **Light:** tuned for bright rooms and docs-heavy work. -![All four HearthCode themes: Moss Dark, Ember Dark, Moss Light, and Ember Light](images/preview-contrast-v2.png) +![All four HearthCode themes: Moss Dark, Ember Dark, Moss Light, and Ember Light](images/family-overview.png) The extension includes: @@ -35,7 +35,7 @@ The extension includes: Want your own primary color? Run **HearthCode: Open Theme Forge** inside the editor. -![Theme Forge workflow: pick a direction and seed, preview Dark and Light, then apply or restore](images/preview-forge-workflow.png) +![Theme Forge workflow: pick a direction and seed, preview Dark and Light, then apply or restore](images/theme-forge-workflow.png) 1. **Pick** Moss or Ember and choose a seed color. 2. **Preview** syntax and editor chrome together in Dark and Light. diff --git a/extension/images/editor-moss-dark-light.png b/extension/images/editor-moss-dark-light.png new file mode 100644 index 0000000..479b2c1 Binary files /dev/null and b/extension/images/editor-moss-dark-light.png differ diff --git a/extension/images/family-overview.png b/extension/images/family-overview.png new file mode 100644 index 0000000..4a6cee6 Binary files /dev/null and b/extension/images/family-overview.png differ diff --git a/extension/images/preview-contrast-v2.png b/extension/images/preview-contrast-v2.png deleted file mode 100644 index 24e93f6..0000000 Binary files a/extension/images/preview-contrast-v2.png and /dev/null differ diff --git a/extension/images/preview-editor-hero.png b/extension/images/preview-editor-hero.png deleted file mode 100644 index 58b7b2d..0000000 Binary files a/extension/images/preview-editor-hero.png and /dev/null differ diff --git a/extension/images/preview-forge-workflow.png b/extension/images/preview-forge-workflow.png deleted file mode 100644 index cfcde94..0000000 Binary files a/extension/images/preview-forge-workflow.png and /dev/null differ diff --git a/extension/images/theme-forge-workflow.png b/extension/images/theme-forge-workflow.png new file mode 100644 index 0000000..03c3ab2 Binary files /dev/null and b/extension/images/theme-forge-workflow.png differ diff --git a/extension/package.json b/extension/package.json index 897c1d6..a79d8e9 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,8 +1,8 @@ { "name": "hearth-theme", "displayName": "HearthCode Theme", - "description": "Low-glare dark and light themes for long coding sessions, with warm Ember, editorial Moss, and a built-in Theme Forge customizer.", - "version": "3.7.4", + "description": "Warmth or structure, with meaning kept clear across Dark and Light. Includes calibrated Ember and Moss themes plus the built-in Theme Forge.", + "version": "3.7.7", "publisher": "hearth-code", "engines": { "vscode": "^1.70.0" diff --git a/extension/themes/ember-dark.json b/extension/themes/ember-dark.json index 7335017..9f54eeb 100644 --- a/extension/themes/ember-dark.json +++ b/extension/themes/ember-dark.json @@ -228,7 +228,7 @@ "string.regexp" ], "settings": { - "foreground": "#8eaa79" + "foreground": "#c39b69" } }, { diff --git a/extension/themes/ember-light.json b/extension/themes/ember-light.json index 8041e34..0f116b6 100644 --- a/extension/themes/ember-light.json +++ b/extension/themes/ember-light.json @@ -134,7 +134,7 @@ "punctuation.definition.comment" ], "settings": { - "foreground": "#83776e", + "foreground": "#83776d", "fontStyle": "italic" } }, @@ -146,7 +146,7 @@ "keyword.control" ], "settings": { - "foreground": "#b34732", + "foreground": "#b6442f", "fontStyle": "bold" } }, @@ -156,7 +156,7 @@ "keyword.operator.assignment" ], "settings": { - "foreground": "#62685c", + "foreground": "#64675f", "fontStyle": "" } }, @@ -185,7 +185,7 @@ "meta.method-call.rust entity.name.function.rust" ], "settings": { - "foreground": "#a0684b" + "foreground": "#a46746" } }, { @@ -206,7 +206,7 @@ "entity.name.function.member.rust" ], "settings": { - "foreground": "#a0684b" + "foreground": "#a46746" } }, { @@ -217,7 +217,7 @@ "support.type.property-name" ], "settings": { - "foreground": "#555d42" + "foreground": "#5c6447" } }, { @@ -228,7 +228,7 @@ "string.regexp" ], "settings": { - "foreground": "#6a7f58" + "foreground": "#7a4f21" } }, { @@ -239,7 +239,7 @@ "constant.language.undefined" ], "settings": { - "foreground": "#9a637e" + "foreground": "#8d5e75" } }, { @@ -266,7 +266,7 @@ "variable.other.constant" ], "settings": { - "foreground": "#49453f" + "foreground": "#4a453f" } }, { @@ -310,7 +310,7 @@ "meta.brace" ], "settings": { - "foreground": "#847845" + "foreground": "#8c804a" } }, { @@ -319,7 +319,7 @@ "support.constant" ], "settings": { - "foreground": "#9a637e" + "foreground": "#8d5e75" } }, { @@ -387,14 +387,14 @@ ], "semanticHighlighting": true, "semanticTokenColors": { - "variable": "#49453f", + "variable": "#4a453f", "variable.readonly": "#914300", - "property": "#555d42", + "property": "#5c6447", "property.readonly": "#914300", "function": "#2e6f8f", - "method": "#a0684b", + "method": "#a46746", "function.defaultLibrary": "#2e6f8f", - "method.defaultLibrary": "#a0684b", + "method.defaultLibrary": "#a46746", "class": { "foreground": "#736a18", "fontStyle": "italic" @@ -419,10 +419,10 @@ "foreground": "#736a18", "fontStyle": "italic" }, - "enumMember": "#9a637e", + "enumMember": "#8d5e75", "namespace": "#705533", "keyword": { - "foreground": "#b34732", + "foreground": "#b6442f", "fontStyle": "bold" }, "parameter": "#5a4431", diff --git a/obsidian/app-theme/manifest.json b/obsidian/app-theme/manifest.json index fa22fd2..5291893 100644 --- a/obsidian/app-theme/manifest.json +++ b/obsidian/app-theme/manifest.json @@ -1,6 +1,6 @@ { "name": "HearthCode", - "version": "3.7.4", + "version": "3.7.7", "minAppVersion": "1.0.0", "author": "HearthCode", "authorUrl": "https://theme.hearthcode.dev" diff --git a/obsidian/app-theme/screenshot.png b/obsidian/app-theme/screenshot.png index 99d6a40..376993e 100644 Binary files a/obsidian/app-theme/screenshot.png and b/obsidian/app-theme/screenshot.png differ diff --git a/obsidian/app-theme/versions.json b/obsidian/app-theme/versions.json index 1b71623..acb64c4 100644 --- a/obsidian/app-theme/versions.json +++ b/obsidian/app-theme/versions.json @@ -41,5 +41,8 @@ "3.7.1": "1.0.0", "3.7.2": "1.0.0", "3.7.3": "1.0.0", - "3.7.4": "1.0.0" + "3.7.4": "1.0.0", + "3.7.5": "1.0.0", + "3.7.6": "1.0.0", + "3.7.7": "1.0.0" } diff --git a/obsidian/mirror-README.md b/obsidian/mirror-README.md index 53d255e..2e6771b 100644 --- a/obsidian/mirror-README.md +++ b/obsidian/mirror-README.md @@ -1,6 +1,6 @@ # HearthCode for Obsidian -Warm, calm Dark and Light themes for [Obsidian](https://obsidian.md), designed to make structured notes easy to scan without making the page noisy. +Moss turns color into reading order for [Obsidian](https://obsidian.md). Its calm Dark and Light modes keep structured notes easy to scan without making the page noisy. ![HearthCode for Obsidian](./hero.png) diff --git a/package.json b/package.json index 16990ad..70ba7d0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "generate:obsidian": "node scripts/generate-obsidian-themes.mjs", "generate:obsidian:app": "node scripts/generate-obsidian-app-theme.mjs", "generate:terminal": "node scripts/generate-terminal-themes.mjs", + "generate:zed": "node scripts/generate-zed-themes.mjs", "generate:color-report": "node scripts/generate-color-language-report.mjs", "generate:contract-checklist": "node scripts/generate-color-language-contract-checklist.mjs", "generate:contract-review": "node scripts/generate-color-language-contract-review.mjs", @@ -30,6 +31,7 @@ "test:site": "node scripts/check-built-site.mjs", "verify": "pnpm run check:sync && pnpm run check:preview && pnpm run test && pnpm run audit:deps && pnpm run audit:all && pnpm run build && pnpm run test:site", "preview:generate": "node scripts/generate-preview-images.mjs", + "marketing:generate": "pnpm run sync && node scripts/generate-preview-images.mjs --force", "check:sync": "node scripts/check-sync-clean.mjs", "check:schemes": "node scripts/check-schemes.mjs", "check:preview": "node scripts/check-preview-clean.mjs", @@ -49,12 +51,13 @@ "audit:lineage": "node scripts/audit-lineage.mjs", "audit:parity": "node scripts/audit-parity.mjs", "audit:obsidian": "node scripts/obsidian-audit.mjs", + "audit:zed": "node scripts/zed-audit.mjs", "audit:copy": "node scripts/content-sync-audit.mjs", "audit:claims": "node scripts/claims-audit.mjs", "audit:cjk": "node scripts/cjk-audit.mjs", "audit:release": "node scripts/release-consistency-audit.mjs", "audit:forge": "node scripts/audit-forge-quality.mjs", - "audit:all": "node scripts/audit-source-schema.mjs && node scripts/theme-audit.mjs && node scripts/audit-source-layer.mjs && node scripts/audit-contract-checklist.mjs && node scripts/audit-contract-review.mjs && node scripts/audit-color-contract.mjs && node scripts/audit-ink-contrast.mjs && node scripts/review-moss-visual.mjs --ci && node scripts/audit-moss-release-contract.mjs && node scripts/check-schemes.mjs && node scripts/audit-lineage.mjs && node scripts/audit-parity.mjs && node scripts/audit-compatibility-boundaries.mjs && node scripts/obsidian-audit.mjs && node scripts/content-sync-audit.mjs && node scripts/claims-audit.mjs && node scripts/generated-origin-audit.mjs && node scripts/cjk-audit.mjs && node scripts/release-consistency-audit.mjs && node scripts/audit-forge-quality.mjs && node scripts/check-preview-clean.mjs", + "audit:all": "node scripts/audit-source-schema.mjs && node scripts/theme-audit.mjs && node scripts/audit-source-layer.mjs && node scripts/audit-contract-checklist.mjs && node scripts/audit-contract-review.mjs && node scripts/audit-color-contract.mjs && node scripts/audit-ink-contrast.mjs && node scripts/review-moss-visual.mjs --ci && node scripts/audit-moss-release-contract.mjs && node scripts/check-schemes.mjs && node scripts/audit-lineage.mjs && node scripts/audit-parity.mjs && node scripts/audit-compatibility-boundaries.mjs && node scripts/obsidian-audit.mjs && node scripts/zed-audit.mjs && node scripts/content-sync-audit.mjs && node scripts/claims-audit.mjs && node scripts/generated-origin-audit.mjs && node scripts/cjk-audit.mjs && node scripts/release-consistency-audit.mjs && node scripts/audit-forge-quality.mjs && node scripts/check-preview-clean.mjs", "bump:release:patch": "node scripts/bump-extension-version.mjs patch", "bump:release:minor": "node scripts/bump-extension-version.mjs minor", "bump:release:major": "node scripts/bump-extension-version.mjs major", @@ -75,7 +78,8 @@ "pack:moss:local": "node scripts/pack-local-preview.mjs moss moss-local", "pack:obsidian": "node scripts/pack-obsidian.mjs", "pack:obsidian:publish": "node scripts/pack-obsidian-publish.mjs --repo hearth-code/hearthcode-obsidian", - "pack:obsidian:snippets": "node scripts/pack-obsidian-snippets.mjs" + "pack:obsidian:snippets": "node scripts/pack-obsidian-snippets.mjs", + "pack:zed:publish": "node scripts/pack-zed-publish.mjs --repo hearth-code/hearthcode-zed" }, "devDependencies": { "husky": "^9.1.7", @@ -85,6 +89,7 @@ "pnpm": { "overrides": { "esbuild": "^0.28.1", + "postcss": "^8.5.18", "rollup": "npm:@rollup/wasm-node@4.60.2", "yaml": "^2.8.3" } @@ -92,7 +97,7 @@ "dependencies": { "@astrojs/sitemap": "^3.7.3", "@tailwindcss/vite": "^4.3.2", - "astro": "^7.0.7", + "astro": "^7.1.0", "shiki": "^4.3.1", "tailwindcss": "^4.3.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 313c7a4..e8dd78e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: esbuild: ^0.28.1 + postcss: ^8.5.18 rollup: npm:@rollup/wasm-node@4.60.2 yaml: ^2.8.3 @@ -20,8 +21,8 @@ importers: specifier: ^4.3.2 version: 4.3.2(vite@8.1.4(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)) astro: - specifier: ^7.0.7 - version: 7.0.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@rollup/wasm-node@4.60.2)(@types/node@24.12.2)(jiti@2.7.0) + specifier: ^7.1.0 + version: 7.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@rollup/wasm-node@4.60.2)(@types/node@24.12.2)(jiti@2.7.0) shiki: specifier: ^4.3.1 version: 4.3.1 @@ -109,8 +110,8 @@ packages: '@astrojs/internal-helpers@0.10.1': resolution: {integrity: sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==} - '@astrojs/markdown-satteri@0.3.3': - resolution: {integrity: sha512-Lje33Ittd8UQGgbIIWQvhPkj5X5c4b1sZnZWX3JQV/AWpfbuQGxVi2ONt6+ScydcwfR4egilslEWyczMclrJ1g==} + '@astrojs/markdown-satteri@0.3.4': + resolution: {integrity: sha512-6Lvt/bQZEBW+zzdhPblvfZEy5PGEYJaUsUqaCgwHeRPxZJL1gc9I+DRLKWJjjYTWDzVUTzXlMq4WwSK+X34CVw==} '@astrojs/prism@4.0.2': resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} @@ -847,8 +848,8 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - astro@7.0.7: - resolution: {integrity: sha512-swqrKDSI/B83GFroYPZYMFcxqbSe9+tkynu1WDBk3GLgBfV9++qVM4Z+2uFT6uu9A53Q0TROnxLketfMEENuqQ==} + astro@7.1.3: + resolution: {integrity: sha512-4dhPyAAXthf3xLEYnG8SeL7yr/nTPPABfY7e9YF0yuO+vK9Xp+8Q5j4xzsmL3GueukQv4oNwGNTBepLOiDGeJA==} engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true peerDependencies: @@ -902,9 +903,9 @@ packages: cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} @@ -977,6 +978,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} @@ -1041,12 +1046,24 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -1196,13 +1213,13 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - neotraverse@0.6.18: - resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} nlcst-to-string@4.0.0: @@ -1252,6 +1269,9 @@ packages: package-manager-detector@1.7.0: resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -1266,8 +1286,8 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - postcss@8.5.17: - resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} prismjs@1.30.0: @@ -1489,6 +1509,9 @@ packages: uploadthing: optional: true + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -1546,6 +1569,9 @@ packages: vite: optional: true + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -1633,11 +1659,12 @@ snapshots: smol-toml: 1.7.0 unified: 11.0.5 - '@astrojs/markdown-satteri@0.3.3': + '@astrojs/markdown-satteri@0.3.4': dependencies: '@astrojs/internal-helpers': 0.10.1 '@astrojs/prism': 4.0.2 github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 satteri: 0.9.5 '@astrojs/prism@4.0.2': @@ -2184,11 +2211,11 @@ snapshots: aria-query@5.3.2: {} - astro@7.0.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@rollup/wasm-node@4.60.2)(@types/node@24.12.2)(jiti@2.7.0): + astro@7.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@rollup/wasm-node@4.60.2)(@types/node@24.12.2)(jiti@2.7.0): dependencies: '@astrojs/compiler-rs': 0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) '@astrojs/internal-helpers': 0.10.1 - '@astrojs/markdown-satteri': 0.3.3 + '@astrojs/markdown-satteri': 0.3.4 '@astrojs/telemetry': 3.3.3 '@capsizecss/unpack': 4.0.1 '@clack/prompts': 1.7.0 @@ -2200,7 +2227,7 @@ snapshots: ci-info: 4.4.0 clsx: 2.1.1 common-ancestor-path: 2.0.0 - cookie: 1.1.1 + cookie: 2.0.1 devalue: 5.8.1 diff: 8.0.4 dset: 3.1.4 @@ -2217,7 +2244,7 @@ snapshots: magic-string: 0.30.21 magicast: 0.5.3 mrmime: 2.0.1 - neotraverse: 0.6.18 + neotraverse: 1.0.1 obug: 2.1.3 p-limit: 7.3.0 p-queue: 9.3.1 @@ -2304,7 +2331,7 @@ snapshots: cookie-es@1.2.3: {} - cookie@1.1.1: {} + cookie@2.0.1: {} crossws@0.3.5: dependencies: @@ -2377,6 +2404,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + es-module-lexer@2.3.0: {} esbuild@0.28.1: @@ -2461,6 +2490,30 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.5 @@ -2479,6 +2532,14 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -2597,9 +2658,9 @@ snapshots: mrmime@2.0.1: {} - nanoid@3.3.15: {} + nanoid@3.3.16: {} - neotraverse@0.6.18: {} + neotraverse@1.0.1: {} nlcst-to-string@4.0.0: dependencies: @@ -2646,6 +2707,10 @@ snapshots: package-manager-detector@1.7.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -2654,9 +2719,9 @@ snapshots: picomatch@4.0.5: {} - postcss@8.5.17: + postcss@8.5.23: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2886,6 +2951,11 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -2900,7 +2970,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.17 + postcss: 8.5.23 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: @@ -2913,6 +2983,8 @@ snapshots: optionalDependencies: vite: 8.1.4(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0) + web-namespaces@2.0.1: {} + xxhash-wasm@1.1.0: {} yargs-parser@22.0.0: {} diff --git a/products/hearthcode-moss/release.json b/products/hearthcode-moss/release.json index 6229a30..0bc764e 100644 --- a/products/hearthcode-moss/release.json +++ b/products/hearthcode-moss/release.json @@ -45,7 +45,7 @@ "author": "HearthCode", "authorUrl": "https://theme.hearthcode.dev", "minAppVersion": "1.0.0", - "screenshotSourcePath": "public/previews/preview-contrast-v2.png", + "screenshotSourcePath": "public/previews/family-overview.png", "screenshotPath": "obsidian/app-theme/screenshot.png", "packageBasename": "hearthcode-moss-obsidian-app-theme", "modes": [ diff --git a/products/hearthcode/marketing-assets.json b/products/hearthcode/marketing-assets.json new file mode 100644 index 0000000..6e51762 --- /dev/null +++ b/products/hearthcode/marketing-assets.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": 1, + "brandSystem": "semantic-materials-v1", + "renderer": "semantic-materials-v1", + "formats": { + "readme-wide": { + "width": 1600, + "height": 900, + "safeInset": 56 + }, + "github-social": { + "width": 1280, + "height": 640, + "safeInset": 48 + }, + "site-og": { + "width": 1200, + "height": 630, + "safeInset": 44 + }, + "social-square": { + "width": 1200, + "height": 1200, + "safeInset": 64 + }, + "social-portrait": { + "width": 1080, + "height": 1350, + "safeInset": 64 + }, + "social-story": { + "width": 1080, + "height": 1920, + "safeInset": 72 + } + }, + "assets": [ + { + "id": "family-readme", + "template": "family", + "composition": "semantic-rift-wide", + "format": "readme-wide", + "channels": ["github-readme", "vscode-marketplace", "open-vsx", "website"], + "outputs": [ + "extension/images/family-overview.png", + "public/previews/family-overview.png" + ] + }, + { + "id": "editor-marketplace", + "template": "editor-proof", + "format": "readme-wide", + "channels": ["vscode-marketplace", "open-vsx"], + "outputs": ["extension/images/editor-moss-dark-light.png"] + }, + { + "id": "forge-marketplace", + "template": "forge-workflow", + "format": "readme-wide", + "channels": ["vscode-marketplace"], + "outputs": ["extension/images/theme-forge-workflow.png"] + }, + { + "id": "direction-atlas", + "template": "direction-atlas", + "format": "readme-wide", + "channels": ["github-readme", "website", "press-kit"], + "outputs": ["docs/marketing/direction-atlas.png"] + }, + { + "id": "platform-coverage", + "template": "platform-coverage", + "format": "readme-wide", + "channels": ["github-readme", "website", "press-kit"], + "outputs": ["docs/marketing/platform-coverage.png"] + }, + { + "id": "moss-surfaces", + "template": "moss-surfaces", + "format": "readme-wide", + "channels": ["github-readme", "website", "press-kit"], + "outputs": ["docs/marketing/moss-surfaces.png"] + }, + { + "id": "github-social", + "template": "family", + "composition": "semantic-rift-wide", + "format": "github-social", + "channels": ["github-social"], + "outputs": ["docs/marketing/exports/github-social.png"] + }, + { + "id": "site-og", + "template": "family", + "composition": "semantic-rift-wide", + "format": "site-og", + "channels": ["website-og"], + "outputs": ["public/og-hearth.png"] + }, + { + "id": "family-square", + "template": "family", + "composition": "editorial-square", + "format": "social-square", + "channels": ["social-square"], + "outputs": ["docs/marketing/exports/family-square.png"] + }, + { + "id": "family-portrait", + "template": "family", + "composition": "stacked-directions", + "format": "social-portrait", + "channels": ["social-feed"], + "outputs": ["docs/marketing/exports/family-portrait.png"] + }, + { + "id": "family-story", + "template": "family", + "composition": "campaign-story", + "format": "social-story", + "channels": ["social-story", "short-video-cover"], + "outputs": ["docs/marketing/exports/family-story.png"] + }, + { + "id": "ember-square", + "template": "direction-card", + "format": "social-square", + "schemeId": "ember", + "channels": ["social-square", "press-kit"], + "outputs": ["docs/marketing/exports/ember-square.png"] + }, + { + "id": "moss-square", + "template": "direction-card", + "format": "social-square", + "schemeId": "moss", + "channels": ["social-square", "press-kit"], + "outputs": ["docs/marketing/exports/moss-square.png"] + }, + { + "id": "zed-platform", + "template": "channel-proof", + "format": "readme-wide", + "channelId": "zed", + "channels": ["zed", "zed-mirror-readme"], + "outputs": ["zed/images/hearthcode-zed.png"] + }, + { + "id": "terminal-platform", + "template": "channel-proof", + "format": "readme-wide", + "channelId": "terminal", + "channels": ["terminal-readme", "press-kit"], + "outputs": ["terminal/hearthcode-terminal.png"] + } + ], + "managedAssets": [ + { + "id": "obsidian-hero", + "generator": "scripts/generate-obsidian-app-theme.mjs", + "format": "readme-wide", + "channels": ["github-readme", "obsidian-mirror-readme"], + "outputs": ["docs/marketing/obsidian-hero.png"] + }, + { + "id": "obsidian-community-screenshot", + "generator": "scripts/generate-obsidian-app-theme.mjs", + "channels": ["obsidian-community-themes"], + "outputs": ["obsidian/app-theme/screenshot.png"] + } + ] +} diff --git a/products/hearthcode/preview.json b/products/hearthcode/preview.json index d779971..3aa14a9 100644 --- a/products/hearthcode/preview.json +++ b/products/hearthcode/preview.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "badgeLabel": "HEARTHCODE", - "headline": "Four stable starting points", - "subheadline": "Moss and Ember, each shown in Dark and Light with real syntax and editor chrome.", + "headline": "Two directions. Four calibrated themes.", + "subheadline": "Ember brings warm softness. Moss brings dry structure. Both ship in Dark and Light.", "familyLabels": { "dark": "DARK STARTING POINTS", "light": "LIGHT STARTING POINTS" @@ -10,5 +10,99 @@ "variantNames": { "dark": "Ember Dark", "light": "Ember Light" + }, + "marketing": { + "familyHeadline": "EMBER / MOSS", + "familySubheadline": "WARMTH OR STRUCTURE. MEANING STAYS CLEAR.", + "directionHeadline": "Warmth or structure. Meaning stays clear.", + "directionSubheadline": "Different material. Same reading rhythm.", + "platformHeadline": "Where each direction ships.", + "platformSubheadline": "Every channel shows only the directions and modes it actually ships.", + "mossSurfaceHeadline": "Same roles. Different surface.", + "mossSurfaceSubheadline": "Code, notes, and terminal output keep the same visual hierarchy.", + "editorsHeadline": "Same roles. Different material.", + "editorsSubheadline": "VS Code, Open VSX editors, and Zed share one syntax proof.", + "obsidianHeadline": "Color as reading order.", + "obsidianSubheadline": "Moss carries structure through Markdown without turning notes into code.", + "terminalHeadline": "Meaning survives the surface.", + "terminalSubheadline": "Five terminal formats use the shipped ANSI palette.", + "forgeHeadline": "Your color. Same safeguards.", + "forgeSubheadline": "Choose, preview, apply, and restore inside VS Code.", + "directions": { + "ember": { + "summary": "Warm charcoal and paper, with ember control flow and cool callable anchors.", + "chips": ["warm neutrals", "ember control", "denim callables"], + "comment": "// warm-neutral structure with cool anchors", + "sampleFunction": "renderTheme", + "sampleVariable": "theme", + "sampleString": "\"ember\"", + "sampleValue": "\"hearth\"", + "directionLabel": "WARM-NEUTRAL DIRECTION", + "focusLabel": "COOL CALLABLES" + }, + "moss": { + "summary": "Dry charcoal and paper, with clearer lane split and greener callable structure.", + "chips": ["dry paper", "editorial lane split", "lichen callables"], + "comment": "// dry editorial lanes with calm callables", + "sampleFunction": "routeSignal", + "sampleVariable": "palette", + "sampleString": "\"moss\"", + "sampleValue": "\"field\"", + "directionLabel": "DRY EDITORIAL DIRECTION", + "focusLabel": "GREEN CALLABLES" + } + } + }, + "samples": { + "family": { + "language": "typescript", + "lines": [ + "const theme = {", + " direction: \"{direction}\",", + " mode: \"{mode}\",", + "} as const;" + ] + }, + "editors": { + "language": "typescript", + "lines": [ + "type ThemePreview = {", + " direction: \"ember\" | \"moss\";", + " mode: \"dark\" | \"light\";", + "};", + "const theme: ThemePreview = {", + " direction: \"moss\",", + " mode: \"{mode}\",", + "};" + ] + }, + "obsidian": { + "language": "markdown", + "lines": [ + "# Color as reading order", + "> [!tip] Structure leads. Noise recedes.", + "- [x] Dark and Light stay paired", + "- [ ] Tune with Style Settings" + ] + }, + "terminal": { + "language": "shell", + "lines": [ + "$ pnpm run verify", + "[PASS] Color contract audit passed.", + "[PASS] Zed theme audit passed.", + "[PASS] Obsidian theme audit passed." + ] + }, + "forge": { + "language": "workflow", + "lines": [ + "Choose direction", + "Pick seed", + "Preview Dark + Light", + "Apply", + "Restore original theme" + ] + } } } diff --git a/products/hearthcode/product.json b/products/hearthcode/product.json index 2834e10..fce315a 100644 --- a/products/hearthcode/product.json +++ b/products/hearthcode/product.json @@ -3,12 +3,12 @@ "id": "hearthcode", "name": "HearthCode", "displayName": "HearthCode Theme", - "summary": "A low-glare theme family for code interfaces.", + "summary": "A calibrated Ember and Moss theme family for code interfaces.", "brand": { "id": "hearthcode-theme", "name": "HearthCode Theme", "displayName": "HearthCode Theme", - "summary": "A theme family for code interfaces." + "summary": "Warmth or structure, with one semantic color system." }, "publisher": "HearthCode", "websiteUrl": "https://theme.hearthcode.dev", @@ -77,6 +77,34 @@ "vscode": true, "openvsx": true, "obsidian": true, - "terminal": true + "terminal": true, + "zed": true + }, + "channelAvailability": { + "vscode": { + "schemeIds": ["ember", "moss"], + "variantIds": ["dark", "light"], + "capabilityIds": ["theme-forge"] + }, + "openvsx": { + "schemeIds": ["ember", "moss"], + "variantIds": ["dark", "light"], + "capabilityIds": [] + }, + "zed": { + "schemeIds": ["ember", "moss"], + "variantIds": ["dark", "light"], + "capabilityIds": [] + }, + "terminal": { + "schemeIds": ["ember", "moss"], + "variantIds": ["dark", "light"], + "capabilityIds": ["five-formats"] + }, + "obsidian": { + "schemeIds": ["moss"], + "variantIds": ["dark", "light"], + "capabilityIds": ["style-settings"] + } } } diff --git a/products/hearthcode/release.json b/products/hearthcode/release.json index ff2c23b..7fa4b3f 100644 --- a/products/hearthcode/release.json +++ b/products/hearthcode/release.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "site": { - "titleDescriptor": "Color Language for Code Interfaces", + "titleDescriptor": "Ember and Moss Themes for Code Interfaces", "ogImagePath": "/og-hearth.png" }, "vscodeExtension": { "name": "hearth-theme", "publisher": "hearth-code", - "description": "Low-glare dark and light themes for long coding sessions, with warm Ember, editorial Moss, and a built-in Theme Forge customizer.", + "description": "Warmth or structure, with meaning kept clear across Dark and Light. Includes calibrated Ember and Moss themes plus the built-in Theme Forge.", "categories": [ "Themes" ], @@ -59,12 +59,21 @@ "author": "HearthCode", "authorUrl": "https://theme.hearthcode.dev", "minAppVersion": "1.0.0", - "screenshotSourcePath": "public/previews/preview-contrast-v2.png", + "screenshotSourcePath": "public/previews/family-overview.png", "screenshotPath": "obsidian/app-theme/screenshot.png", "packageBasename": "hearth-obsidian-app-theme", "modes": [ "dark", "light" ] + }, + "zedExtension": { + "id": "hearthcode-theme", + "name": "HearthCode Theme", + "authors": [ + "HearthCode" + ], + "description": "Warm Ember or structured Moss in calibrated Dark and Light themes for Zed, with one consistent semantic color system.", + "repository": "https://github.com/hearth-code/hearthcode-zed" } } diff --git a/products/moss-local/release.json b/products/moss-local/release.json index 0e00c48..5594038 100644 --- a/products/moss-local/release.json +++ b/products/moss-local/release.json @@ -38,7 +38,7 @@ "author": "HearthCode", "authorUrl": "https://theme.hearthcode.dev", "minAppVersion": "1.0.0", - "screenshotSourcePath": "public/previews/preview-contrast-v2.png", + "screenshotSourcePath": "public/previews/family-overview.png", "screenshotPath": "obsidian/app-theme/screenshot.png", "packageBasename": "moss-obsidian-app-theme-local", "modes": [ diff --git a/public/og-hearth.png b/public/og-hearth.png index 7971d7f..fb975c1 100644 Binary files a/public/og-hearth.png and b/public/og-hearth.png differ diff --git a/public/previews/family-overview.png b/public/previews/family-overview.png new file mode 100644 index 0000000..4a6cee6 Binary files /dev/null and b/public/previews/family-overview.png differ diff --git a/public/previews/preview-contrast-v2.png b/public/previews/preview-contrast-v2.png deleted file mode 100644 index 24e93f6..0000000 Binary files a/public/previews/preview-contrast-v2.png and /dev/null differ diff --git a/public/themes/ember-dark.json b/public/themes/ember-dark.json index 7335017..9f54eeb 100644 --- a/public/themes/ember-dark.json +++ b/public/themes/ember-dark.json @@ -228,7 +228,7 @@ "string.regexp" ], "settings": { - "foreground": "#8eaa79" + "foreground": "#c39b69" } }, { diff --git a/public/themes/ember-light.json b/public/themes/ember-light.json index 8041e34..0f116b6 100644 --- a/public/themes/ember-light.json +++ b/public/themes/ember-light.json @@ -134,7 +134,7 @@ "punctuation.definition.comment" ], "settings": { - "foreground": "#83776e", + "foreground": "#83776d", "fontStyle": "italic" } }, @@ -146,7 +146,7 @@ "keyword.control" ], "settings": { - "foreground": "#b34732", + "foreground": "#b6442f", "fontStyle": "bold" } }, @@ -156,7 +156,7 @@ "keyword.operator.assignment" ], "settings": { - "foreground": "#62685c", + "foreground": "#64675f", "fontStyle": "" } }, @@ -185,7 +185,7 @@ "meta.method-call.rust entity.name.function.rust" ], "settings": { - "foreground": "#a0684b" + "foreground": "#a46746" } }, { @@ -206,7 +206,7 @@ "entity.name.function.member.rust" ], "settings": { - "foreground": "#a0684b" + "foreground": "#a46746" } }, { @@ -217,7 +217,7 @@ "support.type.property-name" ], "settings": { - "foreground": "#555d42" + "foreground": "#5c6447" } }, { @@ -228,7 +228,7 @@ "string.regexp" ], "settings": { - "foreground": "#6a7f58" + "foreground": "#7a4f21" } }, { @@ -239,7 +239,7 @@ "constant.language.undefined" ], "settings": { - "foreground": "#9a637e" + "foreground": "#8d5e75" } }, { @@ -266,7 +266,7 @@ "variable.other.constant" ], "settings": { - "foreground": "#49453f" + "foreground": "#4a453f" } }, { @@ -310,7 +310,7 @@ "meta.brace" ], "settings": { - "foreground": "#847845" + "foreground": "#8c804a" } }, { @@ -319,7 +319,7 @@ "support.constant" ], "settings": { - "foreground": "#9a637e" + "foreground": "#8d5e75" } }, { @@ -387,14 +387,14 @@ ], "semanticHighlighting": true, "semanticTokenColors": { - "variable": "#49453f", + "variable": "#4a453f", "variable.readonly": "#914300", - "property": "#555d42", + "property": "#5c6447", "property.readonly": "#914300", "function": "#2e6f8f", - "method": "#a0684b", + "method": "#a46746", "function.defaultLibrary": "#2e6f8f", - "method.defaultLibrary": "#a0684b", + "method.defaultLibrary": "#a46746", "class": { "foreground": "#736a18", "fontStyle": "italic" @@ -419,10 +419,10 @@ "foreground": "#736a18", "fontStyle": "italic" }, - "enumMember": "#9a637e", + "enumMember": "#8d5e75", "namespace": "#705533", "keyword": { - "foreground": "#b34732", + "foreground": "#b6442f", "fontStyle": "bold" }, "parameter": "#5a4431", diff --git a/releases/color-language.json b/releases/color-language.json index ec729a2..9d04e49 100644 --- a/releases/color-language.json +++ b/releases/color-language.json @@ -1,4 +1,4 @@ { - "version": "3.7.4", + "version": "3.7.7", "channel": "stable" } diff --git a/reports/color-contract-audit.json b/reports/color-contract-audit.json index be74d78..0eebb7c 100644 --- a/reports/color-contract-audit.json +++ b/reports/color-contract-audit.json @@ -472,22 +472,22 @@ } } }, - "livedInSage": { + "warmWheatLiteral": { "hueBand": [ - 85, - 105 + 28, + 45 ], - "minSaturation": 0.14, + "minSaturation": 0.3, "minContrast": null, "minBackgroundHueDistance": null, "roles": { "string": { - "color": "#8eaa79", - "hue": 94.3, - "saturation": 0.224, - "lightness": 0.571, - "contrast": 6.71, - "backgroundHueDistance": 71.8 + "color": "#c39b69", + "hue": 33.3, + "saturation": 0.429, + "lightness": 0.588, + "contrast": 6.74, + "backgroundHueDistance": 10.8 } } }, @@ -531,17 +531,23 @@ } }, "criticalPairs": [ + { + "left": "property", + "right": "string", + "minDeltaE": 24, + "deltaE": 26 + }, { "left": "keyword", "right": "string", "minDeltaE": 9, - "deltaE": 64.3 + "deltaE": 37 }, { "left": "function", "right": "string", "minDeltaE": 8, - "deltaE": 36.8 + "deltaE": 48.1 }, { "left": "keyword", @@ -564,12 +570,12 @@ "minBackgroundHueDistance": null, "roles": { "keyword": { - "color": "#b34732", - "hue": 9.8, - "saturation": 0.563, + "color": "#b6442f", + "hue": 9.3, + "saturation": 0.59, "lightness": 0.449, - "contrast": 4.14, - "backgroundHueDistance": 25.1 + "contrast": 4.15, + "backgroundHueDistance": 25.5 }, "tag": { "color": "#77291e", @@ -610,31 +616,31 @@ "minBackgroundHueDistance": null, "roles": { "method": { - "color": "#a0684b", - "hue": 20.5, - "saturation": 0.362, - "lightness": 0.461, - "contrast": 3.5, - "backgroundHueDistance": 14.4 + "color": "#a46746", + "hue": 21.1, + "saturation": 0.402, + "lightness": 0.459, + "contrast": 3.47, + "backgroundHueDistance": 13.8 } } }, - "livedInSage": { + "warmWheatLiteral": { "hueBand": [ - 85, - 105 + 28, + 45 ], - "minSaturation": 0.14, + "minSaturation": 0.3, "minContrast": null, "minBackgroundHueDistance": null, "roles": { "string": { - "color": "#6a7f58", - "hue": 92.3, - "saturation": 0.181, - "lightness": 0.422, - "contrast": 3.34, - "backgroundHueDistance": 57.5 + "color": "#7a4f21", + "hue": 31, + "saturation": 0.574, + "lightness": 0.304, + "contrast": 5.39, + "backgroundHueDistance": 3.8 } } }, @@ -667,34 +673,40 @@ "minBackgroundHueDistance": null, "roles": { "number": { - "color": "#9a637e", - "hue": 330.5, - "saturation": 0.217, - "lightness": 0.496, - "contrast": 3.59, - "backgroundHueDistance": 64.3 + "color": "#8d5e75", + "hue": 330.6, + "saturation": 0.2, + "lightness": 0.461, + "contrast": 4.01, + "backgroundHueDistance": 64.2 } } } }, "criticalPairs": [ + { + "left": "property", + "right": "string", + "minDeltaE": 24, + "deltaE": 28.9 + }, { "left": "keyword", "right": "string", "minDeltaE": 9, - "deltaE": 60.3 + "deltaE": 32.4 }, { "left": "function", "right": "string", "minDeltaE": 8, - "deltaE": 43.2 + "deltaE": 62.2 }, { "left": "keyword", "right": "number", "minDeltaE": 8, - "deltaE": 43.9 + "deltaE": 47.2 } ] } diff --git a/reports/color-contract-audit.md b/reports/color-contract-audit.md index c6b95d5..c2cf774 100644 --- a/reports/color-contract-audit.md +++ b/reports/color-contract-audit.md @@ -62,33 +62,35 @@ Hearthlight over paper and warm tooling with bounded cool structure. | emberOrange | tag | #af513b | 11.4 | 0.496 | 3.33 | 11.1 | | controlledMineralBlue | function | #6f94a4 | 198.1 | 0.226 | 5.29 | 175.6 | | leatherMethodWarmth | method | #ad6a45 | 21.3 | 0.43 | 4.04 | 1.2 | -| livedInSage | string | #8eaa79 | 94.3 | 0.224 | 6.71 | 71.8 | +| warmWheatLiteral | string | #c39b69 | 33.3 | 0.429 | 6.74 | 10.8 | | dryOchreType | type | #99904c | 53 | 0.336 | 5.29 | 30.5 | | dustyConstant | number | #aa7a94 | 327.5 | 0.22 | 4.86 | 55 | | Pair | deltaE | Minimum | | --- | ---: | ---: | -| keyword / string | 64.3 | 9 | -| function / string | 36.8 | 8 | +| property / string | 26 | 24 | +| keyword / string | 37 | 9 | +| function / string | 48.1 | 8 | | keyword / number | 47.2 | 8 | ### light | Lane | Role | Color | Hue | Sat | Contrast | Bg Hue Gap | | --- | --- | --- | ---: | ---: | ---: | ---: | -| emberOrange | keyword | #b34732 | 9.8 | 0.563 | 4.14 | 25.1 | +| emberOrange | keyword | #b6442f | 9.3 | 0.59 | 4.15 | 25.5 | | emberOrange | tag | #77291e | 7.4 | 0.597 | 7.55 | 27.4 | | controlledMineralBlue | function | #2e6f8f | 199.8 | 0.513 | 4.23 | 165 | -| leatherMethodWarmth | method | #a0684b | 20.5 | 0.362 | 3.5 | 14.4 | -| livedInSage | string | #6a7f58 | 92.3 | 0.181 | 3.34 | 57.5 | +| leatherMethodWarmth | method | #a46746 | 21.1 | 0.402 | 3.47 | 13.8 | +| warmWheatLiteral | string | #7a4f21 | 31 | 0.574 | 5.39 | 3.8 | | dryOchreType | type | #736a18 | 54.1 | 0.655 | 4.21 | 19.2 | -| dustyConstant | number | #9a637e | 330.5 | 0.217 | 3.59 | 64.3 | +| dustyConstant | number | #8d5e75 | 330.6 | 0.2 | 4.01 | 64.2 | | Pair | deltaE | Minimum | | --- | ---: | ---: | -| keyword / string | 60.3 | 9 | -| function / string | 43.2 | 8 | -| keyword / number | 43.9 | 8 | +| property / string | 28.9 | 24 | +| keyword / string | 32.4 | 9 | +| function / string | 62.2 | 8 | +| keyword / number | 47.2 | 8 | ## Issues diff --git a/reports/obsidian-hero-manifest.json b/reports/obsidian-hero-manifest.json index 4f14bba..70163c3 100644 --- a/reports/obsidian-hero-manifest.json +++ b/reports/obsidian-hero-manifest.json @@ -1,5 +1,5 @@ { - "renderer": "obsidian-functional-markdown-v3", - "hash": "791a68ac74326cefcd2cfd455f93a4e4d46a6f7913b69bafd2b197567d1fcc5d", + "renderer": "obsidian-functional-markdown-v7", + "hash": "cdd0a7ec1adace21120da690a979f761c246813acd0abeb4e722b20b2f7c95a7", "width": 1600 } diff --git a/reports/obsidian-screenshot-manifest.json b/reports/obsidian-screenshot-manifest.json index de86e97..e9702e7 100644 --- a/reports/obsidian-screenshot-manifest.json +++ b/reports/obsidian-screenshot-manifest.json @@ -1,6 +1,6 @@ { - "renderer": "obsidian-functional-markdown-v3", - "hash": "dbe9bf49545670940eb8f60e00f19542aa36411d4df64da7364936448a90109f", + "renderer": "obsidian-functional-markdown-v7", + "hash": "49a2e2ce479fe49dedf87a2462f23d8ba28edd30f1b8f9e1f1823931dad07504", "width": 512, "height": 288 } diff --git a/reports/preview-manifest.json b/reports/preview-manifest.json index e2a19a3..21a4326 100644 --- a/reports/preview-manifest.json +++ b/reports/preview-manifest.json @@ -1,30 +1,287 @@ { - "schemaVersion": 3, + "schemaVersion": 6, "generator": "scripts/generate-preview-images.mjs", - "renderer": "marketplace-source-color-v10", - "generatorSourceSha256": "7e650964f7bde6689a6549490d3ca3512d2da18d803a8f8789c7021a783648b3", - "promoSpecSha256": "0f551ce9ccad45b45e1c517d38862c978a42c37fcbeb05369a7fb05215869749", + "renderer": "semantic-materials-v1", + "generatorSourceSha256": "e9af2eefc8cc9d2f9fca4e47fd90302557c2fcba3d43e04fb53ada52d5fad057", + "brandSystem": { + "id": "semantic-materials-v1", + "source": "scripts/marketing/brand-system.mjs", + "sourceSha256": "6be90a3a41fb656023d1a7b04e9c16b2f861d8f565a58a1b8ca92d56e85491d4", + "templateComponentsSource": "scripts/marketing/template-components.mjs", + "templateComponentsSourceSha256": "c4717477067b40023634b264c6f028e9a6eba7fd9f655caf33d416408a84eb97" + }, + "assetSpec": { + "source": "products/hearthcode/marketing-assets.json", + "sourceSha256": "e8b0e359b56e1947fde9b21f9ef4435a35c9bfc151541bb3effde73104a2fa05" + }, + "promoSpecSha256": "0091fed3159115deb439d8a67889ac08a65ac2b776e2264a885090bee02b574b", + "colorFidelity": { + "policy": "theme-source-only-v1", + "structuralColorRule": "Signature fields and swatches use unmodified shipped theme tokens; structural overlays use only alpha or mixes of those tokens.", + "themes": { + "moss-dark": { + "source": "themes/moss-dark.json", + "sourceSha256": "37c7185a46cea02b22e2933c694705d4cc0b54f854f3455ac93833a8581730c9", + "colors": { + "surface": "#1b1d1a", + "foreground": "#d2bea2", + "keyword": "#d4b84f", + "function": "#93ce75", + "type": "#68adc1", + "string": "#caa46c", + "property": "#8ebb7f", + "operator": "#979087" + } + }, + "moss-light": { + "source": "themes/moss-light.json", + "sourceSha256": "9c7c90c955bb815caabc0176ff57d981271b75c1cbad5d771a7a41ee8c40bc9c", + "colors": { + "surface": "#e7e5d8", + "foreground": "#342d28", + "keyword": "#83640f", + "function": "#3e752b", + "type": "#237296", + "string": "#7e5a2a", + "property": "#46703f", + "operator": "#5f5c57" + } + }, + "ember-dark": { + "source": "themes/ember-dark.json", + "sourceSha256": "f40fee3e10b50188479836fedbd67f69a028628fc3f5a627a085f51ed779b80a", + "colors": { + "surface": "#1f1a17", + "foreground": "#d3c9b8", + "keyword": "#ca5b41", + "function": "#6f94a4", + "type": "#99904c", + "string": "#c39b69", + "property": "#788058", + "operator": "#9c978f" + } + }, + "ember-light": { + "source": "themes/ember-light.json", + "sourceSha256": "96b4d6823ffad6ef6669e1e71e4c0e0c1d27b07c190a2e96899ad0b8d837c988", + "colors": { + "surface": "#ecdfcd", + "foreground": "#30261b", + "keyword": "#b6442f", + "function": "#2e6f8f", + "type": "#736a18", + "string": "#7a4f21", + "property": "#5c6447", + "operator": "#64675f" + } + } + } + }, "canvas": { "width": 1600, "height": 900 }, + "formats": { + "readme-wide": { + "width": 1600, + "height": 900, + "safeInset": 56 + }, + "github-social": { + "width": 1280, + "height": 640, + "safeInset": 48 + }, + "site-og": { + "width": 1200, + "height": 630, + "safeInset": 44 + }, + "social-square": { + "width": 1200, + "height": 1200, + "safeInset": 64 + }, + "social-portrait": { + "width": 1080, + "height": 1350, + "safeInset": 64 + }, + "social-story": { + "width": 1080, + "height": 1920, + "safeInset": 72 + } + }, "editorHero": { - "inputSha256": "5207b23067012e92d85a8f9cab9777b3d8fa8aa9f311568b2cf9c49aa8f88894", + "inputSha256": "61ab730236ad026141ec1a74459fa8a26773b755d60528cfb7e55458628b3c96", "outputs": [ - "extension/images/preview-editor-hero.png" + "extension/images/editor-moss-dark-light.png" ] }, "contrastImage": { - "inputSha256": "85e91ada8ec0983b14fb24421acfe0b6933796634534787e7dec037d835af9d0", + "inputSha256": "5bef617f92b895664ae33cc50669709e618b0cb5776b391572e0a4773b872463", "outputs": [ - "extension/images/preview-contrast-v2.png", - "public/previews/preview-contrast-v2.png" + "extension/images/family-overview.png", + "public/previews/family-overview.png" ] }, "forgeWorkflow": { - "inputSha256": "4b92979a687ebd66e2f62dff503196dce113fedf6c207a58dc53f8b8c55fb50f", + "inputSha256": "45f6adea44eea64bd3b1de56b8bbee142f4b52a319dd7686ba01ec32fff53cc4", "outputs": [ - "extension/images/preview-forge-workflow.png" + "extension/images/theme-forge-workflow.png" ] - } + }, + "directionAtlas": { + "inputSha256": "d732e746d325b73b969e340d201d44c20adcb291f51b25890c986a241a27cbab", + "outputs": [ + "docs/marketing/direction-atlas.png" + ] + }, + "platformCoverage": { + "inputSha256": "6c615cdd984c87607b35bea7345335d1f50bd02540154efe9e280c5c4e6412c2", + "outputs": [ + "docs/marketing/platform-coverage.png" + ] + }, + "mossSurfaces": { + "inputSha256": "d918014be5df3342c40befc5471d54d6236a207c0e477714655eb65fadf06516", + "outputs": [ + "docs/marketing/moss-surfaces.png" + ] + }, + "socialCard": { + "inputSha256": "b1360fddbf782108d7146d729f5cded31160e1acce811120a078d8658c3fc082", + "canvas": { + "width": 1200, + "height": 630, + "safeInset": 44 + }, + "outputs": [ + "public/og-hearth.png" + ] + }, + "githubSocial": { + "inputSha256": "2c6f4a697bb8882cbf621e4f650cc8b1735a6c2e27d288eea8283da4f0842c83", + "canvas": { + "width": 1280, + "height": 640, + "safeInset": 48 + }, + "outputs": [ + "docs/marketing/exports/github-social.png" + ] + }, + "familySquare": { + "inputSha256": "2f2a6afdae0791f6ffccffdeda99c3919cda12ca01d6de06f711745bbc3efacc", + "canvas": { + "width": 1200, + "height": 1200, + "safeInset": 64 + }, + "outputs": [ + "docs/marketing/exports/family-square.png" + ] + }, + "familyPortrait": { + "inputSha256": "24780ed8ba0835db2c311efbb9fb627843c98c015dab9fe4dc8936555ba9477e", + "canvas": { + "width": 1080, + "height": 1350, + "safeInset": 64 + }, + "outputs": [ + "docs/marketing/exports/family-portrait.png" + ] + }, + "familyStory": { + "inputSha256": "4bc22268e4fe31d3596eb151f71a9ca954256585e00965d9a64e2ba845e3d2fd", + "canvas": { + "width": 1080, + "height": 1920, + "safeInset": 72 + }, + "outputs": [ + "docs/marketing/exports/family-story.png" + ] + }, + "emberSquare": { + "inputSha256": "ffec4b38931bd90dd61a26444ff9d24eab00c952108bba737eb12965c4f363db", + "canvas": { + "width": 1200, + "height": 1200, + "safeInset": 64 + }, + "outputs": [ + "docs/marketing/exports/ember-square.png" + ] + }, + "mossSquare": { + "inputSha256": "48dc77f1757043e94cf8199ea46a7459c428d3e2420c02c971f111e21ed6238f", + "canvas": { + "width": 1200, + "height": 1200, + "safeInset": 64 + }, + "outputs": [ + "docs/marketing/exports/moss-square.png" + ] + }, + "zedPlatform": { + "inputSha256": "e66d31cd2e0c543ae92d1bdf6cd4a94f384e421577d093079f1aeea67c555d47", + "canvas": { + "width": 1600, + "height": 900, + "safeInset": 56 + }, + "outputs": [ + "zed/images/hearthcode-zed.png" + ] + }, + "terminalPlatform": { + "inputSha256": "4d6b9c00ef9c0ddc0d7c8e8bc2a39d62971d57ad0ac314332336d31ede8cd7b7", + "canvas": { + "width": 1600, + "height": 900, + "safeInset": 56 + }, + "outputs": [ + "terminal/hearthcode-terminal.png" + ] + }, + "managedAssets": [ + { + "id": "obsidian-hero", + "generator": "scripts/generate-obsidian-app-theme.mjs", + "format": "readme-wide", + "channels": [ + "github-readme", + "obsidian-mirror-readme" + ], + "outputs": [ + "docs/marketing/obsidian-hero.png" + ], + "canvas": { + "width": 1600, + "height": 900, + "safeInset": 56 + }, + "outputSha256": { + "docs/marketing/obsidian-hero.png": "3b1bf84aafc5bad0cc12a18783b0c5ea0f020707173334e2ff9d22ca72a0fa2f" + } + }, + { + "id": "obsidian-community-screenshot", + "generator": "scripts/generate-obsidian-app-theme.mjs", + "channels": [ + "obsidian-community-themes" + ], + "outputs": [ + "obsidian/app-theme/screenshot.png" + ], + "outputSha256": { + "obsidian/app-theme/screenshot.png": "eb633045b5d5ce63dec5a911ab5b65bd695969bde59d28aea9ff389f61736f64" + } + } + ] } diff --git a/schemas/marketing-assets.schema.json b/schemas/marketing-assets.schema.json new file mode 100644 index 0000000..5301da8 --- /dev/null +++ b/schemas/marketing-assets.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "marketing-assets.schema.json", + "title": "HearthCode marketing asset matrix", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "brandSystem", "renderer", "formats", "assets", "managedAssets"], + "properties": { + "schemaVersion": { "const": 1 }, + "brandSystem": { "type": "string", "minLength": 1 }, + "renderer": { "type": "string", "minLength": 1 }, + "formats": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/definitions/format" } + }, + "assets": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/generatedAsset" } + }, + "managedAssets": { + "type": "array", + "items": { "$ref": "#/definitions/managedAsset" } + } + }, + "definitions": { + "format": { + "type": "object", + "additionalProperties": false, + "required": ["width", "height", "safeInset"], + "properties": { + "width": { "type": "integer", "minimum": 256 }, + "height": { "type": "integer", "minimum": 256 }, + "safeInset": { "type": "integer", "minimum": 16 } + } + }, + "generatedAsset": { + "type": "object", + "additionalProperties": false, + "required": ["id", "template", "format", "channels", "outputs"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "template": { + "enum": [ + "family", + "editor-proof", + "forge-workflow", + "direction-atlas", + "platform-coverage", + "moss-surfaces", + "direction-card", + "channel-proof" + ] + }, + "format": { "type": "string", "minLength": 1 }, + "composition": { + "enum": [ + "semantic-rift-wide", + "editorial-square", + "stacked-directions", + "campaign-story" + ] + }, + "schemeId": { "enum": ["ember", "moss"] }, + "channelId": { "enum": ["vscode", "openvsx", "zed", "terminal", "obsidian"] }, + "channels": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "outputs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[^/].*\\.png$" } + } + } + }, + "managedAsset": { + "type": "object", + "additionalProperties": false, + "required": ["id", "generator", "channels", "outputs"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "generator": { "type": "string", "minLength": 1 }, + "format": { "type": "string", "minLength": 1 }, + "channels": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "outputs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[^/].*\\.png$" } + } + } + } + } +} diff --git a/schemas/preview.schema.json b/schemas/preview.schema.json index e7fa06b..a3a744a 100644 --- a/schemas/preview.schema.json +++ b/schemas/preview.schema.json @@ -27,6 +27,113 @@ "dark": { "type": "string" }, "light": { "type": "string" } } + }, + "marketing": { + "type": "object", + "additionalProperties": false, + "required": [ + "familyHeadline", + "familySubheadline", + "directionHeadline", + "directionSubheadline", + "platformHeadline", + "platformSubheadline", + "mossSurfaceHeadline", + "mossSurfaceSubheadline", + "editorsHeadline", + "editorsSubheadline", + "obsidianHeadline", + "obsidianSubheadline", + "terminalHeadline", + "terminalSubheadline", + "forgeHeadline", + "forgeSubheadline" + ], + "properties": { + "familyHeadline": { "type": "string" }, + "familySubheadline": { "type": "string" }, + "directionHeadline": { "type": "string" }, + "directionSubheadline": { "type": "string" }, + "platformHeadline": { "type": "string" }, + "platformSubheadline": { "type": "string" }, + "mossSurfaceHeadline": { "type": "string" }, + "mossSurfaceSubheadline": { "type": "string" }, + "editorsHeadline": { "type": "string" }, + "editorsSubheadline": { "type": "string" }, + "obsidianHeadline": { "type": "string" }, + "obsidianSubheadline": { "type": "string" }, + "terminalHeadline": { "type": "string" }, + "terminalSubheadline": { "type": "string" }, + "forgeHeadline": { "type": "string" }, + "forgeSubheadline": { "type": "string" }, + "directions": { + "type": "object", + "additionalProperties": false, + "required": ["ember", "moss"], + "properties": { + "ember": { "$ref": "#/definitions/directionCopy" }, + "moss": { "$ref": "#/definitions/directionCopy" } + } + } + } + }, + "samples": { + "type": "object", + "additionalProperties": false, + "required": ["family", "editors", "obsidian", "terminal", "forge"], + "properties": { + "family": { "$ref": "#/definitions/sample" }, + "editors": { "$ref": "#/definitions/sample" }, + "obsidian": { "$ref": "#/definitions/sample" }, + "terminal": { "$ref": "#/definitions/sample" }, + "forge": { "$ref": "#/definitions/sample" } + } + } + }, + "definitions": { + "directionCopy": { + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "chips", + "comment", + "sampleFunction", + "sampleVariable", + "sampleString", + "sampleValue", + "directionLabel", + "focusLabel" + ], + "properties": { + "summary": { "type": "string", "minLength": 1 }, + "chips": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { "type": "string", "minLength": 1 } + }, + "comment": { "type": "string", "minLength": 1 }, + "sampleFunction": { "type": "string", "minLength": 1 }, + "sampleVariable": { "type": "string", "minLength": 1 }, + "sampleString": { "type": "string", "minLength": 1 }, + "sampleValue": { "type": "string", "minLength": 1 }, + "directionLabel": { "type": "string", "minLength": 1 }, + "focusLabel": { "type": "string", "minLength": 1 } + } + }, + "sample": { + "type": "object", + "additionalProperties": false, + "required": ["language", "lines"], + "properties": { + "language": { "type": "string", "minLength": 1 }, + "lines": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } } } } diff --git a/schemas/product.schema.json b/schemas/product.schema.json index d105ddf..6ac6a5e 100644 --- a/schemas/product.schema.json +++ b/schemas/product.schema.json @@ -106,7 +106,35 @@ "vscode": { "type": "boolean" }, "openvsx": { "type": "boolean" }, "obsidian": { "type": "boolean" }, - "terminal": { "type": "boolean" } + "terminal": { "type": "boolean" }, + "zed": { "type": "boolean" } + } + }, + "channelAvailability": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["schemeIds", "variantIds", "capabilityIds"], + "properties": { + "schemeIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } + }, + "variantIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "enum": ["dark", "light"] } + }, + "capabilityIds": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } + } + } } } } diff --git a/schemas/release.schema.json b/schemas/release.schema.json index 4bdb4ff..66f5b4c 100644 --- a/schemas/release.schema.json +++ b/schemas/release.schema.json @@ -83,6 +83,18 @@ "items": { "type": "string", "enum": ["dark", "light"] } } } + }, + "zedExtension": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "authors", "description", "repository"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*-theme$" }, + "name": { "type": "string" }, + "authors": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "description": { "type": "string" }, + "repository": { "type": "string", "pattern": "^https://" } + } } } } diff --git a/scripts/audit-source-schema.mjs b/scripts/audit-source-schema.mjs index 11b89b6..84ce370 100644 --- a/scripts/audit-source-schema.mjs +++ b/scripts/audit-source-schema.mjs @@ -23,6 +23,7 @@ const schemas = { product: loadSchema('product.schema.json'), release: loadSchema('release.schema.json'), preview: loadSchema('preview.schema.json'), + marketingAssets: loadSchema('marketing-assets.schema.json'), } const schemaFailures = [] @@ -69,6 +70,8 @@ for (const dir of productDirs) { const product = checkFile(path.join(base, 'product.json'), schemas.product) checkFile(path.join(base, 'release.json'), schemas.release) checkFile(path.join(base, 'preview.json'), schemas.preview) + const marketingAssetsPath = path.join(base, 'marketing-assets.json') + if (fs.existsSync(marketingAssetsPath)) checkFile(marketingAssetsPath, schemas.marketingAssets) // Cross-field invariants a flat schema can't express. if (product) { diff --git a/scripts/check-built-site.mjs b/scripts/check-built-site.mjs index b8355c5..7d493b9 100644 --- a/scripts/check-built-site.mjs +++ b/scripts/check-built-site.mjs @@ -44,7 +44,11 @@ for (const expected of [ 'data-variant-id="dark"', 'data-variant-id="light"', 'data-install="openvsx"', + 'data-install="zed"', 'data-install="obsidian"', + 'data-install="terminal"', + 'href="https://zed.dev/extensions/hearthcode-theme"', + 'href="https://github.com/hearth-code/HearthTheme/tree/main/terminal"', 'data-install-placement="hero"', 'data-hero-forge-link', 'data-forge-base="/forge"', @@ -53,6 +57,27 @@ for (const expected of [ if (!home.includes(expected)) fail(`Home page is missing ${expected}`) } +const installAnchors = home.match(/]*>/g) || [] +const equalChannelIds = ['vscode', 'openvsx', 'zed', 'obsidian', 'terminal'] +for (const placement of ['hero', 'final']) { + const cards = installAnchors.filter((anchor) => ( + anchor.includes('install-channel-card') + && anchor.includes(`data-install-placement="${placement}"`) + )) + const ids = cards.map((anchor) => anchor.match(/data-install="([^"]+)"/)?.[1]).sort() + const expectedIds = [...equalChannelIds].sort() + if (JSON.stringify(ids) !== JSON.stringify(expectedIds)) { + fail(`${placement} install grid must contain one equal card for each editor channel`) + } + if (new Set(cards.map((anchor) => anchor.match(/class="([^"]+)"/)?.[1])).size !== 1) { + fail(`${placement} install grid must use the same card class for every editor channel`) + } +} + +for (const legacyPriorityClass of ['hero-action--primary', 'final-install-primary']) { + if (home.includes(legacyPriorityClass)) fail(`Home page still contains priority styling: ${legacyPriorityClass}`) +} + const forge = read('dist/forge/index.html') for (const expected of [ 'data-quality-label="CONTRACT OK"', diff --git a/scripts/check-preview-clean.mjs b/scripts/check-preview-clean.mjs index 1beb6e3..1bdef17 100644 --- a/scripts/check-preview-clean.mjs +++ b/scripts/check-preview-clean.mjs @@ -43,7 +43,7 @@ function main() { for (const file of dirty) { process.stderr.write(` - ${file}\n`) } - process.stderr.write('\nRun: pnpm run preview:generate && git add reports/preview-manifest.json extension/images public/previews\n') + process.stderr.write('\nRun: pnpm run preview:generate && git add reports/preview-manifest.json extension/images public/previews docs/marketing public/og-hearth.png zed/images terminal/hearthcode-terminal.png\n') process.exit(1) } diff --git a/scripts/check-sync-clean.mjs b/scripts/check-sync-clean.mjs index 399909a..656dcee 100644 --- a/scripts/check-sync-clean.mjs +++ b/scripts/check-sync-clean.mjs @@ -15,6 +15,7 @@ const SYNCED_PATHS = [ 'terminal/kitty', 'terminal/alacritty', 'terminal/iterm2', + 'zed/extension', 'docs/color-language-report.md', 'docs/color-language-contract-checklist.md', 'docs/color-language-contract-review.md', diff --git a/scripts/color-system.mjs b/scripts/color-system.mjs index 0197aaa..cd8c493 100644 --- a/scripts/color-system.mjs +++ b/scripts/color-system.mjs @@ -813,6 +813,28 @@ export function loadColorProductManifest(productId = COLOR_SYSTEM_PRODUCT_ID) { Object.entries(data.channels).map(([channelId, enabled]) => [String(channelId || '').trim(), Boolean(enabled)]) ) : {} + const channelAvailability = data.channelAvailability && typeof data.channelAvailability === 'object' && !Array.isArray(data.channelAvailability) + ? Object.fromEntries( + Object.entries(data.channelAvailability).map(([channelIdRaw, entry]) => { + const channelId = String(channelIdRaw || '').trim() + assert(channelId, `${productPath}: channelAvailability has an invalid channel id`) + assert(entry && typeof entry === 'object' && !Array.isArray(entry), `${productPath}: channelAvailability.${channelId} must be an object`) + const schemeIds = toStringList(entry.schemeIds, `${productPath}: channelAvailability.${channelId}.schemeIds`) + const channelVariantIds = toStringList(entry.variantIds, `${productPath}: channelAvailability.${channelId}.variantIds`) + const capabilityIds = toStringList(entry.capabilityIds, `${productPath}: channelAvailability.${channelId}.capabilityIds`) + assert(channels[channelId] === true, `${productPath}: channelAvailability.${channelId} must reference an enabled channel`) + assert(schemeIds.length > 0, `${productPath}: channelAvailability.${channelId}.schemeIds must not be empty`) + assert(channelVariantIds.length > 0, `${productPath}: channelAvailability.${channelId}.variantIds must not be empty`) + for (const schemeId of schemeIds) { + assert(supportedSchemeIds.includes(schemeId), `${productPath}: channelAvailability.${channelId} references unsupported scheme "${schemeId}"`) + } + for (const variantId of channelVariantIds) { + assert(variantIds.has(variantId), `${productPath}: channelAvailability.${channelId} references unregistered variant "${variantId}"`) + } + return [channelId, { schemeIds, variantIds: channelVariantIds, capabilityIds }] + }), + ) + : {} assert(id === expectedProductId, `${productPath}: id must match product "${expectedProductId}"`) assert(name, `${productPath}: name is required`) @@ -871,6 +893,7 @@ export function loadColorProductManifest(productId = COLOR_SYSTEM_PRODUCT_ID) { featuredThemes, brandFlavorIds, channels, + channelAvailability, } } @@ -878,6 +901,14 @@ export function loadColorProductPreviewConfig(productId = COLOR_SYSTEM_PRODUCT_I const { productPreviewPath } = getProductPaths(productId) const data = readJson(productPreviewPath) assert(data && typeof data === 'object' && !Array.isArray(data), `${productPreviewPath} must be an object`) + const toStringList = (value, label) => { + assert(Array.isArray(value), `${label} must be an array`) + return value.map((item, index) => { + const normalized = String(item || '').trimEnd() + assert(normalized.trim(), `${label}[${index}] must be a non-empty string`) + return normalized + }) + } const badgeLabel = String(data.badgeLabel || '').trim() const headline = String(data.headline || '').trim() @@ -892,6 +923,49 @@ export function loadColorProductPreviewConfig(productId = COLOR_SYSTEM_PRODUCT_I Object.entries(data.variantNames).map(([variantId, label]) => [String(variantId || '').trim(), String(label || '').trim()]) ) : {} + const marketing = {} + if (data.marketing && typeof data.marketing === 'object' && !Array.isArray(data.marketing)) { + for (const [keyRaw, value] of Object.entries(data.marketing)) { + const key = String(keyRaw || '').trim() + assert(key, `${productPreviewPath}: marketing has an invalid key`) + if (key !== 'directions') { + marketing[key] = String(value || '').trim() + continue + } + + assert(value && typeof value === 'object' && !Array.isArray(value), `${productPreviewPath}: marketing.directions must be an object`) + marketing.directions = Object.fromEntries(Object.entries(value).map(([schemeIdRaw, direction]) => { + const schemeId = String(schemeIdRaw || '').trim() + assert(schemeId, `${productPreviewPath}: marketing.directions has an invalid scheme id`) + assert(direction && typeof direction === 'object' && !Array.isArray(direction), `${productPreviewPath}: marketing.directions.${schemeId} must be an object`) + return [schemeId, { + summary: String(direction.summary || '').trim(), + chips: toStringList(direction.chips, `${productPreviewPath}: marketing.directions.${schemeId}.chips`), + comment: String(direction.comment || '').trimEnd(), + sampleFunction: String(direction.sampleFunction || '').trim(), + sampleVariable: String(direction.sampleVariable || '').trim(), + sampleString: String(direction.sampleString || '').trim(), + sampleValue: String(direction.sampleValue || '').trim(), + directionLabel: String(direction.directionLabel || '').trim(), + focusLabel: String(direction.focusLabel || '').trim(), + }] + })) + } + } + const samples = data.samples && typeof data.samples === 'object' && !Array.isArray(data.samples) + ? Object.fromEntries( + Object.entries(data.samples).map(([sampleIdRaw, sample]) => { + const sampleId = String(sampleIdRaw || '').trim() + assert(sampleId, `${productPreviewPath}: samples has an invalid id`) + assert(sample && typeof sample === 'object' && !Array.isArray(sample), `${productPreviewPath}: samples.${sampleId} must be an object`) + const language = String(sample.language || '').trim() + const lines = toStringList(sample.lines, `${productPreviewPath}: samples.${sampleId}.lines`) + assert(language, `${productPreviewPath}: samples.${sampleId}.language is required`) + assert(lines.length > 0, `${productPreviewPath}: samples.${sampleId}.lines must not be empty`) + return [sampleId, { language, lines }] + }), + ) + : {} assert(headline, `${productPreviewPath}: headline is required`) assert(subheadline, `${productPreviewPath}: subheadline is required`) @@ -900,7 +974,6 @@ export function loadColorProductPreviewConfig(productId = COLOR_SYSTEM_PRODUCT_I for (const variant of loadColorSystemVariants().variants) { assert(variantNames[variant.id], `${productPreviewPath}: variantNames.${variant.id} is required`) } - return { schemaVersion: Number(data.schemaVersion || 1), badgeLabel, @@ -908,6 +981,8 @@ export function loadColorProductPreviewConfig(productId = COLOR_SYSTEM_PRODUCT_I subheadline, familyLabels, variantNames, + marketing, + samples, } } @@ -918,6 +993,7 @@ export function loadColorProductReleaseConfig(productId = COLOR_SYSTEM_PRODUCT_I assert(data.site && typeof data.site === 'object' && !Array.isArray(data.site), `${productReleasePath}: site must be an object`) assert(data.vscodeExtension && typeof data.vscodeExtension === 'object' && !Array.isArray(data.vscodeExtension), `${productReleasePath}: vscodeExtension must be an object`) assert(data.obsidianAppTheme && typeof data.obsidianAppTheme === 'object' && !Array.isArray(data.obsidianAppTheme), `${productReleasePath}: obsidianAppTheme must be an object`) + assert(data.zedExtension == null || (typeof data.zedExtension === 'object' && !Array.isArray(data.zedExtension)), `${productReleasePath}: zedExtension must be an object when set`) const toStringList = (value, label) => { assert(Array.isArray(value), `${label} must be an array`) @@ -966,6 +1042,16 @@ export function loadColorProductReleaseConfig(productId = COLOR_SYSTEM_PRODUCT_I : [], } + const zedExtension = data.zedExtension + ? { + id: String(data.zedExtension.id || '').trim(), + name: String(data.zedExtension.name || '').trim(), + authors: toStringList(data.zedExtension.authors, `${productReleasePath}: zedExtension.authors`), + description: String(data.zedExtension.description || '').trim(), + repository: String(data.zedExtension.repository || '').trim(), + } + : null + assert(site.titleDescriptor, `${productReleasePath}: site.titleDescriptor is required`) assert(site.ogImagePath, `${productReleasePath}: site.ogImagePath is required`) assert(vscodeExtension.name, `${productReleasePath}: vscodeExtension.name is required`) @@ -987,12 +1073,20 @@ export function loadColorProductReleaseConfig(productId = COLOR_SYSTEM_PRODUCT_I assert(obsidianAppTheme.screenshotPath, `${productReleasePath}: obsidianAppTheme.screenshotPath is required`) assert(obsidianAppTheme.packageBasename, `${productReleasePath}: obsidianAppTheme.packageBasename is required`) assert(obsidianAppTheme.modes.length > 0, `${productReleasePath}: obsidianAppTheme.modes must include at least one mode`) + if (zedExtension) { + assert(/^[a-z][a-z0-9-]*-theme$/.test(zedExtension.id), `${productReleasePath}: zedExtension.id must end in "-theme"`) + assert(zedExtension.name, `${productReleasePath}: zedExtension.name is required`) + assert(!/\b(?:zed|extension)\b/i.test(zedExtension.name), `${productReleasePath}: zedExtension.name cannot contain "Zed" or "extension"`) + assert(zedExtension.description, `${productReleasePath}: zedExtension.description is required`) + assert(/^https:\/\//.test(zedExtension.repository), `${productReleasePath}: zedExtension.repository must use HTTPS`) + } return { schemaVersion: Number(data.schemaVersion || 1), site, vscodeExtension, obsidianAppTheme, + zedExtension, } } diff --git a/scripts/content-sync-audit.mjs b/scripts/content-sync-audit.mjs index 5df95cd..2119bce 100644 --- a/scripts/content-sync-audit.mjs +++ b/scripts/content-sync-audit.mjs @@ -568,15 +568,26 @@ function validateReadmePreviewAssets() { const editorHeroOutput = normalizeRepoPath(manifest.editorHero?.outputs?.[0]) const contrastOutput = normalizeRepoPath(manifest.contrastImage?.outputs?.[0]) const forgeWorkflowOutput = normalizeRepoPath(manifest.forgeWorkflow?.outputs?.[0]) + const directionAtlasOutput = normalizeRepoPath(manifest.directionAtlas?.outputs?.[0]) + const platformCoverageOutput = normalizeRepoPath(manifest.platformCoverage?.outputs?.[0]) - if (!editorHeroOutput || !contrastOutput || !forgeWorkflowOutput) { - addIssue(`${PREVIEW_MANIFEST}: missing editor hero, contrast, or Forge workflow output`) + if (!editorHeroOutput || !contrastOutput || !forgeWorkflowOutput || !directionAtlasOutput || !platformCoverageOutput) { + addIssue(`${PREVIEW_MANIFEST}: missing editor hero, family overview, direction atlas, platform coverage, or Forge workflow output`) return } - const expectedRootPreviewPaths = [`./${contrastOutput}`] + const expectedRootPreviewPaths = [ + `./${contrastOutput}`, + `./${directionAtlasOutput}`, + `./${platformCoverageOutput}`, + `./${forgeWorkflowOutput}`, + ] const extensionPath = (path) => path.startsWith('extension/') ? path.slice('extension/'.length) : path const expectedExtensionPreviewPaths = [editorHeroOutput, contrastOutput, forgeWorkflowOutput].map(extensionPath) + const generatedPreviewPaths = new Set([ + ...expectedRootPreviewPaths, + ...expectedExtensionPreviewPaths, + ]) const expectedReadmes = [ { @@ -601,7 +612,7 @@ function validateReadmePreviewAssets() { const readme = readText(spec.file) if (!readme) continue - const previewPaths = collectMarkdownImagePaths(readme).filter((path) => /preview-.*\.png$/i.test(path)) + const previewPaths = collectMarkdownImagePaths(readme).filter((path) => generatedPreviewPaths.has(path)) if (previewPaths.length !== spec.previewPaths.length) { addIssue( `${spec.file}: expected ${spec.previewPaths.length} generated preview images, got ${previewPaths.length}` diff --git a/scripts/generate-obsidian-app-theme.mjs b/scripts/generate-obsidian-app-theme.mjs index 6d59534..bd0e6fe 100644 --- a/scripts/generate-obsidian-app-theme.mjs +++ b/scripts/generate-obsidian-app-theme.mjs @@ -6,6 +6,7 @@ import { getReleaseVersion } from './release-metadata.mjs' import { loadColorProductReleaseConfig } from './color-system.mjs' import { RENDERER_VERSION, + buildObsidianHeroSvg, buildObsidianScreenshotSvg, renderObsidianScreenshotBuffer, renderObsidianHeroBuffer, @@ -206,13 +207,14 @@ async function generateScreenshot() { return changed } -// Reproducible README marketing hero: the SAME theme-driven diagonal SVG as the -// community screenshot, rasterized larger. Guarded by the same renderer+SVG hash -// so check:sync stays clean across machines (the PNG raster is not byte-stable). +// Reproducible README product proof: the same theme-driven functional Markdown +// surface as the community screenshot, enlarged into one Dark/Light mode-cut +// application frame. Guarded by a renderer+SVG hash so check:sync stays clean +// across machines (the PNG raster is not byte-stable). async function generateHero() { let svg = null try { - svg = buildObsidianScreenshotSvg(readFileSync(THEME_CSS_PATH, 'utf8')) + svg = buildObsidianHeroSvg(readFileSync(THEME_CSS_PATH, 'utf8')) } catch { svg = null } diff --git a/scripts/generate-preview-images.mjs b/scripts/generate-preview-images.mjs index f2ae7c4..049369e 100644 --- a/scripts/generate-preview-images.mjs +++ b/scripts/generate-preview-images.mjs @@ -3,28 +3,52 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { dirname, join } from "node:path"; import sharp from "sharp"; import { getThemeMetaListForSchemeId, loadColorProductManifest, loadColorProductPreviewConfig, loadColorSchemeManifestById, loadRoleAdapters } from "./color-system.mjs"; - -const WIDTH = 1600; -const HEIGHT = 900; +import { BRAND_SYSTEM, escapeXml, mixHex, normalizeHex, withAlpha } from "./marketing/brand-system.mjs"; +import { indexMarketingAssets, loadMarketingAssetSpec } from "./marketing/asset-spec.mjs"; +import { assertSemanticRiftLayout, buildSemanticRiftLayout, buildTornPaperGeometry, renderDistressedText, renderFieldGuideFooter, renderFieldGuideGrid, renderFieldGuideHeader, renderMaterialTexture, renderRegistrationMarks, renderTornPaperSeam } from "./marketing/template-components.mjs"; + +const MARKETING_SPEC = loadMarketingAssetSpec(); +const MARKETING_ASSETS = indexMarketingAssets(MARKETING_SPEC); +const WIDTH = MARKETING_SPEC.formats["readme-wide"].width; +const HEIGHT = MARKETING_SPEC.formats["readme-wide"].height; const OUTPUT_DIR = join("extension", "images"); const WEBSITE_OUTPUT_DIR = join("public", "previews"); +const MARKETING_OUTPUT_DIR = join("docs", "marketing"); const MANIFEST_PATH = join("reports", "preview-manifest.json"); -const PREVIEW_RENDERER = "marketplace-source-color-v10"; +const PREVIEW_RENDERER = MARKETING_SPEC.renderer; const GENERATOR_SOURCE_SHA256 = createHash("sha256").update(readFileSync(new URL(import.meta.url))).digest("hex"); +const BRAND_SYSTEM_SOURCE_SHA256 = createHash("sha256").update(readFileSync(new URL("./marketing/brand-system.mjs", import.meta.url))).digest("hex"); +const TEMPLATE_COMPONENTS_SOURCE_SHA256 = createHash("sha256").update(readFileSync(new URL("./marketing/template-components.mjs", import.meta.url))).digest("hex"); +const ASSET_SPEC_SOURCE_SHA256 = createHash("sha256").update(readFileSync(new URL("../products/hearthcode/marketing-assets.json", import.meta.url))).digest("hex"); const PRODUCT = loadColorProductManifest(); const PREVIEW = loadColorProductPreviewConfig(); -const CONTRAST_OUTPUTS = [ +function assetOutputs(id) { + const asset = MARKETING_ASSETS[id]; + if (!asset) throw new Error(`Missing marketing asset spec: ${id}`); + return asset.outputs; +} + +const CONTRAST_OUTPUTS = assetOutputs("family-readme"); +const EDITOR_HERO_OUTPUTS = assetOutputs("editor-marketplace"); +const FORGE_WORKFLOW_OUTPUTS = assetOutputs("forge-marketplace"); +const DIRECTION_ATLAS_OUTPUTS = assetOutputs("direction-atlas"); +const PLATFORM_COVERAGE_OUTPUTS = assetOutputs("platform-coverage"); +const MOSS_SURFACES_OUTPUTS = assetOutputs("moss-surfaces"); +const GITHUB_SOCIAL_OUTPUTS = assetOutputs("github-social"); +const OG_OUTPUTS = assetOutputs("site-og"); +const FAMILY_SQUARE_OUTPUTS = assetOutputs("family-square"); +const FAMILY_PORTRAIT_OUTPUTS = assetOutputs("family-portrait"); +const FAMILY_STORY_OUTPUTS = assetOutputs("family-story"); +const EMBER_SQUARE_OUTPUTS = assetOutputs("ember-square"); +const MOSS_SQUARE_OUTPUTS = assetOutputs("moss-square"); +const ZED_PLATFORM_OUTPUTS = assetOutputs("zed-platform"); +const TERMINAL_PLATFORM_OUTPUTS = assetOutputs("terminal-platform"); +const LEGACY_PREVIEW_OUTPUTS = [ join(OUTPUT_DIR, "preview-contrast-v2.png"), join(WEBSITE_OUTPUT_DIR, "preview-contrast-v2.png"), -]; -const EDITOR_HERO_OUTPUTS = [ join(OUTPUT_DIR, "preview-editor-hero.png"), -]; -const FORGE_WORKFLOW_OUTPUTS = [ join(OUTPUT_DIR, "preview-forge-workflow.png"), -]; -const LEGACY_PREVIEW_OUTPUTS = [ join(WEBSITE_OUTPUT_DIR, "preview-dark.png"), join(WEBSITE_OUTPUT_DIR, "preview-dark-soft.png"), join(WEBSITE_OUTPUT_DIR, "preview-light.png"), @@ -47,30 +71,7 @@ const ROLE_SCOPES = Object.fromEntries(loadRoleAdapters().map((role) => [role.id const FLAVOR_IDS = PRODUCT.brandFlavorIds?.length ? PRODUCT.brandFlavorIds : PRODUCT.supportedSchemeIds; const FLAVORS_BY_ID = Object.fromEntries(FLAVOR_IDS.map((schemeId) => [schemeId, loadColorSchemeManifestById(schemeId)])); const VARIANTS_BY_SCHEME_ID = Object.fromEntries(FLAVOR_IDS.map((schemeId) => [schemeId, getThemeMetaListForSchemeId(schemeId)])); -const FLAVOR_PREVIEW_COPY = { - ember: { - summary: "Warm charcoal and paper, with ember control flow and cool callable anchors.", - chips: ["warm neutrals", "ember control", "denim callables"], - comment: "// warm-neutral structure with cool anchors", - sampleFunction: "renderTheme", - sampleVariable: "theme", - sampleString: '"ember"', - sampleValue: '"hearth"', - directionLabel: "WARM-NEUTRAL DIRECTION", - focusLabel: "COOL CALLABLES", - }, - moss: { - summary: "Dry charcoal and paper, with clearer lane split and greener callable structure.", - chips: ["dry paper", "editorial lane split", "lichen callables"], - comment: "// dry editorial lanes with calm callables", - sampleFunction: "routeSignal", - sampleVariable: "palette", - sampleString: '"moss"', - sampleValue: '"field"', - directionLabel: "DRY EDITORIAL DIRECTION", - focusLabel: "GREEN CALLABLES", - }, -}; +const FLAVOR_PREVIEW_COPY = PREVIEW.marketing?.directions || {}; function buildFallbackThemeMeta() { return FLAVOR_IDS.map((schemeId) => { @@ -136,63 +137,6 @@ function writeJsonIfChanged(path, data) { return true; } -function escapeXml(input) { - return String(input) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function normalizeHex(hex) { - if (typeof hex !== "string") return null; - const value = hex.trim().toLowerCase(); - if (/^#[0-9a-f]{6}$/i.test(value)) return value; - if (/^#[0-9a-f]{8}$/i.test(value)) return value.slice(0, 7); - return null; -} - -function hexToRgb(hex) { - const normalized = normalizeHex(hex); - if (!normalized) return null; - const value = normalized.slice(1); - return [ - Number.parseInt(value.slice(0, 2), 16), - Number.parseInt(value.slice(2, 4), 16), - Number.parseInt(value.slice(4, 6), 16), - ]; -} - -function rgbToHex(rgb) { - return `#${rgb - .map((channel) => - Math.max(0, Math.min(255, Math.round(channel))) - .toString(16) - .padStart(2, "0")) - .join("")}`; -} - -function mixHex(a, b, weight = 0.5) { - const rgbA = hexToRgb(a); - const rgbB = hexToRgb(b); - if (!rgbA && !rgbB) return "#000000"; - if (!rgbA) return normalizeHex(b) ?? "#000000"; - if (!rgbB) return normalizeHex(a) ?? "#000000"; - const t = Math.max(0, Math.min(1, weight)); - return rgbToHex([ - rgbA[0] + (rgbB[0] - rgbA[0]) * t, - rgbA[1] + (rgbB[1] - rgbA[1]) * t, - rgbA[2] + (rgbB[2] - rgbA[2]) * t, - ]); -} - -function withAlpha(hex, alpha) { - const rgb = hexToRgb(hex); - if (!rgb) return `rgba(0, 0, 0, ${alpha})`; - return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`; -} - function themeColor(theme, key, fallback) { return normalizeHex(theme.colors?.[key]) ?? fallback; } @@ -339,6 +283,43 @@ function roleColor(theme, role) { return resolvePreviewStyle(theme, role).color; } +function requiredThemeColor(meta, key) { + const color = normalizeHex(meta.theme?.colors?.[key]); + if (!color) { + throw new Error(`Preview color contract: ${meta.id} is missing theme color "${key}"`); + } + return color; +} + +function requiredRoleColor(meta, role) { + const color = normalizeHex(roleColor(meta.theme, role)); + if (!color) { + throw new Error(`Preview color contract: ${meta.id} is missing syntax role "${role}"`); + } + return color; +} + +function buildMarketingColorContract(themes) { + return { + policy: "theme-source-only-v1", + structuralColorRule: "Signature fields and swatches use unmodified shipped theme tokens; structural overlays use only alpha or mixes of those tokens.", + themes: Object.fromEntries(themes.map((meta) => [meta.id, { + source: toPosixPath(meta.file), + sourceSha256: meta.sourceSha256, + colors: { + surface: requiredThemeColor(meta, "editor.background"), + foreground: requiredThemeColor(meta, "editor.foreground"), + keyword: requiredRoleColor(meta, "keyword"), + function: requiredRoleColor(meta, "function"), + type: requiredRoleColor(meta, "type"), + string: requiredRoleColor(meta, "string"), + property: requiredRoleColor(meta, "property"), + operator: requiredRoleColor(meta, "operator"), + }, + }])), + }; +} + function fontWeightForStyle(style, fallback = 600) { return style?.fontStyle?.includes("bold") ? 700 : fallback; } @@ -443,7 +424,8 @@ function getFlagshipThemes(themes) { } function orderThemesForPreview(themes) { - const flavorOrder = new Map(FLAVOR_IDS.map((schemeId, index) => [schemeId, index])); + const previewFlavorIds = [...new Set(["ember", "moss", ...FLAVOR_IDS])]; + const flavorOrder = new Map(previewFlavorIds.map((schemeId, index) => [schemeId, index])); const variantOrder = new Map([ ["dark", 0], ["light", 1], @@ -680,142 +662,475 @@ function renderPaletteSwatchStrip({ x, y, colors }) { `).join(""); } -function renderThemeCodeCard({ meta, x, y, width, height }) { - const theme = meta.theme; - const copy = getFlavorPreviewCopy(meta.schemeId); - const bg = themeColor(theme, "editor.background", "#211d1a"); - const fg = themeColor(theme, "editor.foreground", "#d3c9b8"); - const panel = themeColor( - theme, - "editorGroupHeader.tabsBackground", - mixHex(bg, meta.isDark ? "#000000" : "#ffffff", meta.isDark ? 0.07 : 0.03), - ); - const cardFill = bg; - const border = mixHex(themeColor(theme, "tab.border", "#35302b"), fg, meta.isDark ? 0.14 : 0.24); - const muted = mixHex(fg, bg, 0.48); - const labelMuted = mixHex(fg, bg, 0.6); - const surface = themeColor(theme, "editor.background", bg); - const surfaceStroke = withAlpha(fg, meta.isDark ? 0.1 : 0.16); - const accentKeyword = roleColor(theme, "keyword"); - const accentCallable = roleColor(theme, "function"); - const accentType = roleColor(theme, "type"); - const accentString = roleColor(theme, "string"); - const accent = meta.schemeId === "moss" ? accentCallable : accentKeyword; - const previewX = x + 24; - const previewY = y + 132; - const previewWidth = width - 48; - const previewHeight = 104; - const codeX = previewX + 52; - const lineNumberX = previewX + 16; - const codeY = previewY + 10; - const lineHeight = 22; - const fontSize = 13.5; - const rows = [ - [{ role: "keyword", text: "type " }, { role: "type", text: meta.flavor.name }, { role: "plain", text: " = {" }], - [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "plain", text: ": " }, { role: "string", text: `"${meta.variantId}"` }, { role: "plain", text: ";" }], - [{ role: "plain", text: " " }, { role: "property", text: "accent" }, { role: "plain", text: ": " }, { role: "function", text: "createTheme" }, { role: "plain", text: "();" }], - [{ role: "plain", text: "};" }], +function buildFamilySampleLines(meta) { + const lines = [ + [{ role: "keyword", text: "const " }, { role: "variable.readonly", text: "theme" }, { role: "operator", text: " = " }, { role: "punctuation", text: "{" }], + [{ role: "plain", text: " " }, { role: "property", text: "direction" }, { role: "operator", text: ": " }, { role: "string", text: `"${meta.schemeId}"` }, { role: "punctuation", text: "," }], + [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "operator", text: ": " }, { role: "string", text: `"${meta.variantId}"` }, { role: "punctuation", text: "," }], + [{ role: "punctuation", text: "} " }, { role: "keyword", text: "as const" }, { role: "punctuation", text: ";" }], ]; + const expected = (PREVIEW.samples?.family?.lines || []).map((line) => line + .replaceAll("{direction}", meta.schemeId) + .replaceAll("{mode}", meta.variantId)); + const actual = lines.map((segments) => segments.map((segment) => segment.text).join("")); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Preview sample contract: generated family sample for ${meta.id} does not match products/hearthcode/preview.json`); + } + return lines; +} - const renderedRows = rows.map((segments, index) => { - const rowY = codeY + index * lineHeight; - return ` - ${index + 1} - ${renderCodeLine({ theme, segments, x: codeX, y: rowY, fontSize })} - `; - }).join(""); +function buildEditorSampleLines(meta) { + const lines = [ + [{ role: "keyword", text: "type " }, { role: "type", text: "ThemePreview" }, { role: "operator", text: " = " }, { role: "punctuation", text: "{" }], + [{ role: "plain", text: " " }, { role: "property", text: "direction" }, { role: "operator", text: ": " }, { role: "string", text: '"ember"' }, { role: "operator", text: " | " }, { role: "string", text: '"moss"' }, { role: "punctuation", text: ";" }], + [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "operator", text: ": " }, { role: "string", text: '"dark"' }, { role: "operator", text: " | " }, { role: "string", text: '"light"' }, { role: "punctuation", text: ";" }], + [{ role: "punctuation", text: "};" }], + [{ role: "keyword", text: "const " }, { role: "variable.readonly", text: "theme" }, { role: "operator", text: ": " }, { role: "type", text: "ThemePreview" }, { role: "operator", text: " = " }, { role: "punctuation", text: "{" }], + [{ role: "plain", text: " " }, { role: "property", text: "direction" }, { role: "operator", text: ": " }, { role: "string", text: '"moss"' }, { role: "punctuation", text: "," }], + [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "operator", text: ": " }, { role: "string", text: `"${meta.variantId}"` }, { role: "punctuation", text: "," }], + [{ role: "punctuation", text: "};" }], + ]; + const expected = (PREVIEW.samples?.editors?.lines || []).map((line) => line.replaceAll("{mode}", meta.variantId)); + const actual = lines.map((segments) => segments.map((segment) => segment.text).join("")); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Preview sample contract: generated editor sample for ${meta.id} does not match products/hearthcode/preview.json`); + } + return lines; +} +function renderRiftSample({ meta, x, y, layout }) { + const foreground = requiredThemeColor(meta, "editor.foreground"); + const lines = buildFamilySampleLines(meta); + const { fontSize, lineHeight, labelSize, swatchOffset, swatchStep, swatchWidth, swatchHeight } = layout.sample; + const tokenColors = ["keyword", "function", "type", "string", "property", "operator"] + .map((role) => requiredRoleColor(meta, role)); + const label = `${meta.flavor.name} ${meta.climateLabel}`.toUpperCase(); return ` - - - - - - - - palette.ts - - ${escapeXml(`${meta.flavor.name} / ${meta.climateLabel}`.toUpperCase())} - ${escapeXml(meta.shortName)} - - ${escapeXml(meta.isDark ? "DARK" : "LIGHT")} - - - ${renderedRows} - - ${renderPaletteSwatchStrip({ - x: x + 24, - y: y + height - 36, - colors: [accentKeyword, accentCallable, accentType, accentString], - })} - ${escapeXml(copy.focusLabel)} + ${escapeXml(label)} + ${lines.map((segments, index) => renderCodeLine({ + theme: meta.theme, + segments, + x, + y: y + labelSize + 15 * layout.scale + index * lineHeight, + fontSize, + })).join("")} + ${tokenColors.map((color, index) => ``).join("")} `; } -function renderPromoBoardSvg({ themes }) { - const orderedThemes = orderThemesForPreview(themes); - const darkThemes = orderedThemes.filter((theme) => theme.isDark); - const lightThemes = orderedThemes.filter((theme) => !theme.isDark); - const defaultThemeMeta = orderedThemes.find((theme) => theme.isDefaultTheme) || orderedThemes[0]; - const emberTheme = orderedThemes.find((theme) => theme.schemeId === "ember") || defaultThemeMeta; - const mossTheme = orderedThemes.find((theme) => theme.schemeId === "moss") || orderedThemes.find((theme) => theme !== emberTheme) || defaultThemeMeta; - const emberDark = darkThemes.find((theme) => theme.schemeId === "ember") || emberTheme; - const mossDark = darkThemes.find((theme) => theme.schemeId === "moss") || mossTheme; - const emberSurface = themeColor(emberDark.theme, "editor.background", "#1f1a17"); - const mossSurface = themeColor(mossDark.theme, "editor.background", "#1b1d1a"); - const boardFg = themeColor(mossDark.theme, "editor.foreground", "#d2bea2"); - const boardMuted = mixHex(boardFg, mossSurface, 0.46); - const gradientId = "promo-board-bg"; - const warmGlowId = "promo-board-glow-warm"; - const mossGlowId = "promo-board-glow-moss"; - const darkLabel = PREVIEW.familyLabels?.dark || "DARK STARTING POINTS"; - const lightLabel = PREVIEW.familyLabels?.light || "LIGHT STARTING POINTS"; - const cardWidth = 748; - const cardHeight = 286; - const leftX = 40; - const rightX = leftX + cardWidth + 24; - const topRowY = 206; - const bottomRowY = 536; +function renderSemanticRiftSvg({ themes, width = WIDTH, height = HEIGHT }) { + const layout = buildSemanticRiftLayout({ width, height }); + assertSemanticRiftLayout(layout); + const { scale, splitY } = layout; + const emberDark = getPreviewTheme(themes, "ember", "dark"); + const mossDark = getPreviewTheme(themes, "moss", "dark"); + const emberLight = getPreviewTheme(themes, "ember", "light"); + const mossLight = getPreviewTheme(themes, "moss", "light"); + const emberDarkBg = requiredThemeColor(emberDark, "editor.background"); + const mossDarkBg = requiredThemeColor(mossDark, "editor.background"); + const emberLightBg = requiredThemeColor(emberLight, "editor.background"); + const mossLightBg = requiredThemeColor(mossLight, "editor.background"); + const emberDarkFg = requiredThemeColor(emberDark, "editor.foreground"); + const mossDarkFg = requiredThemeColor(mossDark, "editor.foreground"); + const emberLightFg = requiredThemeColor(emberLight, "editor.foreground"); + const mossLightFg = requiredThemeColor(mossLight, "editor.foreground"); + const emberAccent = requiredRoleColor(emberDark, "keyword"); + const mossAccent = requiredRoleColor(mossDark, "function"); + const headline = PREVIEW.marketing?.familyHeadline || "EMBER / MOSS"; + const subheadline = PREVIEW.marketing?.familySubheadline || "FOUR THEMES. ONE COLOR LANGUAGE."; + const [emberWord = "EMBER", mossWord = "MOSS"] = headline.split("/").map((word) => word.trim()); + const subheadlineMatch = subheadline.match(/^(.+?\.)\s+(.+)$/); + const subheadlineLeft = subheadlineMatch?.[1] || subheadline; + const subheadlineRight = subheadlineMatch?.[2] || ""; + const leftX = layout.title.emberX; + const rightX = layout.sample.rightX; + const darkSampleY = layout.sample.darkY; + const lightSampleY = layout.sample.lightY; + const headlineY = layout.title.y; + const headlineSize = layout.title.fontSize; + const mossX = layout.title.mossX; + const tearGeometry = buildTornPaperGeometry({ + controlPoints: layout.tearControlPoints, + seed: 83, + segmentLength: 13 * scale, + jitter: 6 * scale, + paperWidth: 14 * scale, + widthVariation: 0.84, + }); + const pointsString = (points) => points.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" "); + const emberMask = `0,0 ${pointsString(tearGeometry.sideB)} 0,${height}`; + const mossMask = `${pointsString([tearGeometry.sideA[0], { x: width, y: 0 }, { x: width, y: height }, ...[...tearGeometry.sideA].reverse()])}`; return ` - + - - - - - - - - - - - - - - - + + - - - - ${escapeXml((PREVIEW.badgeLabel || PRODUCT.name).toUpperCase())} - ${escapeXml(PREVIEW.headline)} - ${escapeXml(PREVIEW.subheadline)} - ${escapeXml(darkLabel)} - ${darkThemes[0] ? renderThemeCodeCard({ meta: darkThemes[0], x: leftX, y: topRowY, width: cardWidth, height: cardHeight }) : ""} - ${darkThemes[1] ? renderThemeCodeCard({ meta: darkThemes[1], x: rightX, y: topRowY, width: cardWidth, height: cardHeight }) : ""} - ${escapeXml(lightLabel)} - ${lightThemes[0] ? renderThemeCodeCard({ meta: lightThemes[0], x: leftX, y: bottomRowY, width: cardWidth, height: cardHeight }) : ""} - ${lightThemes[1] ? renderThemeCodeCard({ meta: lightThemes[1], x: rightX, y: bottomRowY, width: cardWidth, height: cardHeight }) : ""} + + + + + + + ${renderMaterialTexture({ id: "rift-ember-dark", ink: emberDarkFg, width, height, points: emberMask, seed: 11, intensity: BRAND_SYSTEM.material.posterTexture * 0.55 })} + ${renderMaterialTexture({ id: "rift-moss-dark", ink: mossDarkFg, width, height, points: mossMask, seed: 23, intensity: BRAND_SYSTEM.material.posterTexture * 0.55 })} + + + ${renderMaterialTexture({ id: "rift-ember-light", ink: emberLightFg, width, height, points: emberMask, seed: 37, intensity: BRAND_SYSTEM.material.posterTexture * 0.55 })} + ${renderMaterialTexture({ id: "rift-moss-light", ink: mossLightFg, width, height, points: mossMask, seed: 41, intensity: BRAND_SYSTEM.material.posterTexture * 0.55 })} + + + + ${renderTornPaperSeam({ + id: "family-rift", + geometry: tearGeometry, + paper: emberDarkFg, + warmInk: emberAccent, + coolInk: mossAccent, + shadowInk: emberDarkBg, + seed: 89, + intensity: BRAND_SYSTEM.material.tornRift * 0.86, + })} + + ${escapeXml((PREVIEW.badgeLabel || PRODUCT.name).toUpperCase())} + ${renderDistressedText({ id: "rift-title-ember", text: emberWord, x: leftX, y: headlineY, fill: emberAccent, wear: emberDarkBg, fontSize: headlineSize, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.025em", seed: 13, intensity: BRAND_SYSTEM.material.typeWear * 0.42 })} + ${renderDistressedText({ id: "rift-title-moss", text: mossWord, x: mossX, y: headlineY, fill: mossAccent, wear: mossDarkBg, fontSize: headlineSize, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.025em", seed: 29, intensity: BRAND_SYSTEM.material.typeWear * 0.42 })} + ${escapeXml(subheadlineLeft)} + ${escapeXml(subheadlineRight)} + + ${renderRiftSample({ meta: emberDark, x: layout.sample.leftX, y: darkSampleY, layout })} + ${renderRiftSample({ meta: mossDark, x: rightX, y: darkSampleY, layout })} + ${renderRiftSample({ meta: emberLight, x: layout.sample.leftX, y: lightSampleY, layout })} + ${renderRiftSample({ meta: mossLight, x: rightX, y: lightSampleY, layout })} `; } +function getFamilyThemeSet(themes) { + return { + emberDark: getPreviewTheme(themes, "ember", "dark"), + mossDark: getPreviewTheme(themes, "moss", "dark"), + emberLight: getPreviewTheme(themes, "ember", "light"), + mossLight: getPreviewTheme(themes, "moss", "light"), + }; +} + +function renderFamilyLockup({ + emberDark, + mossDark, + width, + inset, + kickerY, + titleY, + titleSize, + subheadingY, + subheadingSize, + kicker = BRAND_SYSTEM.copy.familyKicker, + kickerSize = Math.max(15, Math.round(width * 0.015)), + subheadline = PREVIEW.marketing?.familySubheadline || "FOUR THEMES. ONE COLOR LANGUAGE.", +}) { + const foreground = requiredThemeColor(emberDark, "editor.foreground"); + const emberSurface = requiredThemeColor(emberDark, "editor.background"); + const mossSurface = requiredThemeColor(mossDark, "editor.background"); + const emberAccent = requiredRoleColor(emberDark, "keyword"); + const mossAccent = requiredRoleColor(mossDark, "function"); + const headline = PREVIEW.marketing?.familyHeadline || "EMBER / MOSS"; + const [emberWord = "EMBER", mossWord = "MOSS"] = headline.split("/").map((word) => word.trim()); + + return ` + ${escapeXml(kicker)} + ${renderDistressedText({ id: `family-lockup-ember-${width}`, text: emberWord, x: inset, y: titleY, fill: emberAccent, wear: emberSurface, fontSize: titleSize, seed: 47, intensity: BRAND_SYSTEM.material.typeWear })} + ${renderDistressedText({ id: `family-lockup-moss-${width}`, text: mossWord, x: width - inset, y: titleY, fill: mossAccent, wear: mossSurface, fontSize: titleSize, textAnchor: "end", seed: 59, intensity: BRAND_SYSTEM.material.typeWear })} + ${escapeXml(subheadline)} + `; +} + +function renderFamilySpecimenCard({ + meta, + x, + y, + width, + height, + modeLabel, + title, + titleSize = 34, + codeFontSize = 23, + lineHeight = 40, + showSurface = true, + showSwatches = true, + pairLabel = "PAIRED MODE", +}) { + const background = requiredThemeColor(meta, "editor.background"); + const foreground = requiredThemeColor(meta, "editor.foreground"); + const accent = requiredRoleColor(meta, meta.schemeId === "moss" ? "function" : "keyword"); + const muted = mixHex(foreground, background, 0.48); + const lines = buildFamilySampleLines(meta); + const frameX = x + 28; + const frameY = y + 112; + const frameWidth = width - 56; + const frameHeight = height - 176; + const codeY = frameY + Math.max(24, Math.round((frameHeight - lineHeight * lines.length) / 2)); + const swatches = ["keyword", "function", "type", "string", "property", "operator"] + .map((role) => requiredRoleColor(meta, role)); + + return ` + + ${showSurface ? `` : ""} + + ${renderMaterialTexture({ id: `specimen-${meta.id}-${x}-${y}`, ink: foreground, x, y, width, height, seed: Math.round(x + y + width), intensity: BRAND_SYSTEM.material.proofTexture })} + + ${escapeXml(modeLabel)} + ${escapeXml(title)} + ${lines.map((segments, index) => renderCodeLine({ + theme: meta.theme, + segments, + x: frameX + 24, + y: codeY + index * lineHeight, + fontSize: codeFontSize, + })).join("")} + ${showSwatches + ? swatches.map((color, index) => ``).join("") + : pairLabel + ? `${escapeXml(pairLabel)}` + : ""} + + `; +} + +function renderEditorialSquareSvg({ themes, width, height }) { + const { + emberDark, + mossDark, + emberLight, + mossLight, + } = getFamilyThemeSet(themes); + const emberDarkBg = requiredThemeColor(emberDark, "editor.background"); + const mossDarkBg = requiredThemeColor(mossDark, "editor.background"); + const emberLightBg = requiredThemeColor(emberLight, "editor.background"); + const mossLightBg = requiredThemeColor(mossLight, "editor.background"); + const emberDarkFg = requiredThemeColor(emberDark, "editor.foreground"); + const mossDarkFg = requiredThemeColor(mossDark, "editor.foreground"); + const emberLightFg = requiredThemeColor(emberLight, "editor.foreground"); + const mossLightFg = requiredThemeColor(mossLight, "editor.foreground"); + const inset = 64; + const splitY = Math.round(height * 0.65); + const seamTopX = Math.round(width * 0.54); + const seamMidX = Math.round(width * 0.51); + const seamBottomX = Math.round(width * 0.48); + const emberDarkMask = `0,0 ${seamTopX},0 ${seamMidX},${splitY} 0,${splitY}`; + const mossDarkMask = `${seamTopX},0 ${width},0 ${width},${splitY} ${seamMidX},${splitY}`; + const emberLightMask = `0,${splitY} ${seamMidX},${splitY} ${seamBottomX},${height} 0,${height}`; + const mossLightMask = `${seamMidX},${splitY} ${width},${splitY} ${width},${height} ${seamBottomX},${height}`; + const tearGeometry = buildTornPaperGeometry({ + controlPoints: [ + { x: seamTopX, y: 0 }, + { x: seamMidX, y: splitY }, + { x: seamBottomX, y: height }, + ], + seed: 173, + segmentLength: 16, + jitter: 6, + paperWidth: 14, + widthVariation: 0.7, + }); + const emberLines = buildFamilySampleLines(emberLight); + const mossLines = buildFamilySampleLines(mossLight); + const renderRoleRail = (meta, x, y, railWidth) => ["keyword", "function", "type", "string", "property", "operator"] + .map((role, index) => ``) + .join(""); + + return ` + + + + + + ${renderMaterialTexture({ id: "square-ember-dark", ink: emberDarkFg, width, height: splitY, points: emberDarkMask, seed: 67, intensity: 0.52 })} + ${renderMaterialTexture({ id: "square-moss-dark", ink: mossDarkFg, width, height: splitY, points: mossDarkMask, seed: 71, intensity: 0.52 })} + ${renderMaterialTexture({ id: "square-ember-light", ink: emberLightFg, width, height, points: emberLightMask, seed: 73, intensity: 0.46 })} + ${renderMaterialTexture({ id: "square-moss-light", ink: mossLightFg, width, height, points: mossLightMask, seed: 79, intensity: 0.46 })} + ${renderTornPaperSeam({ id: "square-family-seam", geometry: tearGeometry, paper: emberDarkFg, warmInk: requiredRoleColor(emberDark, "keyword"), coolInk: requiredRoleColor(mossDark, "function"), shadowInk: emberDarkBg, seed: 179, intensity: 0.56 })} + + HEARTHCODE · FOUR CALIBRATED THEMES + ${renderDistressedText({ id: "square-ember-title", text: "EMBER", x: inset, y: 88, fill: requiredRoleColor(emberDark, "keyword"), wear: emberDarkBg, fontSize: 154, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.03em", seed: 181, intensity: 0.42 })} + ${renderDistressedText({ id: "square-moss-title", text: "MOSS", x: width - inset, y: 88, fill: requiredRoleColor(mossDark, "function"), wear: mossDarkBg, fontSize: 154, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.03em", textAnchor: "end", seed: 191, intensity: 0.42 })} + WARMTH OR + STRUCTURE. + MEANING + STAYS CLEAR. + EMBER DARK · WARM CONTROL FLOW + MOSS DARK · DRY STRUCTURE + ${renderRoleRail(emberDark, inset, 542, 60)} + ${renderRoleRail(mossDark, Math.round(width * 0.58), 542, 48)} + + EMBER LIGHT + MOSS LIGHT + ${emberLines.slice(0, 4).map((segments, index) => renderCodeLine({ theme: emberLight.theme, segments, x: inset, y: splitY + 88 + index * 48, fontSize: 25 })).join("")} + ${mossLines.slice(0, 4).map((segments, index) => renderCodeLine({ theme: mossLight.theme, segments, x: Math.round(width * 0.58), y: splitY + 88 + index * 48, fontSize: 25 })).join("")} + DARK + LIGHT · SAME READING RHYTHM + ${escapeXml(BRAND_SYSTEM.copy.site)} + + `; +} + +function renderStackedDirectionsSvg({ themes, width, height }) { + const { + emberDark, + mossDark, + emberLight, + mossLight, + } = getFamilyThemeSet(themes); + const emberDarkBg = requiredThemeColor(emberDark, "editor.background"); + const mossDarkBg = requiredThemeColor(mossDark, "editor.background"); + const emberLightBg = requiredThemeColor(emberLight, "editor.background"); + const mossLightBg = requiredThemeColor(mossLight, "editor.background"); + const emberDarkFg = requiredThemeColor(emberDark, "editor.foreground"); + const mossDarkFg = requiredThemeColor(mossDark, "editor.foreground"); + const emberLightFg = requiredThemeColor(emberLight, "editor.foreground"); + const mossLightFg = requiredThemeColor(mossLight, "editor.foreground"); + const inset = 60; + const lightY = Math.round(height * 0.77); + const lightSplit = Math.round(width * 0.48); + const diagonalLeftY = Math.round(height * 0.49); + const diagonalRightY = Math.round(height * 0.38); + const emberMask = `0,0 ${width},0 ${width},${diagonalRightY} 0,${diagonalLeftY}`; + const mossMask = `0,${diagonalLeftY} ${width},${diagonalRightY} ${width},${lightY} 0,${lightY}`; + const tearGeometry = buildTornPaperGeometry({ + controlPoints: [{ x: 0, y: diagonalLeftY }, { x: width, y: diagonalRightY }], + seed: 193, + segmentLength: 18, + jitter: 6, + paperWidth: 13, + widthVariation: 0.72, + }); + const emberLines = buildFamilySampleLines(emberLight); + const mossLines = buildFamilySampleLines(mossLight); + + return ` + + + + + + + ${renderMaterialTexture({ id: "portrait-ember", ink: emberDarkFg, width, height: lightY, points: emberMask, seed: 83, intensity: 0.5 })} + ${renderMaterialTexture({ id: "portrait-moss", ink: mossDarkFg, width, height: lightY, points: mossMask, seed: 89, intensity: 0.5 })} + ${renderMaterialTexture({ id: "portrait-ember-light", ink: emberLightFg, y: lightY, width: lightSplit, height: height - lightY, seed: 97, intensity: 0.42 })} + ${renderMaterialTexture({ id: "portrait-moss-light", ink: mossLightFg, x: lightSplit, y: lightY, width: width - lightSplit, height: height - lightY, seed: 101, intensity: 0.42 })} + ${renderTornPaperSeam({ id: "portrait-family-seam", geometry: tearGeometry, paper: emberDarkFg, warmInk: requiredRoleColor(emberDark, "keyword"), coolInk: requiredRoleColor(mossDark, "function"), shadowInk: emberDarkBg, seed: 199, intensity: 0.54 })} + + HEARTHCODE · SEMANTIC MATERIALS + ${renderDistressedText({ id: "portrait-ember-title", text: "EMBER", x: inset, y: 88, fill: requiredRoleColor(emberDark, "keyword"), wear: emberDarkBg, fontSize: 176, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.035em", seed: 211, intensity: 0.42 })} + WARMTH OR STRUCTURE. + MEANING STAYS CLEAR. + ${renderDistressedText({ id: "portrait-moss-title", text: "MOSS", x: width - inset, y: 626, fill: requiredRoleColor(mossDark, "function"), wear: mossDarkBg, fontSize: 190, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.035em", textAnchor: "end", seed: 223, intensity: 0.42 })} + DIFFERENT MATERIAL. SAME READING RHYTHM. + ${["keyword", "function", "type", "string", "property", "operator"].map((role, index) => ``).join("")} + + EMBER LIGHT + MOSS LIGHT + ${emberLines.map((segments, index) => renderCodeLine({ theme: emberLight.theme, segments, x: inset, y: lightY + 76 + index * 42, fontSize: 21 })).join("")} + ${mossLines.map((segments, index) => renderCodeLine({ theme: mossLight.theme, segments, x: lightSplit + 34, y: lightY + 76 + index * 42, fontSize: 21 })).join("")} + DARK + LIGHT + ${escapeXml(BRAND_SYSTEM.copy.site)} + + `; +} + +function renderCampaignStorySvg({ themes, width, height }) { + const { + emberDark, + mossDark, + emberLight, + mossLight, + } = getFamilyThemeSet(themes); + const emberDarkBg = requiredThemeColor(emberDark, "editor.background"); + const mossDarkBg = requiredThemeColor(mossDark, "editor.background"); + const emberDarkFg = requiredThemeColor(emberDark, "editor.foreground"); + const mossDarkFg = requiredThemeColor(mossDark, "editor.foreground"); + const emberLightBg = requiredThemeColor(emberLight, "editor.background"); + const mossLightBg = requiredThemeColor(mossLight, "editor.background"); + const emberLightFg = requiredThemeColor(emberLight, "editor.foreground"); + const mossLightFg = requiredThemeColor(mossLight, "editor.foreground"); + const emberAccent = requiredRoleColor(emberDark, "keyword"); + const mossAccent = requiredRoleColor(mossDark, "function"); + const inset = 72; + const lightY = Math.round(height * 0.735); + const diagonalLeftY = Math.round(height * 0.38); + const diagonalRightY = Math.round(height * 0.31); + const emberMask = `0,0 ${width},0 ${width},${diagonalRightY} 0,${diagonalLeftY}`; + const mossMask = `0,${diagonalLeftY} ${width},${diagonalRightY} ${width},${lightY} 0,${lightY}`; + const lightSplit = Math.round(width * 0.5); + const tearGeometry = buildTornPaperGeometry({ + controlPoints: [{ x: 0, y: diagonalLeftY }, { x: width, y: diagonalRightY }], + seed: 227, + segmentLength: 18, + jitter: 7, + paperWidth: 14, + widthVariation: 0.74, + }); + const emberLines = buildFamilySampleLines(emberLight); + const mossLines = buildFamilySampleLines(mossLight); + + return ` + + + + + + + ${renderMaterialTexture({ id: "story-ember", ink: emberDarkFg, width, height: lightY, points: emberMask, seed: 107, intensity: 0.5 })} + ${renderMaterialTexture({ id: "story-moss", ink: mossDarkFg, width, height: lightY, points: mossMask, seed: 109, intensity: 0.5 })} + ${renderMaterialTexture({ id: "story-ember-light", ink: emberLightFg, y: lightY, width: lightSplit, height: height - lightY, seed: 113, intensity: 0.42 })} + ${renderMaterialTexture({ id: "story-moss-light", ink: mossLightFg, x: lightSplit, y: lightY, width: width - lightSplit, height: height - lightY, seed: 127, intensity: 0.42 })} + ${renderTornPaperSeam({ id: "story-family-seam", geometry: tearGeometry, paper: emberDarkFg, warmInk: emberAccent, coolInk: mossAccent, shadowInk: emberDarkBg, seed: 229, intensity: 0.58 })} + + HEARTHCODE · FOUR CALIBRATED THEMES + ${renderDistressedText({ id: "story-title-ember", text: "EMBER", x: inset, y: 124, fill: emberAccent, wear: emberDarkBg, fontSize: 210, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.04em", seed: 233, intensity: 0.42 })} + WARMTH OR STRUCTURE. + MEANING STAYS CLEAR. + + ${renderDistressedText({ id: "story-title-moss", text: "MOSS", x: width - inset, y: 712, fill: mossAccent, wear: mossDarkBg, fontSize: 230, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.04em", textAnchor: "end", seed: 239, intensity: 0.42 })} + DIFFERENT MATERIAL. + SAME READING RHYTHM. + ${["keyword", "function", "type", "string", "property", "operator"].map((role, index) => ``).join("")} + + EMBER LIGHT + MOSS LIGHT + ${emberLines.map((segments, index) => renderCodeLine({ theme: emberLight.theme, segments, x: inset, y: lightY + 94 + index * 51, fontSize: 24 })).join("")} + ${mossLines.map((segments, index) => renderCodeLine({ theme: mossLight.theme, segments, x: lightSplit + 36, y: lightY + 94 + index * 51, fontSize: 24 })).join("")} + DARK + LIGHT · SOURCE-TRUE COLOR + ${escapeXml(BRAND_SYSTEM.copy.site)} + + `; +} + +function renderFamilyAssetSvg({ + themes, + width = WIDTH, + height = HEIGHT, + composition = "semantic-rift-wide", +}) { + switch (composition) { + case "semantic-rift-wide": + return renderSemanticRiftSvg({ themes, width, height }); + case "editorial-square": + return renderEditorialSquareSvg({ themes, width, height }); + case "stacked-directions": + return renderStackedDirectionsSvg({ themes, width, height }); + case "campaign-story": + return renderCampaignStorySvg({ themes, width, height }); + default: + throw new Error(`Unknown family composition: ${composition}`); + } +} + function renderContrastSvg({ themes }) { - return renderPromoBoardSvg({ themes }); + const asset = MARKETING_ASSETS["family-readme"]; + return renderFamilyAssetSvg({ themes, composition: asset.composition, ...asset.canvas }); } function renderNumberedCodeBlock({ theme, lines, x, y, fontSize = 19, lineHeight = 34 }) { @@ -839,9 +1154,9 @@ function renderEditorHeroSvg({ themes }) { const mossLight = ordered.find((theme) => theme.schemeId === "moss" && !theme.isDark) || ordered.find((theme) => !theme.isDark) || ordered[0]; const darkBg = themeColor(mossDark.theme, "editor.background", "#1b1d1a"); const darkFg = themeColor(mossDark.theme, "editor.foreground", "#d2bea2"); - const darkSide = themeColor(mossDark.theme, "sideBar.background", "#191815"); - const darkTitle = themeColor(mossDark.theme, "titleBar.activeBackground", darkSide); - const darkTabs = themeColor(mossDark.theme, "editorGroupHeader.tabsBackground", darkSide); + const darkShell = themeColor(mossDark.theme, "sideBar.background", "#191815"); + const darkTitle = themeColor(mossDark.theme, "titleBar.activeBackground", darkShell); + const darkTabs = themeColor(mossDark.theme, "editorGroupHeader.tabsBackground", darkShell); const darkStatus = themeColor(mossDark.theme, "statusBar.background", "#b37f16"); const darkStatusFg = themeColor(mossDark.theme, "statusBar.foreground", "#191815"); const lightBg = themeColor(mossLight.theme, "editor.background", "#e7e5d8"); @@ -853,76 +1168,70 @@ function renderEditorHeroSvg({ themes }) { const seam = themeColor(mossDark.theme, "focusBorder", themeColor(mossDark.theme, "button.background", "#cb9322")); const darkMuted = mixHex(darkFg, darkBg, 0.52); const lightMuted = mixHex(lightFg, lightBg, 0.52); - const darkLines = [ - [{ role: "comment", text: "// tactile charcoal, clear structure" }], - [{ role: "keyword", text: "type " }, { role: "type", text: "Workspace" }, { role: "plain", text: " = {" }], - [{ role: "plain", text: " " }, { role: "property", text: "direction" }, { role: "plain", text: ": " }, { role: "string", text: '"moss"' }, { role: "plain", text: ";" }], - [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "plain", text: ": " }, { role: "string", text: '"dark"' }, { role: "plain", text: ";" }], - [{ role: "plain", text: "};" }], - [], - [{ role: "keyword", text: "const " }, { role: "variable.readonly", text: "theme" }, { role: "plain", text: " = " }, { role: "function", text: "build" }, { role: "plain", text: "({" }], - [{ role: "plain", text: " " }, { role: "property", text: "syntax" }, { role: "plain", text: ": " }, { role: "string", text: '"clear"' }, { role: "plain", text: "," }], - [{ role: "plain", text: " " }, { role: "property", text: "glare" }, { role: "plain", text: ": " }, { role: "number", text: "0" }, { role: "plain", text: "," }], - [{ role: "plain", text: "});" }], - ]; - const lightLines = [ - [{ role: "comment", text: "// dry paper, the same semantics" }], - [{ role: "keyword", text: "const " }, { role: "variable.readonly", text: "palette" }, { role: "plain", text: " = " }, { role: "function", text: "createTheme" }, { role: "plain", text: "({" }], - [{ role: "plain", text: " " }, { role: "property", text: "surface" }, { role: "plain", text: ": " }, { role: "string", text: '"paper"' }, { role: "plain", text: "," }], - [{ role: "plain", text: " " }, { role: "property", text: "callable" }, { role: "plain", text: ": " }, { role: "string", text: '"lichen"' }, { role: "plain", text: "," }], - [{ role: "plain", text: " " }, { role: "property", text: "contrast" }, { role: "plain", text: ": " }, { role: "string", text: '"calibrated"' }, { role: "plain", text: "," }], - [{ role: "plain", text: "});" }], - ]; + const darkLines = buildEditorSampleLines(mossDark); + const lightLines = buildEditorSampleLines(mossLight); + const semanticRoles = ["keyword", "function", "type", "string", "property", "operator"]; + const renderSemanticRail = (meta, x, y, foreground) => semanticRoles.map((role, index) => { + const roleX = x + index * 112; + return ` + + ${role.toUpperCase()} + `; + }).join(""); + + const frameX = 18; + const frameY = 18; + const frameWidth = WIDTH - frameX * 2; + const frameHeight = HEIGHT - frameY * 2; + const splitTopX = 824; + const splitBottomX = 778; + const leftCodeX = 68; + const rightCodeX = 892; + const codeY = 188; return ` - + - - + + - - - - - - - - - HEARTHCODE MOSS - DARK - LIGHT - - - - EXPLORER - src - components - - - theme.ts - tokens.ts - README.md - - - - theme.ts - - - theme.ts - - ${renderNumberedCodeBlock({ theme: mossDark.theme, lines: darkLines, x: 390, y: 164, fontSize: 19, lineHeight: 36 })} - ${renderNumberedCodeBlock({ theme: mossLight.theme, lines: lightLines, x: 988, y: 164, fontSize: 18, lineHeight: 38 })} - - - - main ✓ TypeScript - Ln 6, Col 4 + + + + + + + + + HEARTHCODE MOSS + DARK + LIGHT + + + + semantic-theme.ts + + + semantic-theme.ts + + MOSS DARK · STRUCTURED WITHOUT GLARE + MOSS LIGHT · THE SAME READING RHYTHM + ${renderNumberedCodeBlock({ theme: mossDark.theme, lines: darkLines, x: leftCodeX, y: codeY, fontSize: 26, lineHeight: 58 })} + ${renderNumberedCodeBlock({ theme: mossLight.theme, lines: lightLines, x: rightCodeX, y: codeY, fontSize: 26, lineHeight: 58 })} + + ${renderSemanticRail(mossDark, leftCodeX, 700, darkMuted)} + ${renderSemanticRail(mossLight, rightCodeX, 700, lightMuted)} + + + + main ✓ TypeScript + Dark + Light · paired roles - + `; } @@ -944,10 +1253,12 @@ function renderForgeWorkflowSvg({ themes }) { const inputBg = themeColor(darkTheme, "input.background", mixHex(bg, fg, 0.06)); const button = themeColor(darkTheme, "button.background", "#cb9322"); const buttonFg = themeColor(darkTheme, "button.foreground", "#191815"); - const secondaryButton = themeColor(darkTheme, "button.secondaryBackground", mixHex(bg, fg, 0.16)); - const secondaryButtonFg = themeColor(darkTheme, "button.secondaryForeground", fg); const emberAccent = themeColor(emberTheme, "button.background", roleColor(emberTheme, "keyword")); - const seed = "#8fc06b"; + const seed = requiredRoleColor(mossDark, "function"); + const forgeSteps = PREVIEW.samples?.forge?.lines || []; + if (forgeSteps.length !== 5) { + throw new Error("Preview sample contract: products/hearthcode/preview.json must define the five Theme Forge workflow steps"); + } const previewLines = [ [{ role: "comment", text: "// both modes rebuild together" }], [{ role: "keyword", text: "const " }, { role: "variable.readonly", text: "palette" }, { role: "plain", text: " = " }, { role: "function", text: "forgeTheme" }, { role: "plain", text: "({" }], @@ -955,75 +1266,588 @@ function renderForgeWorkflowSvg({ themes }) { [{ role: "plain", text: " " }, { role: "property", text: "seed" }, { role: "plain", text: ": " }, { role: "string", text: `"${seed}"` }, { role: "plain", text: "," }], [{ role: "plain", text: "});" }], ]; - const renderPreviewLines = (theme, x) => previewLines.map((segments, index) => - renderCodeLine({ theme, segments, x, y: 370 + index * 38, fontSize: 17 }) + const renderPreviewLines = (theme, x, y) => previewLines.map((segments, index) => + renderCodeLine({ theme, segments, x, y: y + index * 48, fontSize: 23 }) ).join(""); + const darkFg = requiredThemeColor(mossDark, "editor.foreground"); + const lightFg = requiredThemeColor(mossLight, "editor.foreground"); + const lightBg = requiredThemeColor(mossLight, "editor.background"); + const railRoles = ["keyword", "function", "type", "string", "property", "operator"]; + const controlX = 52; + const controlY = 154; + const controlWidth = 316; + const outputX = 404; + const outputY = 154; + const outputWidth = 1144; + const paneGap = 14; + const paneWidth = (outputWidth - paneGap) / 2; + const paneHeight = 594; + + return ` + + + + + + + + + HEARTHCODE THEME FORGE + LIVE · THEME-SCOPED · REVERSIBLE + + YOUR COLOR. SAME SAFEGUARDS. + ${escapeXml(forgeSteps[2].toUpperCase())} · BOTH MODES REBUILD TOGETHER + + + 1 · ${escapeXml(forgeSteps[0].toUpperCase())} + + + Moss + + + Ember + + 2 · ${escapeXml(forgeSteps[1].toUpperCase())} + + + + ${seed} + MOSS FUNCTION TOKEN · SOURCE TRUE + + 4–5 · APPLY / RESTORE + + ${escapeXml(forgeSteps[3])} + + Restore + + One reversible change + Applies to this direction’s + Dark and Light. Restore removes + exactly what Forge wrote. + + + + + + + + + + + MOSS DARK · FORGED + MOSS LIGHT · FORGED + ${renderPreviewLines(darkTheme, outputX + 34, outputY + 112)} + ${renderPreviewLines(lightTheme, outputX + paneWidth + paneGap + 34, outputY + 112)} + ${railRoles.map((role, index) => ``).join("")} + ${railRoles.map((role, index) => ``).join("")} + ROLE ORDER PRESERVED + SAME READING RHYTHM + + + Moss · Dark · verified + Moss · Light · verified + + + ROLE SEPARATION + + AA-CHECKED CHROME + + FUNCTIONAL COLORS + + RESTORABLE + + `; +} + +function getPreviewTheme(themes, schemeId, variantId) { + return themes.find((theme) => theme.schemeId === schemeId && theme.variantId === variantId) + || themes.find((theme) => theme.schemeId === schemeId) + || themes[0]; +} + +function renderDirectionSpecimen({ darkMeta, lightMeta, x, width, index }) { + const darkTheme = darkMeta.theme; + const lightTheme = lightMeta.theme; + const copy = getFlavorPreviewCopy(darkMeta.schemeId); + const darkBg = themeColor(darkTheme, "editor.background", "#1b1d1a"); + const darkFg = themeColor(darkTheme, "editor.foreground", "#d3c9b8"); + const lightBg = themeColor(lightTheme, "editor.background", "#e7e5d8"); + const lightFg = themeColor(lightTheme, "editor.foreground", "#342d28"); + const darkMuted = mixHex(darkFg, darkBg, 0.5); + const lightMuted = mixHex(lightFg, lightBg, 0.48); + const keyword = roleColor(darkTheme, "keyword"); + const callable = roleColor(darkTheme, "function"); + const string = roleColor(darkTheme, "string"); + const type = roleColor(darkTheme, "type"); + const accent = darkMeta.schemeId === "moss" ? callable : keyword; + const y = 228; + const height = 608; + const dividerY = y + 398; + const darkLines = [ + [{ role: "comment", text: copy.comment }], + [{ role: "keyword", text: "const " }, { role: "variable", text: copy.sampleVariable }, { role: "plain", text: " = " }, { role: "function", text: copy.sampleFunction }, { role: "plain", text: "({" }], + [{ role: "plain", text: " " }, { role: "property", text: "direction" }, { role: "plain", text: ": " }, { role: "string", text: `"${darkMeta.schemeId}"` }, { role: "plain", text: "," }], + [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "plain", text: ": " }, { role: "string", text: '"dark"' }], + [{ role: "plain", text: "});" }], + ]; + const lightLines = [ + [{ role: "keyword", text: "type " }, { role: "type", text: "Surface" }, { role: "plain", text: " = {" }], + [{ role: "plain", text: " " }, { role: "property", text: "paper" }, { role: "plain", text: ": " }, { role: "string", text: '"light"' }, { role: "plain", text: ";" }], + [{ role: "plain", text: "};" }], + ]; + const chips = copy.chips.slice(0, 3).map((chip, chipIndex) => { + const chipWidth = Math.max(112, chip.length * 7 + 24); + const chipX = x + 28 + chipIndex * 174; + return ` + + ${escapeXml(chip)} + `; + }).join(""); + + return ` + + + + ${renderMaterialTexture({ id: `atlas-${darkMeta.schemeId}-dark`, ink: darkFg, x, y, width, height: dividerY - y, seed: 109 + index, intensity: BRAND_SYSTEM.material.proofTexture })} + ${renderMaterialTexture({ id: `atlas-${darkMeta.schemeId}-light`, ink: lightFg, x, y: dividerY, width, height: y + height - dividerY, seed: 113 + index, intensity: BRAND_SYSTEM.material.proofTexture })} + + 0${index} / ${escapeXml(darkMeta.flavor.name.toUpperCase())} + ${escapeXml(darkMeta.flavor.name)} + DARK FIELD + ${chips} + + ${renderNumberedCodeBlock({ theme: darkTheme, lines: darkLines, x: x + 50, y: y + 170, fontSize: 17, lineHeight: 35 })} + + LIGHT FIELD / ${escapeXml(lightMeta.summary.toUpperCase())} + + ${renderNumberedCodeBlock({ theme: lightTheme, lines: lightLines, x: x + 50, y: dividerY + 72, fontSize: 15.5, lineHeight: 27 })} + + + + + + + + `; +} + +function renderDirectionAtlasSvg({ themes }) { + const emberDark = getPreviewTheme(themes, "ember", "dark"); + const emberLight = getPreviewTheme(themes, "ember", "light"); + const mossDark = getPreviewTheme(themes, "moss", "dark"); + const mossLight = getPreviewTheme(themes, "moss", "light"); + const bg = mixHex(themeColor(mossDark.theme, "editor.background", "#191a17"), "#000000", 0.06); + const fg = themeColor(mossDark.theme, "editor.foreground", "#d3c9b8"); + const muted = mixHex(fg, bg, 0.5); + const heading = PREVIEW.marketing?.directionHeadline || "Warmth or structure. Choose your material."; + const subheading = PREVIEW.marketing?.directionSubheadline || "Two distinct atmospheres, built from one semantic color language."; + + return ` + + + ${renderMaterialTexture({ id: "atlas-field", ink: fg, width: WIDTH, height: HEIGHT, seed: 127, intensity: BRAND_SYSTEM.material.posterTexture })} + ${renderFieldGuideGrid({ color: fg })} + + + ${renderRegistrationMarks({ color: fg })} + HEARTHCODE / COLOR FIELD GUIDE 01 + ${escapeXml(heading)} + ${escapeXml(subheading)} + + ${renderDirectionSpecimen({ darkMeta: emberDark, lightMeta: emberLight, x: 56, width: 724, index: 1 })} + ${renderDirectionSpecimen({ darkMeta: mossDark, lightMeta: mossLight, x: 820, width: 724, index: 2 })} + TWO DIRECTIONS · TWO MODES · ONE SEMANTIC SYSTEM + theme.hearthcode.dev + + `; +} + +function renderDirectionCardSvg({ themes, schemeId, width, height }) { + const darkMeta = getPreviewTheme(themes, schemeId, "dark"); + const lightMeta = getPreviewTheme(themes, schemeId, "light"); + const darkTheme = darkMeta.theme; + const lightTheme = lightMeta.theme; + const darkBg = themeColor(darkTheme, "editor.background", "#191a17"); + const darkFg = themeColor(darkTheme, "editor.foreground", "#d3c9b8"); + const lightBg = themeColor(lightTheme, "editor.background", "#e7e5d8"); + const lightFg = themeColor(lightTheme, "editor.foreground", "#342d28"); + const accent = roleColor(darkTheme, schemeId === "moss" ? "function" : "keyword"); + const muted = mixHex(darkFg, darkBg, 0.5); + const inset = 64; + const splitLeftY = Math.round(height * 0.66); + const splitRightY = Math.round(height * 0.56); + const lightMask = `0,${splitLeftY} ${width},${splitRightY} ${width},${height} 0,${height}`; + const codeLines = buildFamilySampleLines(darkMeta); + const lightLines = buildFamilySampleLines(lightMeta); + const promise = schemeId === "ember" ? "WARMTH WITHOUT MUD." : "STRUCTURE WITHOUT NOISE."; + const descriptor = schemeId === "ember" ? "WARM PAPER · COOL CALLABLE ANCHORS" : "DRY PAPER · CLEAR SEMANTIC LANES"; + const titleSize = schemeId === "ember" ? 190 : 218; + const swatches = ["keyword", "function", "type", "string", "property", "operator"].map((role) => roleColor(darkTheme, role)); + + return ` + + + + ${renderMaterialTexture({ id: `direction-${schemeId}-dark`, ink: darkFg, width, height: splitLeftY, seed: 101, intensity: 0.5 })} + ${renderMaterialTexture({ id: `direction-${schemeId}-light`, ink: lightFg, width, height, points: lightMask, seed: 103, intensity: 0.44 })} + + + HEARTHCODE · ${schemeId.toUpperCase()} · DARK + LIGHT + ${renderDistressedText({ id: `direction-title-${schemeId}`, text: darkMeta.flavor.name.toUpperCase(), x: inset, y: 92, fill: accent, wear: darkBg, fontSize: titleSize, fontFamily: BRAND_SYSTEM.typography.displayCondensed, letterSpacing: "-0.035em", seed: 107, intensity: 0.42 })} + ${promise} + ${descriptor} + ${swatches.map((color, index) => ``).join("")} + + DARK / SHIPPED SYNTAX + ${codeLines.map((segments, index) => renderCodeLine({ theme: darkTheme, segments, x: inset, y: 526 + index * 50, fontSize: 27 })).join("")} + + LIGHT / SAME ROLE ORDER + ${lightLines.map((segments, index) => renderCodeLine({ theme: lightTheme, segments, x: inset, y: Math.max(splitLeftY, splitRightY) + 104 + index * 50, fontSize: 27 })).join("")} + DIFFERENT SURFACE · SAME MEANING + ${escapeXml(BRAND_SYSTEM.copy.site)} + + `; +} + +function renderZedUnifiedProof({ themes, x, y, width, height }) { + const emberDark = getPreviewTheme(themes, "ember", "dark"); + const mossDark = getPreviewTheme(themes, "moss", "dark"); + const emberLight = getPreviewTheme(themes, "ember", "light"); + const mossLight = getPreviewTheme(themes, "moss", "light"); + const emberDarkBg = requiredThemeColor(emberDark, "editor.background"); + const mossDarkBg = requiredThemeColor(mossDark, "editor.background"); + const emberDarkFg = requiredThemeColor(emberDark, "editor.foreground"); + const mossDarkFg = requiredThemeColor(mossDark, "editor.foreground"); + const emberLightBg = requiredThemeColor(emberLight, "editor.background"); + const mossLightBg = requiredThemeColor(mossLight, "editor.background"); + const emberLightFg = requiredThemeColor(emberLight, "editor.foreground"); + const mossLightFg = requiredThemeColor(mossLight, "editor.foreground"); + const emberAccent = requiredRoleColor(emberDark, "keyword"); + const mossAccent = requiredRoleColor(mossDark, "function"); + const half = width / 2; + const chromeHeight = 58; + const lightStripHeight = 188; + const contentY = y + chromeHeight; + const contentHeight = height - chromeHeight - lightStripHeight; + const buildZedSampleLines = (meta) => [ + [{ role: "keyword", text: "type " }, { role: "type", text: "ThemePreview" }, { role: "operator", text: " = " }, { role: "punctuation", text: "{" }], + [{ role: "plain", text: " " }, { role: "property", text: "direction" }, { role: "operator", text: ": " }, { role: "string", text: '"ember"' }, { role: "operator", text: " | " }, { role: "string", text: '"moss"' }, { role: "punctuation", text: ";" }], + [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "operator", text: ": " }, { role: "string", text: '"dark"' }, { role: "operator", text: " | " }, { role: "string", text: '"light"' }, { role: "punctuation", text: ";" }], + [{ role: "punctuation", text: "};" }], + [{ role: "keyword", text: "const " }, { role: "variable.readonly", text: "theme" }, { role: "operator", text: ": " }, { role: "type", text: "ThemePreview" }, { role: "operator", text: " = " }, { role: "punctuation", text: "{" }], + [{ role: "plain", text: " " }, { role: "property", text: "direction" }, { role: "operator", text: ": " }, { role: "string", text: `"${meta.schemeId}"` }, { role: "punctuation", text: "," }], + [{ role: "plain", text: " " }, { role: "property", text: "mode" }, { role: "operator", text: ": " }, { role: "string", text: `"${meta.variantId}"` }, { role: "punctuation", text: "," }], + ]; + const emberLines = buildZedSampleLines(emberDark); + const mossLines = buildZedSampleLines(mossDark); + const emberLightLines = buildFamilySampleLines(emberLight).slice(0, 3); + const mossLightLines = buildFamilySampleLines(mossLight).slice(0, 3); + const railRoles = ["keyword", "function", "type", "string", "property", "operator"]; + + return ` + + + + + + + + + hearthcode.ts — Zed + GENERATED ZED SPECIMEN · 4 THEMES + + + + + + EMBER DARK · WARMTH WITHOUT MUD + MOSS DARK · STRUCTURE WITHOUT NOISE + ${renderNumberedCodeBlock({ theme: emberDark.theme, lines: emberLines, x: x + 34, y: contentY + 72, fontSize: 25, lineHeight: 49 })} + ${renderNumberedCodeBlock({ theme: mossDark.theme, lines: mossLines, x: x + half + 34, y: contentY + 72, fontSize: 25, lineHeight: 49 })} + ${railRoles.map((role, index) => ``).join("")} + ${railRoles.map((role, index) => ``).join("")} + + + + EMBER LIGHT · PAIRED ROLES + MOSS LIGHT · PAIRED ROLES + ${emberLightLines.map((segments, index) => renderCodeLine({ theme: emberLight.theme, segments, x: x + 34, y: y + height - lightStripHeight + 60 + index * 39, fontSize: 22 })).join("")} + ${mossLightLines.map((segments, index) => renderCodeLine({ theme: mossLight.theme, segments, x: x + half + 34, y: y + height - lightStripHeight + 60 + index * 39, fontSize: 22 })).join("")} + + + `; +} + +function renderTerminalLane({ meta, x, y, width, height, terminalLines }) { + const theme = meta.theme; + const bg = requiredThemeColor(meta, "editor.background"); + const fg = requiredThemeColor(meta, "editor.foreground"); + const accent = requiredRoleColor(meta, meta.schemeId === "moss" ? "function" : "keyword"); + const green = themeColor(theme, "terminal.ansiGreen", requiredRoleColor(meta, "function")); + const blue = themeColor(theme, "terminal.ansiBlue", requiredRoleColor(meta, "type")); + const yellow = themeColor(theme, "terminal.ansiYellow", requiredRoleColor(meta, "keyword")); + const red = themeColor(theme, "terminal.ansiRed", requiredRoleColor(meta, "string")); + const ansi = [red, yellow, green, blue, themeColor(theme, "terminal.ansiMagenta", requiredRoleColor(meta, "property")), themeColor(theme, "terminal.ansiCyan", requiredRoleColor(meta, "type"))]; + return ` + + + + ${escapeXml(meta.flavor.name.toUpperCase())} / DARK + ${escapeXml(terminalLines[0] || "$ pnpm run verify")} + ${(terminalLines.slice(1).map((line, index) => `${escapeXml(line)}`).join(""))} + ${ansi.map((color, index) => ``).join("")} + + `; +} + +function renderTerminalUnifiedProof({ themes, x, y, width, height }) { + const emberDark = getPreviewTheme(themes, "ember", "dark"); + const mossDark = getPreviewTheme(themes, "moss", "dark"); + const emberLight = getPreviewTheme(themes, "ember", "light"); + const mossLight = getPreviewTheme(themes, "moss", "light"); + const emberDarkBg = requiredThemeColor(emberDark, "editor.background"); + const mossDarkBg = requiredThemeColor(mossDark, "editor.background"); + const emberDarkFg = requiredThemeColor(emberDark, "editor.foreground"); + const mossDarkFg = requiredThemeColor(mossDark, "editor.foreground"); + const emberAccent = requiredRoleColor(emberDark, "keyword"); + const mossAccent = requiredRoleColor(mossDark, "function"); + const terminalLines = PREVIEW.samples?.terminal?.lines || []; + const laneGap = 18; + const laneWidth = (width - 52 - laneGap) / 2; + const laneY = y + 76; + const laneHeight = 500; + const lightY = laneY + laneHeight + 18; + const lightHeight = 126; + const formats = ["WARP", "WINDOWS TERMINAL", "KITTY", "ALACRITTY", "ITERM2"]; + const emberLightLines = buildFamilySampleLines(emberLight).slice(0, 2); + const mossLightLines = buildFamilySampleLines(mossLight).slice(0, 2); + + return ` + + + + + + + + + HEARTHCODE TERMINAL + 5 FORMATS · 4 THEMES + + ${renderTerminalLane({ meta: emberDark, x: x + 26, y: laneY, width: laneWidth, height: laneHeight, terminalLines })} + ${renderTerminalLane({ meta: mossDark, x: x + 26 + laneWidth + laneGap, y: laneY, width: laneWidth, height: laneHeight, terminalLines })} + + + + EMBER LIGHT · PAIRED ANSI + MOSS LIGHT · PAIRED ANSI + ${emberLightLines.map((segments, index) => renderCodeLine({ theme: emberLight.theme, segments, x: x + 46, y: lightY + 52 + index * 34, fontSize: 18 })).join("")} + ${mossLightLines.map((segments, index) => renderCodeLine({ theme: mossLight.theme, segments, x: x + 46 + laneWidth + laneGap, y: lightY + 52 + index * 34, fontSize: 18 })).join("")} + ${formats.map((format, index) => { + const chipWidth = index === 1 ? 156 : index === 3 ? 112 : 82; + const precedingWidth = formats.slice(0, index).reduce((sum, item, itemIndex) => sum + (itemIndex === 1 ? 156 : itemIndex === 3 ? 112 : 82) + 12, 0); + return ` + + ${format} + `; + }).join("")} + ONE ANSI LANGUAGE · FIVE OUTPUTS + + `; +} + +function renderChannelProofSvg({ themes, channelId, width = WIDTH, height = HEIGHT }) { + const mossDark = getPreviewTheme(themes, "moss", "dark"); + const bg = mixHex(themeColor(mossDark.theme, "editor.background", "#191a17"), "#000000", 0.06); + const panelX = 18; + const panelY = 18; + const panelWidth = width - panelX * 2; + const panelHeight = height - panelY * 2; + const proof = channelId === "terminal" + ? renderTerminalUnifiedProof({ themes, x: panelX, y: panelY, width: panelWidth, height: panelHeight }) + : renderZedUnifiedProof({ themes, x: panelX, y: panelY, width: panelWidth, height: panelHeight }); + + return ` + + + + ${proof} + + `; +} + +function renderAvailabilityCell({ available, x, y, accent, fg, muted }) { + if (!available) { + return ``; + } + return ` + + + + DARK + + + LIGHT + + `; +} + +function renderPlatformCoverageSvg({ themes }) { + const emberDark = getPreviewTheme(themes, "ember", "dark"); + const mossDark = getPreviewTheme(themes, "moss", "dark"); + const bg = mixHex(themeColor(mossDark.theme, "editor.background", "#191a17"), "#000000", 0.08); + const fg = themeColor(mossDark.theme, "editor.foreground", "#d3c9b8"); + const muted = mixHex(fg, bg, 0.52); + const emberAccent = roleColor(emberDark.theme, "keyword"); + const mossAccent = roleColor(mossDark.theme, "function"); + const coverage = PRODUCT.channelAvailability || {}; + const platformRows = [ + { id: "vscode", label: "VS CODE", route: "MARKETPLACE" }, + { id: "openvsx", label: "VSX EDITORS", route: "OPEN VSX" }, + { id: "zed", label: "ZED", route: "ZED EXTENSIONS" }, + { id: "terminal", label: "TERMINALS", route: "GITHUB PACKS" }, + { id: "obsidian", label: "OBSIDIAN", route: "COMMUNITY THEMES" }, + ]; + const capabilityLabels = { + "theme-forge": "THEME FORGE", + "five-formats": "5 FORMATS", + "style-settings": "STYLE SETTINGS", + }; + const rows = platformRows.map((platform, index) => { + const entry = coverage[platform.id] || { schemeIds: [], capabilityIds: [] }; + const rowY = 302 + index * 94; + const capability = (entry.capabilityIds || []).map((id) => capabilityLabels[id] || id.toUpperCase()).join(" · ") || "—"; + return ` + + + ${platform.label} + ${platform.route} + ${renderAvailabilityCell({ available: entry.schemeIds.includes("ember"), x: 500, y: rowY + 5, accent: emberAccent, fg, muted })} + ${renderAvailabilityCell({ available: entry.schemeIds.includes("moss"), x: 820, y: rowY + 5, accent: mossAccent, fg, muted })} + ${escapeXml(capability)} + + `; + }).join(""); + const heading = PREVIEW.marketing?.platformHeadline || "One system. Accurate on every surface."; + const subheading = PREVIEW.marketing?.platformSubheadline || "Every channel shows only the directions and modes it actually ships."; return ` - - - - - HearthCode Theme Forge - - - Theme Forge - Choose a base direction and seed color. Forge rebuilds and verifies Dark and Light together. - - 1 · BASE DIRECTION - - - - Moss - dry + structural - - - - Ember - warm + soft - - SEED COLOR - - - - ${seed} - hue 96° · saturation 44% - Role lightness stays calibrated; saturation remains inside the audited range. - - 3 · APPLY OR RESTORE - - Apply in editor - - Reset color - - Restore original theme - Ready — pick a color, then Apply - - - One reversible change - Theme-scoped overrides apply live to both modes. - Restore removes exactly what Forge wrote. - - 2 · DARK + LIGHT PREVIEW - - - - - MOSS DARK - MOSS LIGHT - ${renderPreviewLines(darkTheme, 720)} - ${renderPreviewLines(lightTheme, 1120)} - - - Moss · Dark - Moss · Light - ROLE SEPARATION · AA-CHECKED CHROME · FUNCTIONAL COLORS PRESERVED + ${renderMaterialTexture({ id: "coverage-field", ink: fg, width: WIDTH, height: HEIGHT, seed: 173, intensity: BRAND_SYSTEM.material.posterTexture })} + ${renderFieldGuideGrid({ color: fg })} + + + ${renderRegistrationMarks({ color: fg })} + HEARTHCODE / COLOR FIELD GUIDE 02 + ${escapeXml(heading)} + ${escapeXml(subheading)} + + SURFACE / INSTALL ROUTE + EMBER + MOSS + CHANNEL CAPABILITY + ${rows} + AMBER IS AN OBSIDIAN ACCENT PRESET — NOT A THEME DIRECTION. + SOURCE: products/hearthcode/product.json + + `; +} + +function renderMossSurfacesSvg({ themes }) { + const mossDark = getPreviewTheme(themes, "moss", "dark"); + const mossLight = getPreviewTheme(themes, "moss", "light"); + const darkTheme = mossDark.theme; + const lightTheme = mossLight.theme; + const bg = mixHex(themeColor(darkTheme, "editor.background", "#191a17"), "#000000", 0.06); + const fg = themeColor(darkTheme, "editor.foreground", "#d3c9b8"); + const muted = mixHex(fg, bg, 0.52); + const accent = roleColor(darkTheme, "function"); + const yellow = roleColor(darkTheme, "keyword"); + const blue = roleColor(darkTheme, "type"); + const string = roleColor(darkTheme, "string"); + const terminalGreen = themeColor(darkTheme, "terminal.ansiGreen", accent); + const terminalYellow = themeColor(darkTheme, "terminal.ansiYellow", yellow); + const terminalBlue = themeColor(darkTheme, "terminal.ansiBlue", blue); + const lightBg = themeColor(lightTheme, "editor.background", "#e7e5d8"); + const lightFg = themeColor(lightTheme, "editor.foreground", "#342d28"); + const lightMuted = mixHex(lightFg, lightBg, 0.5); + const codeLines = buildFamilySampleLines(mossDark); + const noteLines = PREVIEW.samples?.obsidian?.lines || []; + const terminalLines = PREVIEW.samples?.terminal?.lines || []; + if (noteLines.length !== 4 || terminalLines.length !== 4) { + throw new Error("Preview sample contract: Obsidian and terminal samples must each define four lines"); + } + const heading = PREVIEW.marketing?.mossSurfaceHeadline || "Moss follows the work, not the app."; + const subheading = PREVIEW.marketing?.mossSurfaceSubheadline || "The same hierarchy moves through code, notes, and terminal output."; + + return ` + + + ${renderMaterialTexture({ id: "moss-surfaces-field", ink: fg, width: WIDTH, height: HEIGHT, seed: 179, intensity: BRAND_SYSTEM.material.posterTexture })} + ${renderFieldGuideGrid({ color: fg })} + ${renderRegistrationMarks({ color: fg })} + + HEARTHCODE / COLOR FIELD GUIDE 03 / MOSS + ${escapeXml(heading)} + ${escapeXml(subheading)} + + + + 01 / CODE + + + + + + workspace.ts + ${renderNumberedCodeBlock({ theme: darkTheme, lines: codeLines, x: 92, y: 362, fontSize: 18, lineHeight: 43 })} + + MOSS DARK · SEMANTIC TOKENS + + + + 02 / NOTES + + MARKDOWN · SOURCE SAMPLE + ${escapeXml(noteLines[0])} + + + ${escapeXml(noteLines[1])} + ${escapeXml(noteLines[2])} + ${escapeXml(noteLines[3])} + OBSIDIAN · MOSS LIGHT + + + + 03 / TERMINAL + + + + + + ${escapeXml(terminalLines[0])} + ${escapeXml(terminalLines[1])} + ${escapeXml(terminalLines[2])} + ${escapeXml(terminalLines[3])} + + WARP · KITTY · ALACRITTY + ITERM2 · WINDOWS TERMINAL + + + + Same roles, same hierarchy. + The surface changes; the meaning does not. + GENERATED SEMANTIC PREVIEW · NOT APP SCREENSHOTS `; } +function renderOgSvg({ themes }) { + const asset = MARKETING_ASSETS["site-og"]; + return renderFamilyAssetSvg({ themes, composition: asset.composition, ...asset.canvas }); +} + function removeFileIfExists(path) { if (!existsSync(path)) return false; rmSync(path); @@ -1033,7 +1857,9 @@ function removeFileIfExists(path) { async function writePng(svg, outputPath) { mkdirSync(dirname(outputPath), { recursive: true }); await sharp(Buffer.from(svg)) - .png({ compressionLevel: 9, quality: 100 }) + // Material texture introduces many nearby tones. Keep true-color PNGs so + // adaptive palette quantization cannot replace rare source-token swatches. + .png({ compressionLevel: 9, palette: false }) .toFile(outputPath); console.log(`✓ generated ${outputPath}`); } @@ -1041,19 +1867,56 @@ async function writePng(svg, outputPath) { async function run() { mkdirSync(OUTPUT_DIR, { recursive: true }); mkdirSync(WEBSITE_OUTPUT_DIR, { recursive: true }); + mkdirSync(MARKETING_OUTPUT_DIR, { recursive: true }); mkdirSync(dirname(MANIFEST_PATH), { recursive: true }); - const themes = FEATURED_THEME_META.map((meta) => ({ ...meta, theme: readJson(meta.file) })); + const themes = FEATURED_THEME_META.map((meta) => { + const source = readFileSync(meta.file); + return { + ...meta, + sourceSha256: sha256(source), + theme: JSON.parse(source.toString("utf8")), + }; + }); const missingThemeIds = FEATURED_THEME_META .map((meta) => meta.id) .filter((id) => !themes.some((meta) => meta.id === id)); if (missingThemeIds.length > 0) { throw new Error(`Theme metadata is incomplete: ${missingThemeIds.join(", ")}`); } + const colorFidelity = buildMarketingColorContract(themes); const contrastSvg = renderContrastSvg({ themes }); const editorHeroSvg = renderEditorHeroSvg({ themes }); const forgeWorkflowSvg = renderForgeWorkflowSvg({ themes }); + const directionAtlasSvg = renderDirectionAtlasSvg({ themes }); + const platformCoverageSvg = renderPlatformCoverageSvg({ themes }); + const mossSurfacesSvg = renderMossSurfacesSvg({ themes }); + const githubSocialSvg = renderFamilyAssetSvg({ + themes, + composition: MARKETING_ASSETS["github-social"].composition, + ...MARKETING_ASSETS["github-social"].canvas, + }); + const ogSvg = renderOgSvg({ themes }); + const familySquareSvg = renderFamilyAssetSvg({ + themes, + composition: MARKETING_ASSETS["family-square"].composition, + ...MARKETING_ASSETS["family-square"].canvas, + }); + const familyPortraitSvg = renderFamilyAssetSvg({ + themes, + composition: MARKETING_ASSETS["family-portrait"].composition, + ...MARKETING_ASSETS["family-portrait"].canvas, + }); + const familyStorySvg = renderFamilyAssetSvg({ + themes, + composition: MARKETING_ASSETS["family-story"].composition, + ...MARKETING_ASSETS["family-story"].canvas, + }); + const emberSquareSvg = renderDirectionCardSvg({ themes, schemeId: "ember", ...MARKETING_ASSETS["ember-square"].canvas }); + const mossSquareSvg = renderDirectionCardSvg({ themes, schemeId: "moss", ...MARKETING_ASSETS["moss-square"].canvas }); + const zedPlatformSvg = renderChannelProofSvg({ themes, channelId: "zed", ...MARKETING_ASSETS["zed-platform"].canvas }); + const terminalPlatformSvg = renderChannelProofSvg({ themes, channelId: "terminal", ...MARKETING_ASSETS["terminal-platform"].canvas }); const previewFlavorMeta = FLAVOR_IDS.map((schemeId) => ({ id: schemeId, name: FLAVORS_BY_ID[schemeId].name, @@ -1064,11 +1927,16 @@ async function run() { const promoSpecSha256 = sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, + brandSystemSourceSha256: BRAND_SYSTEM_SOURCE_SHA256, + templateComponentsSourceSha256: TEMPLATE_COMPONENTS_SOURCE_SHA256, + assetSpecSourceSha256: ASSET_SPEC_SOURCE_SHA256, product: { id: PRODUCT.id, name: PRODUCT.name, displayName: PRODUCT.displayName, summary: PRODUCT.summary, + channels: PRODUCT.channels, + channelAvailability: PRODUCT.channelAvailability, }, flavors: previewFlavorMeta, featuredThemes: themes.map((meta) => ({ @@ -1084,14 +1952,27 @@ async function run() { })); const manifest = { - schemaVersion: 3, + schemaVersion: 6, generator: "scripts/generate-preview-images.mjs", renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, + brandSystem: { + id: BRAND_SYSTEM.id, + source: "scripts/marketing/brand-system.mjs", + sourceSha256: BRAND_SYSTEM_SOURCE_SHA256, + templateComponentsSource: "scripts/marketing/template-components.mjs", + templateComponentsSourceSha256: TEMPLATE_COMPONENTS_SOURCE_SHA256, + }, + assetSpec: { + source: "products/hearthcode/marketing-assets.json", + sourceSha256: ASSET_SPEC_SOURCE_SHA256, + }, promoSpecSha256, + colorFidelity, canvas: { width: WIDTH, height: HEIGHT }, + formats: MARKETING_SPEC.formats, editorHero: { - inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, themes, asset: "editor-hero" })), + inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, themes, samples: PREVIEW.samples?.editors, asset: "editor-hero" })), outputs: EDITOR_HERO_OUTPUTS.map(toPosixPath), }, contrastImage: { @@ -1128,9 +2009,75 @@ async function run() { outputs: CONTRAST_OUTPUTS.map(toPosixPath), }, forgeWorkflow: { - inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, themes, asset: "forge-workflow" })), + inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, themes, samples: PREVIEW.samples?.forge, asset: "forge-workflow" })), outputs: FORGE_WORKFLOW_OUTPUTS.map(toPosixPath), }, + directionAtlas: { + inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, themes, preview: PREVIEW.marketing, asset: "direction-atlas" })), + outputs: DIRECTION_ATLAS_OUTPUTS.map(toPosixPath), + }, + platformCoverage: { + inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, channelAvailability: PRODUCT.channelAvailability, preview: PREVIEW.marketing, asset: "platform-coverage" })), + outputs: PLATFORM_COVERAGE_OUTPUTS.map(toPosixPath), + }, + mossSurfaces: { + inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, themes: themes.filter((theme) => theme.schemeId === "moss"), preview: PREVIEW.marketing, samples: PREVIEW.samples, asset: "moss-surfaces" })), + outputs: MOSS_SURFACES_OUTPUTS.map(toPosixPath), + }, + socialCard: { + inputSha256: sha256(JSON.stringify({ renderer: PREVIEW_RENDERER, generatorSourceSha256: GENERATOR_SOURCE_SHA256, themes, preview: PREVIEW, channelAvailability: PRODUCT.channelAvailability, asset: "og" })), + canvas: MARKETING_ASSETS["site-og"].canvas, + outputs: OG_OUTPUTS.map(toPosixPath), + }, + githubSocial: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["github-social"] })), + canvas: MARKETING_ASSETS["github-social"].canvas, + outputs: GITHUB_SOCIAL_OUTPUTS.map(toPosixPath), + }, + familySquare: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["family-square"] })), + canvas: MARKETING_ASSETS["family-square"].canvas, + outputs: FAMILY_SQUARE_OUTPUTS.map(toPosixPath), + }, + familyPortrait: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["family-portrait"] })), + canvas: MARKETING_ASSETS["family-portrait"].canvas, + outputs: FAMILY_PORTRAIT_OUTPUTS.map(toPosixPath), + }, + familyStory: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["family-story"] })), + canvas: MARKETING_ASSETS["family-story"].canvas, + outputs: FAMILY_STORY_OUTPUTS.map(toPosixPath), + }, + emberSquare: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["ember-square"] })), + canvas: MARKETING_ASSETS["ember-square"].canvas, + outputs: EMBER_SQUARE_OUTPUTS.map(toPosixPath), + }, + mossSquare: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["moss-square"] })), + canvas: MARKETING_ASSETS["moss-square"].canvas, + outputs: MOSS_SQUARE_OUTPUTS.map(toPosixPath), + }, + zedPlatform: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["zed-platform"], availability: PRODUCT.channelAvailability?.zed })), + canvas: MARKETING_ASSETS["zed-platform"].canvas, + outputs: ZED_PLATFORM_OUTPUTS.map(toPosixPath), + }, + terminalPlatform: { + inputSha256: sha256(JSON.stringify({ promoSpecSha256, asset: MARKETING_ASSETS["terminal-platform"], availability: PRODUCT.channelAvailability?.terminal })), + canvas: MARKETING_ASSETS["terminal-platform"].canvas, + outputs: TERMINAL_PLATFORM_OUTPUTS.map(toPosixPath), + }, + managedAssets: MARKETING_SPEC.managedAssets.map((asset) => ({ + ...asset, + canvas: asset.format ? MARKETING_SPEC.formats[asset.format] : undefined, + outputs: asset.outputs.map(toPosixPath), + outputSha256: Object.fromEntries(asset.outputs.map((output) => [ + toPosixPath(output), + existsSync(output) ? sha256(readFileSync(output)) : null, + ])), + })), }; for (const legacyOutput of LEGACY_PREVIEW_OUTPUTS) { @@ -1146,7 +2093,23 @@ async function run() { const previousManifest = existsSync(MANIFEST_PATH) ? readJson(MANIFEST_PATH) : null; const manifestUnchanged = previousManifest != null && JSON.stringify(previousManifest) === JSON.stringify(manifest); - const previewOutputs = [...EDITOR_HERO_OUTPUTS, ...CONTRAST_OUTPUTS, ...FORGE_WORKFLOW_OUTPUTS]; + const previewOutputs = [ + ...EDITOR_HERO_OUTPUTS, + ...CONTRAST_OUTPUTS, + ...FORGE_WORKFLOW_OUTPUTS, + ...DIRECTION_ATLAS_OUTPUTS, + ...PLATFORM_COVERAGE_OUTPUTS, + ...MOSS_SURFACES_OUTPUTS, + ...GITHUB_SOCIAL_OUTPUTS, + ...OG_OUTPUTS, + ...FAMILY_SQUARE_OUTPUTS, + ...FAMILY_PORTRAIT_OUTPUTS, + ...FAMILY_STORY_OUTPUTS, + ...EMBER_SQUARE_OUTPUTS, + ...MOSS_SQUARE_OUTPUTS, + ...ZED_PLATFORM_OUTPUTS, + ...TERMINAL_PLATFORM_OUTPUTS, + ]; const outputsPresent = previewOutputs.every((output) => existsSync(output)); if (!forceRender && manifestUnchanged && outputsPresent) { @@ -1161,6 +2124,18 @@ async function run() { [editorHeroSvg, EDITOR_HERO_OUTPUTS], [contrastSvg, CONTRAST_OUTPUTS], [forgeWorkflowSvg, FORGE_WORKFLOW_OUTPUTS], + [directionAtlasSvg, DIRECTION_ATLAS_OUTPUTS], + [platformCoverageSvg, PLATFORM_COVERAGE_OUTPUTS], + [mossSurfacesSvg, MOSS_SURFACES_OUTPUTS], + [githubSocialSvg, GITHUB_SOCIAL_OUTPUTS], + [ogSvg, OG_OUTPUTS], + [familySquareSvg, FAMILY_SQUARE_OUTPUTS], + [familyPortraitSvg, FAMILY_PORTRAIT_OUTPUTS], + [familyStorySvg, FAMILY_STORY_OUTPUTS], + [emberSquareSvg, EMBER_SQUARE_OUTPUTS], + [mossSquareSvg, MOSS_SQUARE_OUTPUTS], + [zedPlatformSvg, ZED_PLATFORM_OUTPUTS], + [terminalPlatformSvg, TERMINAL_PLATFORM_OUTPUTS], ]) { for (const output of outputs) { await writePng(svg, output); diff --git a/scripts/generate-site-assets.mjs b/scripts/generate-site-assets.mjs index 10fdf72..6bbc637 100644 --- a/scripts/generate-site-assets.mjs +++ b/scripts/generate-site-assets.mjs @@ -272,6 +272,8 @@ function renderProductDataModule(productData) { featuredFlavorIds: productData.featuredFlavorIds, flavors: productData.flavors, themes: productData.themes, + channels: productData.channels, + channelAvailability: productData.channelAvailability, site: productData.site, release: productData.release, extension: { diff --git a/scripts/generate-zed-themes.mjs b/scripts/generate-zed-themes.mjs new file mode 100644 index 0000000..b762c43 --- /dev/null +++ b/scripts/generate-zed-themes.mjs @@ -0,0 +1,118 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { pathToFileURL } from 'url' +import { + getThemeMetaListForSchemeId, + loadColorProductManifest, + loadColorProductReleaseConfig, + loadColorSchemeManifest, +} from './color-system.mjs' +import { buildColorLanguageModel } from './color-system/build.mjs' +import { buildVscodeThemes } from './generate-theme-variants-node.mjs' +import { getReleaseVersion } from './release-metadata.mjs' +import { compile } from './theme-engine/compile.mjs' +import { createZedEmitter } from './theme-engine/emit/zed.mjs' + +export const ZED_EXTENSION_DIR = 'zed/extension' +export const ZED_THEMES_DIR = `${ZED_EXTENSION_DIR}/themes` +export const ZED_MANIFEST_PATH = `${ZED_EXTENSION_DIR}/extension.toml` + +function writeIfChanged(path, content) { + if (existsSync(path)) { + const previous = readFileSync(path, 'utf8').replace(/\r\n/g, '\n') + const next = content.replace(/\r\n/g, '\n') + if (previous === next) return false + } + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + return true +} + +function tomlString(value) { + return JSON.stringify(String(value)) +} + +function renderManifest({ config, version }) { + return [ + `id = ${tomlString(config.id)}`, + `name = ${tomlString(config.name)}`, + 'schema_version = 1', + `version = ${tomlString(version)}`, + `authors = [${config.authors.map(tomlString).join(', ')}]`, + `description = ${tomlString(config.description)}`, + `repository = ${tomlString(config.repository)}`, + '', + ].join('\n') +} + +function removeStaleThemeFiles(expectedPaths, log) { + if (!existsSync(ZED_THEMES_DIR)) return + for (const name of readdirSync(ZED_THEMES_DIR)) { + const path = join(ZED_THEMES_DIR, name) + if (!statSync(path).isFile() || expectedPaths.has(path)) continue + rmSync(path, { force: true }) + log(`✓ removed stale ${path}`) + } +} + +export function buildZedExtensionFiles() { + const product = loadColorProductManifest() + const releaseConfig = loadColorProductReleaseConfig() + const zedConfig = releaseConfig.zedExtension + if (!zedConfig) throw new Error('Active product release config is missing zedExtension metadata') + + const themeFiles = product.supportedSchemeIds.flatMap((schemeId) => { + const scheme = loadColorSchemeManifest(schemeId) + const model = buildColorLanguageModel({ schemeId }) + const { themes } = buildVscodeThemes({ + schemeId, + model, + writeReferenceFiles: false, + log: null, + }) + const flavorName = product.flavorNames?.[schemeId]?.theme || `${product.name} ${scheme.name}` + const themeNames = Object.fromEntries( + getThemeMetaListForSchemeId(schemeId).map((variant) => [ + variant.id, + `${flavorName} ${variant.climateLabel}`, + ]) + ) + const emitter = createZedEmitter({ + familyName: flavorName, + author: product.author.name, + themeNames, + outputPath: `${ZED_THEMES_DIR}/${product.id}-${schemeId}.json`, + }) + return compile({ model, themes, variant: null, emitters: [emitter] }) + }) + + return [ + { + path: ZED_MANIFEST_PATH, + content: renderManifest({ config: zedConfig, version: getReleaseVersion() }), + }, + ...themeFiles, + ] +} + +export function generateZedThemes({ log = console.log } = {}) { + const files = buildZedExtensionFiles() + const expectedThemePaths = new Set( + files.filter((file) => file.path.startsWith(`${ZED_THEMES_DIR}/`)).map((file) => file.path) + ) + removeStaleThemeFiles(expectedThemePaths, log) + for (const file of files) { + const changed = writeIfChanged(file.path, file.content) + log(`${changed ? '✓ generated' : '- unchanged'} ${file.path}`) + } + return files +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + generateZedThemes() + } catch (error) { + console.error(`[FAIL] ${error.message}`) + process.exit(1) + } +} diff --git a/scripts/generated-origin-audit.mjs b/scripts/generated-origin-audit.mjs index 2cd59be..aae367e 100644 --- a/scripts/generated-origin-audit.mjs +++ b/scripts/generated-origin-audit.mjs @@ -13,6 +13,8 @@ const GENERATED_PATH_RULES = [ { type: 'prefix', value: 'terminal/kitty/' }, { type: 'prefix', value: 'terminal/alacritty/' }, { type: 'prefix', value: 'terminal/iterm2/' }, + { type: 'prefix', value: 'zed/extension/themes/' }, + { type: 'exact', value: 'zed/extension/extension.toml' }, { type: 'exact', value: 'docs/color-language-report.md' }, { type: 'exact', value: 'docs/color-language-contract-checklist.md' }, { type: 'exact', value: 'docs/color-language-contract-review.md' }, @@ -30,6 +32,18 @@ const GENERATED_PATH_RULES = [ { type: 'exact', value: 'src/data/tokens.ts' }, { type: 'exact', value: 'src/data/product.ts' }, { type: 'exact', value: 'src/styles/theme-vars.css' }, + { type: 'exact', value: 'extension/images/editor-moss-dark-light.png' }, + { type: 'exact', value: 'extension/images/family-overview.png' }, + { type: 'exact', value: 'extension/images/theme-forge-workflow.png' }, + { type: 'exact', value: 'public/previews/family-overview.png' }, + { type: 'exact', value: 'docs/marketing/direction-atlas.png' }, + { type: 'exact', value: 'docs/marketing/platform-coverage.png' }, + { type: 'exact', value: 'docs/marketing/moss-surfaces.png' }, + { type: 'prefix', value: 'docs/marketing/exports/' }, + { type: 'exact', value: 'zed/images/hearthcode-zed.png' }, + { type: 'exact', value: 'terminal/hearthcode-terminal.png' }, + { type: 'exact', value: 'public/og-hearth.png' }, + { type: 'exact', value: 'reports/preview-manifest.json' }, ] const SOURCE_OF_TRUTH_RULES = [ @@ -134,7 +148,9 @@ function isSourceOfTruthPath(path) { } function isVersionDerivedGeneratedPath(path) { - return path === 'obsidian/app-theme/manifest.json' || path === 'obsidian/app-theme/versions.json' + return path === 'obsidian/app-theme/manifest.json' || + path === 'obsidian/app-theme/versions.json' || + path === 'zed/extension/extension.toml' } function resolveDiffMode(args) { diff --git a/scripts/marketing/asset-spec.mjs b/scripts/marketing/asset-spec.mjs new file mode 100644 index 0000000..b82d00d --- /dev/null +++ b/scripts/marketing/asset-spec.mjs @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { validate } from "../json-schema-validator.mjs"; + +const ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const SPEC_PATH = resolve(ROOT, "products/hearthcode/marketing-assets.json"); +const SCHEMA_PATH = resolve(ROOT, "schemas/marketing-assets.schema.json"); + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +export function loadMarketingAssetSpec() { + const spec = readJson(SPEC_PATH); + const schema = readJson(SCHEMA_PATH); + const errors = validate(spec, schema); + if (errors.length) { + throw new Error(`Marketing asset spec is invalid:\n${errors.map((error) => `- ${error}`).join("\n")}`); + } + + const ids = new Set(); + const outputs = new Set(); + for (const asset of [...spec.assets, ...spec.managedAssets]) { + if (ids.has(asset.id)) throw new Error(`Duplicate marketing asset id: ${asset.id}`); + ids.add(asset.id); + if (asset.format && !spec.formats[asset.format]) { + throw new Error(`Marketing asset ${asset.id} references unknown format: ${asset.format}`); + } + if (asset.template === "family" && !asset.composition) { + throw new Error(`Family marketing asset ${asset.id} must declare an explicit composition`); + } + if (asset.template !== "family" && asset.composition) { + throw new Error(`Non-family marketing asset ${asset.id} cannot declare a family composition`); + } + for (const output of asset.outputs) { + if (outputs.has(output)) throw new Error(`Duplicate marketing asset output: ${output}`); + outputs.add(output); + } + } + + return spec; +} + +export function indexMarketingAssets(spec) { + return Object.fromEntries(spec.assets.map((asset) => [asset.id, { + ...asset, + canvas: spec.formats[asset.format], + }])); +} diff --git a/scripts/marketing/brand-system.mjs b/scripts/marketing/brand-system.mjs new file mode 100644 index 0000000..cfd8478 --- /dev/null +++ b/scripts/marketing/brand-system.mjs @@ -0,0 +1,80 @@ +export const BRAND_SYSTEM = Object.freeze({ + id: "semantic-materials-v1", + typography: Object.freeze({ + display: "'Segoe UI', 'Noto Sans', sans-serif", + displayCondensed: "'Avenir Next Condensed', 'Arial Narrow', 'Liberation Sans Narrow', sans-serif", + ui: "'Segoe UI', 'Noto Sans', sans-serif", + mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", + }), + fieldGuide: Object.freeze({ + gridStep: 40, + gridOpacity: 0.055, + registrationInset: 24, + registrationSize: 18, + }), + material: Object.freeze({ + posterTexture: 1, + proofTexture: 0.72, + typeWear: 0.9, + tornRift: 0.82, + }), + copy: Object.freeze({ + familyKicker: "HEARTHCODE", + fieldGuideKicker: "HEARTHCODE / COLOR FIELD GUIDE", + site: "theme.hearthcode.dev", + }), +}); + +export function escapeXml(input) { + return String(input) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function normalizeHex(hex) { + if (typeof hex !== "string") return null; + const value = hex.trim().toLowerCase(); + if (/^#[0-9a-f]{6}$/i.test(value)) return value; + if (/^#[0-9a-f]{8}$/i.test(value)) return value.slice(0, 7); + return null; +} + +export function hexToRgb(hex) { + const normalized = normalizeHex(hex); + if (!normalized) return null; + const value = normalized.slice(1); + return [ + Number.parseInt(value.slice(0, 2), 16), + Number.parseInt(value.slice(2, 4), 16), + Number.parseInt(value.slice(4, 6), 16), + ]; +} + +export function rgbToHex(rgb) { + return `#${rgb + .map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")) + .join("")}`; +} + +export function mixHex(a, b, weight = 0.5) { + const rgbA = hexToRgb(a); + const rgbB = hexToRgb(b); + if (!rgbA && !rgbB) return "#000000"; + if (!rgbA) return normalizeHex(b) ?? "#000000"; + if (!rgbB) return normalizeHex(a) ?? "#000000"; + const t = Math.max(0, Math.min(1, weight)); + return rgbToHex([ + rgbA[0] + (rgbB[0] - rgbA[0]) * t, + rgbA[1] + (rgbB[1] - rgbA[1]) * t, + rgbA[2] + (rgbB[2] - rgbA[2]) * t, + ]); +} + +export function withAlpha(hex, alpha) { + const rgb = hexToRgb(hex); + if (!rgb) return `rgba(0, 0, 0, ${alpha})`; + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`; +} diff --git a/scripts/marketing/template-components.mjs b/scripts/marketing/template-components.mjs new file mode 100644 index 0000000..d1635e3 --- /dev/null +++ b/scripts/marketing/template-components.mjs @@ -0,0 +1,486 @@ +import { BRAND_SYSTEM, escapeXml, withAlpha } from "./brand-system.mjs"; + +function hashSeed(input) { + let hash = 2166136261; + for (const character of String(input)) { + hash ^= character.codePointAt(0); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function createSeededRandom(seed) { + let state = seed || 1; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +function svgPoint(point) { + return `${point.x.toFixed(2)},${point.y.toFixed(2)}`; +} + +function svgPath(points) { + return points.map((point, index) => `${index === 0 ? "M" : "L"} ${svgPoint(point)}`).join(" "); +} + +function polylineXAtY(points, y) { + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]; + const end = points[index + 1]; + if (y < Math.min(start.y, end.y) || y > Math.max(start.y, end.y)) continue; + const progress = end.y === start.y ? 0 : (y - start.y) / (end.y - start.y); + return start.x + (end.x - start.x) * progress; + } + return points.at(-1)?.x ?? 0; +} + +export function assertSemanticRiftLayout(layout) { + const titleCenterY = layout.title.y + layout.title.fontSize * 0.52; + const titleSeamX = polylineXAtY(layout.tearControlPoints, titleCenterY); + const emberTitleRight = layout.title.emberX + layout.title.emberApproxWidth; + const mossTitleLeft = layout.title.mossX; + if (emberTitleRight > titleSeamX - layout.semanticGap) { + throw new Error("Semantic rift layout places EMBER outside the Ember field"); + } + if (mossTitleLeft < titleSeamX + layout.semanticGap) { + throw new Error("Semantic rift layout places MOSS outside the Moss field"); + } + if (layout.title.mossX !== layout.sample.rightX) { + throw new Error("Semantic rift layout disconnects MOSS from its proof column"); + } + if (layout.sample.fontSize < 24 * layout.scale || layout.sample.lineHeight < 36 * layout.scale) { + throw new Error("Semantic rift layout makes the code proof secondary or unreadable"); + } + + const subheadingSeamX = polylineXAtY(layout.tearControlPoints, layout.subheading.y + layout.subheading.fontSize * 0.5); + if (layout.subheading.leftX + layout.subheading.leftApproxWidth > subheadingSeamX - layout.semanticGap) { + throw new Error("Semantic rift layout lets the Ember subheading cross the torn boundary"); + } + if (layout.subheading.rightX == null || layout.subheading.rightX < subheadingSeamX + layout.semanticGap) { + throw new Error("Semantic rift layout does not reserve a Moss subheading field"); + } + + for (const y of [layout.sample.darkY + layout.sample.blockHeight * 0.5, layout.sample.lightY + layout.sample.blockHeight * 0.5]) { + const seamX = polylineXAtY(layout.tearControlPoints, y); + if (layout.sample.leftX + layout.sample.approxWidth > seamX - layout.semanticGap) { + throw new Error("Semantic rift layout lets Ember code cross the torn boundary"); + } + if (layout.sample.rightX < seamX + layout.semanticGap) { + throw new Error("Semantic rift layout lets Moss code cross the torn boundary"); + } + } +} + +export function buildSemanticRiftLayout({ width, height }) { + const scale = Math.min(width / 1600, height / 900); + const splitY = Math.round(height * 0.585); + const rightColumnX = width * 0.59; + const inset = width * 0.035; + return { + scale, + splitY, + semanticGap: width * 0.016, + tearControlPoints: [ + { x: width * 0.465, y: 0 }, + { x: width * 0.46, y: height * 0.15 }, + { x: width * 0.475, y: height * 0.27 }, + { x: width * 0.49, y: height * 0.39 }, + { x: width * 0.505, y: height * 0.5 }, + { x: width * 0.515, y: splitY }, + { x: width * 0.522, y: height * 0.71 }, + { x: width * 0.532, y: height * 0.85 }, + { x: width * 0.545, y: height }, + ], + title: { + emberX: inset, + mossX: rightColumnX, + y: height * 0.065, + fontSize: Math.min(height * 0.17, width * 0.096), + emberApproxWidth: width * 0.34, + mossApproxWidth: width * 0.27, + }, + subheading: { + leftX: inset, + rightX: rightColumnX, + y: height * 0.245, + fontSize: Math.min(height * 0.038, width * 0.0215), + leftApproxWidth: width * 0.34, + }, + sample: { + leftX: inset, + rightX: rightColumnX, + darkY: height * 0.335, + lightY: height * 0.67, + fontSize: Math.min(height * 0.036, width * 0.021), + lineHeight: height * 0.049, + labelSize: height * 0.016, + swatchOffset: height * 0.226, + swatchStep: width * 0.032, + swatchWidth: width * 0.024, + swatchHeight: Math.max(6 * scale, height * 0.008), + blockHeight: height * 0.245, + approxWidth: width * 0.34, + }, + }; +} + +export function buildTornPaperGeometry({ + controlPoints, + seed = 1, + segmentLength = 26, + jitter = 8, + paperWidth = 24, + widthVariation = 0.52, +}) { + if (!Array.isArray(controlPoints) || controlPoints.length < 2) { + throw new Error("Torn paper geometry requires at least two control points"); + } + + const random = createSeededRandom(hashSeed(`torn-paper:${seed}`)); + const base = []; + for (let segmentIndex = 0; segmentIndex < controlPoints.length - 1; segmentIndex += 1) { + const start = controlPoints[segmentIndex]; + const end = controlPoints[segmentIndex + 1]; + const distance = Math.hypot(end.x - start.x, end.y - start.y); + const steps = Math.max(2, Math.ceil(distance / segmentLength)); + for (let step = 0; step <= steps; step += 1) { + if (segmentIndex > 0 && step === 0) continue; + const progress = step / steps; + base.push({ + x: start.x + (end.x - start.x) * progress, + y: start.y + (end.y - start.y) * progress, + }); + } + } + + const center = []; + const sideA = []; + const sideB = []; + const normals = []; + const tangents = []; + let centerNoise = 0; + let widthNoise = 0; + + for (let index = 0; index < base.length; index += 1) { + const previous = base[Math.max(0, index - 1)]; + const next = base[Math.min(base.length - 1, index + 1)]; + const tangentLength = Math.hypot(next.x - previous.x, next.y - previous.y) || 1; + const tangent = { + x: (next.x - previous.x) / tangentLength, + y: (next.y - previous.y) / tangentLength, + }; + const normal = { x: -tangent.y, y: tangent.x }; + const isCanvasEndpoint = index === 0 || index === base.length - 1; + const centerTarget = (random() - 0.5) * jitter * 2; + centerNoise = centerNoise * 0.68 + centerTarget * 0.32; + const widthTarget = (random() - 0.5) * widthVariation; + widthNoise = widthNoise * 0.58 + widthTarget * 0.42; + const centerOffset = isCanvasEndpoint ? 0 : centerNoise; + let halfWidth = paperWidth * 0.5 * (1 + widthNoise); + if (!isCanvasEndpoint && random() < 0.12) { + halfWidth *= random() < 0.5 ? 0.58 : 1.42; + } + const point = { + x: base[index].x + normal.x * centerOffset, + y: base[index].y + normal.y * centerOffset, + }; + const firstSide = { + x: point.x + normal.x * halfWidth, + y: isCanvasEndpoint ? base[index].y : point.y + normal.y * halfWidth, + }; + const secondSide = { + x: point.x - normal.x * halfWidth, + y: isCanvasEndpoint ? base[index].y : point.y - normal.y * halfWidth, + }; + + center.push(point); + sideA.push(firstSide); + sideB.push(secondSide); + normals.push(normal); + tangents.push(tangent); + } + + return { center, sideA, sideB, normals, tangents, paperWidth }; +} + +export function renderTornPaperSeam({ + id, + geometry, + paper, + warmInk, + coolInk, + shadowInk, + seed = 1, + intensity = 1, +}) { + const safeId = String(id).replaceAll(/[^a-zA-Z0-9_-]/g, "-"); + const random = createSeededRandom(hashSeed(`${safeId}:seam:${seed}`)); + const { center, sideA, sideB, normals, tangents, paperWidth } = geometry; + const paperMarks = renderTextureMarks({ id: `${safeId}-paper`, ink: shadowInk, seed: seed + 11, intensity: intensity * 0.72 }); + const abrasionMarks = renderTextureMarks({ id: `${safeId}-abrasion`, ink: warmInk, seed: seed + 17, intensity: intensity * 2.4 }); + const ribbon = [...sideA, ...[...sideB].reverse()].map(svgPoint).join(" "); + let abrasionDepth = paperWidth; + const abrasionOuterA = sideA.map((point, index) => { + const target = paperWidth * (1.1 + random() * 2.4 * intensity); + abrasionDepth = abrasionDepth * 0.7 + target * 0.3; + const burst = index > 0 && index < sideA.length - 1 && random() < 0.12 + ? paperWidth * random() * 1.8 + : 0; + return { + x: point.x + normals[index].x * (abrasionDepth + burst), + y: point.y + normals[index].y * (abrasionDepth + burst), + }; + }); + const abrasionRibbon = [...abrasionOuterA, ...[...sideA].reverse()].map(svgPoint).join(" "); + const sideAPath = svgPath(sideA); + const sideBPath = svgPath(sideB); + const edgeSegments = sideA.slice(0, -1).map((point, index) => { + if (random() < 0.2) return ""; + const width = (0.8 + random() * 1.8 * intensity).toFixed(2); + const opacity = (0.34 + random() * 0.48).toFixed(3); + return ``; + }).join(""); + const coolEdgeSegments = sideB.slice(0, -1).map((point, index) => { + if (random() < 0.38) return ""; + const width = (0.7 + random() * 1.8 * intensity).toFixed(2); + const opacity = (0.18 + random() * 0.34).toFixed(3); + return ``; + }).join(""); + const abrasionSlashCount = Math.max(24, Math.round(center.length * 1.1 * intensity)); + const abrasionSlashes = Array.from({ length: abrasionSlashCount }, () => { + const index = 1 + Math.floor(random() * Math.max(1, center.length - 2)); + const normal = normals[index]; + const tangent = tangents[index]; + const distance = paperWidth * (0.9 + random() * 3.2); + const origin = { + x: sideA[index].x + normal.x * distance + tangent.x * (random() - 0.5) * 18, + y: sideA[index].y + normal.y * distance + tangent.y * (random() - 0.5) * 18, + }; + const length = 3 + random() * 18 * intensity; + const end = { + x: origin.x + tangent.x * length + normal.x * (random() - 0.25) * 5, + y: origin.y + tangent.y * length + normal.y * (random() - 0.25) * 5, + }; + return ``; + }).join(""); + const fiberCount = Math.max(18, Math.round(center.length * 1.45 * intensity)); + const fibers = Array.from({ length: fiberCount }, (_, fiberIndex) => { + const index = 1 + Math.floor(random() * Math.max(1, center.length - 2)); + const fromSideA = random() < 0.54; + const origin = fromSideA ? sideA[index] : sideB[index]; + const direction = fromSideA ? 1 : -1; + const normal = normals[index]; + const tangent = tangents[index]; + const length = 4 + random() * 19 * intensity; + const sideways = (random() - 0.5) * 9; + const start = { + x: origin.x + tangent.x * sideways, + y: origin.y + tangent.y * sideways, + }; + const end = { + x: start.x + normal.x * length * direction + tangent.x * (random() - 0.5) * 5, + y: start.y + normal.y * length * direction + tangent.y * (random() - 0.5) * 5, + }; + const ink = fiberIndex % 7 === 0 ? (fromSideA ? warmInk : coolInk) : paper; + return ``; + }).join(""); + const chipCount = Math.max(8, Math.round(center.length * 0.3 * intensity)); + const chips = Array.from({ length: chipCount }, (_, chipIndex) => { + const index = 1 + Math.floor(random() * Math.max(1, center.length - 2)); + const fromSideA = random() < 0.5; + const origin = fromSideA ? sideA[index] : sideB[index]; + const direction = fromSideA ? 1 : -1; + const normal = normals[index]; + const tangent = tangents[index]; + const distance = 7 + random() * 27 * intensity; + const size = 1.6 + random() * 4.5; + const centerX = origin.x + normal.x * distance * direction + tangent.x * (random() - 0.5) * 16; + const centerY = origin.y + normal.y * distance * direction + tangent.y * (random() - 0.5) * 16; + const first = { x: centerX + tangent.x * size, y: centerY + tangent.y * size }; + const second = { x: centerX - tangent.x * size * 0.7, y: centerY - tangent.y * size * 0.7 }; + const third = { x: centerX + normal.x * size * direction, y: centerY + normal.y * size * direction }; + const fill = chipIndex % 4 === 0 ? (fromSideA ? warmInk : coolInk) : paper; + return ``; + }).join(""); + const paperCuts = center.slice(2, -2).filter((_, index) => index % 3 === 0).map((point, index) => { + const tangent = tangents[index * 3 + 2]; + const length = 2 + random() * 8; + return ``; + }).join(""); + + return ` + + ${paperMarks.grains} + ${paperMarks.fibers} + ${abrasionMarks.grains} + ${abrasionMarks.fibers} + + + `; +} + +function renderTextureMarks({ id, ink, seed, intensity }) { + const random = createSeededRandom(hashSeed(`${id}:${seed}`)); + const grainWidth = 97; + const grainHeight = 89; + const fiberWidth = 163; + const fiberHeight = 149; + const grainCount = Math.max(18, Math.round(42 * intensity)); + const fiberCount = Math.max(7, Math.round(13 * intensity)); + const grains = Array.from({ length: grainCount }, () => { + const x = (random() * grainWidth).toFixed(2); + const y = (random() * grainHeight).toFixed(2); + const radius = (0.35 + random() * 1.15).toFixed(2); + const opacity = (0.045 + random() * 0.12).toFixed(3); + return ``; + }).join(""); + const fibers = Array.from({ length: fiberCount }, () => { + const x = random() * fiberWidth; + const y = random() * fiberHeight; + const length = 3 + random() * 11; + const angle = (random() - 0.5) * 0.9; + const dx = Math.cos(angle) * length; + const dy = Math.sin(angle) * length; + const opacity = (0.035 + random() * 0.085).toFixed(3); + return ``; + }).join(""); + return { + grainWidth, + grainHeight, + fiberWidth, + fiberHeight, + grains, + fibers, + }; +} + +export function renderMaterialTexture({ + id, + ink, + x = 0, + y = 0, + width, + height, + points = null, + seed = 1, + intensity = 1, +}) { + const safeId = String(id).replaceAll(/[^a-zA-Z0-9_-]/g, "-"); + const marks = renderTextureMarks({ id: safeId, ink, seed, intensity }); + const shape = points + ? `` + : ``; + + return ` + + ${marks.grains} + ${marks.fibers} + + + `; +} + +export function renderDistressedText({ + id, + text, + x, + y, + fill, + wear, + fontSize, + fontFamily = BRAND_SYSTEM.typography.display, + fontWeight = 850, + letterSpacing = "-0.04em", + textAnchor = null, + seed = 1, + intensity = 1, +}) { + const safeId = String(id).replaceAll(/[^a-zA-Z0-9_-]/g, "-"); + const marks = renderTextureMarks({ id: `${safeId}-type`, ink: wear, seed, intensity: intensity * 1.3 }); + const anchor = textAnchor ? ` text-anchor="${textAnchor}"` : ""; + const attributes = `x="${x}" y="${y}" font-size="${fontSize}" font-family="${fontFamily}" font-weight="${fontWeight}" letter-spacing="${letterSpacing}" dominant-baseline="text-before-edge"${anchor}`; + return ` + + ${marks.grains}${marks.fibers} + + ${escapeXml(text)} + ${escapeXml(text)} + `; +} + +export function renderFieldGuideGrid({ + width = 1600, + height = 900, + color, + opacity = BRAND_SYSTEM.fieldGuide.gridOpacity, + step = BRAND_SYSTEM.fieldGuide.gridStep, +}) { + const vertical = Array.from({ length: Math.ceil(width / step) + 1 }, (_, index) => ( + `` + )).join(""); + const horizontal = Array.from({ length: Math.ceil(height / step) + 1 }, (_, index) => ( + `` + )).join(""); + return `${vertical}${horizontal}`; +} + +export function renderRegistrationMarks({ width = 1600, height = 900, color }) { + const inset = BRAND_SYSTEM.fieldGuide.registrationInset; + const size = BRAND_SYSTEM.fieldGuide.registrationSize; + const farX = width - inset; + const farY = height - inset; + return ` + + + + + + + `; +} + +export function renderFieldGuideHeader({ + width, + inset, + serial, + heading, + subheading, + foreground, + muted, + ruleY = 196, + headingSize = 45, +}) { + return ` + ${escapeXml(`${BRAND_SYSTEM.copy.fieldGuideKicker} ${serial}`)} + ${escapeXml(heading)} + ${escapeXml(subheading)} + + `; +} + +export function renderFieldGuideFooter({ width, height, inset, left, right, muted }) { + const y = height - 34; + return ` + ${escapeXml(left)} + ${escapeXml(right)} + `; +} diff --git a/scripts/pack-zed-publish.mjs b/scripts/pack-zed-publish.mjs new file mode 100644 index 0000000..17f8165 --- /dev/null +++ b/scripts/pack-zed-publish.mjs @@ -0,0 +1,205 @@ +// Mirrors the generated Zed extension into a dedicated public repository. +// The mirror remains a distribution artifact; HearthTheme is the only source. +// +// Usage: +// node scripts/pack-zed-publish.mjs --repo owner/name [flags] +// flags: --dry-run stage + show changes, never commit or push +// --no-push commit in the work clone without pushing +// --no-generate use the already-generated extension files + +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'fs' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import { spawnSync } from 'child_process' +import { buildZedExtensionFiles } from './generate-zed-themes.mjs' + +const SOURCE_DIR = 'zed/extension' +const SOURCE_THEMES_DIR = `${SOURCE_DIR}/themes` +const SOURCE_MARKETING_IMAGE = 'zed/images/hearthcode-zed.png' +const WORK_ROOT = 'release/zed-publish' +const DEFAULT_BRANCH = 'main' + +function getArg(name, fallback = null) { + const inline = process.argv.find((arg) => arg.startsWith(`${name}=`)) + if (inline) return inline.slice(name.length + 1) + const index = process.argv.indexOf(name) + if (index === -1) return fallback + const next = process.argv[index + 1] + return next && !next.startsWith('--') ? next : true +} + +function hasFlag(name) { + return process.argv.includes(name) +} + +function git(args, { cwd, allowFail = false, env = process.env } = {}) { + const result = spawnSync('git', args, { cwd, env, encoding: 'utf8', stdio: 'pipe' }) + if (result.status !== 0 && !allowFail) { + throw new Error(`git ${args.join(' ')} failed:\n${result.stderr || result.stdout}`) + } + return { + status: result.status, + stdout: (result.stdout || '').trim(), + stderr: (result.stderr || '').trim(), + } +} + +function remoteUrlFor(repo, authenticated) { + return authenticated + ? `https://github.com/${repo}.git` + : `git@github.com:${repo}.git` +} + +function createGitAuth(token) { + if (!token) return { env: process.env, cleanup() {} } + + const authDir = mkdtempSync(join(tmpdir(), 'hearthcode-zed-auth-')) + const askpassPath = join(authDir, 'askpass.sh') + writeFileSync(askpassPath, [ + '#!/bin/sh', + 'case "$1" in', + ' *Username*) printf "%s\\n" "x-access-token" ;;', + ' *) printf "%s\\n" "$ZED_GIT_TOKEN" ;;', + 'esac', + '', + ].join('\n'), { mode: 0o700 }) + chmodSync(askpassPath, 0o700) + + return { + env: { + ...process.env, + GIT_ASKPASS: askpassPath, + GIT_ASKPASS_REQUIRE: 'force', + GIT_TERMINAL_PROMPT: '0', + ZED_GIT_TOKEN: token, + }, + cleanup() { + rmSync(authDir, { recursive: true, force: true }) + }, + } +} + +function generate() { + const result = spawnSync('node', ['scripts/generate-zed-themes.mjs'], { stdio: 'inherit' }) + if (result.status !== 0) throw new Error('generate-zed-themes failed') + const marketingResult = spawnSync('node', ['scripts/generate-preview-images.mjs'], { stdio: 'inherit' }) + if (marketingResult.status !== 0) throw new Error('generate-preview-images failed') +} + +function copyPublishFiles(cloneDir, expectedFiles) { + mkdirSync(join(cloneDir, 'themes'), { recursive: true }) + copyFileSync(join(SOURCE_DIR, 'extension.toml'), join(cloneDir, 'extension.toml')) + for (const file of expectedFiles) { + copyFileSync(join(SOURCE_THEMES_DIR, file), join(cloneDir, 'themes', file)) + } + + for (const file of readdirSync(join(cloneDir, 'themes'))) { + const path = join(cloneDir, 'themes', file) + if (!statSync(path).isFile() || expectedFiles.has(file)) continue + rmSync(path, { force: true }) + } + + copyFileSync('LICENSE', join(cloneDir, 'LICENSE')) + copyFileSync('zed/mirror-README.md', join(cloneDir, 'README.md')) + mkdirSync(join(cloneDir, 'images'), { recursive: true }) + copyFileSync(SOURCE_MARKETING_IMAGE, join(cloneDir, 'images', 'hearthcode-zed.png')) + for (const file of readdirSync(join(cloneDir, 'images'))) { + if (file !== 'hearthcode-zed.png') rmSync(join(cloneDir, 'images', file), { recursive: true, force: true }) + } +} + +function main() { + const repo = process.env.ZED_PUBLISH_REPO || getArg('--repo') + if (!repo || repo === true || !String(repo).includes('/')) { + throw new Error('Missing publish repo. Pass --repo owner/name or set ZED_PUBLISH_REPO.') + } + const dryRun = hasFlag('--dry-run') + const noPush = hasFlag('--no-push') || dryRun + const token = process.env.ZED_PUBLISH_TOKEN || '' + const auth = createGitAuth(token) + + try { + if (!hasFlag('--no-generate')) generate() + if (!existsSync(SOURCE_MARKETING_IMAGE)) { + throw new Error(`Missing generated Zed marketing image: ${SOURCE_MARKETING_IMAGE}`) + } + + const generatedFiles = buildZedExtensionFiles() + const expectedThemeFiles = new Set( + generatedFiles + .filter((file) => file.path.startsWith(`${SOURCE_THEMES_DIR}/`)) + .map((file) => file.path.slice(`${SOURCE_THEMES_DIR}/`.length)) + ) + for (const file of generatedFiles) { + if (!existsSync(file.path) || readFileSync(file.path, 'utf8') !== file.content) { + throw new Error(`Generated Zed artifact is missing or stale: ${file.path}`) + } + } + + const cloneDir = resolve(WORK_ROOT, String(repo).replace(/[/]/g, '__')) + mkdirSync(WORK_ROOT, { recursive: true }) + rmSync(cloneDir, { recursive: true, force: true }) + git(['clone', '--quiet', remoteUrlFor(repo, Boolean(token)), cloneDir], { env: auth.env }) + + let branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: cloneDir, + allowFail: true, + env: auth.env, + }).stdout + if (!branch || branch === 'HEAD') { + branch = DEFAULT_BRANCH + git(['checkout', '-B', branch], { cwd: cloneDir, env: auth.env }) + } + + copyPublishFiles(cloneDir, expectedThemeFiles) + git(['add', '-A'], { cwd: cloneDir, env: auth.env }) + const status = git(['status', '--porcelain'], { cwd: cloneDir, env: auth.env }).stdout + if (!status) { + console.log(`[publish] ${repo} already matches the generated Zed extension.`) + return + } + + console.log('[publish] Zed mirror changes:') + console.log(git(['diff', '--cached', '--stat'], { cwd: cloneDir, env: auth.env }).stdout) + if (dryRun) { + console.log('[publish] --dry-run: not committing or pushing.') + return + } + + const version = readFileSync(join(SOURCE_DIR, 'extension.toml'), 'utf8').match(/^version\s*=\s*"([^"]+)"/m)?.[1] + if (!version) throw new Error('Unable to read Zed extension version') + git([ + '-c', 'user.name=github-actions[bot]', + '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com', + '-c', 'commit.gpgsign=false', + 'commit', '-m', `Sync HearthCode ${version}`, + ], { cwd: cloneDir, env: auth.env }) + + if (noPush) { + console.log(`[publish] committed in ${cloneDir}; --no-push set.`) + return + } + git(['push', '-u', 'origin', branch], { cwd: cloneDir, env: auth.env }) + console.log(`[publish] pushed HearthCode ${version} to ${repo} (${branch}).`) + } finally { + auth.cleanup() + } +} + +try { + main() +} catch (error) { + console.error(`[FAIL] ${error.message}`) + process.exit(1) +} diff --git a/scripts/product-metadata.mjs b/scripts/product-metadata.mjs index 86b7ca4..4e2a31e 100644 --- a/scripts/product-metadata.mjs +++ b/scripts/product-metadata.mjs @@ -109,6 +109,7 @@ export function buildProductMetadata({ productId = null } = {}) { const repositoryUrl = trimTrailingSlash(product.repository.url) const websiteUrl = trimTrailingSlash(product.websiteUrl) const marketplaceItemName = `${releaseConfig.vscodeExtension.publisher}.${releaseConfig.vscodeExtension.name}` + const zedExtensionId = releaseConfig.zedExtension?.id || '' const wordmark = splitBrandWordmark(product.name) const brand = product.brand || { id: product.id, @@ -215,6 +216,8 @@ export function buildProductMetadata({ productId = null } = {}) { featuredFlavorIds, flavors, themes: publicThemeCatalog, + channels: product.channels, + channelAvailability: product.channelAvailability, preview, release: { version: releaseVersion, @@ -267,6 +270,8 @@ export function buildProductMetadata({ productId = null } = {}) { issuesUrl: `${repositoryUrl}/issues`, releasesUrl: `${repositoryUrl}/releases`, obsidianUrl: 'https://community.obsidian.md/themes/hearthcode', + zedUrl: zedExtensionId ? `https://zed.dev/extensions/${zedExtensionId}` : '', + terminalUrl: `${repositoryUrl}/tree/main/terminal`, changelogUrl: `${repositoryUrl}/blob/main/extension/CHANGELOG.md`, licenseUrl: `${repositoryUrl}/blob/main/LICENSE`, docsRootUrl: `${repositoryUrl}/blob/main/docs`, diff --git a/scripts/release-consistency-audit.mjs b/scripts/release-consistency-audit.mjs index 33b9be8..9fff382 100644 --- a/scripts/release-consistency-audit.mjs +++ b/scripts/release-consistency-audit.mjs @@ -14,6 +14,8 @@ const SITE_PRODUCT_IMPORT_FILES = [ path.join(ROOT, 'src', 'layouts', 'Layout.astro'), path.join(ROOT, 'src', 'components', 'ui', 'HeroSection.astro'), path.join(ROOT, 'src', 'components', 'ui', 'FinalCtaSection.astro'), + path.join(ROOT, 'src', 'components', 'ui', 'InstallChannelGrid.astro'), + path.join(ROOT, 'src', 'components', 'ui', 'MultiSurfaceSection.astro'), path.join(ROOT, 'src', 'components', 'ui', 'BaselineDocs.astro'), ] @@ -178,6 +180,7 @@ try { metadata.site.defaultTitle, metadata.links.marketplaceUrl, metadata.links.openVsxUrl, + metadata.links.zedUrl, metadata.links.releasesUrl, metadata.links.changelogUrl, metadata.links.reportUrl, @@ -195,7 +198,7 @@ try { if (!source.includes('data/product')) { findings.push(`${path.relative(ROOT, file)} should import shared product metadata from src/data/product.ts.`) } - if (/marketplace\.visualstudio\.com\/items\?itemName=|open-vsx\.org\/extension\/|github\.com\//.test(source)) { + if (/marketplace\.visualstudio\.com\/items\?itemName=|open-vsx\.org\/extension\/|zed\.dev\/extensions\/|community\.obsidian\.md\/themes\/|github\.com\//.test(source)) { findings.push(`${path.relative(ROOT, file)} still contains hardcoded release/repository URLs.`) } } diff --git a/scripts/render-obsidian-screenshot.mjs b/scripts/render-obsidian-screenshot.mjs index b00aec3..b360b49 100644 --- a/scripts/render-obsidian-screenshot.mjs +++ b/scripts/render-obsidian-screenshot.mjs @@ -1,22 +1,19 @@ // Renders the Obsidian community-theme screenshot directly from the generated // theme.css, so the promo image always reflects the active scheme's real -// Obsidian variables. The frame shows one markdown note split on a diagonal: -// dark mode on the upper-left, light mode on the lower-right — communicating -// that the theme ships both modes from a single image. +// Obsidian variables. Both exports use one oversized application frame with a +// clean Moss Light / Moss Dark mode cut, keeping the real Markdown proof as the +// dominant subject instead of turning the modes into separate poster cards. // Bump when the layout/markup changes so the screenshot is re-rasterized even // if the theme colors are unchanged. The hash of (this version + the SVG markup) // is what the generator stores to decide whether to skip re-rendering. -export const RENDERER_VERSION = 'obsidian-functional-markdown-v3' +export const RENDERER_VERSION = 'obsidian-functional-markdown-v7' const CANVAS_W = 512 const CANVAS_H = 288 const SUPERSAMPLE = 2 - -// Diagonal seam (dark left, light right). Runs through the middle of the -// headings and code block so both modes show dense, representative content. -const SEAM_TOP_X = 262 -const SEAM_BOTTOM_X = 212 +const MODE_CUT_TOP_X = 370 +const MODE_CUT_BOTTOM_X = 290 const UI_FONT = "-apple-system, 'Segoe UI', 'Noto Sans', 'Helvetica Neue', sans-serif" const MONO_FONT = "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace" @@ -92,7 +89,7 @@ function checkbox(x, y, { checked, fill, stroke, marker, label }) { } // Renders the full note frame for one mode. Geometry is identical across modes; -// only the resolved colors differ, so the diagonal split lines up perfectly. +// only the resolved colors differ, keeping Dark and Light directly comparable. function renderFrame(vars) { const c = (name, fallback) => cssColor(vars[name] || fallback || '#000000') @@ -230,7 +227,9 @@ function renderFrame(vars) { // code block const codeX = cx const codeY = 221 - const codeW = 185 + // Leave a quiet gutter before the callout rail so the Dark/Light mode cut + // can cross the frame without clipping code or live copy. + const codeW = 160 const codeH = 46 els.push(``) const lx = codeX + 11 @@ -254,45 +253,64 @@ function renderFrame(vars) { return els.join('') } +function renderObsidianModeCutMarkup(dark, light, id) { + const darkFaint = cssColor(dark['--text-faint'] || '#888') + const lightFaint = cssColor(light['--text-faint'] || '#888') + const clipId = `${id}-dark-clip` + return [ + ``, + ``, + `${renderFrame(light)}`, + `${renderFrame(dark)}`, + text(14, CANVAS_H - 12, 'LIGHT', { fill: lightFaint, size: 8, weight: 700, spacing: '1.5' }), + `DARK`, + '', + ].join('') +} + export function buildObsidianScreenshotSvg(themeCss) { const dark = extractVarBlock(themeCss, '\\.theme-dark') const light = extractVarBlock(themeCss, '\\.theme-light') - - const lightFaint = cssColor(light['--text-faint'] || '#888') - const darkFaint = cssColor(dark['--text-faint'] || '#888') - const w = CANVAS_W * SUPERSAMPLE const h = CANVAS_H * SUPERSAMPLE return [ ``, - '', - ``, - '', - // dark fills the whole canvas - `${renderFrame(dark)}`, - // light is clipped to the lower-right diagonal half - `${renderFrame(light)}`, - // seam - ``, - // mode labels - text(14, CANVAS_H - 12, 'DARK', { fill: darkFaint, size: 8, weight: 700, spacing: '1.5' }), - text(CANVAS_W - 42, 16, 'LIGHT', { fill: lightFaint, size: 8, weight: 700, spacing: '1.5' }), + renderObsidianModeCutMarkup(dark, light, 'obsidian-community-mode-cut'), '', ].join('') } +export function buildObsidianHeroSvg(themeCss) { + const dark = extractVarBlock(themeCss, '\\.theme-dark') + const light = extractVarBlock(themeCss, '\\.theme-light') + const background = cssColor(dark['--background-secondary'] || '#151713') + const border = cssColor(dark['--background-modifier-border'] || '#343630') + const subjectX = 24 + const subjectY = 24 + const subjectWidth = 1552 + const subjectHeight = 852 + + return [ + '', + ``, + ``, + `${renderObsidianModeCutMarkup(dark, light, 'obsidian-hero-mode-cut')}`, + '', + ].flat().join('') +} + export async function renderObsidianScreenshotBuffer(themeCss) { const sharp = (await import('sharp')).default const svg = buildObsidianScreenshotSvg(themeCss) return sharp(Buffer.from(svg)).resize(CANVAS_W, CANVAS_H).png().toBuffer() } -// README-scale render of the SAME diagonal dark/light SVG. Vector text stays -// crisp at any size, so a larger raster is a reproducible marketing hero that -// tracks the active scheme — no manual device screenshots per release. +// README-scale render with one oversized mode-cut application proof. Vector +// text stays crisp at any size, so the marketing hero tracks the active scheme +// without manual app screenshots per release. export async function renderObsidianHeroBuffer(themeCss, { width = 1600 } = {}) { const sharp = (await import('sharp')).default - const svg = buildObsidianScreenshotSvg(themeCss) + const svg = buildObsidianHeroSvg(themeCss) return sharp(Buffer.from(svg)).resize(width).png().toBuffer() } diff --git a/scripts/sync-themes.mjs b/scripts/sync-themes.mjs index 762dad5..7dd5c42 100644 --- a/scripts/sync-themes.mjs +++ b/scripts/sync-themes.mjs @@ -13,6 +13,7 @@ import { generateColorLanguageContractReview } from './generate-color-language-c import { generateNoItalicsOverride } from './generate-no-italics-override.mjs' import { generateThemeForgeSource } from './generate-theme-forge-source.mjs' import { generateTerminalThemes } from './generate-terminal-themes.mjs' +import { generateZedThemes } from './generate-zed-themes.mjs' import { compile } from './theme-engine/compile.mjs' import { webEmitter } from './theme-engine/emit/web.mjs' import { vscodeEmitter } from './theme-engine/emit/vscode.mjs' @@ -76,6 +77,9 @@ for (const target of targets) { // 2. 从同一套多 scheme token maps 生成跨终端主题包 generateTerminalThemes() +// 2.1 从同一套多 scheme token maps 生成 Zed 主题扩展 +generateZedThemes() + // 3. 由 theme compiler 输出 web token file descriptor,生成 src/data/tokens.ts const [tokensFile] = compile({ themes: activeThemes, emitters: [webEmitter] }) if (!tokensFile) throw new Error('compile({ emitters: [webEmitter] }) did not produce src/data/tokens.ts') diff --git a/scripts/theme-engine/emit/zed-core.mjs b/scripts/theme-engine/emit/zed-core.mjs new file mode 100644 index 0000000..ac02b45 --- /dev/null +++ b/scripts/theme-engine/emit/zed-core.mjs @@ -0,0 +1,390 @@ +import { deltaE, mixHex } from '../../color-utils.mjs' + +export const ZED_THEME_SCHEMA_URL = 'https://zed.dev/schema/themes/v0.2.0.json' +export const ZED_STATE_DELTA_E_FLOOR = 5.5 + +const COLOR_RE = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i +const FONT_WEIGHTS = new Set([100, 200, 300, 400, 500, 600, 700, 800, 900]) +const FONT_STYLES = new Set(['normal', 'italic', 'oblique']) + +const ROLE_STYLE_PROBES = Object.freeze({ + comment: ['comment'], + keyword: ['keyword'], + operator: ['keyword.operator'], + punctuation: ['punctuation'], + function: ['entity.name.function'], + property: ['variable.other.property', 'meta.property-name'], + string: ['string'], + number: ['constant.numeric'], + type: ['entity.name.type', 'support.type'], + variable: ['variable.other.readwrite', 'variable'], + parameter: ['variable.parameter'], + tag: ['entity.name.tag'], +}) + +const SYNTAX_GROUPS = Object.freeze([ + { token: 'comment', role: 'comment', captures: ['comment', 'comment.doc'] }, + { token: 'keyword', role: 'keyword', captures: ['keyword', 'preproc'] }, + { token: 'operator', role: 'operator', captures: ['operator'] }, + { + token: 'punctuation', + role: 'punctuation', + captures: [ + 'punctuation', + 'punctuation.bracket', + 'punctuation.delimiter', + 'punctuation.list_marker', + 'punctuation.special', + ], + }, + { token: 'fn', role: 'function', captures: ['function', 'constructor'] }, + { token: 'property', role: 'property', captures: ['property', 'attribute', 'label'] }, + { + token: 'string', + role: 'string', + captures: ['string', 'string.escape', 'string.regex', 'string.special', 'string.special.symbol'], + }, + { + token: 'number', + role: 'number', + captures: ['number', 'constant', 'constant.builtin', 'boolean', 'enum', 'variant'], + }, + { token: 'type', role: 'type', captures: ['type', 'type.builtin'] }, + { token: 'variable', role: 'variable', captures: ['variable', 'variable.special'] }, + { token: 'parameter', role: 'parameter', captures: ['variable.parameter'] }, + { token: 'tag', role: 'tag', captures: ['tag', 'tag.doctype'] }, +]) + +export const ZED_REQUIRED_STYLE_KEYS = Object.freeze([ + 'background', + 'surface.background', + 'text', + 'border', + 'editor.background', + 'editor.foreground', + 'editor.gutter.background', + 'editor.line_number', + 'terminal.background', + 'terminal.foreground', + 'syntax', +]) + +function requireColor(tokens, key, label) { + const value = String(tokens?.[key] ?? '').trim().toLowerCase() + if (!COLOR_RE.test(value)) { + throw new Error(`zed emitter: ${label} is missing valid token "${key}"`) + } + return value +} + +function withAlpha(color, alpha) { + const rgb = color.slice(0, 7) + return `${rgb}${alpha}`.toLowerCase() +} + +function separateSelectedFromHover(selected, hover, text) { + if (deltaE(selected, hover) >= ZED_STATE_DELTA_E_FLOOR) return selected + for (let step = 1; step <= 15; step += 1) { + const candidate = mixHex(selected, text, step * 0.02) + if (deltaE(candidate, hover) >= ZED_STATE_DELTA_E_FLOOR) return candidate + } + throw new Error(`zed emitter: unable to separate picker selected state from hover by ${ZED_STATE_DELTA_E_FLOOR} deltaE`) +} + +function toScopes(entry) { + if (!entry?.scope) return [] + return Array.isArray(entry.scope) ? entry.scope : [entry.scope] +} + +function findRoleFontStyle(theme, role) { + const probes = ROLE_STYLE_PROBES[role] ?? [] + for (const probe of probes) { + const entry = (theme?.tokenColors ?? []).find((candidate) => toScopes(candidate).includes(probe)) + const value = String(entry?.settings?.fontStyle ?? '').trim() + if (value) return value + } + return '' +} + +function buildHighlightStyle(color, vscodeFontStyle = '') { + const words = new Set(String(vscodeFontStyle).split(/\s+/).filter(Boolean)) + const style = { color } + if (words.has('italic')) style.font_style = 'italic' + if (words.has('bold')) style.font_weight = 700 + return style +} + +function buildSyntax(tokens, vscodeTheme) { + const syntax = {} + for (const group of SYNTAX_GROUPS) { + const color = requireColor(tokens, group.token, `syntax.${group.role}`) + const style = buildHighlightStyle(color, findRoleFontStyle(vscodeTheme, group.role)) + for (const capture of group.captures) syntax[capture] = { ...style } + } + + const foreground = requireColor(tokens, 'fg', 'syntax.primary') + const info = requireColor(tokens, 'info', 'syntax.link') + const note = requireColor(tokens, 'note', 'syntax.hint') + syntax.primary = { color: foreground } + syntax.embedded = { color: foreground } + syntax.hint = { color: note } + syntax.predictive = { color: note } + syntax.link_text = { color: info } + syntax.link_uri = { color: info, font_style: 'italic' } + syntax.emphasis = { color: foreground, font_style: 'italic' } + syntax['emphasis.strong'] = { color: foreground, font_weight: 700 } + syntax.title = { color: requireColor(tokens, 'keyword', 'syntax.title'), font_weight: 700 } + syntax['text.literal'] = { color: requireColor(tokens, 'string', 'syntax.text.literal') } + return syntax +} + +function feedbackStyle(style, name, color) { + style[name] = color + style[`${name}.background`] = withAlpha(color, '1a') + style[`${name}.border`] = withAlpha(color, '66') +} + +function buildPlayers(colors) { + return [...new Set(colors)].map((color) => ({ + cursor: color, + background: color, + selection: withAlpha(color, '3d'), + })) +} + +export function buildZedStyle(tokens, vscodeTheme) { + const bg = requireColor(tokens, 'bg', 'editor background') + const fg = requireColor(tokens, 'fg', 'editor foreground') + const sidebar = requireColor(tokens, 'sidebar', 'application background') + const panel = requireColor(tokens, 'lineBg', 'panel background') + const border = requireColor(tokens, 'border', 'border') + const guide = requireColor(tokens, 'guide', 'guide') + const guideActive = requireColor(tokens, 'guideActive', 'active guide') + const whitespace = requireColor(tokens, 'whitespace', 'invisible characters') + const status = requireColor(tokens, 'status', 'accent') + const cursor = requireColor(tokens, 'cursor', 'cursor') + const selection = requireColor(tokens, 'selection', 'selection') + const shellInk = requireColor(tokens, 'shellInk', 'shell text') + const supportInk = requireColor(tokens, 'shellSupport', 'muted shell text') + const mutedInk = requireColor(tokens, 'shellMuted', 'disabled shell text') + const subtleInk = requireColor(tokens, 'shellSubtle', 'placeholder shell text') + const shellRaised = requireColor(tokens, 'shellRaised', 'raised surface') + const shellBand = requireColor(tokens, 'shellBand', 'shell band') + const navActiveFill = requireColor(tokens, 'navActiveFill', 'active navigation') + const navInactiveFill = requireColor(tokens, 'navInactiveFill', 'inactive navigation') + const navActiveInk = requireColor(tokens, 'navActiveInk', 'active navigation text') + const pickerSelectedFill = separateSelectedFromHover(navActiveFill, navInactiveFill, shellInk) + const lineNo = requireColor(tokens, 'lineNo', 'line number') + const terminalBrightWhite = requireColor(tokens, 'terminalBrightWhite', 'terminal bright foreground') + const accentColors = [ + cursor, + status, + requireColor(tokens, 'bracketWarm', 'warm bracket'), + requireColor(tokens, 'bracketBright', 'bright bracket'), + requireColor(tokens, 'bracketCool', 'cool bracket'), + requireColor(tokens, 'info', 'info'), + requireColor(tokens, 'success', 'success'), + requireColor(tokens, 'warning', 'warning'), + requireColor(tokens, 'error', 'error'), + ] + + const style = { + 'background.appearance': 'opaque', + background: sidebar, + 'surface.background': shellBand, + 'elevated_surface.background': shellRaised, + border, + 'border.variant': guide, + 'border.focused': status, + 'border.selected': cursor, + 'border.transparent': '#00000000', + 'border.disabled': border, + 'pane_group.border': border, + 'pane.focused_border': status, + 'panel.focused_border': status, + 'element.background': navInactiveFill, + 'element.hover': navActiveFill, + 'element.active': navActiveFill, + 'element.selected': navActiveFill, + 'element.disabled': navInactiveFill, + 'drop_target.background': withAlpha(cursor, '40'), + 'ghost_element.background': '#00000000', + 'ghost_element.hover': navInactiveFill, + 'ghost_element.active': pickerSelectedFill, + 'ghost_element.selected': pickerSelectedFill, + 'ghost_element.disabled': '#00000000', + text: shellInk, + 'text.muted': supportInk, + 'text.placeholder': subtleInk, + 'text.disabled': mutedInk, + 'text.accent': requireColor(tokens, 'keyword', 'accent text'), + icon: shellInk, + 'icon.muted': supportInk, + 'icon.disabled': mutedInk, + 'icon.placeholder': subtleInk, + 'icon.accent': requireColor(tokens, 'keyword', 'accent icon'), + // Zed exposes the status-bar fill but no paired foreground token. Keep the + // fill inside the shell surface family so the shared `text` color remains + // readable; the stronger status color is still used for focus/accent cues. + 'status_bar.background': shellBand, + 'title_bar.background': sidebar, + 'title_bar.inactive_background': sidebar, + 'toolbar.background': shellBand, + 'tab_bar.background': sidebar, + 'tab.inactive_background': sidebar, + 'tab.active_background': bg, + 'search.match_background': selection, + 'panel.background': sidebar, + 'scrollbar.thumb.background': withAlpha(supportInk, '4d'), + 'scrollbar.thumb.hover_background': withAlpha(supportInk, '80'), + 'scrollbar.thumb.border': withAlpha(border, 'b3'), + 'scrollbar.track.background': '#00000000', + 'scrollbar.track.border': border, + 'editor.background': bg, + 'editor.foreground': fg, + 'editor.gutter.background': bg, + 'editor.subheader.background': panel, + 'editor.active_line.background': panel, + 'editor.highlighted_line.background': panel, + 'editor.line_number': lineNo, + 'editor.active_line_number': navActiveInk, + 'editor.invisible': whitespace, + 'editor.wrap_guide': withAlpha(guide, '66'), + 'editor.active_wrap_guide': guideActive, + 'editor.indent_guide': guide, + 'editor.indent_guide_active': guideActive, + 'editor.document_highlight.bracket_background': requireColor(tokens, 'bracketMatchFill', 'bracket match'), + 'editor.document_highlight.read_background': selection, + 'editor.document_highlight.write_background': withAlpha(status, '42'), + 'terminal.background': bg, + 'terminal.foreground': fg, + 'terminal.bright_foreground': terminalBrightWhite, + 'terminal.dim_foreground': mutedInk, + 'terminal.ansi.background': bg, + 'terminal.ansi.black': requireColor(tokens, 'terminalBlack', 'terminal black'), + 'terminal.ansi.red': requireColor(tokens, 'terminalRed', 'terminal red'), + 'terminal.ansi.green': requireColor(tokens, 'terminalGreen', 'terminal green'), + 'terminal.ansi.yellow': requireColor(tokens, 'terminalYellow', 'terminal yellow'), + 'terminal.ansi.blue': requireColor(tokens, 'terminalBlue', 'terminal blue'), + 'terminal.ansi.magenta': requireColor(tokens, 'terminalMagenta', 'terminal magenta'), + 'terminal.ansi.cyan': requireColor(tokens, 'terminalCyan', 'terminal cyan'), + 'terminal.ansi.white': requireColor(tokens, 'terminalWhite', 'terminal white'), + 'terminal.ansi.bright_black': requireColor(tokens, 'terminalBrightBlack', 'terminal bright black'), + 'terminal.ansi.bright_red': requireColor(tokens, 'terminalBrightRed', 'terminal bright red'), + 'terminal.ansi.bright_green': requireColor(tokens, 'terminalBrightGreen', 'terminal bright green'), + 'terminal.ansi.bright_yellow': requireColor(tokens, 'terminalBrightYellow', 'terminal bright yellow'), + 'terminal.ansi.bright_blue': requireColor(tokens, 'terminalBrightBlue', 'terminal bright blue'), + 'terminal.ansi.bright_magenta': requireColor(tokens, 'terminalBrightMagenta', 'terminal bright magenta'), + 'terminal.ansi.bright_cyan': requireColor(tokens, 'terminalBrightCyan', 'terminal bright cyan'), + 'terminal.ansi.bright_white': terminalBrightWhite, + accents: [...new Set(accentColors)], + players: buildPlayers(accentColors), + syntax: buildSyntax(tokens, vscodeTheme), + } + + const note = requireColor(tokens, 'note', 'note feedback') + const info = requireColor(tokens, 'info', 'info feedback') + const success = requireColor(tokens, 'success', 'success feedback') + const warning = requireColor(tokens, 'warning', 'warning feedback') + const error = requireColor(tokens, 'error', 'error feedback') + for (const name of ['hint', 'hidden', 'ignored', 'predictive', 'unreachable']) feedbackStyle(style, name, note) + for (const name of ['info', 'renamed']) feedbackStyle(style, name, info) + for (const name of ['success', 'created']) feedbackStyle(style, name, success) + for (const name of ['warning', 'modified', 'conflict']) feedbackStyle(style, name, warning) + for (const name of ['error', 'deleted']) feedbackStyle(style, name, error) + + return style +} + +function validateHighlightStyle(value, path, errors) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + errors.push(`${path} must be an object`) + return + } + if (value.color != null && !COLOR_RE.test(value.color)) errors.push(`${path}.color is not a valid hex color`) + if (value.background_color != null && !COLOR_RE.test(value.background_color)) { + errors.push(`${path}.background_color is not a valid hex color`) + } + if (value.font_style != null && !FONT_STYLES.has(value.font_style)) { + errors.push(`${path}.font_style is not supported`) + } + if (value.font_weight != null && !FONT_WEIGHTS.has(value.font_weight)) { + errors.push(`${path}.font_weight is not supported`) + } +} + +export function validateZedThemeFamily(family) { + const errors = [] + if (!family || typeof family !== 'object' || Array.isArray(family)) return ['theme family must be an object'] + if (family.$schema !== ZED_THEME_SCHEMA_URL) errors.push(`$schema must be ${ZED_THEME_SCHEMA_URL}`) + if (!String(family.name ?? '').trim()) errors.push('name is required') + if (!String(family.author ?? '').trim()) errors.push('author is required') + if (!Array.isArray(family.themes) || family.themes.length === 0) { + errors.push('themes must be a non-empty array') + return errors + } + + const names = new Set() + family.themes.forEach((theme, index) => { + const path = `themes[${index}]` + const name = String(theme?.name ?? '').trim() + if (!name) errors.push(`${path}.name is required`) + if (names.has(name)) errors.push(`${path}.name is duplicated: ${name}`) + names.add(name) + if (theme?.appearance !== 'dark' && theme?.appearance !== 'light') { + errors.push(`${path}.appearance must be dark or light`) + } + if (!theme?.style || typeof theme.style !== 'object' || Array.isArray(theme.style)) { + errors.push(`${path}.style must be an object`) + return + } + for (const key of ZED_REQUIRED_STYLE_KEYS) { + if (!(key in theme.style)) errors.push(`${path}.style is missing ${key}`) + } + for (const [key, value] of Object.entries(theme.style)) { + const stylePath = `${path}.style.${key}` + if (key === 'background.appearance') { + if (!['opaque', 'transparent', 'blurred'].includes(value)) errors.push(`${stylePath} is invalid`) + } else if (key === 'syntax') { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + errors.push(`${stylePath} must be an object`) + } else { + for (const [capture, highlight] of Object.entries(value)) { + validateHighlightStyle(highlight, `${stylePath}.${capture}`, errors) + } + } + } else if (key === 'accents') { + if (!Array.isArray(value) || value.some((color) => !COLOR_RE.test(color))) { + errors.push(`${stylePath} must be an array of hex colors`) + } + } else if (key === 'players') { + if (!Array.isArray(value) || value.length === 0) { + errors.push(`${stylePath} must be a non-empty array`) + } else { + value.forEach((player, playerIndex) => { + for (const field of ['cursor', 'background', 'selection']) { + if (!COLOR_RE.test(player?.[field] ?? '')) { + errors.push(`${stylePath}[${playerIndex}].${field} is not a valid hex color`) + } + } + }) + } + } else if (value != null && !COLOR_RE.test(value)) { + errors.push(`${stylePath} is not a valid hex color`) + } + } + }) + return errors +} + +export function renderZedThemeFamily({ name, author, themes }) { + const family = { + $schema: ZED_THEME_SCHEMA_URL, + name, + author, + themes, + } + const errors = validateZedThemeFamily(family) + if (errors.length > 0) throw new Error(`zed emitter: invalid theme family\n- ${errors.join('\n- ')}`) + return `${JSON.stringify(family, null, 2)}\n` +} diff --git a/scripts/theme-engine/emit/zed.mjs b/scripts/theme-engine/emit/zed.mjs new file mode 100644 index 0000000..1f43caf --- /dev/null +++ b/scripts/theme-engine/emit/zed.mjs @@ -0,0 +1,32 @@ +import { buildZedStyle, renderZedThemeFamily } from './zed-core.mjs' + +export function createZedEmitter({ familyName, author, themeNames, outputPath }) { + if (!familyName || !author || !outputPath) { + throw new Error('createZedEmitter: familyName, author, and outputPath are required') + } + + return { + name: `zed:${familyName}`, + consumes: ['tokenSets', 'themes'], + emit(maps) { + const themes = Object.entries(maps.tokenSets ?? {}).map(([variantId, tokens]) => { + const appearance = maps.themes?.[variantId]?.type ?? variantId + if (appearance !== 'dark' && appearance !== 'light') { + throw new Error(`zed emitter: unsupported appearance for variant "${variantId}"`) + } + const name = String(themeNames?.[variantId] ?? '').trim() + if (!name) throw new Error(`zed emitter: missing theme name for variant "${variantId}"`) + return { + name, + appearance, + style: buildZedStyle(tokens, maps.themes?.[variantId]), + } + }) + + return [{ + path: outputPath, + content: renderZedThemeFamily({ name: familyName, author, themes }), + }] + }, + } +} diff --git a/scripts/theme-review-guide.mjs b/scripts/theme-review-guide.mjs index bbef084..7b28239 100644 --- a/scripts/theme-review-guide.mjs +++ b/scripts/theme-review-guide.mjs @@ -34,7 +34,7 @@ const lines = [ "", "Generated review artifacts:", "- reports/color-contract-audit.json", - "- public/previews/preview-contrast-v2.png", + "- public/previews/family-overview.png", "- obsidian/app-theme/screenshot.png", `- themes/${scheme}-dark.json`, `- themes/${scheme}-light.json`, diff --git a/scripts/zed-audit.mjs b/scripts/zed-audit.mjs new file mode 100644 index 0000000..9b3a720 --- /dev/null +++ b/scripts/zed-audit.mjs @@ -0,0 +1,123 @@ +import { existsSync, readFileSync } from 'fs' +import { contrastRatio, deltaE } from './color-utils.mjs' +import { buildZedExtensionFiles, ZED_MANIFEST_PATH, ZED_THEMES_DIR } from './generate-zed-themes.mjs' +import { getReleaseVersion } from './release-metadata.mjs' +import { validateZedThemeFamily, ZED_STATE_DELTA_E_FLOOR } from './theme-engine/emit/zed-core.mjs' + +const findings = [] + +function parseManifest(source) { + const readString = (key) => { + const match = source.match(new RegExp(`^${key}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*$`, 'm')) + if (!match) return null + try { + return JSON.parse(match[1]) + } catch { + return null + } + } + const schemaVersion = Number(source.match(/^schema_version\s*=\s*(\d+)\s*$/m)?.[1] ?? Number.NaN) + return { + id: readString('id'), + name: readString('name'), + version: readString('version'), + description: readString('description'), + repository: readString('repository'), + schemaVersion, + } +} + +let generatedFiles = [] +try { + generatedFiles = buildZedExtensionFiles() +} catch (error) { + findings.push(`generator failed: ${error.message}`) +} + +for (const file of generatedFiles) { + if (!existsSync(file.path)) { + findings.push(`missing generated file: ${file.path}`) + continue + } + const committed = readFileSync(file.path, 'utf8').replace(/\r\n/g, '\n') + if (committed !== file.content.replace(/\r\n/g, '\n')) { + findings.push(`${file.path} does not match the current generator output`) + } +} + +if (existsSync(ZED_MANIFEST_PATH)) { + const manifest = parseManifest(readFileSync(ZED_MANIFEST_PATH, 'utf8')) + if (!manifest.id?.endsWith('-theme')) findings.push('extension.toml id must end in "-theme"') + if (/\b(?:zed|extension)\b/i.test(manifest.name ?? '')) { + findings.push('extension.toml name cannot contain "Zed" or "extension"') + } + if (manifest.schemaVersion !== 1) findings.push('extension.toml schema_version must be 1') + if (manifest.version !== getReleaseVersion()) { + findings.push(`extension.toml version ${manifest.version ?? '(missing)'} does not match release ${getReleaseVersion()}`) + } + if (!manifest.description) findings.push('extension.toml description is required') + if (!/^https:\/\/github\.com\//.test(manifest.repository ?? '')) { + findings.push('extension.toml repository must be an HTTPS GitHub URL') + } +} + +const expectedThemeNames = new Set([ + 'HearthCode Moss Dark', + 'HearthCode Moss Light', + 'HearthCode Ember Dark', + 'HearthCode Ember Light', +]) +const actualThemeNames = new Set() + +for (const file of generatedFiles.filter((entry) => entry.path.startsWith(`${ZED_THEMES_DIR}/`))) { + let family + try { + family = JSON.parse(readFileSync(file.path, 'utf8')) + } catch (error) { + findings.push(`${file.path} is invalid JSON: ${error.message}`) + continue + } + for (const error of validateZedThemeFamily(family)) findings.push(`${file.path}: ${error}`) + const appearances = new Set() + for (const theme of family.themes ?? []) { + actualThemeNames.add(theme.name) + appearances.add(theme.appearance) + const syntax = theme.style?.syntax ?? {} + const pickerHover = theme.style?.['ghost_element.hover'] + const pickerSelected = theme.style?.['ghost_element.selected'] + if (syntax.comment?.font_style !== 'italic') { + findings.push(`${file.path}: ${theme.name} should preserve comment italics`) + } + if (syntax.keyword?.font_weight !== 700) { + findings.push(`${file.path}: ${theme.name} should preserve keyword bold weight`) + } + if (!pickerHover || !pickerSelected) { + findings.push(`${file.path}: ${theme.name} is missing picker hover or selected fills`) + } else { + if (deltaE(pickerHover, pickerSelected) < ZED_STATE_DELTA_E_FLOOR) { + findings.push(`${file.path}: ${theme.name} picker selected and hover fills are not visually distinct`) + } + if (!theme.style?.text || contrastRatio(theme.style.text, pickerSelected) < 4.5) { + findings.push(`${file.path}: ${theme.name} picker selected text does not clear 4.5:1 contrast`) + } + } + } + if (family.themes?.length !== 2 || !appearances.has('dark') || !appearances.has('light')) { + findings.push(`${file.path} must contain exactly one dark and one light theme`) + } +} + +for (const name of expectedThemeNames) { + if (!actualThemeNames.has(name)) findings.push(`missing Zed theme: ${name}`) +} +for (const name of actualThemeNames) { + if (!expectedThemeNames.has(name)) findings.push(`unexpected Zed theme: ${name}`) +} + +if (findings.length > 0) { + console.error('[FAIL] Zed theme audit found issues:') + for (const finding of findings) console.error(` - ${finding}`) + process.exit(1) +} + +console.log(`[PASS] Zed theme audit passed (${actualThemeNames.size} themes, schema v0.2.0 contract).`) diff --git a/src/components/ui/EditorChannelIcon.astro b/src/components/ui/EditorChannelIcon.astro new file mode 100644 index 0000000..b4075c8 --- /dev/null +++ b/src/components/ui/EditorChannelIcon.astro @@ -0,0 +1,81 @@ +--- +type EditorChannelId = 'vscode' | 'openvsx' | 'zed' | 'obsidian' | 'terminal' + +interface Props { + channel: EditorChannelId +} + +const { channel } = Astro.props +--- + + + + diff --git a/src/components/ui/FinalCtaSection.astro b/src/components/ui/FinalCtaSection.astro index e279f20..1f29ac3 100644 --- a/src/components/ui/FinalCtaSection.astro +++ b/src/components/ui/FinalCtaSection.astro @@ -1,22 +1,14 @@ --- import { t, type Lang } from '../../i18n/utils' import { productData } from '../../data/product' +import InstallChannelGrid from './InstallChannelGrid.astro' interface Props { lang: Lang } const { lang } = Astro.props -const marketplaceUrl = productData.links.marketplaceUrl -const openVsxUrl = productData.links.openVsxUrl -const obsidianUrl = productData.links.obsidianUrl const repositoryUrl = productData.links.repositoryUrl - -// VS Code and Open VSX are the two editor channels. Obsidian remains a distinct -// secondary surface; GitHub is a non-install "view source" link below. -const secondary = [ - { href: obsidianUrl, key: 'obsidian', labelKey: 'hero.cta.obsidian', subKey: 'final.option.obsidian.body' }, -] as const ---
@@ -29,57 +21,8 @@ const secondary = [

-
- - -
- {secondary.map(({ href, key, labelKey, subKey }) => ( - - - {t(lang, labelKey)} - {t(lang, subKey)} - - - - ))} -
+
+

02{t(lang, 'hero.flow.install')}

-
+