From 5f821cb6f3ef79ca2515091ba1174e6de5cf8227 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 29 Apr 2026 15:11:06 -0400 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20three-stage=20scan=20pipeline=20(?= =?UTF-8?q?map=20=E2=86=92=20bucket=20=E2=86=92=20expression);=20fold=20gh?= =?UTF-8?q?ost-map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the bucket pipeline and consolidate to a four-tool topology. A scan now runs in three stages — topology (map.md) → objective (bucket.json) → subjective (expression.md) — all owned by ghost-expression. ghost.bucket/v1: new artifact catalogues every concrete design value with structured specs, occurrence counts, and deterministic content-hashed IDs. Schema, lint, merge, and fix-ids primitives live in @ghost/core. New ghost-expression verbs: - inventory [path] — raw repo signals JSON (migrated from ghost-map). - lint [file] — auto-detects expression.md / map.md / bucket.json. - bucket merge — deterministic concat with id-based dedup, idempotent. - bucket fix-ids — recompute row IDs from content; lets surveyor agents author rows with empty id fields and finalize in one pass. - scan-status [dir] — report per-stage state and recommended next stage. New skill recipes (ghost-expression bundle): - map.md — topology stage (migrated from ghost-map skill recipe). - survey.md — objective stage. LLM-driven extraction with dialect-specific grep strategies, exhaustiveness discipline, saturation predicate. - scan.md — meta-recipe orchestrating topology → survey → profile. Refactored: profile.md is now strictly the subjective interpretation stage (reads bucket.json as ground truth; cannot fabricate values). ghost-map package deleted. ghost.map/v1 schema/types moved to @ghost/core; inventory + lint moved to ghost-expression. ghost-fleet now imports map types from @ghost/core. CLAUDE.md, README.md, and docs IA updated to reflect the four-tool topology. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/add-bucket-schema-and-merge.md | 27 ++ .changeset/config.json | 8 +- CLAUDE.md | 52 ++-- README.md | 39 ++- apps/docs/src/content/docs/cli-reference.mdx | 79 +++--- .../docs/src/content/docs/getting-started.mdx | 9 +- apps/docs/src/generated/cli-manifest.json | 87 ++++--- packages/ghost-core/package.json | 5 +- packages/ghost-core/src/bucket/fix-ids.ts | 50 ++++ packages/ghost-core/src/bucket/id.ts | 60 +++++ packages/ghost-core/src/bucket/index.ts | 53 ++++ packages/ghost-core/src/bucket/lint.ts | 154 ++++++++++++ packages/ghost-core/src/bucket/merge.ts | 71 ++++++ packages/ghost-core/src/bucket/schema.ts | 164 ++++++++++++ packages/ghost-core/src/bucket/types.ts | 186 ++++++++++++++ packages/ghost-core/src/index.ts | 57 ++++- packages/ghost-core/src/map/index.ts | 23 ++ .../src/core => ghost-core/src/map}/schema.ts | 0 .../src/core => ghost-core/src/map}/types.ts | 2 +- .../ghost-core/test/bucket-fix-ids.test.ts | 79 ++++++ packages/ghost-core/test/bucket-id.test.ts | 122 +++++++++ packages/ghost-core/test/bucket-lint.test.ts | 124 ++++++++++ packages/ghost-core/test/bucket-merge.test.ts | 142 +++++++++++ packages/ghost-expression/src/cli.ts | 228 ++++++++++++++++- packages/ghost-expression/src/core/index.ts | 14 ++ .../src/core/inventory.ts | 2 +- .../src/core/lint-map.ts} | 39 ++- .../ghost-expression/src/core/scan-status.ts | 93 +++++++ .../src/skill-bundle/SKILL.md | 21 +- .../src/skill-bundle/references/map.md | 83 +++++++ .../src/skill-bundle/references/profile.md | 152 ++++++++---- .../src/skill-bundle/references/scan.md | 108 ++++++++ .../src/skill-bundle/references/survey.md | 156 ++++++++++++ packages/ghost-expression/test/cli.test.ts | 234 +++++++++++++++++- .../app/src/main/AndroidManifest.xml | 0 .../java/com/example/fixture/MainActivity.kt | 0 .../android-gradle-repo/build.gradle.kts | 0 .../design-system/Theme.kt | 0 .../android-gradle-repo/settings.gradle.kts | 0 .../test/fixtures/bazel-repo/.bazelversion | 0 .../test/fixtures/bazel-repo/BUILD.bazel | 0 .../Code/DesignSystem/Color+Brand.swift | 0 .../bazel-repo/Code/DesignSystem/Theme.swift | 0 .../test/fixtures/bazel-repo/MODULE.bazel | 0 .../test/fixtures/bazel-repo/WORKSPACE | 0 .../bazel-bin/should-be-skipped.swift | 0 .../features/banking/BankingView.swift | 0 .../features/banking/Color+Banking.swift | 0 .../Code/DesignSystem/Color.swift | 0 .../Code/DesignSystem/Spacing.swift | 0 .../Code/DesignSystem/Theme.swift | 0 .../Code/DesignSystem/Typography.swift | 0 .../test/fixtures/bazel-ruby-ios-repo/Gemfile | 0 .../fixtures/bazel-ruby-ios-repo/MODULE.bazel | 0 .../fixtures/bazel-ruby-ios-repo/WORKSPACE | 0 .../bazel-ruby-ios-repo/fastlane/Fastfile | 0 .../derived-tokens-repo/derived.map.md | 0 .../external-tokens-repo/external.map.md | 0 .../test/fixtures/flutter-repo/lib/main.dart | 0 .../test/fixtures/flutter-repo/lib/theme.dart | 0 .../test/fixtures/flutter-repo/pubspec.yaml | 0 .../test/fixtures/ios-spm-repo/Package.swift | 0 .../ios-spm-repo/Sources/Fixture.swift | 0 .../fixtures/ios-spm-repo/Sources/Theme.swift | 0 .../test/fixtures/maps/bad-frontmatter.md | 0 .../test/fixtures/maps/empty-section.md | 0 .../test/fixtures/maps/good.md | 0 .../test/fixtures/maps/missing-section.md | 0 .../test/fixtures/maps/out-of-order.md | 0 .../multi-platform-repo/multi-platform.map.md | 0 .../multi-upstream-repo/multi-upstream.map.md | 0 .../fixtures/python-venv-repo/pyproject.toml | 0 .../test/fixtures/python-venv-repo/src/foo.py | 0 .../python-venv-repo/venv/lib/python.py | 0 .../design-tokens/typography.yaml | 0 .../fixtures/token-pipeline-repo/package.json | 0 .../token-pipeline-repo/src/styles/tokens.css | 0 .../token-pipeline-repo/tokens/colors.json | 0 .../test/fixtures/vite-nx-repo/nx.json | 0 .../test/fixtures/vite-nx-repo/package.json | 0 .../vite-nx-repo/packages/foo/package.json | 0 .../vite-nx-repo/packages/foo/src/index.ts | 0 .../test/fixtures/vite-nx-repo/src/main.ts | 0 .../test/fixtures/vite-nx-repo/vite.config.ts | 0 .../fixtures/web-repo/coverage/ignored.js | 0 .../test/fixtures/web-repo/package.json | 0 .../test/fixtures/web-repo/registry.json | 0 .../web-repo/src/components/Button.tsx | 0 .../fixtures/web-repo/src/components/Card.tsx | 0 .../test/fixtures/web-repo/src/index.ts | 0 .../fixtures/web-repo/src/styles/tokens.css | 0 .../test/fixtures/web-repo/tailwind.config.ts | 0 .../test/fixtures/web-repo/test/sample.ts | 0 .../test/fixtures/web-repo/tsconfig.json | 0 .../workspace-repo/apps/web/package.json | 0 .../workspace-repo/common/lint/package.json | 0 .../workspace-repo/libs/util/package.json | 0 .../test/fixtures/workspace-repo/package.json | 0 .../workspace-repo/packages/bar/package.json | 0 .../workspace-repo/packages/foo/package.json | 0 .../test/inventory.test.ts | 0 .../test/lint-map.test.ts} | 2 +- .../ghost-expression/test/scan-status.test.ts | 71 ++++++ packages/ghost-fleet/package.json | 1 - packages/ghost-fleet/src/core/members.ts | 6 +- packages/ghost-fleet/src/core/types.ts | 5 +- packages/ghost-fleet/tsconfig.json | 6 +- packages/ghost-map/package.json | 55 ---- packages/ghost-map/src/bin.ts | 21 -- packages/ghost-map/src/cli.ts | 89 ------- packages/ghost-map/src/core/index.ts | 26 -- packages/ghost-map/tsconfig.json | 9 - pnpm-lock.yaml | 21 +- scripts/dump-cli-help.mjs | 1 - tsconfig.json | 1 - 115 files changed, 2563 insertions(+), 474 deletions(-) create mode 100644 .changeset/add-bucket-schema-and-merge.md create mode 100644 packages/ghost-core/src/bucket/fix-ids.ts create mode 100644 packages/ghost-core/src/bucket/id.ts create mode 100644 packages/ghost-core/src/bucket/index.ts create mode 100644 packages/ghost-core/src/bucket/lint.ts create mode 100644 packages/ghost-core/src/bucket/merge.ts create mode 100644 packages/ghost-core/src/bucket/schema.ts create mode 100644 packages/ghost-core/src/bucket/types.ts create mode 100644 packages/ghost-core/src/map/index.ts rename packages/{ghost-map/src/core => ghost-core/src/map}/schema.ts (100%) rename packages/{ghost-map/src/core => ghost-core/src/map}/types.ts (98%) create mode 100644 packages/ghost-core/test/bucket-fix-ids.test.ts create mode 100644 packages/ghost-core/test/bucket-id.test.ts create mode 100644 packages/ghost-core/test/bucket-lint.test.ts create mode 100644 packages/ghost-core/test/bucket-merge.test.ts rename packages/{ghost-map => ghost-expression}/src/core/inventory.ts (99%) rename packages/{ghost-map/src/core/lint.ts => ghost-expression/src/core/lint-map.ts} (91%) create mode 100644 packages/ghost-expression/src/core/scan-status.ts create mode 100644 packages/ghost-expression/src/skill-bundle/references/map.md create mode 100644 packages/ghost-expression/src/skill-bundle/references/scan.md create mode 100644 packages/ghost-expression/src/skill-bundle/references/survey.md rename packages/{ghost-map => ghost-expression}/test/fixtures/android-gradle-repo/app/src/main/AndroidManifest.xml (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/android-gradle-repo/app/src/main/java/com/example/fixture/MainActivity.kt (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/android-gradle-repo/build.gradle.kts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/android-gradle-repo/design-system/Theme.kt (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/android-gradle-repo/settings.gradle.kts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/.bazelversion (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/BUILD.bazel (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/Code/DesignSystem/Color+Brand.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/Code/DesignSystem/Theme.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/MODULE.bazel (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/WORKSPACE (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/bazel-bin/should-be-skipped.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/features/banking/BankingView.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-repo/features/banking/Color+Banking.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Color.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Spacing.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Theme.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Typography.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/Gemfile (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/MODULE.bazel (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/WORKSPACE (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/bazel-ruby-ios-repo/fastlane/Fastfile (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/derived-tokens-repo/derived.map.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/external-tokens-repo/external.map.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/flutter-repo/lib/main.dart (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/flutter-repo/lib/theme.dart (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/flutter-repo/pubspec.yaml (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/ios-spm-repo/Package.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/ios-spm-repo/Sources/Fixture.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/ios-spm-repo/Sources/Theme.swift (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/maps/bad-frontmatter.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/maps/empty-section.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/maps/good.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/maps/missing-section.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/maps/out-of-order.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/multi-platform-repo/multi-platform.map.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/multi-upstream-repo/multi-upstream.map.md (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/python-venv-repo/pyproject.toml (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/python-venv-repo/src/foo.py (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/python-venv-repo/venv/lib/python.py (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/token-pipeline-repo/design-tokens/typography.yaml (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/token-pipeline-repo/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/token-pipeline-repo/src/styles/tokens.css (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/token-pipeline-repo/tokens/colors.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/vite-nx-repo/nx.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/vite-nx-repo/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/vite-nx-repo/packages/foo/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/vite-nx-repo/packages/foo/src/index.ts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/vite-nx-repo/src/main.ts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/vite-nx-repo/vite.config.ts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/coverage/ignored.js (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/registry.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/src/components/Button.tsx (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/src/components/Card.tsx (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/src/index.ts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/src/styles/tokens.css (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/tailwind.config.ts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/test/sample.ts (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/web-repo/tsconfig.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/workspace-repo/apps/web/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/workspace-repo/common/lint/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/workspace-repo/libs/util/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/workspace-repo/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/workspace-repo/packages/bar/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/fixtures/workspace-repo/packages/foo/package.json (100%) rename packages/{ghost-map => ghost-expression}/test/inventory.test.ts (100%) rename packages/{ghost-map/test/lint.test.ts => ghost-expression/test/lint-map.test.ts} (99%) create mode 100644 packages/ghost-expression/test/scan-status.test.ts delete mode 100644 packages/ghost-map/package.json delete mode 100644 packages/ghost-map/src/bin.ts delete mode 100644 packages/ghost-map/src/cli.ts delete mode 100644 packages/ghost-map/src/core/index.ts delete mode 100644 packages/ghost-map/tsconfig.json diff --git a/.changeset/add-bucket-schema-and-merge.md b/.changeset/add-bucket-schema-and-merge.md new file mode 100644 index 0000000..4f3991c --- /dev/null +++ b/.changeset/add-bucket-schema-and-merge.md @@ -0,0 +1,27 @@ +--- +"ghost-expression": minor +--- + +Land the three-stage scan pipeline: topology (`map.md`) → objective (`bucket.json`) → subjective (`expression.md`). All three stages are now owned by `ghost-expression`; the previously separate `ghost-map` package is folded in. + +**New artifact: `ghost.bucket/v1`** — catalogues every concrete design value with structured specs, occurrence counts, and deterministic content-hashed IDs. + +**New verbs:** +- `ghost-expression inventory [path]` — emit deterministic raw repo signals as JSON (manifests, language histogram, registry, top-level tree, git remote). Feeds the topology recipe. (Migrated from `ghost-map inventory`.) +- `ghost-expression bucket ` — `merge` (concat with id-based dedup, idempotent — useful for modular rollups and fleet cohort views), `fix-ids` (recompute every row's `id` from content, so surveyor agents can author rows with empty `id` fields and finalize in one pass). +- `ghost-expression scan-status [dir]` — report which scan stages have produced artifacts (`map.md`, `bucket.json`, `expression.md`) and which stage to run next. The build-system glue orchestrators call between stages. + +**Updated verbs:** +- `ghost-expression lint` now auto-detects file kind by extension/content and dispatches to the right validator (`expression.md`, `map.md`, or `bucket.json`). + +**New skill recipes:** +- `map.md` — author `map.md` from a target (the topology stage). Migrated from the standalone `ghost-map` package. +- `survey.md` — author `bucket.json` from a target (the objective stage). Walks the agent through LLM-driven extraction with dialect-specific grep strategies, exhaustiveness discipline, and saturation predicate. +- `scan.md` — meta-recipe that orchestrates topology → survey → profile end-to-end via `scan-status` checkpoints. Use when the user wants a full scan rather than a specific stage. + +**Refactored skill recipe:** +- `profile.md` — now strictly the subjective interpretation stage. Reads `bucket.json` as ground truth; cannot fabricate values not in the bucket; cites bucket rows as evidence. Pre-requires `map.md` + `bucket.json`. Hard split from the previous one-pass extract+interpret recipe. + +**Removed:** the `ghost-map` package is deleted. `ghost.map/v1` schema and types now live in `@ghost/core`; `inventory` and `lint` (for `map.md`) move to `ghost-expression`. Consumers that imported from `ghost-map` should switch to `@ghost/core` (schemas/types) or `ghost-expression` (CLI verbs / library functions). + +Bucket schema, deterministic-id generation, lint, merge, and fix-ids primitives live in `@ghost/core`. diff --git a/.changeset/config.json b/.changeset/config.json index 3aa985a..346c54b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,11 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [ - "ghost-ui", - "ghost-docs", - "@ghost/core", - "ghost-fleet", - "ghost-map" - ] + "ignore": ["ghost-ui", "ghost-docs", "@ghost/core", "ghost-fleet"] } diff --git a/CLAUDE.md b/CLAUDE.md index f2597d3..9584237 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,9 @@ Agents can ship UI now. The problem: what they ship drifts — wrong palette, wrong density, wrong hierarchy — because they have no canonical answer to "what does this project's design language *actually* look like." -Ghost is the layer that gives them one. The design language lives in your repo as `expression.md`. Agents read it before generating, compare against it after, and either correct the drift or codify the divergence as a deliberate change. Five tools split the loop: +Ghost is the layer that gives them one. The design language lives in your repo as `expression.md`. Agents read it before generating, compare against it after, and either correct the drift or codify the divergence as a deliberate change. A scan runs in three stages — topology (`map.md`) → objective (`bucket.json`) → subjective (`expression.md`) — all owned by `ghost-expression`. Four tools plus a reference design system split the loop: -- **ghost-map** — where the design system lives -- **ghost-expression** — what the design language is (the canonical artifact) +- **ghost-expression** — authors all three scan artifacts (`map.md`, `bucket.json`, `expression.md`); the canonical home of the design language - **ghost-drift** — when generated UI strays - **ghost-fleet** — how the language propagates across many projects - **ghost-ui** — a reference design system to test the loop against @@ -24,7 +23,6 @@ Run any tool's CLI after building: ```bash node packages/ghost-drift/dist/bin.js node packages/ghost-expression/dist/bin.js -node packages/ghost-map/dist/bin.js node packages/ghost-fleet/dist/bin.js # or via the workspace pnpm --filter ghost-drift exec ghost-drift @@ -61,18 +59,19 @@ Run `just` to list all recipes. Key ones: `setup`, `build`, `check`, `fmt`, `tes Ghost is **BYOA (bring-your-own-agent)**. The host agent — Claude Code, Codex, Cursor, Goose, whatever ships next — does the reading, deciding, and writing. The judgement work (profile, review, verify, remediate) lives in [agentskills.io](https://agentskills.io)-compatible skill bundles the agent executes. Ghost's CLIs are the calculator the agent reaches for when it needs a reproducible answer (vector math, schema validation, structural diffs); see [`INVARIANTS.md`](./INVARIANTS.md) §1 and §4 for the underlying constraints. -The repo decomposes into **five tools plus a reference design system**, each with a single responsibility: +The repo decomposes into **four tools plus a reference design system**, each with a single responsibility: ``` -@ghost/core library only — embedding math, types, target resolver, skill loader -ghost-map topology (map.md) — inventory, lint -ghost-expression authoring (expression.md) — lint, describe, diff, emit +@ghost/core library only — embedding math, target resolver, skill loader, + ghost.map/v1 schema, ghost.bucket/v1 schema +ghost-expression scan pipeline (map.md → bucket.json → expression.md) + — inventory, lint, describe, diff, bucket, emit ghost-drift drift (.ghost/*) — compare, ack, track, diverge, emit skill ghost-fleet elevation (fleet.md) — members, view, emit skill ghost-ui reference design system — 97 shadcn components + MCP server ``` -Dependency flow: `@ghost/core` ← everyone. `ghost-expression` ← `ghost-drift`, `ghost-fleet`. `ghost-map` ← `ghost-fleet`. No cycles. +Dependency flow: `@ghost/core` ← everyone. `ghost-expression` ← `ghost-drift`, `ghost-fleet`. No cycles. Each tool lives under `packages//` with the same shape: @@ -86,25 +85,24 @@ Each tool lives under `packages//` with the same shape: | Package | Published? | Description | |---------|-----------|-------------| -| `packages/ghost-core` | ❌ private (`@ghost/core`) | Workspace-only library. Embedding math, shared types, target resolution, skill-bundle loader. No CLI. Consumed by every other tool. | +| `packages/ghost-core` | ❌ private (`@ghost/core`) | Workspace-only library. Embedding math, shared types, target resolution, skill-bundle loader, `ghost.map/v1` schema, `ghost.bucket/v1` schema + lint/merge/fix-ids primitives. No CLI. Consumed by every other tool. | | `packages/ghost-drift` | ✅ `ghost-drift` on npm (v0.2+) | Drift detection. CLI verbs: `compare`, `ack`, `track`, `diverge`, `emit skill`. Skill recipes: `compare.md`, `review.md`, `verify.md`, `remediate.md`. Old `lint`/`describe`/`emit review-command`/`emit context-bundle` stay registered as stub commands that point users to `ghost-expression`. | -| `packages/ghost-expression` | ✅ intended-public (`publishConfig.access: public`, currently v0.0.0) | Authoring & validating `expression.md`. CLI verbs: `lint`, `describe`, `diff`, `emit` (kinds: `review-command`, `context-bundle`, `skill`). Skill recipes: `profile.md`, `schema.md`. Owns the canonical artifact. | -| `packages/ghost-map` | ❌ private (currently) | Generates `map.md` — the navigation card every Ghost tool reads. CLI verbs: `inventory` (raw signals as JSON), `lint`. Will eventually publish; gated on the `describe` and `emit skill` follow-ups. | +| `packages/ghost-expression` | ✅ intended-public (`publishConfig.access: public`, currently v0.0.0) | Owns the three-stage scan pipeline (`map.md` topology → `bucket.json` objective → `expression.md` subjective). CLI verbs: `lint` (auto-detects file kind), `inventory`, `describe`, `diff`, `bucket ` (merge / fix-ids), `emit` (kinds: `review-command`, `context-bundle`, `skill`). Skill recipes: `map.md`, `survey.md`, `profile.md`, `schema.md`. | | `packages/ghost-fleet` | ❌ private | Read-only elevation across many `(map.md, expression.md)` members. CLI verbs: `members`, `view`, `emit skill`. Skill recipes: `target.md`. | | `packages/ghost-ui` | ❌ private | Reference component library — 49 UI primitives + 48 AI elements + theme + hooks, distributed via the shadcn `registry.json`, not npm. Also ships the `ghost-mcp` bin (`src/mcp/`, built via `tsconfig.mcp.json` → `dist-mcp/`) — an MCP server re-exposing the registry to AI assistants (5 tools, 2 resources). | | `apps/docs` | ❌ private | The deployed docs site (`ghost-docs`) — home, drift tooling docs, design language foundations, live component catalogue. Consumes `ghost-ui`. | ## CLI Commands -Verbs are scoped to the tool that owns the artifact. The full surface across all four tools: +Verbs are scoped to the tool that owns the artifact. The full surface across all three tools: | Tool | Command | Description | |------|---------|-------------| -| `ghost-map` | `inventory [path]` | Emit raw repo signals (manifests, language histogram, registry, top-level tree, git remote) as JSON. | -| `ghost-map` | `lint [map]` | Validate `map.md` against `ghost.map/v1`. | -| `ghost-expression` | `lint [expression]` | Validate `expression.md` schema + body/frontmatter coherence. | +| `ghost-expression` | `inventory [path]` | Emit raw repo signals (manifests, language histogram, registry, top-level tree, git remote) as JSON. Feeds the topology recipe. | +| `ghost-expression` | `lint [file]` | Validate `expression.md`, `map.md`, or `bucket.json` — auto-detects the kind from path/content. | | `ghost-expression` | `describe [expression]` | Print section ranges + token estimates (so agents can selectively load). | -| `ghost-expression` | `diff ` | Structural prose-level diff (decisions + palette roles). **Not** vector distance. | +| `ghost-expression` | `diff ` | Structural prose-level diff between expressions (decisions + palette roles). **Not** vector distance. | +| `ghost-expression` | `bucket [...buckets]` | Operate on `ghost.bucket/v1` files. Ops: `merge` (concat with id-based dedup), `fix-ids` (recompute IDs from content). | | `ghost-expression` | `emit ` | Derive an artifact from `expression.md`: `review-command`, `context-bundle`, or `skill`. | | `ghost-drift` | `compare [...expressions]` | Pairwise (N=2) or composite (N≥3) over expression embeddings. `--semantic`, `--temporal`. | | `ghost-drift` | `ack` | Record a stance toward the tracked expression in `.ghost-sync.json`. | @@ -117,16 +115,15 @@ Verbs are scoped to the tool that owns the artifact. The full surface across all **Workflows (agent recipes).** Each tool ships its own skill-bundle references under `packages//src/skill-bundle/references/`. These are the agent's job, not CLI verbs: -- **Profile** (write `expression.md` from a project) — `ghost-expression/.../profile.md` -- **Map** (write `map.md` from a repo) — `ghost-map/.../map.md` +- **Map** (write `map.md` from a repo, the topology stage) — `ghost-expression/.../map.md` +- **Survey** (write `bucket.json` from a target, the objective stage) — `ghost-expression/.../survey.md` +- **Profile** (write `expression.md` from a bucket, the subjective stage) — `ghost-expression/.../profile.md` - **Review** (flag drift in PR changes) — `ghost-drift/.../review.md` - **Verify** (generate → review loop) — `ghost-drift/.../verify.md` - **Compare interpretation** — `ghost-drift/.../compare.md` - **Remediate** (suggest minimal fixes for drift) — `ghost-drift/.../remediate.md` - **Fleet narrative** (synthesize `fleet.md` prose from CLI output) — `ghost-fleet/.../target.md` -`discover.md` and `generate.md` are dropped from scope in the decomposition (per `docs/ideas/phase-0-decisions.md`); they are not migrated to any tool. - ## Target Types The `resolveTarget()` function in `@ghost/core` (`packages/ghost-core/src/target-resolver.ts`) accepts: @@ -142,16 +139,17 @@ Used by `resolveTrackedExpression` (in `ghost-drift`) and legacy library consume ## Canonical artifacts -Two canonical Markdown artifacts, each owned by one tool: +Three artifacts produced in sequence by a scan, all owned by `ghost-expression`: -- **`expression.md`** — the design language. Owned by `ghost-expression`. Human-readable, LLM-editable, with YAML frontmatter (machine layer: 49-dim embedding + palette/spacing/typography/surfaces/roles) and a three-section prose body (Character → Signature → Decisions). See `docs/expression-format.md` for the full spec; the condensed reference ships at `packages/ghost-expression/src/skill-bundle/references/schema.md`. -- **`map.md`** — the topology card. Owned by `ghost-map`. Human-readable answer to "where is the design system, which folders matter?" Schema is `ghost.map/v1`, validated by `ghost-map lint`. The condensed reference ships at `packages/ghost-map/src/skill-bundle/references/schema.md`. The repo's own `map.md` lives at the root. +- **`map.md`** — the topology card (stage 1). Human-readable answer to "where is the design system, which folders matter?" Schema is `ghost.map/v1` (lives in `@ghost/core`), validated by `ghost-expression lint map.md`. Authored from `ghost-expression inventory` + the `map.md` skill recipe. The repo's own `map.md` lives at the root. +- **`bucket.json`** — the objective scan (stage 2). Catalogues every concrete design value (colors, spacings, typography, radii, shadows, breakpoints, motion, layout primitives) plus tokens, components, and libraries observed in the target. Each row carries occurrence counts and a deterministic content-hashed `id`. Schema is `ghost.bucket/v1` (lives in `@ghost/core`), validated by `ghost-expression lint bucket.json`. Authored via the `survey.md` skill recipe. +- **`expression.md`** — the design language (stage 3, terminal). Human-readable, LLM-editable, with YAML frontmatter (machine layer: 49-dim embedding + palette/spacing/typography/surfaces/roles) and a three-section prose body (Character → Signature → Decisions). Authored by interpreting `bucket.json` per the `profile.md` skill recipe. See `docs/expression-format.md` for the full spec; the condensed reference ships at `packages/ghost-expression/src/skill-bundle/references/schema.md`. ## Releasing & Changesets -`ghost-drift` is the only currently-published package. `ghost-expression` is set up to publish (`publishConfig.access: public`); `ghost-map` and `ghost-fleet` are private workspace-only for now. Releases go through [Changesets](https://github.com/changesets/changesets); the `.github/workflows/release.yml` workflow opens a "Version Packages" PR whenever pending changesets are on `main`, and publishes to npm when that PR merges. +`ghost-drift` is the only currently-published package. `ghost-expression` is set up to publish (`publishConfig.access: public`); `ghost-fleet` is private workspace-only for now. Releases go through [Changesets](https://github.com/changesets/changesets); the `.github/workflows/release.yml` workflow opens a "Version Packages" PR whenever pending changesets are on `main`, and publishes to npm when that PR merges. -The Changesets config ignores private packages (`@ghost/core`, `ghost-fleet`, `ghost-map`, `ghost-ui`, `apps/docs`) — they don't appear in version PRs. +The Changesets config ignores private packages (`@ghost/core`, `ghost-fleet`, `ghost-ui`, `apps/docs`) — they don't appear in version PRs. **When you (the agent) complete a user-visible change to a published package, write a changeset file yourself instead of asking the user to run `pnpm changeset`.** Create `.changeset/.md` with this shape: @@ -188,5 +186,5 @@ The slug should be short and descriptive: `add-temporal-flag.md`, `fix-palette-l - `ghost-drift compare` takes **file paths** to `expression.md`, not target strings. Mode auto-detects from N and flags: `--semantic` / `--temporal` require N=2; N≥3 returns a composite expression. - `ghost-drift ack` / `track` / `diverge` read the local `expression.md`. The host agent is responsible for regenerating `expression.md` (via the `profile` recipe) before acknowledging drift. - `ghost-expression lint` takes a single `expression.md` and reports schema/partition violations. Use as the success gate when authoring an expression. -- `ghost-map lint` takes a single `map.md` and validates against `ghost.map/v1`. Use as the success gate when authoring a map. +- `ghost-expression lint ` validates against `ghost.map/v1` (auto-detected by frontmatter or filename). Use as the success gate when authoring a map. - The CLI manifest at `apps/docs/src/generated/cli-manifest.json` is auto-generated by `pnpm dump:cli-help`. CI guards drift via `pnpm check:cli-manifest`. Re-run `pnpm dump:cli-help` after adding/removing flags or verbs to any tool. diff --git a/README.md b/README.md index da1e7cc..e43db37 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,18 @@ Every Ghost workflow runs in the host agent you already use — Claude Code, Cod No API key is required to run any CLI verb. Each tool's `emit skill` verb installs its bundle into your agent. -## The five tools +## The four tools -Ghost is split into one responsibility per tool, around two canonical Markdown artifacts. +Ghost is split into one responsibility per tool. A scan produces three artifacts in sequence — `map.md` (topology) → `bucket.json` (objective values) → `expression.md` (subjective interpretation) — all owned by `ghost-expression`. | Tool | Owns | Verbs | | --- | --- | --- | -| **`ghost-map`** | `map.md` — the topology card answering "where is the design system, which folders matter?" | `inventory`, `lint` | -| **`ghost-expression`** | `expression.md` — the canonical design language artifact | `lint`, `describe`, `diff`, `emit` | +| **`ghost-expression`** | the three-stage scan pipeline (`map.md`, `bucket.json`, `expression.md`) | `inventory`, `lint`, `describe`, `diff`, `bucket `, `emit` | | **`ghost-drift`** | `.ghost/history.jsonl` + `.ghost-sync.json` — drift detection and stance | `compare`, `ack`, `track`, `diverge`, `emit skill` | | **`ghost-fleet`** | `fleet.md` — read-only elevation across many `(map.md, expression.md)` members | `members`, `view`, `emit skill` | | **`ghost-ui`** | A reference design system Ghost dogfoods — 97 shadcn components + an MCP server | (no verbs) | -`@ghost/core` underneath is a workspace-only library with the embedding math, target resolver, and skill-bundle loader the four CLIs share. +`@ghost/core` underneath is a workspace-only library with embedding math, target resolution, skill-bundle loader, and the `ghost.map/v1` + `ghost.bucket/v1` schemas the three CLIs share. ## Why Ghost? @@ -38,19 +37,18 @@ Ghost gives agents four capabilities the design-at-scale problem actually needs: ## Repo layout -Ghost is a pnpm monorepo. Five tools, one reference design system, one docs site. +Ghost is a pnpm monorepo. Four tools, one reference design system, one docs site. | Path | Role | Published? | | ---- | ---- | --- | -| [`packages/ghost-core`](./packages/ghost-core) | Workspace-only shared library — embedding math, types, target resolver, skill loader. | ❌ private (`@ghost/core`) | -| [`packages/ghost-map`](./packages/ghost-map) | `map.md` topology generator + linter. | ❌ private (today) | -| [`packages/ghost-expression`](./packages/ghost-expression) | `expression.md` authoring + emit pipeline. | ✅ intended-public on npm | +| [`packages/ghost-core`](./packages/ghost-core) | Workspace-only shared library — embedding math, target resolver, skill loader, `ghost.map/v1` + `ghost.bucket/v1` schemas. | ❌ private (`@ghost/core`) | +| [`packages/ghost-expression`](./packages/ghost-expression) | The three-stage scan pipeline: `map.md` topology → `bucket.json` objective → `expression.md` subjective. Authoring, lint, describe, diff, bucket ops, emit. | ✅ intended-public on npm | | [`packages/ghost-drift`](./packages/ghost-drift) | Drift detection + governance verbs. | ✅ `ghost-drift` on npm | | [`packages/ghost-fleet`](./packages/ghost-fleet) | Fleet elevation across many members. | ❌ private | | [`packages/ghost-ui`](./packages/ghost-ui) | Reference design system: 97 shadcn components + the `ghost-mcp` MCP server. | ❌ private (distributed via shadcn registry, not npm) | | [`apps/docs`](./apps/docs) | Deployed docs site (`ghost-docs`). | ❌ private | -Dependency flow: `@ghost/core` ← everyone. `ghost-expression` ← `ghost-drift`, `ghost-fleet`. `ghost-map` ← `ghost-fleet`. No cycles. +Dependency flow: `@ghost/core` ← everyone. `ghost-expression` ← `ghost-drift`, `ghost-fleet`. No cycles. ## Getting Started @@ -80,11 +78,11 @@ Once a skill is installed, ask your agent in plain English ("profile this design ### Quick start -**1. Map the repo** (optional but speeds up everything that follows). Ask your host agent to write `map.md`, then validate: +**1. Map the repo** (the topology stage — pre-req for survey + profile). Ask your host agent to write `map.md`, then validate: ```bash -ghost-map inventory # raw signals as JSON (the agent reads this to author map.md) -ghost-map lint # validate ./map.md against ghost.map/v1 +ghost-expression inventory # raw signals as JSON (the agent reads this to author map.md) +ghost-expression lint map.md # validate ./map.md against ghost.map/v1 ``` **2. Profile your design system** — ask your host agent to write `expression.md`. It'll follow the `profile` recipe and validate at the end. You validate manually with: @@ -146,11 +144,11 @@ Verbs are scoped to the tool that owns the artifact. Pure inputs → pure output | Tool | Command | Description | | --- | --- | --- | -| `ghost-map` | `inventory` | Emit raw repo signals (manifests, language histogram, registry presence, top-level tree, git remote) as JSON. | -| `ghost-map` | `lint` | Validate `map.md` against `ghost.map/v1`. | -| `ghost-expression` | `lint` | Validate `expression.md` schema + body/frontmatter coherence. | +| `ghost-expression` | `inventory` | Emit raw repo signals (manifests, language histogram, registry presence, top-level tree, git remote) as JSON. Feeds the topology recipe. | +| `ghost-expression` | `lint` | Validate `expression.md`, `map.md`, or `bucket.json` — auto-detects by extension/content. | | `ghost-expression` | `describe` | Print section ranges + token estimates so agents can selectively load. | | `ghost-expression` | `diff` | Structural prose-level diff between two expressions (NOT vector distance — for that, use `ghost-drift compare`). | +| `ghost-expression` | `bucket ` | Operate on `ghost.bucket/v1` files. Ops: `merge` (concat with id-based dedup, idempotent), `fix-ids` (recompute ids from content). | | `ghost-expression` | `emit` | Derive an artifact from `expression.md`: `review-command`, `context-bundle`, or `skill`. | | `ghost-drift` | `compare` | Pairwise (N=2) or composite (N≥3) over expression embeddings. `--semantic` / `--temporal` add qualitative enrichment. | | `ghost-drift` | `ack` | Record stance toward the tracked expression in `.ghost-sync.json`. | @@ -167,8 +165,9 @@ The interpretive verbs from the pitch (*author, self-govern, detect, remediate*) | Recipe | Bundle | Capability | Triggered by | | --- | --- | --- | --- | -| `map` | `ghost-map` | Author the topology card | "map this repo", "write map.md" | -| `profile` | `ghost-expression` | Author the quality bar | "profile this design language", "write expression.md" | +| `map` | `ghost-expression` | Author the topology card (stage 1) | "map this repo", "write map.md" | +| `survey` | `ghost-expression` | Author the bucket of values (stage 2) | "survey design values", "extract design tokens" | +| `profile` | `ghost-expression` | Author the design language (stage 3) | "profile this design language", "write expression.md" | | `review` | `ghost-drift` | Self-govern at PR time | "review this PR for drift" | | `verify` | `ghost-drift` | Self-govern at generation time | "verify generated UI against the expression" | | `compare` | `ghost-drift` | Detect drift across the org | "why did these two expressions drift?" | @@ -203,9 +202,9 @@ Generate one with the `profile` recipe (in the `ghost-expression` skill bundle). ### The map -What every Ghost tool reads to learn the topology of a repo. The canonical artifact is **`map.md`** (owned by `ghost-map`): YAML frontmatter against the `ghost.map/v1` schema (languages, build system, package manifests, registry, design-system paths, UI surface globs, feature areas) plus a short prose body (Identity, Topology, Conventions). The repo's own `map.md` lives at the root. +What every Ghost tool reads to learn the topology of a repo. The canonical artifact is **`map.md`** (owned by `ghost-expression`, stage 1 of a scan): YAML frontmatter against the `ghost.map/v1` schema (languages, build system, package manifests, registry, design-system paths, UI surface globs, feature areas) plus a short prose body (Identity, Topology, Conventions). The repo's own `map.md` lives at the root. -Generate one with the `map` recipe (in the `ghost-map` skill bundle). The agent reads `ghost-map inventory` (raw repo signals as JSON) and synthesizes the prose layer. +Generate one with the `map` recipe (in the `ghost-expression` skill bundle). The agent reads `ghost-expression inventory` (raw repo signals as JSON) and synthesizes the prose layer. ### Author + self-govern loop diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 5829181..10b465a 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -1,6 +1,6 @@ --- title: CLI Reference -description: Sixteen verbs across four tools. Everything interpretive lives in the skill bundles your host agent runs. +description: Sixteen verbs across three tools. Everything interpretive lives in the skill bundles your host agent runs. kicker: Docs section: guide order: 20 @@ -10,22 +10,20 @@ slug: cli The CLIs are the calculator your host agent reaches for when it needs a -reproducible answer. The canonical artifacts are **`expression.md`** (owned -by `ghost-expression`) and **`map.md`** (owned by `ghost-map`) — Markdown -files with YAML frontmatter (machine layer) plus a prose body. Most commands -accept a path to the relevant artifact; they default to `./expression.md` or -`./map.md` in the current directory. No API key required. +reproducible answer. A scan produces three artifacts in sequence — **`map.md`** +(topology) → **`bucket.json`** (objective) → **`expression.md`** (subjective) — +all owned by `ghost-expression`. Most commands accept a path; they default to +`./expression.md` for kind-aware verbs. No API key required. Verbs are scoped to the tool that owns the artifact: -- **`ghost-map`** — topology: `inventory`, `lint` -- **`ghost-expression`** — design language: `lint`, `describe`, `diff`, `emit` +- **`ghost-expression`** — the scan pipeline: `inventory`, `lint`, `describe`, `diff`, `bucket `, `emit` - **`ghost-drift`** — drift detection + governance: `compare`, `ack`, `track`, `diverge`, `emit skill` - **`ghost-fleet`** — elevation across many members: `members`, `view`, `emit skill` -Workflows like _profile_, _review_, _verify_, and _remediate_ are skill -recipes your host agent runs — not CLI verbs. Install them with each -tool's `emit skill` verb. +Workflows like _map_, _survey_, _profile_, _review_, _verify_, and _remediate_ +are skill recipes your host agent runs — not CLI verbs. Install them with +each tool's `emit skill` verb. The tables below are generated from each CLI's source at build time. If a flag changes in any `cli.ts`, the next `pnpm dump:cli-help` run regenerates @@ -33,46 +31,36 @@ this reference — so these docs can't drift from the binaries. - - -`map.md` is the navigation card every Ghost tool reads to learn the -topology of a frontend repo. `ghost-map` ships two verbs: `inventory` -(raw repo signals as JSON, the input the agent reads to author -`map.md`) and `lint` (validate against `ghost.map/v1`). + - +`ghost-expression` owns the three-stage scan pipeline: `map.md` (topology) → +`bucket.json` (objective) → `expression.md` (subjective). Verbs cover +inventory + lint + describe + diff + bucket ops + emit. Drift detection +lives separately in `ghost-drift`. -```bash -# Inventory the current directory -ghost-map inventory +### Topology — `inventory` -# Inventory a different repo -ghost-map inventory ../other-repo -``` +Emit deterministic raw signals about a frontend repo as JSON: package +manifests, language histogram, candidate config files, registry presence, +top-level tree, git remote. Feeds the topology recipe (the agent's input +when authoring `map.md`). - + ```bash -# Default — reads ./map.md -ghost-map lint +# Inventory the current directory +ghost-expression inventory -# Specific file, JSON output -ghost-map lint packages/ghost-ui/map.md --format json +# Inventory a different repo +ghost-expression inventory ../other-repo ``` - - - - -`expression.md` is the canonical design-language artifact. `ghost-expression` -owns authoring (lint + describe + diff + emit) — drift detection lives -separately in `ghost-drift`. - ### Validation — `lint` -Validate `expression.md` schema + body/frontmatter coherence. Use this -before declaring an expression valid — the `profile` recipe ends by -calling it. +Validate `expression.md`, `map.md`, or `bucket.json` against its schema — +auto-detects which by `.json` extension, `schema: ghost.map/v1` frontmatter, +or filename. Use this before declaring an artifact valid; every authoring +recipe ends by calling it. @@ -80,7 +68,13 @@ calling it. # Default — reads ./expression.md ghost-expression lint -# Specific file, JSON output +# Validate a map.md (auto-detected by frontmatter) +ghost-expression lint map.md + +# Validate a bucket.json +ghost-expression lint bucket.json + +# JSON output ghost-expression lint path/to/expression.md --format json ``` @@ -299,7 +293,8 @@ once, then ask your agent in plain English: | Recipe | Bundle | Trigger | | ---------- | ------------------ | ------------------------------------------------------ | -| `map` | `ghost-map` | "map this repo" / "write map.md" | +| `map` | `ghost-expression` | "map this repo" / "write map.md" (topology stage) | +| `survey` | `ghost-expression` | "survey design values" / "extract tokens" (objective) | | `profile` | `ghost-expression` | "profile this design language" / "write expression.md" | | `review` | `ghost-drift` | "review this PR for drift" | | `verify` | `ghost-drift` | "verify generated UI against the expression" | diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index 968bbb7..c524d24 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -18,8 +18,7 @@ a reproducible answer. | Tool | Owns | Verbs | | --- | --- | --- | -| `ghost-map` | `map.md` (topology) | `inventory`, `lint` | -| `ghost-expression` | `expression.md` (design language) | `lint`, `describe`, `diff`, `emit` | +| `ghost-expression` | the three-stage scan: `map.md` → `bucket.json` → `expression.md` | `inventory`, `lint`, `describe`, `diff`, `bucket `, `emit` | | `ghost-drift` | drift detection + governance | `compare`, `ack`, `track`, `diverge`, `emit skill` | | `ghost-fleet` | `fleet.md` (elevation across members) | `members`, `view`, `emit skill` | | `ghost-ui` | reference design system (97 shadcn components) | — | @@ -44,8 +43,8 @@ Or install globally: pnpm add -g ghost-drift ghost-expression ``` -`ghost-map` and `ghost-fleet` are workspace-only for now — clone the repo -and `pnpm build` to use them. +`ghost-fleet` is workspace-only for now — clone the repo and `pnpm build` +to use it. No API key is required for any CLI verb. If your host agent uses Anthropic or OpenAI models, it'll handle auth itself. Each CLI auto-loads `.env` and @@ -68,7 +67,7 @@ ghost-expression emit skill # → .claude/skills/ghost-expression Each bundle ships its own `SKILL.md` plus recipes under `references/`: -- **`ghost-expression`** — `profile.md` (write `expression.md` from a project) + `schema.md` (condensed format reference). +- **`ghost-expression`** — `map.md` (write `map.md` from a repo, the topology stage), `survey.md` (write `bucket.json`, the objective stage), `profile.md` (interpret a bucket into `expression.md`, the subjective stage), `schema.md` (condensed format reference). - **`ghost-drift`** — `compare.md` (interpretation), `review.md` (PR review), `verify.md` (generation→review loop), `remediate.md` (suggest minimal fixes). - **`ghost-fleet`** — `target.md` (synthesize fleet narrative from `view` output). diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 1052a3d..77c7bb7 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-04-27T15:50:16.473Z", + "generatedAt": "2026-04-29T17:56:29.010Z", "tools": [ { "tool": "ghost-drift", @@ -200,8 +200,8 @@ { "tool": "ghost-expression", "name": "lint", - "rawName": "lint [expression]", - "description": "Validate expression.md schema and body/frontmatter coherence", + "rawName": "lint [file]", + "description": "Validate expression.md, map.md, or bucket.json — auto-detects the kind from path/content", "options": [ { "rawName": "--format ", @@ -213,6 +213,29 @@ } ] }, + { + "tool": "ghost-expression", + "name": "scan-status", + "rawName": "scan-status [dir]", + "description": "Report which scan stages have produced artifacts in a directory: topology (map.md), objective (bucket.json), subjective (expression.md). Tells orchestrators which stage to run next.", + "options": [ + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost-expression", + "name": "inventory", + "rawName": "inventory [path]", + "description": "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote. Feeds the topology recipe (map.md authoring).", + "options": [] + }, { "tool": "ghost-expression", "name": "describe", @@ -245,6 +268,22 @@ } ] }, + { + "tool": "ghost-expression", + "name": "bucket", + "rawName": "bucket [...buckets]", + "description": "Operate on ghost.bucket/v1 files. Ops: merge (concat with id-based dedup, deterministic and idempotent), fix-ids (recompute every row's id from content; use after authoring rows with empty id fields).", + "options": [ + { + "rawName": "-o, --out ", + "name": "out", + "description": "Write the result to this path (default: stdout)", + "default": null, + "takesValue": true, + "negated": false + } + ] + }, { "tool": "ghost-expression", "name": "emit", @@ -325,48 +364,6 @@ } ] }, - { - "tool": "ghost-map", - "commands": [ - { - "tool": "ghost-map", - "name": "inventory", - "rawName": "inventory [path]", - "description": "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote.", - "options": [] - }, - { - "tool": "ghost-map", - "name": "lint", - "rawName": "lint [map]", - "description": "Validate map.md against ghost.map/v1", - "options": [ - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - } - ], - "globalOptions": [ - { - "rawName": "-h, --help", - "name": "help", - "description": "Display this message", - "default": null - }, - { - "rawName": "-v, --version", - "name": "version", - "description": "Display version number", - "default": null - } - ] - }, { "tool": "ghost-fleet", "commands": [ diff --git a/packages/ghost-core/package.json b/packages/ghost-core/package.json index 0bf3f07..7ff7627 100644 --- a/packages/ghost-core/package.json +++ b/packages/ghost-core/package.json @@ -2,7 +2,7 @@ "name": "@ghost/core", "version": "0.0.0", "private": true, - "description": "Internal shared primitives for the Ghost toolchain — types, embedding math, target resolution, skill-bundle loader. Consumed by ghost-drift and the upcoming ghost-expression / ghost-fleet / ghost-map packages. Not published.", + "description": "Internal shared primitives for the Ghost toolchain — types, embedding math, target resolution, skill-bundle loader, ghost.bucket/v1 schema, ghost.map/v1 schema. Consumed by every ghost-* tool. Not published.", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", @@ -18,5 +18,8 @@ ], "scripts": { "build": "rm -rf dist && tsc --build --force" + }, + "dependencies": { + "zod": "^4.3.6" } } diff --git a/packages/ghost-core/src/bucket/fix-ids.ts b/packages/ghost-core/src/bucket/fix-ids.ts new file mode 100644 index 0000000..a719eb0 --- /dev/null +++ b/packages/ghost-core/src/bucket/fix-ids.ts @@ -0,0 +1,50 @@ +import { componentRowId, libraryRowId, tokenRowId, valueRowId } from "./id.js"; +import type { + Bucket, + ComponentRow, + LibraryRow, + TokenRow, + ValueRow, +} from "./types.js"; + +/** + * Recompute every row's `id` from its content fields, producing a new + * bucket with deterministic IDs. + * + * Authoring flow: an agent writes bucket rows with `id: ""` (or any + * placeholder), then calls `recomputeBucketIds` to populate them, then + * runs `lintBucket` to validate. This avoids forcing the agent to compute + * SHA-256 hashes by hand for every row, while keeping the bucket + * schema's strict id requirement. + * + * The function is pure — input bucket is unchanged. + */ +export function recomputeBucketIds(bucket: Bucket): Bucket { + return { + ...bucket, + values: bucket.values.map( + (row): ValueRow => ({ + ...row, + id: valueRowId(row.source, row.kind, row.value, row.raw), + }), + ), + tokens: bucket.tokens.map( + (row): TokenRow => ({ + ...row, + id: tokenRowId(row.source, row.name), + }), + ), + components: bucket.components.map( + (row): ComponentRow => ({ + ...row, + id: componentRowId(row.source, row.name), + }), + ), + libraries: bucket.libraries.map( + (row): LibraryRow => ({ + ...row, + id: libraryRowId(row.source, row.name), + }), + ), + }; +} diff --git a/packages/ghost-core/src/bucket/id.ts b/packages/ghost-core/src/bucket/id.ts new file mode 100644 index 0000000..c67a996 --- /dev/null +++ b/packages/ghost-core/src/bucket/id.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import type { BucketSource } from "./types.js"; + +/** + * Deterministic ID generation for bucket rows. + * + * Two scans of the same `(target, commit)` over the same source content + * must produce identical IDs so that re-merging is idempotent and git + * diffs over `bucket.json` show only meaningful changes. Scans of + * different commits or different targets produce distinct IDs so that + * fleet-wide merges preserve every observation. + * + * IDs are 16-hex-char (8-byte) prefixes of SHA-256. At ~10^6 rows in the + * universe of all scans this gives collision probability under 2^-32. + */ + +const ID_LENGTH = 16; + +const VALUE_TAG = "value"; +const TOKEN_TAG = "token"; +const COMPONENT_TAG = "component"; +const LIBRARY_TAG = "library"; + +function digest(...parts: (string | undefined)[]): string { + const hash = createHash("sha256"); + for (const part of parts) { + hash.update(part ?? ""); + hash.update("\x00"); + } + return hash.digest("hex").slice(0, ID_LENGTH); +} + +function sourceKey(source: BucketSource): [string, string] { + return [source.target, source.commit ?? ""]; +} + +export function valueRowId( + source: BucketSource, + kind: string, + value: string, + raw: string, +): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, VALUE_TAG, kind, value, raw); +} + +export function tokenRowId(source: BucketSource, name: string): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, TOKEN_TAG, name); +} + +export function componentRowId(source: BucketSource, name: string): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, COMPONENT_TAG, name); +} + +export function libraryRowId(source: BucketSource, name: string): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, LIBRARY_TAG, name); +} diff --git a/packages/ghost-core/src/bucket/index.ts b/packages/ghost-core/src/bucket/index.ts new file mode 100644 index 0000000..6178171 --- /dev/null +++ b/packages/ghost-core/src/bucket/index.ts @@ -0,0 +1,53 @@ +/** + * Public surface for `ghost.bucket/v1` — types, schemas, ID generation, + * lint, and merge. Consumed by `ghost-expression` and any future ghost + * tool that operates on bucket data. + */ + +export { recomputeBucketIds } from "./fix-ids.js"; +export { + componentRowId, + libraryRowId, + tokenRowId, + valueRowId, +} from "./id.js"; +export { + BUCKET_FILENAME, + type BucketLintIssue, + type BucketLintReport, + type BucketLintSeverity, + lintBucket, +} from "./lint.js"; +export { mergeBuckets } from "./merge.js"; +export { + BucketSchema, + BucketSourceSchema, + ColorSpecSchema, + ComponentRowSchema, + LibraryRowSchema, + RECOMMENDED_VALUE_KINDS, + TokenRowSchema, + ValueRowSchema, + ValueSpecSchema, +} from "./schema.js"; +export type { + BreakpointSpec, + Bucket, + BucketSource, + ColorSpec, + ComponentRow, + LayoutPrimitiveSpec, + LibraryRow, + MotionSpec, + RadiusSpec, + RecommendedValueKind, + RowBase, + ScalarUnit, + ShadowSpec, + SpacingSpec, + TokenRow, + TypographySpec, + UnknownSpec, + ValueRow, + ValueSpec, +} from "./types.js"; diff --git a/packages/ghost-core/src/bucket/lint.ts b/packages/ghost-core/src/bucket/lint.ts new file mode 100644 index 0000000..ba46375 --- /dev/null +++ b/packages/ghost-core/src/bucket/lint.ts @@ -0,0 +1,154 @@ +import type { ZodIssue } from "zod"; +import { componentRowId, libraryRowId, tokenRowId, valueRowId } from "./id.js"; +import { BucketSchema, RECOMMENDED_VALUE_KINDS } from "./schema.js"; +import type { Bucket } from "./types.js"; + +export type BucketLintSeverity = "error" | "warning" | "info"; + +export interface BucketLintIssue { + severity: BucketLintSeverity; + rule: string; + message: string; + /** Dotted path within the bucket (e.g. `values[3].id`). */ + path?: string; +} + +export interface BucketLintReport { + issues: BucketLintIssue[]; + errors: number; + warnings: number; + info: number; +} + +export const BUCKET_FILENAME = "bucket.json"; + +/** + * Lint a parsed bucket object against `ghost.bucket/v1`. + * + * Errors: schema violations (missing fields, wrong types, bad enum values). + * Warnings: unknown value kinds (open-enum policy), ID mismatches (a row's + * recorded `id` doesn't match what the deterministic generator would + * produce for its content), duplicate IDs within the same bucket. + */ +export function lintBucket(input: unknown): BucketLintReport { + const issues: BucketLintIssue[] = []; + + const result = BucketSchema.safeParse(input); + if (!result.success) { + for (const issue of zodIssues(result.error.issues)) { + issues.push(issue); + } + return finalize(issues); + } + + const bucket = result.data as Bucket; + + // Open-enum kind warnings. + bucket.values.forEach((row, idx) => { + if (!RECOMMENDED_VALUE_KINDS.includes(row.kind)) { + issues.push({ + severity: "warning", + rule: "value-kind-unknown", + message: `value row uses non-recommended kind '${row.kind}' — accepted, but cross-fleet tooling may not canonicalize it`, + path: `values[${idx}].kind`, + }); + } + }); + + // Deterministic-ID checks: each row's recorded id must match what the + // generator would produce for its content. Catches scanners that mint + // IDs incorrectly and breaks idempotent merge if not enforced. + bucket.values.forEach((row, idx) => { + const expected = valueRowId(row.source, row.kind, row.value, row.raw); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}' — re-derive via valueRowId(...) to keep merges idempotent`, + path: `values[${idx}].id`, + }); + } + }); + bucket.tokens.forEach((row, idx) => { + const expected = tokenRowId(row.source, row.name); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}'`, + path: `tokens[${idx}].id`, + }); + } + }); + bucket.components.forEach((row, idx) => { + const expected = componentRowId(row.source, row.name); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}'`, + path: `components[${idx}].id`, + }); + } + }); + bucket.libraries.forEach((row, idx) => { + const expected = libraryRowId(row.source, row.name); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}'`, + path: `libraries[${idx}].id`, + }); + } + }); + + // Duplicate-id checks within a single section. (Cross-section duplicates + // are fine since IDs include a section tag.) Within-bucket duplicates + // mean the scanner emitted two rows with the same content, which the + // recorder should have merged. + for (const section of [ + "values", + "tokens", + "components", + "libraries", + ] as const) { + const seen = new Map(); + bucket[section].forEach((row, idx) => { + const prev = seen.get(row.id); + if (prev !== undefined) { + issues.push({ + severity: "error", + rule: "duplicate-id", + message: `duplicate id '${row.id}' in ${section} (also at ${section}[${prev}])`, + path: `${section}[${idx}].id`, + }); + } else { + seen.set(row.id, idx); + } + }); + } + + return finalize(issues); +} + +function zodIssues(issues: ZodIssue[]): BucketLintIssue[] { + return issues.map((issue) => ({ + severity: "error" as const, + rule: `schema/${issue.code}`, + message: issue.message, + path: issue.path.join("."), + })); +} + +function finalize(issues: BucketLintIssue[]): BucketLintReport { + let errors = 0; + let warnings = 0; + let info = 0; + for (const issue of issues) { + if (issue.severity === "error") errors++; + else if (issue.severity === "warning") warnings++; + else info++; + } + return { issues, errors, warnings, info }; +} diff --git a/packages/ghost-core/src/bucket/merge.ts b/packages/ghost-core/src/bucket/merge.ts new file mode 100644 index 0000000..db80f81 --- /dev/null +++ b/packages/ghost-core/src/bucket/merge.ts @@ -0,0 +1,71 @@ +import type { + Bucket, + BucketSource, + ComponentRow, + LibraryRow, + RowBase, + TokenRow, + ValueRow, +} from "./types.js"; + +/** + * Merge N buckets into one. Concat semantics with id-based dedup. + * + * Two scans of the same `(target, commit)` produce rows with identical + * IDs by construction — those rows are deduplicated to one (first wins). + * Two scans of different commits or different targets produce distinct + * IDs, so all observations survive. + * + * `sources` becomes the union of input sources, also deduped on + * `(target, commit)`. + * + * Idempotent: `mergeBuckets(b)` == `b`. Commutative on the rowset (order + * within sections may differ from input order but content is identical). + */ +export function mergeBuckets(...buckets: Bucket[]): Bucket { + if (buckets.length === 0) { + throw new Error("mergeBuckets requires at least one input bucket"); + } + return { + schema: "ghost.bucket/v1", + sources: dedupSources(buckets.flatMap((b) => b.sources)), + values: dedupRows(buckets.flatMap((b) => b.values)), + tokens: dedupRows(buckets.flatMap((b) => b.tokens)), + components: dedupRows(buckets.flatMap((b) => b.components)), + libraries: dedupRows(buckets.flatMap((b) => b.libraries)), + }; +} + +function dedupRows(rows: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const row of rows) { + if (seen.has(row.id)) continue; + seen.add(row.id); + out.push(row); + } + return out; +} + +function dedupSources(sources: BucketSource[]): BucketSource[] { + const seen = new Set(); + const out: BucketSource[] = []; + for (const source of sources) { + const key = `${source.target}\x00${source.commit ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(source); + } + return out; +} + +// Type re-exports kept narrow so consumers don't have to import from `types.js` +// just to use `mergeBuckets` results. +export type { + Bucket, + BucketSource, + ComponentRow, + LibraryRow, + TokenRow, + ValueRow, +}; diff --git a/packages/ghost-core/src/bucket/schema.ts b/packages/ghost-core/src/bucket/schema.ts new file mode 100644 index 0000000..03ec831 --- /dev/null +++ b/packages/ghost-core/src/bucket/schema.ts @@ -0,0 +1,164 @@ +import { z } from "zod"; + +/** + * Zod schemas for `ghost.bucket/v1`. + * + * The `kind` field on value rows is intentionally open (a plain string). + * The validator does not reject unknown kinds — instead the lint step + * surfaces them as warnings so downstream tooling can canonicalize without + * blocking new scanners that emit experimental kinds. + */ + +const BucketSourceSchema = z.object({ + target: z.string().min(1), + commit: z.string().optional(), + scanned_at: z.string().min(1), + scanner_version: z.string().optional(), +}); + +const ScalarUnitSchema = z.object({ + scalar: z.number(), + unit: z.string().min(1), +}); + +const ColorSpecSchema = z.object({ + space: z.enum(["srgb", "p3", "rec2020", "lab", "oklch", "unknown"]), + hex: z.string().optional(), + rgb: z + .object({ + r: z.number(), + g: z.number(), + b: z.number(), + a: z.number().optional(), + }) + .optional(), + hsl: z + .object({ + h: z.number(), + s: z.number(), + l: z.number(), + a: z.number().optional(), + }) + .optional(), +}); + +const TypographySpecSchema = z.object({ + family: z.string().optional(), + weight: z.union([z.string(), z.number()]).optional(), + size: ScalarUnitSchema.optional(), + line_height: z.union([ScalarUnitSchema, z.string()]).optional(), + letter_spacing: ScalarUnitSchema.optional(), +}); + +const ShadowSpecSchema = z.object({ + offset_x: ScalarUnitSchema.optional(), + offset_y: ScalarUnitSchema.optional(), + blur: ScalarUnitSchema.optional(), + spread: ScalarUnitSchema.optional(), + color: z.string().optional(), + inset: z.boolean().optional(), +}); + +const MotionSpecSchema = z.object({ + duration_ms: z.number().optional(), + easing: z.string().optional(), +}); + +const LayoutPrimitiveSpecSchema = z.object({ + kind: z.string().min(1), + scalar: z.number().optional(), + unit: z.string().optional(), + raw: z.string().optional(), +}); + +const BreakpointSpecSchema = ScalarUnitSchema.extend({ + label: z.string().optional(), +}); + +/** + * Spec is open: any of the recommended specs, OR a generic record for + * unknown kinds. We don't bind kind→spec strictly here — the lint step + * surfaces mismatches as warnings so experimental scanners can iterate + * without schema changes. + */ +const ValueSpecSchema = z.union([ + ColorSpecSchema, + TypographySpecSchema, + ShadowSpecSchema, + MotionSpecSchema, + LayoutPrimitiveSpecSchema, + BreakpointSpecSchema, + ScalarUnitSchema, + z.record(z.string(), z.unknown()), +]); + +const RowBaseSchema = z.object({ + id: z.string().min(1), + source: BucketSourceSchema, +}); + +const ValueRowSchema = RowBaseSchema.extend({ + kind: z.string().min(1), + value: z.string().min(1), + raw: z.string(), + spec: ValueSpecSchema.optional(), + occurrences: z.number().int().nonnegative(), + files_count: z.number().int().nonnegative(), + usage: z.record(z.string(), z.number().int().nonnegative()).optional(), + role_hypothesis: z.string().optional(), +}); + +const TokenRowSchema = RowBaseSchema.extend({ + name: z.string().min(1), + alias_chain: z.array(z.string()), + resolved_value: z.string().min(1), + by_theme: z.record(z.string(), z.string()).optional(), + occurrences: z.number().int().nonnegative(), +}); + +const ComponentRowSchema = RowBaseSchema.extend({ + name: z.string().min(1), + discovered_via: z.string().min(1), + variants: z.array(z.string()).optional(), + sizes: z.array(z.string()).optional(), +}); + +const LibraryRowSchema = RowBaseSchema.extend({ + name: z.string().min(1), + kind: z.string().min(1), + version: z.string().optional(), +}); + +export const BucketSchema = z.object({ + schema: z.literal("ghost.bucket/v1"), + sources: z.array(BucketSourceSchema).min(1), + values: z.array(ValueRowSchema), + tokens: z.array(TokenRowSchema), + components: z.array(ComponentRowSchema), + libraries: z.array(LibraryRowSchema), +}); + +export { + BucketSourceSchema, + ColorSpecSchema, + ComponentRowSchema, + LibraryRowSchema, + TokenRowSchema, + ValueRowSchema, + ValueSpecSchema, +}; + +/** + * Recommended value kinds. Used only by the lint step to surface unknown + * kinds as warnings — the schema accepts any string for `kind`. + */ +export const RECOMMENDED_VALUE_KINDS: readonly string[] = [ + "color", + "spacing", + "typography", + "radius", + "shadow", + "breakpoint", + "motion", + "layout-primitive", +]; diff --git a/packages/ghost-core/src/bucket/types.ts b/packages/ghost-core/src/bucket/types.ts new file mode 100644 index 0000000..266afce --- /dev/null +++ b/packages/ghost-core/src/bucket/types.ts @@ -0,0 +1,186 @@ +/** + * Types for `ghost.bucket/v1` — the objective scan artifact. + * + * A bucket is the middle artifact in a scan: produced after topology + * (`map.md`) and before subjective interpretation (`expression.md`). It + * catalogues every concrete design value the agent observed in a target, + * with structured specs and per-row deterministic IDs. + * + * Merge semantics are concat-with-id-dedup. Two scans of the same target at + * the same commit produce identical IDs, so re-merging is idempotent. Two + * scans of different commits (or different targets) produce different IDs, + * so cross-bucket merges preserve every observation as its own row. + */ + +/** Where a scan came from. Denormalized onto every row in the bucket. */ +export interface BucketSource { + /** Target string the scan was pointed at — `github:owner/repo`, `./path`, etc. */ + target: string; + /** Git commit sha at scan time, when knowable. */ + commit?: string; + /** ISO 8601 timestamp the scan started. */ + scanned_at: string; + /** Version of the scanner that produced this row. */ + scanner_version?: string; +} + +/** Fields every row carries regardless of section. */ +export interface RowBase { + /** Deterministic hash of `(source.target, source.commit, kind-tag, content fields)`. */ + id: string; + /** Source attribution. Denormalized so rows survive merges with their origin. */ + source: BucketSource; +} + +// --- Value rows ---------------------------------------------------------- + +/** + * Recommended value kinds. The bucket schema treats `kind` as an open + * string — scanners may emit additional kinds (e.g. `z-index`, `opacity`, + * `cursor`, `gradient`, `iconography`) and validators warn rather than + * reject. The recommended set covers the common cross-fleet vocabulary. + */ +export type RecommendedValueKind = + | "color" + | "spacing" + | "typography" + | "radius" + | "shadow" + | "breakpoint" + | "motion" + | "layout-primitive"; + +export interface ColorSpec { + space: "srgb" | "p3" | "rec2020" | "lab" | "oklch" | "unknown"; + hex?: string; + rgb?: { r: number; g: number; b: number; a?: number }; + hsl?: { h: number; s: number; l: number; a?: number }; +} + +export interface ScalarUnit { + scalar: number; + unit: string; +} + +export interface SpacingSpec extends ScalarUnit {} +export interface RadiusSpec extends ScalarUnit {} +export interface BreakpointSpec extends ScalarUnit { + label?: string; +} + +export interface TypographySpec { + family?: string; + weight?: string | number; + size?: ScalarUnit; + line_height?: ScalarUnit | string; + letter_spacing?: ScalarUnit; +} + +export interface ShadowSpec { + offset_x?: ScalarUnit; + offset_y?: ScalarUnit; + blur?: ScalarUnit; + spread?: ScalarUnit; + color?: string; + inset?: boolean; +} + +export interface MotionSpec { + duration_ms?: number; + easing?: string; +} + +export interface LayoutPrimitiveSpec { + /** Sub-kind: `max-width`, `container-padding`, `grid-track`, `gutter`, etc. Open. */ + kind: string; + scalar?: number; + unit?: string; + raw?: string; +} + +/** Fall-through for unknown / open-enum kinds. */ +export type UnknownSpec = Record; + +export type ValueSpec = + | ColorSpec + | SpacingSpec + | TypographySpec + | RadiusSpec + | ShadowSpec + | BreakpointSpec + | MotionSpec + | LayoutPrimitiveSpec + | UnknownSpec; + +export interface ValueRow extends RowBase { + /** One of `RecommendedValueKind` or an extension kind. Open string. */ + kind: string; + /** Canonical string form (`#f97316`, `8px`, `Inter`). */ + value: string; + /** As-it-appeared in source (`#F97316`, `bg-orange-500`, `var(--brand)`). */ + raw: string; + /** Structured spec per kind. */ + spec?: ValueSpec; + /** Total observed count of this value within this scan. */ + occurrences: number; + /** Distinct files that contained this value. */ + files_count: number; + /** Usage breakdown by context (`className`, `css_var`, `inline_style`, etc.). */ + usage?: Record; + /** Agent-assigned role guess (`brand-primary`, `surface-elevated`). */ + role_hypothesis?: string; +} + +// --- Token rows --------------------------------------------------------- + +export interface TokenRow extends RowBase { + /** Token name as declared in source — e.g. `--color-brand-primary`. */ + name: string; + /** + * Resolution chain from this token to its terminal value. Empty array + * means the token is a leaf (defined inline as a literal). Length > 0 + * means each step indirected through another named token. + */ + alias_chain: string[]; + /** End-of-chain literal value. */ + resolved_value: string; + /** Per-theme variants when the token resolves differently across themes. */ + by_theme?: Record; + /** Total observed usage count of this token within the scan. */ + occurrences: number; +} + +// --- Component rows ----------------------------------------------------- + +export interface ComponentRow extends RowBase { + name: string; + /** Where the component was discovered — `registry.json`, `heuristic`, etc. */ + discovered_via: string; + variants?: string[]; + sizes?: string[]; +} + +// --- Library rows ------------------------------------------------------- + +export interface LibraryRow extends RowBase { + /** Package name. */ + name: string; + /** Library kind — `icons`, `charts`, `animation`, `motion`, etc. Open. */ + kind: string; + version?: string; +} + +// --- Bucket -------------------------------------------------------------- + +export interface Bucket { + schema: "ghost.bucket/v1"; + /** + * Source(s) the bucket came from. Always an array — pre-merge buckets + * have length 1, merged buckets have N entries (one per source scan). + */ + sources: BucketSource[]; + values: ValueRow[]; + tokens: TokenRow[]; + components: ComponentRow[]; + libraries: LibraryRow[]; +} diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index 5566283..b3b5e99 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -1,4 +1,47 @@ // --- Embedding primitives --- + +// --- Bucket (ghost.bucket/v1) --- +export { + type BreakpointSpec, + BUCKET_FILENAME, + type Bucket, + type BucketLintIssue, + type BucketLintReport, + type BucketLintSeverity, + BucketSchema, + type BucketSource, + BucketSourceSchema, + type ColorSpec, + ColorSpecSchema, + type ComponentRow, + ComponentRowSchema, + componentRowId, + type LayoutPrimitiveSpec, + type LibraryRow, + LibraryRowSchema, + libraryRowId, + lintBucket, + type MotionSpec, + mergeBuckets, + type RadiusSpec, + RECOMMENDED_VALUE_KINDS, + type RecommendedValueKind, + type RowBase, + recomputeBucketIds, + type ScalarUnit, + type ShadowSpec, + type SpacingSpec, + type TokenRow, + TokenRowSchema, + type TypographySpec, + tokenRowId, + type UnknownSpec, + type ValueRow, + ValueRowSchema, + type ValueSpec, + ValueSpecSchema, + valueRowId, +} from "./bucket/index.js"; export type { CompareOptions, RoleCandidate } from "./embedding/index.js"; export { classifyContrast, @@ -17,11 +60,21 @@ export { parseColorToOklch, saturationScore, } from "./embedding/index.js"; - +// --- Map (ghost.map/v1) --- +export { + type GitInfo, + type InventoryOutput, + type LanguageHistogramEntry, + MAP_FILENAME, + type MapFrontmatter, + MapFrontmatterSchema, + REQUIRED_BODY_SECTIONS, + type RequiredBodySection, + type TopLevelEntry, +} from "./map/index.js"; // --- Skill bundle loader --- export type { SkillBundleFile } from "./skill-bundle-loader.js"; export { loadSkillBundle } from "./skill-bundle-loader.js"; - // --- Target resolution --- export { resolveTarget } from "./target-resolver.js"; diff --git a/packages/ghost-core/src/map/index.ts b/packages/ghost-core/src/map/index.ts new file mode 100644 index 0000000..32ba5fe --- /dev/null +++ b/packages/ghost-core/src/map/index.ts @@ -0,0 +1,23 @@ +/** + * Public surface for `ghost.map/v1` schema and types. + * + * Map authoring (`inventory`, `lint`) lives in `ghost-expression` (the tool + * that owns the recipe). The schema/types live here so any ghost tool that + * reads `map.md` can do so via `@ghost/core` without depending on the + * authoring CLI. + */ + +export { + type MapFrontmatter, + MapFrontmatterSchema, + REQUIRED_BODY_SECTIONS, + type RequiredBodySection, +} from "./schema.js"; +export type { + GitInfo, + InventoryOutput, + LanguageHistogramEntry, + TopLevelEntry, +} from "./types.js"; + +export const MAP_FILENAME = "map.md"; diff --git a/packages/ghost-map/src/core/schema.ts b/packages/ghost-core/src/map/schema.ts similarity index 100% rename from packages/ghost-map/src/core/schema.ts rename to packages/ghost-core/src/map/schema.ts diff --git a/packages/ghost-map/src/core/types.ts b/packages/ghost-core/src/map/types.ts similarity index 98% rename from packages/ghost-map/src/core/types.ts rename to packages/ghost-core/src/map/types.ts index 82e2eb5..5a0e139 100644 --- a/packages/ghost-map/src/core/types.ts +++ b/packages/ghost-core/src/map/types.ts @@ -1,5 +1,5 @@ /** - * Shared types for the ghost-map package. + * Shared types for `ghost.map/v1`. * * The inventory shape is the deterministic facts the CLI emits; the recipe * synthesizes the final `map.md` from these signals plus its own reads. diff --git a/packages/ghost-core/test/bucket-fix-ids.test.ts b/packages/ghost-core/test/bucket-fix-ids.test.ts new file mode 100644 index 0000000..ae4d6fc --- /dev/null +++ b/packages/ghost-core/test/bucket-fix-ids.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { recomputeBucketIds } from "../src/bucket/fix-ids.js"; +import { tokenRowId, valueRowId } from "../src/bucket/id.js"; +import type { Bucket, BucketSource } from "../src/bucket/types.js"; + +const SOURCE: BucketSource = { + target: "github:block/ghost", + commit: "abc123", + scanned_at: "2026-04-29T12:00:00Z", +}; + +function bucket(): Bucket { + return { + schema: "ghost.bucket/v1", + sources: [SOURCE], + values: [ + { + id: "", + source: SOURCE, + kind: "color", + value: "#f97316", + raw: "#f97316", + occurrences: 1, + files_count: 1, + }, + { + id: "wrong-id", + source: SOURCE, + kind: "spacing", + value: "8", + raw: "8px", + occurrences: 1, + files_count: 1, + }, + ], + tokens: [ + { + id: "", + source: SOURCE, + name: "--brand-primary", + alias_chain: [], + resolved_value: "#f97316", + occurrences: 1, + }, + ], + components: [], + libraries: [], + }; +} + +describe("recomputeBucketIds", () => { + it("populates empty IDs with deterministic hashes", () => { + const fixed = recomputeBucketIds(bucket()); + expect(fixed.values[0].id).toBe( + valueRowId(SOURCE, "color", "#f97316", "#f97316"), + ); + expect(fixed.tokens[0].id).toBe(tokenRowId(SOURCE, "--brand-primary")); + }); + + it("overwrites incorrect IDs with the correct deterministic hash", () => { + const fixed = recomputeBucketIds(bucket()); + expect(fixed.values[1].id).toBe(valueRowId(SOURCE, "spacing", "8", "8px")); + expect(fixed.values[1].id).not.toBe("wrong-id"); + }); + + it("does not mutate the input bucket", () => { + const input = bucket(); + recomputeBucketIds(input); + expect(input.values[0].id).toBe(""); + expect(input.values[1].id).toBe("wrong-id"); + }); + + it("is idempotent — running twice yields the same result", () => { + const once = recomputeBucketIds(bucket()); + const twice = recomputeBucketIds(once); + expect(twice.values).toEqual(once.values); + expect(twice.tokens).toEqual(once.tokens); + }); +}); diff --git a/packages/ghost-core/test/bucket-id.test.ts b/packages/ghost-core/test/bucket-id.test.ts new file mode 100644 index 0000000..b83e297 --- /dev/null +++ b/packages/ghost-core/test/bucket-id.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + componentRowId, + libraryRowId, + tokenRowId, + valueRowId, +} from "../src/bucket/id.js"; +import type { BucketSource } from "../src/bucket/types.js"; + +const SOURCE_A: BucketSource = { + target: "github:block/ghost", + commit: "abc123", + scanned_at: "2026-04-29T12:00:00Z", +}; + +const SOURCE_A_OTHER_TIME: BucketSource = { + ...SOURCE_A, + scanned_at: "2099-12-31T00:00:00Z", // different time, same target+commit +}; + +const SOURCE_B_DIFFERENT_COMMIT: BucketSource = { + ...SOURCE_A, + commit: "def456", +}; + +const SOURCE_C_DIFFERENT_TARGET: BucketSource = { + target: "github:block/other", + commit: "abc123", + scanned_at: "2026-04-29T12:00:00Z", +}; + +describe("valueRowId", () => { + it("produces stable hex IDs", () => { + const id = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); + expect(id).toMatch(/^[0-9a-f]{16}$/); + }); + + it("is deterministic — same inputs give same ID", () => { + const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); + const id2 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); + expect(id1).toBe(id2); + }); + + it("ignores scanned_at and scanner_version — same target+commit+content gives same ID", () => { + const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); + const id2 = valueRowId( + SOURCE_A_OTHER_TIME, + "color", + "#f97316", + "bg-orange-500", + ); + expect(id1).toBe(id2); + }); + + it("differs across commits", () => { + const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); + const id2 = valueRowId( + SOURCE_B_DIFFERENT_COMMIT, + "color", + "#f97316", + "bg-orange-500", + ); + expect(id1).not.toBe(id2); + }); + + it("differs across targets", () => { + const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); + const id2 = valueRowId( + SOURCE_C_DIFFERENT_TARGET, + "color", + "#f97316", + "bg-orange-500", + ); + expect(id1).not.toBe(id2); + }); + + it("differs across kinds", () => { + const colorId = valueRowId(SOURCE_A, "color", "8", "8px"); + const spacingId = valueRowId(SOURCE_A, "spacing", "8", "8px"); + expect(colorId).not.toBe(spacingId); + }); + + it("differs when raw form differs but value matches", () => { + const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); + const id2 = valueRowId(SOURCE_A, "color", "#f97316", "var(--brand)"); + expect(id1).not.toBe(id2); + }); +}); + +describe("section-tagged IDs are non-colliding", () => { + it("token vs value with same name does not collide", () => { + const tokenId = tokenRowId(SOURCE_A, "Button"); + const valueId = valueRowId(SOURCE_A, "color", "Button", "Button"); + expect(tokenId).not.toBe(valueId); + }); + + it("component vs library with same name does not collide", () => { + const componentId = componentRowId(SOURCE_A, "Button"); + const libraryId = libraryRowId(SOURCE_A, "Button"); + expect(componentId).not.toBe(libraryId); + }); +}); + +describe("token / component / library IDs", () => { + it("are deterministic", () => { + expect(tokenRowId(SOURCE_A, "--color-brand-primary")).toBe( + tokenRowId(SOURCE_A, "--color-brand-primary"), + ); + expect(componentRowId(SOURCE_A, "Button")).toBe( + componentRowId(SOURCE_A, "Button"), + ); + expect(libraryRowId(SOURCE_A, "lucide-react")).toBe( + libraryRowId(SOURCE_A, "lucide-react"), + ); + }); + + it("differ across names within a section", () => { + expect(tokenRowId(SOURCE_A, "--brand")).not.toBe( + tokenRowId(SOURCE_A, "--accent"), + ); + }); +}); diff --git a/packages/ghost-core/test/bucket-lint.test.ts b/packages/ghost-core/test/bucket-lint.test.ts new file mode 100644 index 0000000..ffbc864 --- /dev/null +++ b/packages/ghost-core/test/bucket-lint.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { valueRowId } from "../src/bucket/id.js"; +import { lintBucket } from "../src/bucket/lint.js"; +import type { Bucket, BucketSource } from "../src/bucket/types.js"; + +const SOURCE: BucketSource = { + target: "github:block/ghost", + commit: "abc123", + scanned_at: "2026-04-29T12:00:00Z", + scanner_version: "0.1.0", +}; + +function makeValueRow( + kind: string, + value: string, + raw: string, + overrides: Partial<{ + occurrences: number; + files_count: number; + role_hypothesis: string; + }> = {}, +) { + return { + id: valueRowId(SOURCE, kind, value, raw), + source: SOURCE, + kind, + value, + raw, + occurrences: overrides.occurrences ?? 1, + files_count: overrides.files_count ?? 1, + role_hypothesis: overrides.role_hypothesis, + }; +} + +function makeBucket(values: ReturnType[] = []): Bucket { + return { + schema: "ghost.bucket/v1", + sources: [SOURCE], + values, + tokens: [], + components: [], + libraries: [], + }; +} + +describe("lintBucket", () => { + it("accepts an empty well-formed bucket", () => { + const report = lintBucket(makeBucket()); + expect(report.errors).toBe(0); + expect(report.warnings).toBe(0); + }); + + it("accepts a bucket with recommended-kind value rows", () => { + const bucket = makeBucket([ + makeValueRow("color", "#f97316", "bg-orange-500", { + occurrences: 47, + files_count: 12, + }), + makeValueRow("spacing", "8", "8px", { + occurrences: 312, + files_count: 89, + }), + ]); + const report = lintBucket(bucket); + expect(report.errors).toBe(0); + expect(report.warnings).toBe(0); + }); + + it("rejects missing schema field", () => { + const bucket: unknown = { + ...makeBucket(), + schema: "ghost.bucket/v0", + }; + const report = lintBucket(bucket); + expect(report.errors).toBeGreaterThan(0); + expect(report.issues.some((i) => i.rule.startsWith("schema/"))).toBe(true); + }); + + it("rejects negative occurrences", () => { + const row = makeValueRow("color", "#f97316", "#f97316"); + const report = lintBucket(makeBucket([{ ...row, occurrences: -1 }])); + expect(report.errors).toBeGreaterThan(0); + }); + + it("warns on unknown value kinds without rejecting", () => { + const bucket = makeBucket([ + makeValueRow("z-index", "10", "z-10"), // not in recommended set + ]); + const report = lintBucket(bucket); + expect(report.errors).toBe(0); + expect(report.warnings).toBeGreaterThan(0); + expect(report.issues.some((i) => i.rule === "value-kind-unknown")).toBe( + true, + ); + }); + + it("warns when a row's id does not match the deterministic generator", () => { + const bucket = makeBucket([ + { + ...makeValueRow("color", "#f97316", "#f97316"), + id: "deadbeefdeadbeef", // hand-rolled, not from generator + }, + ]); + const report = lintBucket(bucket); + expect(report.warnings).toBeGreaterThan(0); + expect(report.issues.some((i) => i.rule === "id-mismatch")).toBe(true); + }); + + it("flags duplicate IDs within a section as errors", () => { + const row = makeValueRow("color", "#f97316", "#f97316"); + const report = lintBucket(makeBucket([row, { ...row }])); // same ID, two rows + expect(report.errors).toBeGreaterThan(0); + expect(report.issues.some((i) => i.rule === "duplicate-id")).toBe(true); + }); + + it("rejects sources array with no entries", () => { + const bucket: unknown = { + ...makeBucket(), + sources: [], + }; + const report = lintBucket(bucket); + expect(report.errors).toBeGreaterThan(0); + }); +}); diff --git a/packages/ghost-core/test/bucket-merge.test.ts b/packages/ghost-core/test/bucket-merge.test.ts new file mode 100644 index 0000000..5a7db39 --- /dev/null +++ b/packages/ghost-core/test/bucket-merge.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { tokenRowId, valueRowId } from "../src/bucket/id.js"; +import { mergeBuckets } from "../src/bucket/merge.js"; +import type { + Bucket, + BucketSource, + TokenRow, + ValueRow, +} from "../src/bucket/types.js"; + +const SOURCE_A: BucketSource = { + target: "github:block/ghost", + commit: "abc123", + scanned_at: "2026-04-29T12:00:00Z", +}; + +const SOURCE_B: BucketSource = { + target: "github:block/other", + commit: "def456", + scanned_at: "2026-04-29T12:00:00Z", +}; + +function valueRow( + source: BucketSource, + kind: string, + value: string, + raw: string, + occurrences = 1, +): ValueRow { + return { + id: valueRowId(source, kind, value, raw), + source, + kind, + value, + raw, + occurrences, + files_count: 1, + }; +} + +function tokenRow( + source: BucketSource, + name: string, + resolved: string, +): TokenRow { + return { + id: tokenRowId(source, name), + source, + name, + alias_chain: [], + resolved_value: resolved, + occurrences: 1, + }; +} + +function makeBucket( + source: BucketSource, + values: ValueRow[] = [], + tokens: TokenRow[] = [], +): Bucket { + return { + schema: "ghost.bucket/v1", + sources: [source], + values, + tokens, + components: [], + libraries: [], + }; +} + +describe("mergeBuckets", () => { + it("merging a single bucket returns equivalent rowset", () => { + const a = makeBucket(SOURCE_A, [ + valueRow(SOURCE_A, "color", "#f97316", "#f97316"), + ]); + const merged = mergeBuckets(a); + expect(merged.values).toEqual(a.values); + expect(merged.sources).toEqual([SOURCE_A]); + }); + + it("is idempotent — merging the same bucket twice yields the same rowset", () => { + const a = makeBucket(SOURCE_A, [ + valueRow(SOURCE_A, "color", "#f97316", "#f97316"), + valueRow(SOURCE_A, "spacing", "8", "8px"), + ]); + const once = mergeBuckets(a); + const twice = mergeBuckets(a, a); + expect(twice.values).toEqual(once.values); + expect(twice.sources).toEqual(once.sources); + }); + + it("preserves rows with distinct IDs across different sources", () => { + const a = makeBucket(SOURCE_A, [ + valueRow(SOURCE_A, "color", "#f97316", "#f97316"), + ]); + const b = makeBucket(SOURCE_B, [ + valueRow(SOURCE_B, "color", "#f97316", "#f97316"), + ]); + const merged = mergeBuckets(a, b); + expect(merged.values).toHaveLength(2); + expect(merged.sources).toEqual([SOURCE_A, SOURCE_B]); + }); + + it("dedupes rows with identical IDs (same source + same content)", () => { + const row = valueRow(SOURCE_A, "color", "#f97316", "#f97316"); + const a = makeBucket(SOURCE_A, [row]); + const b = makeBucket(SOURCE_A, [row]); // same source, same content -> same ID + const merged = mergeBuckets(a, b); + expect(merged.values).toHaveLength(1); + expect(merged.sources).toHaveLength(1); + }); + + it("preserves tokens, components, libraries independently", () => { + const a = makeBucket( + SOURCE_A, + [], + [tokenRow(SOURCE_A, "--brand-primary", "#f97316")], + ); + const b = makeBucket( + SOURCE_B, + [], + [tokenRow(SOURCE_B, "--brand-primary", "#0000ff")], + ); + const merged = mergeBuckets(a, b); + expect(merged.tokens).toHaveLength(2); + // Same token name, different sources, distinct IDs — both survive. + expect(merged.tokens.map((t) => t.resolved_value).sort()).toEqual([ + "#0000ff", + "#f97316", + ]); + }); + + it("throws when given zero buckets", () => { + expect(() => mergeBuckets()).toThrow(/at least one/); + }); + + it("schema field on the merged bucket is ghost.bucket/v1", () => { + const a = makeBucket(SOURCE_A); + const merged = mergeBuckets(a); + expect(merged.schema).toBe("ghost.bucket/v1"); + }); +}); diff --git a/packages/ghost-expression/src/cli.ts b/packages/ghost-expression/src/cli.ts index e1c7b4f..936b4f4 100644 --- a/packages/ghost-expression/src/cli.ts +++ b/packages/ghost-expression/src/cli.ts @@ -1,26 +1,38 @@ import { readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + type Bucket, + type BucketLintReport, + lintBucket, + mergeBuckets, + recomputeBucketIds, +} from "@ghost/core"; import { cac } from "cac"; import { diffExpressions, EXPRESSION_FILENAME, formatLayout, formatSemanticDiff, + inventory, layoutExpression, lintExpression, + lintMap, loadExpression, + scanStatus, } from "./core/index.js"; import { registerEmitCommand } from "./emit-command.js"; /** * Build the cac CLI for `ghost-expression`. * - * Four deterministic verbs author and validate `expression.md`: - * `lint` (schema check), `describe` (section ranges + token estimates), - * `diff` (structural prose-level diff between two expressions), and - * `emit` (derive review-command, context-bundle, or skill artifacts). + * Verbs author and validate `expression.md` and `bucket.json`: + * `lint` (schema check, auto-detects file kind), `describe` (section ranges + * + token estimates for expressions), `diff` (structural prose-level diff + * between two expressions), `emit` (derive review-command, context-bundle, + * or skill artifacts), and `bucket merge` (deterministic union of N + * `ghost.bucket/v1` files into one). * * Embedding-based comparison lives in `ghost-drift`. `diff` here is * text/structural — what decisions and palette roles changed — not @@ -32,15 +44,22 @@ export function buildCli(): ReturnType { // --- lint --- cli .command( - "lint [expression]", - "Validate expression.md schema and body/frontmatter coherence", + "lint [file]", + "Validate expression.md, map.md, or bucket.json — auto-detects the kind from path/content", ) .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (path: string | undefined, opts) => { try { const target = resolve(process.cwd(), path ?? EXPRESSION_FILENAME); const raw = await readFile(target, "utf-8"); - const report = lintExpression(raw); + const kind = detectFileKind(target, raw); + + const report = + kind === "bucket" + ? lintBucketFile(raw) + : kind === "map" + ? lintMap(raw) + : lintExpression(raw); if (opts.format === "json") { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); @@ -71,6 +90,69 @@ export function buildCli(): ReturnType { } }); + // --- scan-status --- + cli + .command( + "scan-status [dir]", + "Report which scan stages have produced artifacts in a directory: topology (map.md), objective (bucket.json), subjective (expression.md). Tells orchestrators which stage to run next.", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (dirArg: string | undefined, opts) => { + try { + const dir = resolve(process.cwd(), dirArg ?? "."); + const status = await scanStatus(dir); + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + } else { + const fmt = (state: string) => + state === "present" ? "present" : "missing"; + process.stdout.write(`scan dir: ${status.dir}\n\n`); + process.stdout.write( + ` topology (map.md): ${fmt(status.topology.state)}\n`, + ); + process.stdout.write( + ` objective (bucket.json): ${fmt(status.objective.state)}\n`, + ); + process.stdout.write( + ` subjective (expression.md): ${fmt(status.subjective.state)}\n\n`, + ); + if (status.recommended_next) { + process.stdout.write( + `next: run the ${status.recommended_next} stage\n`, + ); + } else { + process.stdout.write("next: scan complete — all stages present\n"); + } + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + // --- inventory --- + cli + .command( + "inventory [path]", + "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote. Feeds the topology recipe (map.md authoring).", + ) + .action(async (path: string | undefined) => { + try { + const target = resolve(process.cwd(), path ?? "."); + const out = inventory(target); + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + process.exit(0); + } catch (err) { + process.stderr.write( + `Error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(2); + } + }); + // --- describe --- cli .command( @@ -132,6 +214,92 @@ export function buildCli(): ReturnType { } }); + // --- bucket --- + cli + .command( + "bucket [...buckets]", + "Operate on ghost.bucket/v1 files. Ops: merge (concat with id-based dedup, deterministic and idempotent), fix-ids (recompute every row's id from content; use after authoring rows with empty id fields).", + ) + .option( + "-o, --out ", + "Write the result to this path (default: stdout)", + ) + .action(async (op: string, buckets: string[], opts) => { + try { + if (op !== "merge" && op !== "fix-ids") { + console.error( + `Error: unknown bucket op '${op}'. Supported: merge, fix-ids`, + ); + process.exit(2); + return; + } + if (!Array.isArray(buckets) || buckets.length === 0) { + console.error(`Error: bucket ${op} requires at least one input file`); + process.exit(2); + return; + } + if (op === "fix-ids" && buckets.length !== 1) { + console.error("Error: bucket fix-ids takes exactly one input file"); + process.exit(2); + return; + } + + const parsed: Bucket[] = []; + for (const path of buckets) { + const target = resolve(process.cwd(), path); + const raw = await readFile(target, "utf-8"); + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + console.error( + `Error: ${target} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + return; + } + if (op === "merge") { + const report = lintBucket(json); + if (report.errors > 0) { + console.error( + `Error: ${target} failed bucket lint with ${report.errors} error(s); fix before merging`, + ); + for (const issue of report.issues) { + if (issue.severity !== "error") continue; + const pathSuffix = issue.path ? ` @ ${issue.path}` : ""; + console.error( + ` [${issue.rule}] ${issue.message}${pathSuffix}`, + ); + } + process.exit(1); + return; + } + } + parsed.push(json as Bucket); + } + + const result = + op === "merge" + ? mergeBuckets(...parsed) + : recomputeBucketIds(parsed[0]); + const out = `${JSON.stringify(result, null, 2)}\n`; + + if (opts.out) { + const outPath = resolve(process.cwd(), opts.out); + await writeFile(outPath, out, "utf-8"); + } else { + process.stdout.write(out); + } + + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + registerEmitCommand(cli); cli.help(); @@ -140,6 +308,50 @@ export function buildCli(): ReturnType { return cli; } +/** + * Decide whether a file is an `expression.md`, a `map.md`, or a + * `bucket.json`. JSON paths/contents route to the bucket linter; markdown + * with `schema: ghost.map/v1` in its YAML frontmatter routes to the map + * linter; everything else stays on the expression path. + */ +function detectFileKind( + path: string, + raw: string, +): "bucket" | "map" | "expression" { + if (path.toLowerCase().endsWith(".json")) return "bucket"; + if (raw.trimStart().startsWith("{")) return "bucket"; + // Cheap markdown frontmatter sniff for `schema: ghost.map/v1`. We don't + // parse YAML here; the linter does the heavy lift. + const fmEnd = raw.indexOf("\n---", 3); + if (raw.startsWith("---") && fmEnd > 0) { + const fm = raw.slice(0, fmEnd); + if (/\bschema:\s*ghost\.map\/v1\b/.test(fm)) return "map"; + } + if (path.toLowerCase().endsWith("map.md")) return "map"; + return "expression"; +} + +function lintBucketFile(raw: string): BucketLintReport { + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + return { + issues: [ + { + severity: "error", + rule: "bucket-not-json", + message: `bucket file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + errors: 1, + warnings: 0, + info: 0, + }; + } + return lintBucket(json); +} + function readPackageVersion(): string { const here = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse( diff --git a/packages/ghost-expression/src/core/index.ts b/packages/ghost-expression/src/core/index.ts index 1c05b15..802a7ca 100644 --- a/packages/ghost-expression/src/core/index.ts +++ b/packages/ghost-expression/src/core/index.ts @@ -54,6 +54,7 @@ export { serializeEmbeddingFragment, } from "./fragments.js"; export type { ExpressionMeta, FrontmatterData } from "./frontmatter.js"; +export { inventory } from "./inventory.js"; export type { ExpressionLayout, ExpressionLayoutSection, @@ -66,8 +67,21 @@ export type { LintSeverity, } from "./lint.js"; export { lintExpression } from "./lint.js"; +export type { + MapLintIssue, + MapLintReport, + MapLintSeverity, +} from "./lint-map.js"; +export { lintMap } from "./lint-map.js"; export type { ParsedExpression, ParseOptions } from "./parser.js"; export { parseExpression, splitRaw } from "./parser.js"; +export type { + ScanStage, + ScanStageReport, + ScanStageState, + ScanStatus, +} from "./scan-status.js"; +export { scanStatus } from "./scan-status.js"; export type { FrontmatterShape } from "./schema.js"; export { FrontmatterSchema, diff --git a/packages/ghost-map/src/core/inventory.ts b/packages/ghost-expression/src/core/inventory.ts similarity index 99% rename from packages/ghost-map/src/core/inventory.ts rename to packages/ghost-expression/src/core/inventory.ts index 4766dad..dbc8bc1 100644 --- a/packages/ghost-map/src/core/inventory.ts +++ b/packages/ghost-expression/src/core/inventory.ts @@ -6,7 +6,7 @@ import type { InventoryOutput, LanguageHistogramEntry, TopLevelEntry, -} from "./types.js"; +} from "@ghost/core"; /** * Canonical package manifests we scan for at the inventoried root. diff --git a/packages/ghost-map/src/core/lint.ts b/packages/ghost-expression/src/core/lint-map.ts similarity index 91% rename from packages/ghost-map/src/core/lint.ts rename to packages/ghost-expression/src/core/lint-map.ts index 8351a98..eb88308 100644 --- a/packages/ghost-map/src/core/lint.ts +++ b/packages/ghost-expression/src/core/lint-map.ts @@ -1,23 +1,19 @@ +import { MapFrontmatterSchema, REQUIRED_BODY_SECTIONS } from "@ghost/core"; import { parse as parseYaml } from "yaml"; import type { z } from "zod"; -import { - MapFrontmatterSchema, - REQUIRED_BODY_SECTIONS, - type RequiredBodySection, -} from "./schema.js"; -export type LintSeverity = "error" | "warning" | "info"; +export type MapLintSeverity = "error" | "warning" | "info"; -export interface LintIssue { - severity: LintSeverity; +export interface MapLintIssue { + severity: MapLintSeverity; rule: string; message: string; /** Dotted path within frontmatter (e.g. "languages[0].share"), or section name. */ path?: string; } -export interface LintReport { - issues: LintIssue[]; +export interface MapLintReport { + issues: MapLintIssue[]; errors: number; warnings: number; info: number; @@ -30,8 +26,8 @@ export interface LintReport { * violations, missing body sections, out-of-order body sections, and empty * body sections. */ -export function lintMap(raw: string): LintReport { - const issues: LintIssue[] = []; +export function lintMap(raw: string): MapLintReport { + const issues: MapLintIssue[] = []; const split = splitFrontmatter(raw); if (!split) { @@ -95,8 +91,8 @@ export function lintMap(raw: string): LintReport { */ function checkDesignSystemCoherence( fm: ReturnType, -): LintIssue[] { - const out: LintIssue[] = []; +): MapLintIssue[] { + const out: MapLintIssue[] = []; const ds = fm.design_system; const hasEntry = (ds.entry_files?.length ?? 0) > 0; const hasDerived = (ds.derived_files?.length ?? 0) > 0; @@ -160,7 +156,7 @@ function splitFrontmatter(raw: string): FrontmatterSplit | null { return { frontmatter, body }; } -function zodIssues(error: z.ZodError): LintIssue[] { +function zodIssues(error: z.ZodError): MapLintIssue[] { return error.issues.map((issue) => { const path = issue.path.filter( (segment): segment is string | number => typeof segment !== "symbol", @@ -170,7 +166,7 @@ function zodIssues(error: z.ZodError): LintIssue[] { rule: `frontmatter:${issue.code}`, message: issue.message, path: path.length > 0 ? formatPath(path) : undefined, - } satisfies LintIssue; + } satisfies MapLintIssue; }); } @@ -194,8 +190,8 @@ interface FoundSection { bodyText: string; // content between this heading and the next } -function checkBodySections(body: string): LintIssue[] { - const issues: LintIssue[] = []; +function checkBodySections(body: string): MapLintIssue[] { + const issues: MapLintIssue[] = []; const sections = scanH2Sections(body); // Build a lookup of which required sections appear, in what order. @@ -305,7 +301,7 @@ function scanH2Sections(body: string): FoundSection[] { return out; } -function finalize(issues: LintIssue[]): LintReport { +function finalize(issues: MapLintIssue[]): MapLintReport { let errors = 0; let warnings = 0; let info = 0; @@ -316,8 +312,3 @@ function finalize(issues: LintIssue[]): LintReport { } return { issues, errors, warnings, info }; } - -export const MAP_FILENAME = "map.md"; - -// Type re-exports for callers -export type { RequiredBodySection }; diff --git a/packages/ghost-expression/src/core/scan-status.ts b/packages/ghost-expression/src/core/scan-status.ts new file mode 100644 index 0000000..bfec25f --- /dev/null +++ b/packages/ghost-expression/src/core/scan-status.ts @@ -0,0 +1,93 @@ +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { BUCKET_FILENAME, MAP_FILENAME } from "@ghost/core"; +import { EXPRESSION_FILENAME } from "./index.js"; + +/** + * Per-stage state in a scan directory. + * + * `missing` — the artifact doesn't exist yet. + * `present` — the artifact exists. Existence is the only signal v1 + * surfaces; hash-based freshness (`stale` vs `present`) is a planned + * enhancement once `.scan-meta.json` is in play. + */ +export type ScanStageState = "missing" | "present"; + +export interface ScanStageReport { + state: ScanStageState; + /** Absolute path to the artifact (whether it exists or not). */ + path: string; +} + +export type ScanStage = "topology" | "objective" | "subjective"; + +export interface ScanStatus { + /** Absolute path to the scan directory. */ + dir: string; + topology: ScanStageReport; + objective: ScanStageReport; + subjective: ScanStageReport; + /** + * The next stage an orchestrator should run, or `null` if every stage + * is `present`. Stages run in order: topology → objective → subjective. + * The recommendation surfaces the first stage in `missing` state. + */ + recommended_next: ScanStage | null; +} + +/** + * Inspect a scan directory and report which stages have produced artifacts. + * + * Existence-only check today. The artifacts checked are: + * + * - topology → `map.md` + * - objective → `bucket.json` + * - subjective → `expression.md` + * + * Hash-keyed freshness (`.scan-meta.json` with input/output hashes per + * stage) is the planned enhancement. For v1, orchestrators that want + * "force rerun" behavior delete the artifact themselves before calling + * scan-status — same idiom design-world-model already uses. + */ +export async function scanStatus(dirPath: string): Promise { + const dir = resolve(dirPath); + const mapPath = resolve(dir, MAP_FILENAME); + const bucketPath = resolve(dir, BUCKET_FILENAME); + const expressionPath = resolve(dir, EXPRESSION_FILENAME); + + const [topologyPresent, objectivePresent, subjectivePresent] = + await Promise.all([ + pathExists(mapPath), + pathExists(bucketPath), + pathExists(expressionPath), + ]); + + const topology: ScanStageReport = { + state: topologyPresent ? "present" : "missing", + path: mapPath, + }; + const objective: ScanStageReport = { + state: objectivePresent ? "present" : "missing", + path: bucketPath, + }; + const subjective: ScanStageReport = { + state: subjectivePresent ? "present" : "missing", + path: expressionPath, + }; + + let recommended_next: ScanStage | null = null; + if (topology.state === "missing") recommended_next = "topology"; + else if (objective.state === "missing") recommended_next = "objective"; + else if (subjective.state === "missing") recommended_next = "subjective"; + + return { dir, topology, objective, subjective, recommended_next }; +} + +async function pathExists(path: string): Promise { + try { + const s = await stat(path); + return s.isFile() && s.size > 0; + } catch { + return false; + } +} diff --git a/packages/ghost-expression/src/skill-bundle/SKILL.md b/packages/ghost-expression/src/skill-bundle/SKILL.md index a6e40f9..72bc056 100644 --- a/packages/ghost-expression/src/skill-bundle/SKILL.md +++ b/packages/ghost-expression/src/skill-bundle/SKILL.md @@ -17,20 +17,29 @@ You do the synthesis (the profile recipe). The `ghost-expression` CLI is the cal | Verb | Purpose | |---|---| -| `ghost-expression lint [expression.md]` | Validate schema + body/frontmatter coherence. Use this before declaring an expression valid. | +| `ghost-expression lint [file]` | Validate `expression.md`, `map.md`, or `bucket.json` (auto-detects by `.json` extension, `schema: ghost.map/v1` frontmatter, or filename). Use before declaring an artifact valid. | +| `ghost-expression inventory [path]` | Emit deterministic raw repo signals (manifests, language histogram, candidate config files, registry presence, top-level tree, git remote) as JSON. Feeds the topology recipe. | +| `ghost-expression scan-status [dir]` | Report which scan stages have produced artifacts (`map.md`, `bucket.json`, `expression.md`) and which stage to run next. Use to decide what to do at the start of a scan or between stages. | | `ghost-expression describe [expression.md]` | Print a section map (line ranges + token estimates) so you can selectively read only the sections you need instead of loading the whole file. Use before review/generate when the expression is large. | -| `ghost-expression diff ` | Structural prose-level diff — what decisions, palette roles, and tokens changed. **Not the same as `ghost-drift compare`** (which returns embedding distance). Use diff when you want to read what changed; use compare when you want a number. | +| `ghost-expression diff ` | Structural prose-level diff between two expressions — what decisions, palette roles, and tokens changed. **Not the same as `ghost-drift compare`** (which returns embedding distance). Use diff when you want to read what changed; use compare when you want a number. | +| `ghost-expression bucket [...buckets]` | Operate on `ghost.bucket/v1` files. `merge` — concat with id-based dedup, deterministic and idempotent (useful for modular rollups and fleet cohort views). `fix-ids` — recompute every row's `id` from content (use after authoring rows with empty `id` fields). | | `ghost-expression emit ` | Derive per-project artifacts from `expression.md`. Kinds: `review-command` (Rams-style slash command), `context-bundle` (multi-file generation prompt), `skill` (this agentskills.io bundle). | -Four verbs. If you find yourself reaching for `ghost-expression profile` — that is *your* workflow, not a CLI command. Follow [references/profile.md](references/profile.md). +If you find yourself reaching for `ghost-expression scan` / `ghost-expression survey` / `ghost-expression profile` — those are *your* workflows, not CLI commands. Follow the recipes below. ## Workflows (your job, not the CLI's) +A full scan of a target produces three artifacts in sequence: `map.md` (topology) → `bucket.json` (objective values) → `expression.md` (subjective interpretation). Each stage feeds the next; each stage is its own recipe. + When the user asks you to: -- "Profile my design language" / "write expression.md" → [references/profile.md](references/profile.md) -- "Diff these two expressions" / "what changed between these expressions" → run `ghost-expression diff `. For embedding distance use `ghost-drift compare`. -- "Lint my expression" → run `ghost-expression lint`. Fix anything it reports. +- "Scan my project" / "do a full scan" / "go end-to-end" → [references/scan.md](references/scan.md). The meta-recipe — orchestrates topology → survey → profile. Use when the user wants the full pipeline, not a specific stage. +- "Map my repo" / "where does the design system live" / "write map.md" → [references/map.md](references/map.md). Pre-req: none. Output: validated `map.md`. +- "Survey my design language" / "scan values" / "extract design tokens" → [references/survey.md](references/survey.md). Pre-req: `map.md` exists. Output: validated `bucket.json`. +- "Profile my design language" / "write expression.md" / "interpret these values" → [references/profile.md](references/profile.md). Pre-req: `map.md` AND `bucket.json` exist (run topology + survey first). Output: validated `expression.md`. +- "Diff these two expressions" → run `ghost-expression diff `. For embedding distance use `ghost-drift compare`. +- "Lint my expression" / "lint my bucket" → run `ghost-expression lint `. Fix anything it reports. +- "Merge these buckets" / "compose a cohort bucket" → run `ghost-expression bucket merge `. For drift detection (compare under change, ack/track/diverge, review PR diffs against an expression) install the `ghost-drift` skill. diff --git a/packages/ghost-expression/src/skill-bundle/references/map.md b/packages/ghost-expression/src/skill-bundle/references/map.md new file mode 100644 index 0000000..e7abf94 --- /dev/null +++ b/packages/ghost-expression/src/skill-bundle/references/map.md @@ -0,0 +1,83 @@ +--- +name: map +description: Author the map.md for a target — Ghost's topology card. The first stage of a scan. +handoffs: + - label: Survey values into bucket.json + command: (next stage — survey recipe) + prompt: Survey the target's design values into bucket.json + - label: Validate the map + command: ghost-expression lint map.md + prompt: Lint the map.md I just wrote +--- + +# Recipe: Author a target's map.md + +**Goal:** produce a valid `map.md` (`ghost.map/v1`) that captures the *topology* of the target — what platform it ships on, what it builds with, where the design system lives, what feature areas matter for sampling. `map.md` is the first stage of a scan: every later stage (`survey.md` → `bucket.json`, `profile.md` → `expression.md`) reads it to skip rediscovery. + +This recipe is *your* job. Ghost's CLI provides `ghost-expression inventory` (deterministic raw signals) and `ghost-expression lint ` (validation), but you do the synthesis. + +## Steps + +### 1. Gather raw signals + +Run `ghost-expression inventory [path]` from (or pointed at) the target root. It returns deterministic JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote, plus best-effort platform and build-system hints. Read it as the foundation — reproducible from inputs. + +### 2. Resolve the schema fields + +The `ghost.map/v1` frontmatter requires: + +- **`schema: ghost.map/v1`** (literal) +- **`id`** — slug (lowercase alphanumeric plus `.` `_` `-`, leading alphanumeric). For fleet scans, this is the fleet target id. +- **`repo`** — GitHub `org/repo`, or any source identifier that uniquely names this target. +- **`mapped_at`** — current ISO date (`YYYY-MM-DD`) or full datetime. +- **`platform`** — one of `web`, `ios`, `android`, `desktop`, `flutter`, `mixed`, `other`, or an array spanning multiple. The inventory's `platform_hints` is your starting point — accept it when consistent, override when you have evidence. +- **`languages`** — array of `{name, files, share}` from the inventory histogram. `share` is fraction in [0,1]. +- **`build_system`** — one of `gradle`, `bazel`, `xcode`, `pnpm`, `npm`, `yarn`, `cargo`, `go`, `maven`, `sbt`, `cmake`, `style-dictionary`, `vite`, `webpack`, `parcel`, `rollup`, `turbopack`, `esbuild`, `nx`, `turbo`, `mixed`, `other`, or an array. The inventory's `build_system_hints` plus lockfile presence (`pnpm-lock.yaml` → `pnpm`, `yarn.lock` → `yarn`, `package-lock.json` → `npm`) usually answers this. +- **`package_manifests`** — array of paths from the inventory. +- **`composition.frameworks`** — array of `{name, version?}` (e.g. `react`, `next`, `swiftui`, `compose`, `style-dictionary`). +- **`composition.rendering`** — short slug (`react-spa`, `next-app-router`, `swiftui`, `compose`, `static`, `mixed`, …). +- **`composition.styling`** — array (e.g. `["tailwindcss"]`, `["scss-modules"]`, `["styled-components"]`). +- **`composition.navigation`** — optional short slug (`next-router`, `react-router`, `swiftui-navigation`, …). +- **`registry`** — optional `{path, components}` if a shadcn-style registry exists. +- **`design_system`** — `{paths[], entry_files?, derived_files?, path_patterns?, token_source?, upstream?, status}`. `token_source` is `inline` / `external` / `mixed`. `status` is `active` / `mixed` / `unclear`. Set `upstream` when `token_source` is `external` or `mixed`. +- **`ui_surface`** — `{include[], exclude[]}` — globs for sampling scope. +- **`feature_areas`** — array of `{name, paths[], sub_areas?[]}` describing the surfaces worth sampling. 3–8 areas is typical; fewer is fine for small repos. +- **`orientation_files`** — array of files an agent should read first to understand the target. + +### 3. Use a manifest if one is provided + +If a `manifest.yaml` is present in CWD (some fleet orchestrators inject hand-curated sampling manifests for big repos), treat it as authoritative for `feature_areas`, `module_signals`, and `design_system.path_patterns`. Don't contradict it without evidence. + +If no manifest is provided, derive `feature_areas` from the inventory's `top_level_tree` and your own brief exploration: which directories represent product surfaces (e.g. `apps/dashboard`, `packages/ui`, `src/features/*`)? + +### 4. Body sections + +`map.md` requires a short prose body with three sections — keep them tight, two-to-four sentences each. The body is interpretation; the frontmatter is ground truth. Sections must appear in this order: + +- `## Identity` — what is this repo, what does it produce, who consumes it? +- `## Topology` — how is the codebase organized? Where does the design system live relative to product code? +- `## Conventions` — notable patterns (token pipelines, registry, framework choices, language mixes) that shape how someone navigates. + +### 5. Validate + + ghost-expression lint map.md + +Fix any errors. Lint passing is the success gate — do not declare done until it exits 0. Common errors: + +- Body section out of order (`## Identity` must precede `## Topology` etc.) +- Missing `entry_files` AND `derived_files` under `design_system` (warning — fine if neither exists, but check) +- `token_source: external` without `upstream` set +- `id` not a slug + +## Always + +- Cite real paths the inventory returned. Do not invent files. +- Prefer the array form (`platform: [web, ios]`) over `mixed` when the repo genuinely spans multiple platforms. +- If there is no design system in the repo (a backend-only app, a marketing site without a tokens layer), say so in `## Identity`, set `design_system.status: unclear`, and omit `entry_files`. Don't fabricate a design-system structure. +- For fleet scans, resolve `id` and `repo` from environment variables when the orchestrator passes them (`TARGET_ID`, `TARGET_REPO`). + +## Never + +- Never put prose into frontmatter or structural data into the body — the partition is load-bearing. +- Never duplicate the inventory's content in the body. The body is interpretation, not data. +- Never declare done before `ghost-expression lint map.md` exits 0. diff --git a/packages/ghost-expression/src/skill-bundle/references/profile.md b/packages/ghost-expression/src/skill-bundle/references/profile.md index a46fff5..247b7ee 100644 --- a/packages/ghost-expression/src/skill-bundle/references/profile.md +++ b/packages/ghost-expression/src/skill-bundle/references/profile.md @@ -1,6 +1,6 @@ --- name: profile -description: Write expression.md from a project's design sources. +description: Interpret a bucket.json into expression.md — the subjective synthesis stage of a scan. handoffs: - label: Compare against another expression command: ghost-drift compare @@ -12,99 +12,145 @@ handoffs: # Recipe: Profile a project into expression.md -**Goal:** produce a valid `expression.md` that captures the project's visual language. Ghost's CLI does not call an LLM for this — you, the host agent, explore the repo and synthesize the result, then hand it to `ghost-expression lint` for validation. +**Goal:** produce a valid `expression.md` that captures the project's design language as an interpretation. **You are the interpreter, not the surveyor.** Read the `bucket.json` as ground truth for what values the project actually ships; assign roles, write decisions, and form the prose body. Do not re-extract values from source — that's the surveyor's job and you'd be doing it twice. -## Steps - -### 1. Locate design sources - -If a `map.md` is present at the repo root, **use it**. Read its frontmatter: +`expression.md` is the terminal artifact in a three-stage scan: topology (`map.md`) → objective (`bucket.json`) → subjective (`expression.md`). Yours is the third stage. -- `design_system.entry_files` — the canonical token sources. Resolve every variable chain in these files end-to-end. -- `design_system.paths` — the directories the design language lives in (one repo may have several). -- `design_system.token_source` — `inline` / `external` / `mixed`. Drives Step 1.5. -- `registry`, `composition.frameworks`, `composition.styling`, `platform` — also feed Step 1.5. -- `feature_areas[].sub_areas[]` — the surfaces or layers worth sampling. +## Pre-requisites -Map.md eliminates location-discovery on monorepos. If `map.md` is **missing**, prompt the user to run `ghost-map inventory` first. Fallback inline discovery: `tailwind.config.{js,ts}`, `@theme {}` blocks, `styles/globals.css`, `tokens/` dirs, SCSS variable files, TypeScript theme objects, shadcn `:root { --… }`, JSON token files. Use Glob/Grep; read real files. +Two artifacts must exist before you start: -### 1.5. Detect repo kind +- `map.md` — `ghost.map/v1`. Read its frontmatter for repo kind signals (`composition.frameworks`, `composition.styling`, `design_system.token_source`, `platform`, `registry`). Read its body for context on identity / topology / conventions. +- `bucket.json` — `ghost.bucket/v1`. Lint-clean. Carries every concrete value, token, component, and library the surveyor observed, with occurrence counts and (for tokens) alias chains. -Branch the rest of the recipe on signals from map.md. Apply rules in order; first match wins: +If either is missing, **stop**. Run topology and survey first. Inventing an expression from incomplete inputs poisons every downstream comparison. -1. `design_system.token_source: external` → **consumer mode**. The repo imports tokens from another package; it doesn't declare them. -2. `composition.frameworks` includes `style-dictionary` (or there is a `tokens/` directory and no `registry`) → **token-pipeline mode**. Components are YAML graph nodes, not tsx. -3. `registry.path` set, or `composition.styling` includes `tailwindcss*` / `css-modules` / similar with component files in `ui_surface.include` → **ui-library mode** (default). -4. `platform` is an array spanning native + web with no single dominant build → **multi-platform**: profile as ui-library but expect coarser `feature_areas` and prefer modules named `*UI` / `*View` / `*Screen`; skip `*Fakes` / `*Mocks` / `*Tests`. +## How to read the bucket -If signals overlap, **token-pipeline** wins over both **ui-library** (decide by `entry_files`: YAML/JSON graphs → token-pipeline; CSS/code → ui-library) and **multi-platform** (pipelines often span multiple platforms by definition; the pipeline branch already handles per-platform output sinks via `feature_areas`). Note the chosen mode in your scratchpad — Step 4 onward depends on it. +A `bucket.json` has four sections: -### 2. Resolve variable chains end-to-end +- **`values[]`** — concrete literals shipped in source. Group by `kind`: `color` rows feed `palette`; `spacing` rows feed `spacing.scale` / `spacing.baseUnit`; `typography` rows feed `typography.*`; `radius` rows feed `surfaces.borderRadii`; `shadow` rows feed `surfaces.shadowComplexity` (count + complexity, not literal shadows); `breakpoint` / `motion` / `layout-primitive` rows feed Decisions where they're load-bearing. Each row has `occurrences` (total count) and `files_count` (spread). Higher numbers = stronger signal. +- **`tokens[]`** — named declarations with `alias_chain` (path through indirection) and `resolved_value`. Long chains and semantic naming (`--color-brand-primary` → `--color-orange-500`) are evidence of a deliberate token layer. Empty chains everywhere = inline literals = no token discipline. +- **`components[]`** — known components (registry entries or heuristically discovered). Feeds the `roles[]` layer when components carry slot-to-color mappings. +- **`libraries[]`** — external dependencies that contribute design surface (icons, fonts, motion, charts). Feeds Decisions when load-bearing — e.g. an icon library's presence shapes the visual register. -If a value is a reference, follow it: `--btn-bg: var(--color-primary)` → `--color-primary: var(--brand-500)` → `--brand-500: #0066cc`. Record the resolved concrete value. +Read `bucket.json` once, fully. Then keep it open while you write. -For **token-pipeline** repos, a chain crosses *layers*: component → semantic → base. Walk all three. Surface light/dark pairs together (the same component slot will reference one semantic name that resolves differently per mode). +## Steps -For **consumer** repos, chains often dead-end at an external import (`import theme from "@market/market-theme"`, `Color.Background.app`). Don't try to resolve to hex — record the upstream slug as the value. The expression describes *what shows up in product code*, not what the upstream declares. +### 1. Detect repo kind from map.md -### 3. Sample for the roles layer +Branch the rest of the recipe on signals from `map.md`. Apply rules in order; first match wins: -Sampling depends on repo kind (Step 1.5): +1. `design_system.token_source: external` → **consumer mode**. The repo imports tokens from another package; the bucket's `tokens[]` is mostly empty or full of upstream slugs. Don't try to interpret upstream values you didn't observe. +2. `composition.frameworks` includes `style-dictionary`, or there's a `tokens/` directory and no `registry`, and the bucket has long alias chains (3+ steps) → **token-pipeline mode**. Components are graph nodes; layering is a first-class decision. +3. `registry.path` set, or `composition.styling` includes `tailwindcss*` / `css-modules` / similar → **ui-library mode** (default). +4. `platform` is an array spanning native + web with no single dominant build → **multi-platform**: profile as ui-library but expect coarser groupings; the bucket likely has fewer rows per dialect. -- **UI-library (default)** — open 6–10 component files: typography primitives, `Button`, `Card`, `Input`, list/table primitives, plus a couple from each `feature_areas[].sub_areas[]` cluster. Populate `roles[]` from imports and class bindings. `feature_areas` are component categories (input / display / feedback / navigation / layout) or AI-element clusters (chat / agent-state / artifacts) — match the registry's `categories` field if present. -- **Token-pipeline** — read 3–5 component-layer YAMLs end-to-end through the layer chain. `roles[]` is populated from semantic-layer mappings (which semantic alias each component slot references), not from imports. Skip product surfaces; `feature_areas` here are token-architecture layers (base / semantic / component) and/or platform output sinks (web / ios / android), 6–15 typical for a multi-layer pipeline. -- **Consumer mode** — sample 6–10 product UI files. Don't resolve tokens to hex; record which upstream slugs (`var(--core-radius-md)`, `Color.Background.app`) appear most often in product code. Where the consumer overrides upstream (custom `@font-face`, a local `theme.css` with primitive tokens), surface that as a Decision. `feature_areas` remain product surfaces — the consumer is an app. +Note the chosen mode in your scratchpad — it shapes Steps 3, 4, and 5. -Only record what you directly observed. Empty `roles` is fine. +### 2. Layer 1 — Observation (holistic prose) -### 4. Form Layer 1 — Observation (holistic) +Subjective. 2–4 sentences capturing what this design language is and how it feels. Read the bucket *and* sample 3–5 high-occurrence files to actually see the surfaces — counts alone don't tell you the visual register. -Write subjectively. 2–4 sentences capturing what this design language is and how it feels. Then: +Then in frontmatter: -- `personality`: 3–6 adjectives (`utilitarian`, `editorial`, `dense`, `playful`, …) -- `distinctiveTraits`: what makes this expression *visually recognizable* — include notable absences ("no decorative elements at all") -- `resembles`: 1–3 well-known references (Linear, Geist, Material 3, …) +- `personality`: 3–6 adjectives (`utilitarian`, `editorial`, `dense`, `playful`, `restrained`, …) +- `distinctiveTraits`: what makes this expression *visually recognizable* — include notable absences ("no decorative elements at all", "no shadows anywhere despite a dark theme") +- `resembles`: 1–3 well-known references (Linear, Geist, Material 3, …) — only if genuinely close -### 5. Derive Layer 2 — Design Decisions (abstract) +### 3. Layer 2 — Design Decisions (abstract prose with evidence) Name the pattern, not the token: -- ✗ Weak: "Spacing follows a 4px base grid with Tailwind defaults." (restates a fact visible in the tokens) +- ✗ Weak: "Spacing follows a 4px base grid with Tailwind defaults." (restates a fact already in the bucket) - ✓ Strong: "Prefer explicit component-height tokens over padding arithmetic, so button/input sizing is decoupled from surrounding layout." (names the pattern and its consequence) -Surface whatever dimensions fit. Common ones: `color-strategy`, `spatial-system`, `typography-voice`, `surface-hierarchy`, `density`, `motion`, `elevation`, `interactive-patterns`. **Absences are decisions** — "No animation — interactions are immediate and non-kinetic" is valid. In **consumer** mode, override patterns are decisions ("App ships its own `@font-face` instead of inheriting upstream sans"); in **token-pipeline** mode, layering choices are decisions ("Component layer never references base tokens directly — always via semantic"). +Surface whatever dimensions fit. Common ones: `color-strategy`, `spatial-system`, `typography-voice`, `surface-hierarchy`, `density`, `motion`, `elevation`, `interactive-patterns`, `token-architecture`. **Absences are decisions** — "No animation — interactions are immediate and non-kinetic" is valid (evidence: empty `motion` rows in the bucket). + +For each decision: `dimension` (slug), `decision` (prose, body), `evidence` (concrete citations from the bucket — preferred form: token definitions like `"--radius-pill: 999px"` or value rows like `"#f97316 (47 occurrences across 12 files)"`). + +**Evidence belongs in the body markdown under `**Evidence:**` bullets per dimension. Do NOT put `evidence:` arrays in frontmatter — the schema is `.strict()` and will reject.** Each `### ` body block should end with a `**Evidence:**` line followed by bullet citations from the bucket; the parser pulls those back onto `decisions[].evidence` in memory. + +Mode-specific framing: + +- **Consumer** — overrides are decisions ("App ships its own `@font-face` instead of inheriting upstream sans" — evidence: bucket `libraries[kind=fonts]` row that's not in the upstream). +- **Token-pipeline** — layering choices are decisions ("Component layer never references base tokens directly — always via semantic" — evidence: bucket `tokens[].alias_chain` lengths). +- **Ui-library** — registry posture is a decision ("Components ship as a flat library with no theme variants" — evidence: bucket `components[]` shape). +- **Multi-platform** — divergence between dialects is a decision when present ("Web and iOS palettes are intentionally different — web is restrained, iOS reuses system colors" — evidence: per-source counts in merged buckets, or noted in the survey scratchpad). + +### 4. Layer 3 — Concrete tokens (read from bucket; do not invent) + +Populate the structured frontmatter fields **from bucket rows**: -For each: `dimension` (slug), `decision` (prose, body), `evidence` (concrete citations — token definitions like `"--radius-pill: 999px"` preferred; `file:line` for behavioral observations). +- `palette.dominant` — top color rows by `occurrences`, with role assigned. Use bucket `role_hypothesis` when present and you agree; override when you don't. Cite the role. +- `palette.neutrals` — neutral-saturation color rows (low chroma — check the `spec.hsl.s` if present, or judge from the hex). `count` is the row count, `steps` is the literal hex array. +- `palette.semantic` — color rows whose `role_hypothesis` or naming suggests success/warning/error/info. Empty array if none. +- `palette.saturationProfile` — `muted` / `vibrant` / `mixed`. Judge from the chroma distribution of dominant colors. +- `palette.contrast` — `low` / `medium` / `high`. Judge from neutrals' lightness range. +- `spacing.scale` — sorted distinct scalar values from `kind: spacing` rows. Convert rem/em to px (1rem = 16px) before recording. +- `spacing.baseUnit` — the GCD of scale entries, or the smallest scalar that divides most others. +- `spacing.regularity` — 1.0 if the scale is a clean modular sequence (4, 8, 16, 24, …), lower as it diverges. +- `typography.families` — distinct `family` values from `kind: typography` rows + bucket `libraries[kind=fonts]`. +- `typography.sizeRamp` — distinct font sizes (in px) from `kind: typography` rows. +- `typography.weightDistribution` — map of weight → relative frequency from `kind: typography` rows. +- `typography.lineHeightPattern` — `tight` / `normal` / `loose` / `mixed`, judged from `line_height` values. +- `surfaces.borderRadii` — distinct scalars from `kind: radius` rows. +- `surfaces.shadowComplexity` — `deliberate-none` (zero shadow rows + you confirmed it's intentional), `simple` (1–2 distinct shadow specs), `layered` (3+), `expressive` (varied + non-default). +- `surfaces.borderUsage` — `none` / `minimal` / `prominent`, judged from how often borders appear in samples (the bucket may not surface this directly — read 2–3 component files if in doubt). -**Evidence belongs in the body markdown under `**Evidence:**` bullets per dimension. Do NOT put `evidence:` arrays in frontmatter — the schema is `.strict()` and will reject.** Each `### ` body block should end with a `**Evidence:**` line followed by bullet citations; the parser pulls those back onto `decisions[].evidence` in memory. +**Hard rule:** every value you put in `palette` / `spacing` / `typography` / `surfaces` must trace to a row in `bucket.json`. If it isn't in the bucket, it isn't in the expression. A missing field beats a fabricated one. If the bucket is sparse for a dimension (e.g. only one shadow), reflect that — `shadowComplexity: simple` with one shadow is honest; making up a layered system is a lie. -### 6. Extract Layer 3 — Concrete tokens +**Hard rule:** every `palette` entry must be cited in at least one decision's `evidence`, or dropped. Uncited tokens are noise. -Populate the structured fields: `palette.dominant`, `palette.neutrals`, `palette.semantic`, `palette.saturationProfile`, `palette.contrast`, `spacing.scale`, `spacing.regularity`, `spacing.baseUnit`, `typography.families`, `typography.sizeRamp`, `typography.weightDistribution`, `typography.lineHeightPattern`, `surfaces.borderRadii`, `surfaces.shadowComplexity`, `surfaces.borderUsage`. +### 5. Roles — slot-to-color mappings -- Convert rem/em to px (1rem = 16px). Output colors as hex (`#1a1a1a`); the CLI computes oklch automatically. -- Every `palette` entry must be cited in at least one decision's `evidence`, or dropped. Uncited neutrals are noise. -- In **consumer** mode, fields you can only see as upstream slugs may be left empty rather than fabricated. A missing field beats an invented one. +Populate `roles[]` from `bucket.tokens[]` and `bucket.components[]`: -### 7. Write the file +- For each role you can identify (e.g. `button-primary-bg`, `surface-elevated`, `text-muted`), record its resolved value from the bucket. Use `tokens[].alias_chain` to trace which named token a slot resolves through. +- Skip roles you can't directly observe in the bucket. Empty `roles[]` is fine. + +In **token-pipeline** mode, this is the richest layer — semantic tokens map cleanly to roles. In **consumer** mode, it's typically empty or upstream-slug-only. In **ui-library** mode, registry-based components give you slot mappings. + +### 6. Write the file Copy [../assets/expression.template.md](../assets/expression.template.md). Fill in: -- **Frontmatter:** all structured fields (identity, `observation.personality`/`.resembles`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`, `roles`). -- **Body:** `# Character` (observation summary), `# Signature` (distinctiveTraits bullets), `# Decisions` (one `### ` block per decision). +- **Frontmatter:** all structured fields (identity, `observation.personality`/`.resembles`, `decisions[].dimension`, `palette`, `spacing`, `typography`, `surfaces`, `roles`). +- **Body:** `# Character` (observation summary), `# Signature` (distinctiveTraits bullets), `# Decisions` (one `### ` block per decision, each ending with `**Evidence:**` bullets citing bucket rows). Partition matters. See [schema.md](schema.md) for which field lives where. -### 8. Validate +### 7. Validate ghost-expression lint expression.md -Fix any errors. Common ones: prose in frontmatter → move to body; `### dim` with no matching `decisions[]` entry → remove the orphan; palette entry not cited in any evidence → cite it or drop it. +Fix any errors. Common ones: + +- Prose in frontmatter → move to body. +- `### dim` with no matching `decisions[]` entry → remove the orphan. +- Palette entry not cited in any evidence → cite it (from a bucket row) or drop it. +- Typography size not in the bucket → drop it; the surveyor missed it or it's not real. + +### 8. Provenance check + +For every value in your expression's frontmatter, confirm it appears in `bucket.json`. Quick sanity: + + jq -r '.values[] | select(.kind=="color") | .value' bucket.json | sort -u + # compare against your palette entries + +Any expression value that doesn't trace back is a hallucination. Remove it. + +### 9. Self-distance sanity + + ghost-drift compare expression.md expression.md + +Self-distance must be 0. Anything else means the file isn't deterministically loadable. -### 9. Sanity check +## When the bucket is incomplete - ghost-drift compare expression.md expression.md # self-distance should be 0 +If the surveyor's `bucket.json` has known gaps (a `# Coverage` note in the survey scratchpad, or thin coverage for a dialect), surface them in the expression's `# Character` body or as a Decision (e.g. `### scan-coverage` with evidence "iOS dialect under-sampled — only 23 color sites recorded; web dialect is the dominant signal in this expression"). Do not paper over gaps with invented values. ## When you cannot profile -If the project has no styling (backend-only, no UI), say so. Do not fabricate an expression. A placeholder expression poisons every downstream comparison. +If `bucket.json` is empty (a backend-only repo, no UI) and `map.md` confirms no design system, say so in `# Character` and emit a minimal expression with empty palette/spacing/typography/surfaces. Do not fabricate. A placeholder expression poisons every downstream comparison. diff --git a/packages/ghost-expression/src/skill-bundle/references/scan.md b/packages/ghost-expression/src/skill-bundle/references/scan.md new file mode 100644 index 0000000..6595f45 --- /dev/null +++ b/packages/ghost-expression/src/skill-bundle/references/scan.md @@ -0,0 +1,108 @@ +--- +name: scan +description: Drive a full three-stage scan of a target — topology, objective, subjective — to produce map.md + bucket.json + expression.md. +handoffs: + - label: Compare against another expression + command: ghost-drift compare + prompt: Compare the expression.md I just wrote against another expression + - label: Inspect what stage to run next + command: ghost-expression scan-status + prompt: What scan stage should I run next in this directory? +--- + +# Recipe: Scan a target end-to-end + +**Goal:** drive a target through all three scan stages — topology → objective → subjective — and end with three valid artifacts in the scan directory: `map.md` + `bucket.json` + `expression.md`. This is the meta-recipe; each stage has its own deeper recipe (see [map.md](map.md), [survey.md](survey.md), [profile.md](profile.md)) that you dispatch into. + +You don't run a single CLI verb here. You orchestrate stages, validate after each, and stop when `scan-status` reports complete. + +## Overview + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ Stage 1 │ → │ Stage 2 │ → │ Stage 3 │ +│ topology │ │ objective │ │ subjective │ +│ map.md │ │ bucket.json │ │ expression.md │ +└──────────────┘ └──────────────┘ └──────────────────┘ + recipe: recipe: recipe: + map.md survey.md profile.md +``` + +Each stage's output is the next stage's input. Stage 3 is terminal — it's what other ghost tools (drift, fleet) consume. + +## Steps + +### 1. Locate the scan directory + +The scan directory is wherever the three artifacts will live. For a local repo, this is usually the repo root. For a fleet member managed centrally, the scan directory lives in the central repo (e.g. `/fleet/members//`) and the *target* (the source repo being scanned) is a separate location. + +Throughout this recipe, "scan dir" = where artifacts land; "target" = where source code lives. They may or may not be the same path. + +### 2. Check status + + ghost-expression scan-status [scan-dir] + +Reports per-stage state (`present` / `missing`) and the recommended next stage. If every stage is `present`, you're done. Otherwise, dispatch to the recipe for the recommended stage. + +Use `--format json` if you want to consume the result programmatically: + + ghost-expression scan-status . --format json + +### 3. Stage 1 — Topology (`map.md`) + +Run when `scan-status` reports `topology: missing`. + +Recipe: [map.md](map.md). The agent reads `ghost-expression inventory ` for raw signals + the recipe's guidance, then writes `map.md` to the scan directory and validates with `ghost-expression lint map.md`. + +After validation, re-run `scan-status` and proceed. + +### 4. Stage 2 — Objective (`bucket.json`) + +Run when `scan-status` reports `topology: present` and `objective: missing`. + +Recipe: [survey.md](survey.md). The agent reads `map.md` to recognize the dialect, runs LLM-driven extraction (its own greps/regexes), records rows with empty `id` fields, finalizes IDs with `ghost-expression bucket fix-ids bucket.json -o bucket.json`, then validates with `ghost-expression lint bucket.json`. + +The survey is the longest stage and the one with the most discipline (exhaustiveness, saturation, cross-checking counts). Don't shortcut it — the interpreter downstream cannot fabricate values that aren't in the bucket, so missed values become missed expression fields permanently. + +After validation, re-run `scan-status` and proceed. + +### 5. Stage 3 — Subjective (`expression.md`) + +Run when `scan-status` reports both prior stages `present` and `subjective: missing`. + +Recipe: [profile.md](profile.md). The agent reads `map.md` (for repo-kind signals) and `bucket.json` (for ground truth) and writes `expression.md` purely as interpretation: assigns roles, names decisions, writes the prose body, fills frontmatter from bucket rows. Cannot invent values not in the bucket. Validates with `ghost-expression lint expression.md` and a self-distance sanity check (`ghost-drift compare expression.md expression.md` returns 0). + +### 6. Confirm complete + +Re-run `scan-status`. If `recommended_next` is `null`, the scan is done. + +## Resumability + +Each stage is resumable independently because `scan-status` checks artifact presence at the start. To force a stage rerun, delete its artifact and call `scan-status` again — the recommended_next will surface that stage. Same idiom orchestrators like design-world-model already use. + +## When a stage fails + +If a stage's lint fails, fix the issue in the recipe pass and re-validate. **Do not move to the next stage on a failed lint** — the next stage's recipe assumes a valid input. A malformed `map.md` poisons the survey; a malformed `bucket.json` poisons the interpretation. + +If you cannot make a stage pass (e.g. the target genuinely has no design system), the recipe for that stage tells you what to do — usually: write a minimal valid artifact that surfaces the gap (e.g. `expression.md` with empty palette and a `# Character` note explaining the absence), so downstream tools see honest "no signal" rather than a hallucinated one. + +## When the bucket should be merged across multiple targets + +Modular targets (one repo with N feature modules profiled separately) and fleet cohorts (N members merged into a cohort bucket) both run the survey stage per unit, then call: + + ghost-expression bucket merge -o merged.json + +Then run the interpreter recipe (Stage 3) against `merged.json` instead of a single-source bucket. The interpreter recipe handles merged buckets the same way as single-source ones — every row still has provenance via `source`, and the prose interpretation is grounded in counts that span sources. This composition lives in the orchestrator (`design-world-model`'s pipeline scripts), not in any ghost CLI verb. + +## Always + +- Run `scan-status` between stages. Don't assume; check. +- Validate after each stage. Lint passing is the success gate. +- Resolve token alias chains end-to-end in stage 2 (the bucket records the chain). +- Cite bucket rows as evidence in stage 3 decisions. + +## Never + +- Never skip a stage. The recipe semantics depend on each stage running in order. +- Never edit a downstream artifact (`expression.md`) to fix a missing value — go upstream and re-run the relevant stage. +- Never invent values absent from `bucket.json` when authoring `expression.md`. If a value is missing from the bucket, either re-run survey (it was missed) or accept the absence. diff --git a/packages/ghost-expression/src/skill-bundle/references/survey.md b/packages/ghost-expression/src/skill-bundle/references/survey.md new file mode 100644 index 0000000..44296b6 --- /dev/null +++ b/packages/ghost-expression/src/skill-bundle/references/survey.md @@ -0,0 +1,156 @@ +--- +name: survey +description: Scan a target and produce a bucket.json — the objective catalogue of design values, with no interpretation. +handoffs: + - label: Interpret the bucket into expression.md + command: (next stage — interpreter recipe) + prompt: Interpret the bucket I just wrote into expression.md + - label: Validate the bucket + command: ghost-expression lint bucket.json + prompt: Lint the bucket I just wrote +--- + +# Recipe: Survey a target into bucket.json + +**Goal:** produce a valid `bucket.json` (`ghost.bucket/v1`) that catalogues every concrete design value the target ships, with structured specs and per-value occurrence counts. **You are the surveyor, not the interpreter.** Record what is there. Do not assign meaning. Do not write prose. Do not invent. + +`bucket.json` is the middle artifact in a three-stage scan: topology (`map.md`) → objective (`bucket.json`) → subjective (`expression.md`). The interpreter (next stage) reads your bucket as ground truth and writes the prose. If you skip values or fabricate them here, the expression downstream is wrong. + +## Pre-requisite + +A `map.md` for the target must exist (Phase 0 — see `references/map.md`). It tells you where the design system lives — `design_system.entry_files`, `design_system.paths`, `feature_areas[].paths`, `composition.styling`, `composition.frameworks`. Without it you waste cycles re-discovering what the topology already specifies. **If `map.md` is missing, stop and run topology first.** + +## Bucket schema + +A `bucket.json` is `ghost.bucket/v1`: + +```json +{ + "schema": "ghost.bucket/v1", + "sources": [{ "target": "...", "commit": "...", "scanned_at": "..." }], + "values": [...], + "tokens": [...], + "components": [...], + "libraries": [...] +} +``` + +Each row carries an `id` (deterministic SHA-256 prefix you do **not** compute by hand — see Step 6) and a `source` object (denormalize the same source entry you put in `sources[]`). Sections: + +- **`values[]`** — every concrete literal that ships in the design language. `kind` is open; recommended values: `color`, `spacing`, `typography`, `radius`, `shadow`, `breakpoint`, `motion`, `layout-primitive`. Other kinds (`z-index`, `opacity`, `cursor`, `gradient`, `iconography`, `aspect-ratio`) get a `value-kind-unknown` warning but are accepted — emit them when they matter. +- **`tokens[]`** — every named token declared in source (CSS variables, theme keys, design-token entries). Each row has `name`, `alias_chain` (path through any indirection — `["--button-bg", "--color-brand-primary"]` for a two-step chain; `[]` for a leaf defined inline), `resolved_value` (end-of-chain literal), optional `by_theme` for light/dark variants. +- **`components[]`** — every named component you can confidently identify (registry entries, exported PascalCase components with variants/sizes). Loose schema: `name`, `discovered_via` (`registry.json` / `heuristic` / etc.), optional `variants[]` and `sizes[]`. +- **`libraries[]`** — every external dependency that contributes design surface (icon libraries, charting, animation, typography). `kind` is open: `icons`, `charts`, `animation`, `motion`, `fonts`, etc. + +Every row needs `occurrences` (total count across the scan) and (for values) `files_count` (distinct files that contain the value). Optional `usage` breaks down by context: `{className: 30, css_var: 17}`. Optional `role_hypothesis` is a single tentative role tag (`brand-primary`, `surface-elevated`); **leave it empty if you are not sure** — the interpreter does role assignment, not you. + +## Steps + +### 1. Read map.md and orient + +Open `map.md`. Note: + +- `composition.styling` — Tailwind, CSS modules, styled-components, scss, swift-tokens, etc. Drives your extraction strategy. +- `composition.frameworks` — react, next, swiftui, compose, … +- `design_system.entry_files` — start here. These declare the canonical token set. +- `design_system.paths` — directories where the design system lives. +- `feature_areas[].paths` — surfaces worth sampling for usage counts. +- `registry.path` if present — every component listed there belongs in `components[]`. + +Decide your extraction strategy from these signals — see Step 2. + +### 2. Choose your extraction strategy per dialect + +**You write your own greps and regexes. There is no pre-built parser.** Adapt to what's actually in the repo: + +- **Tailwind (Tailwind v3 / v4 with `@theme`)** — `rg -oN '\b(bg|text|border|fill|stroke|ring|outline|from|to|via|p[lrtbxy]?|m[lrtbxy]?|w|h|gap|space-[xy]|rounded(-[lrtb][lr]?)?|shadow|z|opacity)-[a-z0-9-]+(\[[^\]]+\])?' -g '*.{tsx,jsx,ts,js,html,vue,svelte}'` for class atoms. Then read `tailwind.config.{ts,js}` and any `@theme {}` block in CSS to map class atoms to literal values. +- **CSS / SCSS / CSS modules** — `rg -oN '#[0-9a-fA-F]{3,8}\b' -g '*.{css,scss,sass}'` for hex; `rg -oN '\b(rgba?|hsla?|oklch|color)\([^)]+\)' -g '*.{css,scss}'` for color functions; `rg -oN '\b[0-9]+(\.[0-9]+)?(px|rem|em|%|vh|vw|fr|ch|svh|dvh)\b' -g '*.{css,scss}'` for scalars; `rg -oN -- '--[a-z0-9-]+\s*:' -g '*.{css,scss}'` for custom properties. +- **CSS-in-JS (styled-components, emotion, vanilla-extract)** — same regex set but expand `-g '*.{ts,tsx,js,jsx}'`. Watch for template literals split across lines. +- **iOS / Swift** — `rg -oN 'Color\([^)]+\)|UIColor\([^)]+\)|\.(red|blue|green|orange|brand[A-Za-z]*)\b' -g '*.swift'` for color sites; `rg -oN '\b[0-9]+(\.[0-9]+)?\b' -g '*.swift' | sort | uniq -c | sort -rn | head -50` for likely scalars (lots of noise; keep top-N by frequency). +- **Android / Compose** — `rg -oN 'Color\(0x[0-9a-fA-F]+\)|colorResource\(R\.color\.[a-z_]+\)' -g '*.kt'`; same scalar approach. +- **Token JSON / YAML** — read directly with `cat`/Read tool. Token files are usually small and structured — parse them as data, don't grep. + +If the repo mixes dialects (e.g. `swiftui` + `arcade`), run extraction per dialect and merge into one bucket. + +### 3. Run extraction passes — be exhaustive + +Recall is the failure mode. Sloppy grep undercounts silently. Discipline: + +- **Multiple passes per kind.** Don't trust your first regex. After your color pass, run a second pass with a slightly different pattern and check the delta. +- **Cross-check counts.** When you record a row with `occurrences: 47`, run `rg -c '\b#f97316\b' .` against the full repo and verify. If the count differs by more than ~10%, your regex is missing something — refine and re-pass. +- **Frequency clustering.** After the first sweep, list candidate values by frequency: `rg -oN '#[0-9a-fA-F]{6}' -g '*.css' | sort | uniq -c | sort -rn`. The top values are almost always real palette entries. Long-tail values are often comments, hashes, or test fixtures — verify before recording. +- **Spread check.** If a value appears in `files_count: 1`, it's likely incidental, not part of the design language. Note the count but don't promote with `role_hypothesis`. +- **Resolve aliases.** When you see `var(--brand-primary)`, follow the chain to its literal end. Record the **token row** with the chain, and the **value row** for the resolved literal. Both belong in the bucket. + +### 4. Sample feature areas for usage counts + +For each `feature_areas[]` entry in `map.md`, walk a few files to measure how the values you found in `entry_files` actually get used. This produces the `occurrences` and `files_count` numbers. Don't sample exhaustively — 3–5 files per feature area is usually enough; the goal is a representative count, not a perfect one. + +Update the `usage` breakdown when context matters. Examples: `{className: 30, css_var: 17}` for a hex used in both Tailwind classes and CSS variables; `{token-resolution: 1, inline: 46}` for a hex defined once and copy-pasted everywhere (a smell worth flagging via `role_hypothesis: "ad-hoc"` or similar). + +### 5. Write rows with empty IDs + +Build the bucket file. For every row, leave `id` as an empty string `""`. You don't compute SHA-256 hashes by hand. Example value row: + +```json +{ + "id": "", + "source": { "target": "github:block/ghost", "commit": "abc123", "scanned_at": "2026-04-29T12:00:00Z" }, + "kind": "color", + "value": "#f97316", + "raw": "bg-orange-500", + "spec": { "space": "srgb", "hex": "#f97316" }, + "occurrences": 47, + "files_count": 12, + "usage": { "className": 30, "css_var": 17 } +} +``` + +Same shape per token / component / library row, just different content fields. **Every row gets the same `source` object** (denormalized so the row survives merges with its origin attribution). Fill `sources[]` at the top of the bucket with the same single source. + +### 6. Populate IDs + +Run: + + ghost-expression bucket fix-ids bucket.json -o bucket.json + +This recomputes every row's `id` from its content fields. Idempotent — running it again does nothing. + +### 7. Validate + + ghost-expression lint bucket.json + +Fix everything `lint` flags as an error. Warnings (unknown `kind`, `id-mismatch` if you skipped Step 6, etc.) are signals — investigate them, but they don't block. + +### 8. Saturation check + +The bucket is saturated when **another extraction pass adds fewer than ~2 new rows**. Concretely: + +- Run one more grep against a different pattern set or a corner you haven't covered. +- If you find <2 new values across all sections, you're done. +- If you find more, do another pass with the same discipline. + +Hard stop conditions: + +- ~100 files read total, OR +- ~20 minutes wall, OR +- ~200k tokens consumed. + +If you hit a hard stop with the soft predicate not yet met, write a `# Coverage` note in your scratchpad explaining what you didn't cover, and surface it in the next stage's interpreter pass — it informs which decisions can be made confidently and which can't. + +## Always + +- Use `bucket.json` as the canonical filename. +- Every value/token row carries `source`, `occurrences`, and (for values) `files_count`. +- Resolve token alias chains end-to-end. The `alias_chain` array captures the path. +- Validate with `ghost-expression lint bucket.json` before declaring success. +- After authoring rows with empty IDs, run `bucket fix-ids` exactly once. + +## Never + +- **Never write prose.** No `description`, no rationale fields. Prose is the interpreter's job. +- **Never invent values.** If you didn't observe it in source, it doesn't go in the bucket. +- **Never assign roles confidently.** `role_hypothesis` is a *hint*, optional, and tentative. The interpreter has the final word. If you're not sure, leave it empty. +- **Never undercount silently.** If your regex coverage is weak (mobile dialects, custom DSLs), surface it in a `# Coverage` scratchpad note and tell the interpreter. +- **Never compute IDs by hand.** Use `bucket fix-ids`. +- **Never edit a bucket after the interpreter has used it.** If you find a missed value later, re-run survey end-to-end. The bucket is the frozen ground truth between scan and interpretation. diff --git a/packages/ghost-expression/test/cli.test.ts b/packages/ghost-expression/test/cli.test.ts index 1efbfd1..ccb72f8 100644 --- a/packages/ghost-expression/test/cli.test.ts +++ b/packages/ghost-expression/test/cli.test.ts @@ -1,6 +1,8 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { Bucket, BucketSource } from "@ghost/core"; +import { tokenRowId, valueRowId } from "@ghost/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildCli } from "../src/cli.js"; @@ -165,3 +167,233 @@ describe("ghost-expression CLI defaults", () => { expect(result.code).toBe(1); }); }); + +const SOURCE_A: BucketSource = { + target: "github:block/ghost", + commit: "abc123", + scanned_at: "2026-04-29T12:00:00Z", +}; + +const SOURCE_B: BucketSource = { + target: "github:block/other", + commit: "def456", + scanned_at: "2026-04-29T12:00:00Z", +}; + +function makeBucket(source: BucketSource, hex = "#f97316"): Bucket { + return { + schema: "ghost.bucket/v1", + sources: [source], + values: [ + { + id: valueRowId(source, "color", hex, hex), + source, + kind: "color", + value: hex, + raw: hex, + occurrences: 1, + files_count: 1, + }, + ], + tokens: [ + { + id: tokenRowId(source, "--brand-primary"), + source, + name: "--brand-primary", + alias_chain: [], + resolved_value: hex, + occurrences: 1, + }, + ], + components: [], + libraries: [], + }; +} + +describe("ghost-expression lint dispatches by file kind", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-expression-cli-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("lints a well-formed bucket.json with exit 0", async () => { + await writeFile( + join(dir, "bucket.json"), + JSON.stringify(makeBucket(SOURCE_A), null, 2), + ); + + const result = await runCli(["lint", "bucket.json"], dir); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("0 error(s)"); + }); + + it("lints a malformed bucket.json with exit 1", async () => { + await writeFile( + join(dir, "bucket.json"), + JSON.stringify({ schema: "ghost.bucket/v0" }, null, 2), + ); + + const result = await runCli(["lint", "bucket.json"], dir); + + expect(result.code).toBe(1); + }); + + it("auto-detects bucket-by-content when path lacks .json extension", async () => { + await writeFile( + join(dir, "bucket.txt"), + JSON.stringify(makeBucket(SOURCE_A), null, 2), + ); + + const result = await runCli(["lint", "bucket.txt"], dir); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("0 error(s)"); + }); +}); + +describe("ghost-expression bucket merge", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-expression-merge-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("merges two buckets with distinct sources into one", async () => { + await writeFile(join(dir, "a.json"), JSON.stringify(makeBucket(SOURCE_A))); + await writeFile(join(dir, "b.json"), JSON.stringify(makeBucket(SOURCE_B))); + + const result = await runCli( + ["bucket", "merge", "a.json", "b.json", "-o", "merged.json"], + dir, + ); + + expect(result.code).toBe(0); + const merged = JSON.parse( + await readFile(join(dir, "merged.json"), "utf-8"), + ); + expect(merged.schema).toBe("ghost.bucket/v1"); + expect(merged.sources).toHaveLength(2); + expect(merged.values).toHaveLength(2); + expect(merged.tokens).toHaveLength(2); + }); + + it("dedupes rows with identical IDs (same source, same content)", async () => { + await writeFile(join(dir, "a.json"), JSON.stringify(makeBucket(SOURCE_A))); + await writeFile(join(dir, "a2.json"), JSON.stringify(makeBucket(SOURCE_A))); + + const result = await runCli( + ["bucket", "merge", "a.json", "a2.json", "-o", "merged.json"], + dir, + ); + + expect(result.code).toBe(0); + const merged = JSON.parse( + await readFile(join(dir, "merged.json"), "utf-8"), + ); + expect(merged.sources).toHaveLength(1); + expect(merged.values).toHaveLength(1); + expect(merged.tokens).toHaveLength(1); + }); + + it("writes to stdout when -o is omitted", async () => { + await writeFile(join(dir, "a.json"), JSON.stringify(makeBucket(SOURCE_A))); + + const result = await runCli(["bucket", "merge", "a.json"], dir); + + expect(result.code).toBe(0); + const merged = JSON.parse(result.stdout); + expect(merged.schema).toBe("ghost.bucket/v1"); + expect(merged.values).toHaveLength(1); + }); + + it("fails when an input bucket has lint errors", async () => { + await writeFile( + join(dir, "bad.json"), + JSON.stringify({ schema: "ghost.bucket/v0" }), + ); + + const result = await runCli( + ["bucket", "merge", "bad.json", "-o", "merged.json"], + dir, + ); + + expect(result.code).toBe(1); + expect(result.stderr).toContain("failed bucket lint"); + }); +}); + +describe("ghost-expression bucket fix-ids", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-expression-fixids-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("populates empty IDs and writes a lint-clean bucket", async () => { + const draft: Bucket = { + schema: "ghost.bucket/v1", + sources: [SOURCE_A], + values: [ + { + id: "", + source: SOURCE_A, + kind: "color", + value: "#f97316", + raw: "#f97316", + occurrences: 1, + files_count: 1, + }, + ], + tokens: [], + components: [], + libraries: [], + }; + await writeFile(join(dir, "draft.json"), JSON.stringify(draft)); + + const fix = await runCli( + ["bucket", "fix-ids", "draft.json", "-o", "fixed.json"], + dir, + ); + expect(fix.code).toBe(0); + + const lint = await runCli(["lint", "fixed.json"], dir); + expect(lint.code).toBe(0); + expect(lint.stdout).toContain("0 error(s)"); + }); + + it("rejects more than one input file", async () => { + await writeFile(join(dir, "a.json"), JSON.stringify(makeBucket(SOURCE_A))); + await writeFile(join(dir, "b.json"), JSON.stringify(makeBucket(SOURCE_B))); + + const result = await runCli(["bucket", "fix-ids", "a.json", "b.json"], dir); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("exactly one input file"); + }); +}); diff --git a/packages/ghost-map/test/fixtures/android-gradle-repo/app/src/main/AndroidManifest.xml b/packages/ghost-expression/test/fixtures/android-gradle-repo/app/src/main/AndroidManifest.xml similarity index 100% rename from packages/ghost-map/test/fixtures/android-gradle-repo/app/src/main/AndroidManifest.xml rename to packages/ghost-expression/test/fixtures/android-gradle-repo/app/src/main/AndroidManifest.xml diff --git a/packages/ghost-map/test/fixtures/android-gradle-repo/app/src/main/java/com/example/fixture/MainActivity.kt b/packages/ghost-expression/test/fixtures/android-gradle-repo/app/src/main/java/com/example/fixture/MainActivity.kt similarity index 100% rename from packages/ghost-map/test/fixtures/android-gradle-repo/app/src/main/java/com/example/fixture/MainActivity.kt rename to packages/ghost-expression/test/fixtures/android-gradle-repo/app/src/main/java/com/example/fixture/MainActivity.kt diff --git a/packages/ghost-map/test/fixtures/android-gradle-repo/build.gradle.kts b/packages/ghost-expression/test/fixtures/android-gradle-repo/build.gradle.kts similarity index 100% rename from packages/ghost-map/test/fixtures/android-gradle-repo/build.gradle.kts rename to packages/ghost-expression/test/fixtures/android-gradle-repo/build.gradle.kts diff --git a/packages/ghost-map/test/fixtures/android-gradle-repo/design-system/Theme.kt b/packages/ghost-expression/test/fixtures/android-gradle-repo/design-system/Theme.kt similarity index 100% rename from packages/ghost-map/test/fixtures/android-gradle-repo/design-system/Theme.kt rename to packages/ghost-expression/test/fixtures/android-gradle-repo/design-system/Theme.kt diff --git a/packages/ghost-map/test/fixtures/android-gradle-repo/settings.gradle.kts b/packages/ghost-expression/test/fixtures/android-gradle-repo/settings.gradle.kts similarity index 100% rename from packages/ghost-map/test/fixtures/android-gradle-repo/settings.gradle.kts rename to packages/ghost-expression/test/fixtures/android-gradle-repo/settings.gradle.kts diff --git a/packages/ghost-map/test/fixtures/bazel-repo/.bazelversion b/packages/ghost-expression/test/fixtures/bazel-repo/.bazelversion similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/.bazelversion rename to packages/ghost-expression/test/fixtures/bazel-repo/.bazelversion diff --git a/packages/ghost-map/test/fixtures/bazel-repo/BUILD.bazel b/packages/ghost-expression/test/fixtures/bazel-repo/BUILD.bazel similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/BUILD.bazel rename to packages/ghost-expression/test/fixtures/bazel-repo/BUILD.bazel diff --git a/packages/ghost-map/test/fixtures/bazel-repo/Code/DesignSystem/Color+Brand.swift b/packages/ghost-expression/test/fixtures/bazel-repo/Code/DesignSystem/Color+Brand.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/Code/DesignSystem/Color+Brand.swift rename to packages/ghost-expression/test/fixtures/bazel-repo/Code/DesignSystem/Color+Brand.swift diff --git a/packages/ghost-map/test/fixtures/bazel-repo/Code/DesignSystem/Theme.swift b/packages/ghost-expression/test/fixtures/bazel-repo/Code/DesignSystem/Theme.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/Code/DesignSystem/Theme.swift rename to packages/ghost-expression/test/fixtures/bazel-repo/Code/DesignSystem/Theme.swift diff --git a/packages/ghost-map/test/fixtures/bazel-repo/MODULE.bazel b/packages/ghost-expression/test/fixtures/bazel-repo/MODULE.bazel similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/MODULE.bazel rename to packages/ghost-expression/test/fixtures/bazel-repo/MODULE.bazel diff --git a/packages/ghost-map/test/fixtures/bazel-repo/WORKSPACE b/packages/ghost-expression/test/fixtures/bazel-repo/WORKSPACE similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/WORKSPACE rename to packages/ghost-expression/test/fixtures/bazel-repo/WORKSPACE diff --git a/packages/ghost-map/test/fixtures/bazel-repo/bazel-bin/should-be-skipped.swift b/packages/ghost-expression/test/fixtures/bazel-repo/bazel-bin/should-be-skipped.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/bazel-bin/should-be-skipped.swift rename to packages/ghost-expression/test/fixtures/bazel-repo/bazel-bin/should-be-skipped.swift diff --git a/packages/ghost-map/test/fixtures/bazel-repo/features/banking/BankingView.swift b/packages/ghost-expression/test/fixtures/bazel-repo/features/banking/BankingView.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/features/banking/BankingView.swift rename to packages/ghost-expression/test/fixtures/bazel-repo/features/banking/BankingView.swift diff --git a/packages/ghost-map/test/fixtures/bazel-repo/features/banking/Color+Banking.swift b/packages/ghost-expression/test/fixtures/bazel-repo/features/banking/Color+Banking.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-repo/features/banking/Color+Banking.swift rename to packages/ghost-expression/test/fixtures/bazel-repo/features/banking/Color+Banking.swift diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Color.swift b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Color.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Color.swift rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Color.swift diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Spacing.swift b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Spacing.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Spacing.swift rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Spacing.swift diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Theme.swift b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Theme.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Theme.swift rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Theme.swift diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Typography.swift b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Typography.swift similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Typography.swift rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Code/DesignSystem/Typography.swift diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Gemfile b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Gemfile similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/Gemfile rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/Gemfile diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/MODULE.bazel b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/MODULE.bazel similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/MODULE.bazel rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/MODULE.bazel diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/WORKSPACE b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/WORKSPACE similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/WORKSPACE rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/WORKSPACE diff --git a/packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/fastlane/Fastfile b/packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/fastlane/Fastfile similarity index 100% rename from packages/ghost-map/test/fixtures/bazel-ruby-ios-repo/fastlane/Fastfile rename to packages/ghost-expression/test/fixtures/bazel-ruby-ios-repo/fastlane/Fastfile diff --git a/packages/ghost-map/test/fixtures/derived-tokens-repo/derived.map.md b/packages/ghost-expression/test/fixtures/derived-tokens-repo/derived.map.md similarity index 100% rename from packages/ghost-map/test/fixtures/derived-tokens-repo/derived.map.md rename to packages/ghost-expression/test/fixtures/derived-tokens-repo/derived.map.md diff --git a/packages/ghost-map/test/fixtures/external-tokens-repo/external.map.md b/packages/ghost-expression/test/fixtures/external-tokens-repo/external.map.md similarity index 100% rename from packages/ghost-map/test/fixtures/external-tokens-repo/external.map.md rename to packages/ghost-expression/test/fixtures/external-tokens-repo/external.map.md diff --git a/packages/ghost-map/test/fixtures/flutter-repo/lib/main.dart b/packages/ghost-expression/test/fixtures/flutter-repo/lib/main.dart similarity index 100% rename from packages/ghost-map/test/fixtures/flutter-repo/lib/main.dart rename to packages/ghost-expression/test/fixtures/flutter-repo/lib/main.dart diff --git a/packages/ghost-map/test/fixtures/flutter-repo/lib/theme.dart b/packages/ghost-expression/test/fixtures/flutter-repo/lib/theme.dart similarity index 100% rename from packages/ghost-map/test/fixtures/flutter-repo/lib/theme.dart rename to packages/ghost-expression/test/fixtures/flutter-repo/lib/theme.dart diff --git a/packages/ghost-map/test/fixtures/flutter-repo/pubspec.yaml b/packages/ghost-expression/test/fixtures/flutter-repo/pubspec.yaml similarity index 100% rename from packages/ghost-map/test/fixtures/flutter-repo/pubspec.yaml rename to packages/ghost-expression/test/fixtures/flutter-repo/pubspec.yaml diff --git a/packages/ghost-map/test/fixtures/ios-spm-repo/Package.swift b/packages/ghost-expression/test/fixtures/ios-spm-repo/Package.swift similarity index 100% rename from packages/ghost-map/test/fixtures/ios-spm-repo/Package.swift rename to packages/ghost-expression/test/fixtures/ios-spm-repo/Package.swift diff --git a/packages/ghost-map/test/fixtures/ios-spm-repo/Sources/Fixture.swift b/packages/ghost-expression/test/fixtures/ios-spm-repo/Sources/Fixture.swift similarity index 100% rename from packages/ghost-map/test/fixtures/ios-spm-repo/Sources/Fixture.swift rename to packages/ghost-expression/test/fixtures/ios-spm-repo/Sources/Fixture.swift diff --git a/packages/ghost-map/test/fixtures/ios-spm-repo/Sources/Theme.swift b/packages/ghost-expression/test/fixtures/ios-spm-repo/Sources/Theme.swift similarity index 100% rename from packages/ghost-map/test/fixtures/ios-spm-repo/Sources/Theme.swift rename to packages/ghost-expression/test/fixtures/ios-spm-repo/Sources/Theme.swift diff --git a/packages/ghost-map/test/fixtures/maps/bad-frontmatter.md b/packages/ghost-expression/test/fixtures/maps/bad-frontmatter.md similarity index 100% rename from packages/ghost-map/test/fixtures/maps/bad-frontmatter.md rename to packages/ghost-expression/test/fixtures/maps/bad-frontmatter.md diff --git a/packages/ghost-map/test/fixtures/maps/empty-section.md b/packages/ghost-expression/test/fixtures/maps/empty-section.md similarity index 100% rename from packages/ghost-map/test/fixtures/maps/empty-section.md rename to packages/ghost-expression/test/fixtures/maps/empty-section.md diff --git a/packages/ghost-map/test/fixtures/maps/good.md b/packages/ghost-expression/test/fixtures/maps/good.md similarity index 100% rename from packages/ghost-map/test/fixtures/maps/good.md rename to packages/ghost-expression/test/fixtures/maps/good.md diff --git a/packages/ghost-map/test/fixtures/maps/missing-section.md b/packages/ghost-expression/test/fixtures/maps/missing-section.md similarity index 100% rename from packages/ghost-map/test/fixtures/maps/missing-section.md rename to packages/ghost-expression/test/fixtures/maps/missing-section.md diff --git a/packages/ghost-map/test/fixtures/maps/out-of-order.md b/packages/ghost-expression/test/fixtures/maps/out-of-order.md similarity index 100% rename from packages/ghost-map/test/fixtures/maps/out-of-order.md rename to packages/ghost-expression/test/fixtures/maps/out-of-order.md diff --git a/packages/ghost-map/test/fixtures/multi-platform-repo/multi-platform.map.md b/packages/ghost-expression/test/fixtures/multi-platform-repo/multi-platform.map.md similarity index 100% rename from packages/ghost-map/test/fixtures/multi-platform-repo/multi-platform.map.md rename to packages/ghost-expression/test/fixtures/multi-platform-repo/multi-platform.map.md diff --git a/packages/ghost-map/test/fixtures/multi-upstream-repo/multi-upstream.map.md b/packages/ghost-expression/test/fixtures/multi-upstream-repo/multi-upstream.map.md similarity index 100% rename from packages/ghost-map/test/fixtures/multi-upstream-repo/multi-upstream.map.md rename to packages/ghost-expression/test/fixtures/multi-upstream-repo/multi-upstream.map.md diff --git a/packages/ghost-map/test/fixtures/python-venv-repo/pyproject.toml b/packages/ghost-expression/test/fixtures/python-venv-repo/pyproject.toml similarity index 100% rename from packages/ghost-map/test/fixtures/python-venv-repo/pyproject.toml rename to packages/ghost-expression/test/fixtures/python-venv-repo/pyproject.toml diff --git a/packages/ghost-map/test/fixtures/python-venv-repo/src/foo.py b/packages/ghost-expression/test/fixtures/python-venv-repo/src/foo.py similarity index 100% rename from packages/ghost-map/test/fixtures/python-venv-repo/src/foo.py rename to packages/ghost-expression/test/fixtures/python-venv-repo/src/foo.py diff --git a/packages/ghost-map/test/fixtures/python-venv-repo/venv/lib/python.py b/packages/ghost-expression/test/fixtures/python-venv-repo/venv/lib/python.py similarity index 100% rename from packages/ghost-map/test/fixtures/python-venv-repo/venv/lib/python.py rename to packages/ghost-expression/test/fixtures/python-venv-repo/venv/lib/python.py diff --git a/packages/ghost-map/test/fixtures/token-pipeline-repo/design-tokens/typography.yaml b/packages/ghost-expression/test/fixtures/token-pipeline-repo/design-tokens/typography.yaml similarity index 100% rename from packages/ghost-map/test/fixtures/token-pipeline-repo/design-tokens/typography.yaml rename to packages/ghost-expression/test/fixtures/token-pipeline-repo/design-tokens/typography.yaml diff --git a/packages/ghost-map/test/fixtures/token-pipeline-repo/package.json b/packages/ghost-expression/test/fixtures/token-pipeline-repo/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/token-pipeline-repo/package.json rename to packages/ghost-expression/test/fixtures/token-pipeline-repo/package.json diff --git a/packages/ghost-map/test/fixtures/token-pipeline-repo/src/styles/tokens.css b/packages/ghost-expression/test/fixtures/token-pipeline-repo/src/styles/tokens.css similarity index 100% rename from packages/ghost-map/test/fixtures/token-pipeline-repo/src/styles/tokens.css rename to packages/ghost-expression/test/fixtures/token-pipeline-repo/src/styles/tokens.css diff --git a/packages/ghost-map/test/fixtures/token-pipeline-repo/tokens/colors.json b/packages/ghost-expression/test/fixtures/token-pipeline-repo/tokens/colors.json similarity index 100% rename from packages/ghost-map/test/fixtures/token-pipeline-repo/tokens/colors.json rename to packages/ghost-expression/test/fixtures/token-pipeline-repo/tokens/colors.json diff --git a/packages/ghost-map/test/fixtures/vite-nx-repo/nx.json b/packages/ghost-expression/test/fixtures/vite-nx-repo/nx.json similarity index 100% rename from packages/ghost-map/test/fixtures/vite-nx-repo/nx.json rename to packages/ghost-expression/test/fixtures/vite-nx-repo/nx.json diff --git a/packages/ghost-map/test/fixtures/vite-nx-repo/package.json b/packages/ghost-expression/test/fixtures/vite-nx-repo/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/vite-nx-repo/package.json rename to packages/ghost-expression/test/fixtures/vite-nx-repo/package.json diff --git a/packages/ghost-map/test/fixtures/vite-nx-repo/packages/foo/package.json b/packages/ghost-expression/test/fixtures/vite-nx-repo/packages/foo/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/vite-nx-repo/packages/foo/package.json rename to packages/ghost-expression/test/fixtures/vite-nx-repo/packages/foo/package.json diff --git a/packages/ghost-map/test/fixtures/vite-nx-repo/packages/foo/src/index.ts b/packages/ghost-expression/test/fixtures/vite-nx-repo/packages/foo/src/index.ts similarity index 100% rename from packages/ghost-map/test/fixtures/vite-nx-repo/packages/foo/src/index.ts rename to packages/ghost-expression/test/fixtures/vite-nx-repo/packages/foo/src/index.ts diff --git a/packages/ghost-map/test/fixtures/vite-nx-repo/src/main.ts b/packages/ghost-expression/test/fixtures/vite-nx-repo/src/main.ts similarity index 100% rename from packages/ghost-map/test/fixtures/vite-nx-repo/src/main.ts rename to packages/ghost-expression/test/fixtures/vite-nx-repo/src/main.ts diff --git a/packages/ghost-map/test/fixtures/vite-nx-repo/vite.config.ts b/packages/ghost-expression/test/fixtures/vite-nx-repo/vite.config.ts similarity index 100% rename from packages/ghost-map/test/fixtures/vite-nx-repo/vite.config.ts rename to packages/ghost-expression/test/fixtures/vite-nx-repo/vite.config.ts diff --git a/packages/ghost-map/test/fixtures/web-repo/coverage/ignored.js b/packages/ghost-expression/test/fixtures/web-repo/coverage/ignored.js similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/coverage/ignored.js rename to packages/ghost-expression/test/fixtures/web-repo/coverage/ignored.js diff --git a/packages/ghost-map/test/fixtures/web-repo/package.json b/packages/ghost-expression/test/fixtures/web-repo/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/package.json rename to packages/ghost-expression/test/fixtures/web-repo/package.json diff --git a/packages/ghost-map/test/fixtures/web-repo/registry.json b/packages/ghost-expression/test/fixtures/web-repo/registry.json similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/registry.json rename to packages/ghost-expression/test/fixtures/web-repo/registry.json diff --git a/packages/ghost-map/test/fixtures/web-repo/src/components/Button.tsx b/packages/ghost-expression/test/fixtures/web-repo/src/components/Button.tsx similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/src/components/Button.tsx rename to packages/ghost-expression/test/fixtures/web-repo/src/components/Button.tsx diff --git a/packages/ghost-map/test/fixtures/web-repo/src/components/Card.tsx b/packages/ghost-expression/test/fixtures/web-repo/src/components/Card.tsx similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/src/components/Card.tsx rename to packages/ghost-expression/test/fixtures/web-repo/src/components/Card.tsx diff --git a/packages/ghost-map/test/fixtures/web-repo/src/index.ts b/packages/ghost-expression/test/fixtures/web-repo/src/index.ts similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/src/index.ts rename to packages/ghost-expression/test/fixtures/web-repo/src/index.ts diff --git a/packages/ghost-map/test/fixtures/web-repo/src/styles/tokens.css b/packages/ghost-expression/test/fixtures/web-repo/src/styles/tokens.css similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/src/styles/tokens.css rename to packages/ghost-expression/test/fixtures/web-repo/src/styles/tokens.css diff --git a/packages/ghost-map/test/fixtures/web-repo/tailwind.config.ts b/packages/ghost-expression/test/fixtures/web-repo/tailwind.config.ts similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/tailwind.config.ts rename to packages/ghost-expression/test/fixtures/web-repo/tailwind.config.ts diff --git a/packages/ghost-map/test/fixtures/web-repo/test/sample.ts b/packages/ghost-expression/test/fixtures/web-repo/test/sample.ts similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/test/sample.ts rename to packages/ghost-expression/test/fixtures/web-repo/test/sample.ts diff --git a/packages/ghost-map/test/fixtures/web-repo/tsconfig.json b/packages/ghost-expression/test/fixtures/web-repo/tsconfig.json similarity index 100% rename from packages/ghost-map/test/fixtures/web-repo/tsconfig.json rename to packages/ghost-expression/test/fixtures/web-repo/tsconfig.json diff --git a/packages/ghost-map/test/fixtures/workspace-repo/apps/web/package.json b/packages/ghost-expression/test/fixtures/workspace-repo/apps/web/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/workspace-repo/apps/web/package.json rename to packages/ghost-expression/test/fixtures/workspace-repo/apps/web/package.json diff --git a/packages/ghost-map/test/fixtures/workspace-repo/common/lint/package.json b/packages/ghost-expression/test/fixtures/workspace-repo/common/lint/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/workspace-repo/common/lint/package.json rename to packages/ghost-expression/test/fixtures/workspace-repo/common/lint/package.json diff --git a/packages/ghost-map/test/fixtures/workspace-repo/libs/util/package.json b/packages/ghost-expression/test/fixtures/workspace-repo/libs/util/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/workspace-repo/libs/util/package.json rename to packages/ghost-expression/test/fixtures/workspace-repo/libs/util/package.json diff --git a/packages/ghost-map/test/fixtures/workspace-repo/package.json b/packages/ghost-expression/test/fixtures/workspace-repo/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/workspace-repo/package.json rename to packages/ghost-expression/test/fixtures/workspace-repo/package.json diff --git a/packages/ghost-map/test/fixtures/workspace-repo/packages/bar/package.json b/packages/ghost-expression/test/fixtures/workspace-repo/packages/bar/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/workspace-repo/packages/bar/package.json rename to packages/ghost-expression/test/fixtures/workspace-repo/packages/bar/package.json diff --git a/packages/ghost-map/test/fixtures/workspace-repo/packages/foo/package.json b/packages/ghost-expression/test/fixtures/workspace-repo/packages/foo/package.json similarity index 100% rename from packages/ghost-map/test/fixtures/workspace-repo/packages/foo/package.json rename to packages/ghost-expression/test/fixtures/workspace-repo/packages/foo/package.json diff --git a/packages/ghost-map/test/inventory.test.ts b/packages/ghost-expression/test/inventory.test.ts similarity index 100% rename from packages/ghost-map/test/inventory.test.ts rename to packages/ghost-expression/test/inventory.test.ts diff --git a/packages/ghost-map/test/lint.test.ts b/packages/ghost-expression/test/lint-map.test.ts similarity index 99% rename from packages/ghost-map/test/lint.test.ts rename to packages/ghost-expression/test/lint-map.test.ts index 0549372..4641eb1 100644 --- a/packages/ghost-map/test/lint.test.ts +++ b/packages/ghost-expression/test/lint-map.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { lintMap } from "../src/core/lint.js"; +import { lintMap } from "../src/core/lint-map.js"; const HERE = dirname(fileURLToPath(import.meta.url)); const FIXTURES = resolve(HERE, "fixtures"); diff --git a/packages/ghost-expression/test/scan-status.test.ts b/packages/ghost-expression/test/scan-status.test.ts new file mode 100644 index 0000000..869316d --- /dev/null +++ b/packages/ghost-expression/test/scan-status.test.ts @@ -0,0 +1,71 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { scanStatus } from "../src/core/scan-status.js"; + +describe("scanStatus", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-scan-status-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("reports all-missing for an empty directory", async () => { + const status = await scanStatus(dir); + expect(status.topology.state).toBe("missing"); + expect(status.objective.state).toBe("missing"); + expect(status.subjective.state).toBe("missing"); + expect(status.recommended_next).toBe("topology"); + }); + + it("recommends objective when only map.md exists", async () => { + await writeFile(join(dir, "map.md"), "---\nschema: ghost.map/v1\n---\n"); + const status = await scanStatus(dir); + expect(status.topology.state).toBe("present"); + expect(status.objective.state).toBe("missing"); + expect(status.recommended_next).toBe("objective"); + }); + + it("recommends subjective when map + bucket exist but expression is missing", async () => { + await writeFile(join(dir, "map.md"), "---\nschema: ghost.map/v1\n---\n"); + await writeFile( + join(dir, "bucket.json"), + JSON.stringify({ schema: "ghost.bucket/v1" }), + ); + const status = await scanStatus(dir); + expect(status.topology.state).toBe("present"); + expect(status.objective.state).toBe("present"); + expect(status.subjective.state).toBe("missing"); + expect(status.recommended_next).toBe("subjective"); + }); + + it("returns recommended_next: null when every stage is present", async () => { + await writeFile(join(dir, "map.md"), "x"); + await writeFile(join(dir, "bucket.json"), "{}"); + await writeFile(join(dir, "expression.md"), "y"); + const status = await scanStatus(dir); + expect(status.recommended_next).toBeNull(); + }); + + it("treats empty (zero-byte) artifacts as missing", async () => { + await writeFile(join(dir, "map.md"), ""); + const status = await scanStatus(dir); + expect(status.topology.state).toBe("missing"); + expect(status.recommended_next).toBe("topology"); + }); + + it("paths returned in the report are absolute", async () => { + const status = await scanStatus(dir); + expect(status.topology.path.startsWith("/")).toBe(true); + expect(status.dir).toBe(dir); + }); +}); diff --git a/packages/ghost-fleet/package.json b/packages/ghost-fleet/package.json index f520c59..88175b6 100644 --- a/packages/ghost-fleet/package.json +++ b/packages/ghost-fleet/package.json @@ -46,7 +46,6 @@ "@ghost/core": "workspace:*", "cac": "^6.7.14", "ghost-expression": "workspace:*", - "ghost-map": "workspace:*", "yaml": "^2.8.3", "zod": "^4.3.6" } diff --git a/packages/ghost-fleet/src/core/members.ts b/packages/ghost-fleet/src/core/members.ts index 28536d6..e26b0c7 100644 --- a/packages/ghost-fleet/src/core/members.ts +++ b/packages/ghost-fleet/src/core/members.ts @@ -1,12 +1,12 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { EXPRESSION_FILENAME, loadExpression } from "ghost-expression"; import { MAP_FILENAME, type MapFrontmatter, MapFrontmatterSchema, -} from "ghost-map"; +} from "@ghost/core"; +import { EXPRESSION_FILENAME, loadExpression } from "ghost-expression"; import { parse as parseYaml } from "yaml"; import { FLEET_MEMBERS_DIRNAME } from "./schema.js"; import type { FleetMember, MemberSummary } from "./types.js"; @@ -129,7 +129,7 @@ async function loadMember(memberPath: string): Promise { /** * Best-effort parse of a map.md frontmatter block. * - * We don't run the full ghost-map linter here — fleet's job is to load, + * We don't run the full map linter here — fleet's job is to load, * not validate. The schema check still rejects clearly-broken frontmatter * so callers get a typed `MapFrontmatter` or nothing. */ diff --git a/packages/ghost-fleet/src/core/types.ts b/packages/ghost-fleet/src/core/types.ts index 76183fb..5f83176 100644 --- a/packages/ghost-fleet/src/core/types.ts +++ b/packages/ghost-fleet/src/core/types.ts @@ -6,8 +6,7 @@ * over them, and what the deterministic artifacts look like on disk. */ -import type { Expression } from "@ghost/core"; -import type { MapFrontmatter } from "ghost-map"; +import type { Expression, MapFrontmatter } from "@ghost/core"; import type { FleetDistance, FleetTrackEdge } from "./schema.js"; /** @@ -15,7 +14,7 @@ import type { FleetDistance, FleetTrackEdge } from "./schema.js"; * * Three states keep the surface small: * • "ok" — the file exists and parses; we don't run the full linter - * here (that's `ghost-map lint` / `ghost-expression lint`). + * here (that's `ghost-expression lint`). * • "missing" — the file is absent from the member directory. * • "error" — the file is present but fails to load/parse. */ diff --git a/packages/ghost-fleet/tsconfig.json b/packages/ghost-fleet/tsconfig.json index 310d4fc..76364b6 100644 --- a/packages/ghost-fleet/tsconfig.json +++ b/packages/ghost-fleet/tsconfig.json @@ -6,9 +6,5 @@ "rootDir": "./src" }, "include": ["src"], - "references": [ - { "path": "../ghost-core" }, - { "path": "../ghost-expression" }, - { "path": "../ghost-map" } - ] + "references": [{ "path": "../ghost-core" }, { "path": "../ghost-expression" }] } diff --git a/packages/ghost-map/package.json b/packages/ghost-map/package.json deleted file mode 100644 index f98f488..0000000 --- a/packages/ghost-map/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "ghost-map", - "version": "0.0.0", - "private": true, - "description": "Generate and validate map.md — the navigation card every Ghost tool reads to learn the topology of a frontend repo", - "license": "Apache-2.0", - "author": "Block, Inc.", - "repository": { - "type": "git", - "url": "git+https://github.com/block/ghost.git" - }, - "homepage": "https://github.com/block/ghost#readme", - "bugs": { - "url": "https://github.com/block/ghost/issues" - }, - "keywords": [ - "design-system", - "navigation", - "topology", - "monorepo", - "cli" - ], - "type": "module", - "main": "./dist/core/index.js", - "types": "./dist/core/index.d.ts", - "bin": { - "ghost-map": "./dist/bin.js" - }, - "exports": { - ".": { - "types": "./dist/core/index.d.ts", - "import": "./dist/core/index.js" - }, - "./cli": { - "types": "./dist/cli.d.ts", - "import": "./dist/cli.js" - } - }, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public", - "provenance": true - }, - "scripts": { - "build": "rm -rf dist && tsc --build --force", - "prepublishOnly": "pnpm build" - }, - "dependencies": { - "cac": "^6.7.14", - "yaml": "^2.8.3", - "zod": "^4.3.6" - } -} diff --git a/packages/ghost-map/src/bin.ts b/packages/ghost-map/src/bin.ts deleted file mode 100644 index d818272..0000000 --- a/packages/ghost-map/src/bin.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node - -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; - -// Load .env from the working directory if present. -for (const envFile of [".env", ".env.local"]) { - const envPath = resolve(process.cwd(), envFile); - if (existsSync(envPath)) { - try { - process.loadEnvFile(envPath); - } catch { - // Node < 20.12 or malformed file — silently skip - } - } -} - -import { buildCli } from "./cli.js"; - -const cli = buildCli(); -cli.parse(); diff --git a/packages/ghost-map/src/cli.ts b/packages/ghost-map/src/cli.ts deleted file mode 100644 index 2b3a3e6..0000000 --- a/packages/ghost-map/src/cli.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { cac } from "cac"; -import { inventory, lintMap, MAP_FILENAME } from "./core/index.js"; - -/** - * Build the cac CLI for `ghost-map`. - * - * Two verbs are wired up here: `inventory` (deterministic raw signals as - * JSON) and `lint` (validate a `map.md`). Both are reproducible from inputs - * — no LLM, no network beyond best-effort `git`. - */ -export function buildCli(): ReturnType { - const cli = cac("ghost-map"); - - // --- inventory --- - cli - .command( - "inventory [path]", - "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote.", - ) - .action(async (path: string | undefined) => { - try { - const target = resolve(process.cwd(), path ?? "."); - const out = inventory(target); - process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); - process.exit(0); - } catch (err) { - process.stderr.write( - `Error: ${err instanceof Error ? err.message : String(err)}\n`, - ); - process.exit(2); - } - }); - - // --- lint --- - cli - .command("lint [map]", "Validate map.md against ghost.map/v1") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (path: string | undefined, opts: { format?: string }) => { - try { - const target = resolve(process.cwd(), path ?? MAP_FILENAME); - const raw = await readFile(target, "utf-8"); - const report = lintMap(raw); - - if (opts.format === "json") { - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - } else { - for (const issue of report.issues) { - const prefix = - issue.severity === "error" - ? "ERROR" - : issue.severity === "warning" - ? "WARN " - : "INFO "; - const pathSuffix = issue.path ? ` @ ${issue.path}` : ""; - process.stdout.write( - `${prefix} [${issue.rule}] ${issue.message}${pathSuffix}\n`, - ); - } - process.stdout.write( - `\n${report.errors} error(s), ${report.warnings} warning(s), ${report.info} info\n`, - ); - } - - process.exit(report.errors > 0 ? 1 : 0); - } catch (err) { - process.stderr.write( - `Error: ${err instanceof Error ? err.message : String(err)}\n`, - ); - process.exit(2); - } - }); - - cli.help(); - cli.version(readPackageVersion()); - - return cli; -} - -function readPackageVersion(): string { - const here = dirname(fileURLToPath(import.meta.url)); - const pkg = JSON.parse( - readFileSync(resolve(here, "../package.json"), "utf8"), - ); - return pkg.version as string; -} diff --git a/packages/ghost-map/src/core/index.ts b/packages/ghost-map/src/core/index.ts deleted file mode 100644 index cc0bf9c..0000000 --- a/packages/ghost-map/src/core/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Public library surface for the `ghost-map` package. - * - * Mirrors `ghost-drift`'s `core/index.ts`: a single barrel that consumers - * import from `ghost-map` (no deep imports required). - */ -export { inventory } from "./inventory.js"; -export { - type LintIssue, - type LintReport, - type LintSeverity, - lintMap, - MAP_FILENAME, -} from "./lint.js"; -export { - type MapFrontmatter, - MapFrontmatterSchema, - REQUIRED_BODY_SECTIONS, - type RequiredBodySection, -} from "./schema.js"; -export type { - GitInfo, - InventoryOutput, - LanguageHistogramEntry, - TopLevelEntry, -} from "./types.js"; diff --git a/packages/ghost-map/tsconfig.json b/packages/ghost-map/tsconfig.json deleted file mode 100644 index f724352..0000000 --- a/packages/ghost-map/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "composite": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afcd691..4c7776d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,7 +304,11 @@ importers: specifier: ^6.3.0 version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) - packages/ghost-core: {} + packages/ghost-core: + dependencies: + zod: + specifier: ^4.3.6 + version: 4.3.6 packages/ghost-drift: dependencies: @@ -347,21 +351,6 @@ importers: ghost-expression: specifier: workspace:* version: link:../ghost-expression - ghost-map: - specifier: workspace:* - version: link:../ghost-map - yaml: - specifier: ^2.8.3 - version: 2.8.3 - zod: - specifier: ^4.3.6 - version: 4.3.6 - - packages/ghost-map: - dependencies: - cac: - specifier: ^6.7.14 - version: 6.7.14 yaml: specifier: ^2.8.3 version: 2.8.3 diff --git a/scripts/dump-cli-help.mjs b/scripts/dump-cli-help.mjs index 821cd2d..9fc5f78 100644 --- a/scripts/dump-cli-help.mjs +++ b/scripts/dump-cli-help.mjs @@ -14,7 +14,6 @@ const ROOT = process.cwd(); const TOOLS = [ { name: "ghost-drift", dist: "packages/ghost-drift/dist/cli.js" }, { name: "ghost-expression", dist: "packages/ghost-expression/dist/cli.js" }, - { name: "ghost-map", dist: "packages/ghost-map/dist/cli.js" }, { name: "ghost-fleet", dist: "packages/ghost-fleet/dist/cli.js" }, ]; diff --git a/tsconfig.json b/tsconfig.json index bcca38b..d02ffa5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,6 @@ { "path": "packages/ghost-drift" }, { "path": "packages/ghost-expression" }, { "path": "packages/ghost-fleet" }, - { "path": "packages/ghost-map" }, { "path": "packages/ghost-ui/tsconfig.mcp.json" } ] } From 916e728489f603407c6d00b798f49acd3975e7bb Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 29 Apr 2026 16:25:42 -0400 Subject: [PATCH 02/14] fix(compare): backfill palette oklch on load; tighten survey exhaustiveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-distance reported 17.5% on freshly authored expressions because loadExpression never backfilled `oklch` on hex-only palette colors. comparePalette then treated every color as fully unmatched and contributed distance 1.0 per color even when comparing an expression to itself. Two-layer fix: - loadExpression now backfills oklch deterministically (parseColorToOklch is pure: same hex → same oklch), so re-parsing produces identical in-memory shapes. - comparePalette resolves oklch on-the-fly when missing and falls back to hex-string equality for non-parseable values (CSS vars, opaque refs). Defensive against third-party producers that emit hex-only. Survey recipe tightened with a load-bearing exhaustiveness rule. Triggered by a dogfood scan that produced ~10% recall on components (6 rows for a 97-component package). The rule is repo-agnostic: the agent identifies the canonical signal in this repo, enumerates exhaustively, and cross-checks counts. Recipe explicitly forbids sampling and placeholder/glob library names. Coverage check (step 8) is now a real gate — exhaustiveness must match independent counts before declaring saturation. Pinned attempt-1 artifacts under dogfood/ghost-ui/attempt-1/ with a structured NOTES.md documenting the failure modes. Future attempts go alongside so we can track improvement. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fix-oklch-backfill-and-tighten-survey.md | 12 + dogfood/ghost-ui/attempt-1/NOTES.md | 54 ++ dogfood/ghost-ui/attempt-1/bucket.json | 764 ++++++++++++++++++ dogfood/ghost-ui/attempt-1/expression.md | 126 +++ dogfood/ghost-ui/attempt-1/map.md | 103 +++ packages/ghost-core/src/embedding/colors.ts | 16 + packages/ghost-core/src/embedding/compare.ts | 35 +- packages/ghost-core/src/embedding/index.ts | 1 + .../embedding/compare-oklch-fallback.test.ts | 99 +++ packages/ghost-expression/src/core/index.ts | 27 +- .../src/skill-bundle/references/survey.md | 49 +- .../expression/load-oklch-backfill.test.ts | 116 +++ 12 files changed, 1382 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-oklch-backfill-and-tighten-survey.md create mode 100644 dogfood/ghost-ui/attempt-1/NOTES.md create mode 100644 dogfood/ghost-ui/attempt-1/bucket.json create mode 100644 dogfood/ghost-ui/attempt-1/expression.md create mode 100644 dogfood/ghost-ui/attempt-1/map.md create mode 100644 packages/ghost-drift/test/embedding/compare-oklch-fallback.test.ts create mode 100644 packages/ghost-expression/test/expression/load-oklch-backfill.test.ts diff --git a/.changeset/fix-oklch-backfill-and-tighten-survey.md b/.changeset/fix-oklch-backfill-and-tighten-survey.md new file mode 100644 index 0000000..40289a0 --- /dev/null +++ b/.changeset/fix-oklch-backfill-and-tighten-survey.md @@ -0,0 +1,12 @@ +--- +"ghost-expression": patch +"ghost-drift": patch +--- + +Fix self-distance bug + tighten the survey recipe's exhaustiveness rule. + +**Bug fix.** `loadExpression` now backfills `oklch` on palette colors that arrive hex-only (frontmatter without an explicit `oklch` tuple). Without this, `comparePalette` treated hex-only colors as fully unmatched and contributed distance `1.0` per color — even when comparing an expression to itself. Self-distance was reported as 17.5% on a freshly authored expression. Backfill is deterministic (same hex → same oklch), so re-parsing the same file always yields the same in-memory shape. + +**Defensive fallback.** `comparePalette` also now resolves oklch on-the-fly when missing, and falls back to hex-string equality when even on-the-fly compute can't parse the color (CSS variables, opaque external refs). This covers third-party producers that don't backfill on write. + +**Recipe tightening.** `survey.md` now states the exhaustiveness rule up front and applies it per section. The rule is repo-agnostic — the recipe doesn't name specific signal sources (no "use registry.json"); the agent identifies the canonical signal in *this* repo, enumerates exhaustively, and cross-checks counts. New `Never sample` rule and explicit guidance against placeholder/glob library names. Triggered by a dogfood scan that produced ~10% recall on `components[]` (6 rows for a 97-component package). diff --git a/dogfood/ghost-ui/attempt-1/NOTES.md b/dogfood/ghost-ui/attempt-1/NOTES.md new file mode 100644 index 0000000..596e27a --- /dev/null +++ b/dogfood/ghost-ui/attempt-1/NOTES.md @@ -0,0 +1,54 @@ +# Attempt 1 — ghost-ui scan, 2026-04-29 + +First end-to-end dogfood of the new three-stage scan pipeline (`map.md` → `bucket.json` → `expression.md`) against `packages/ghost-ui`. Authored by an agent (Claude Opus 4.7) following the bundled `map.md`, `survey.md`, `profile.md` skill recipes. All three artifacts lint clean. + +## What worked + +- Pipeline ran end-to-end: every stage produced a lint-clean artifact. +- `bucket fix-ids` worked as designed — agent authored rows with `"id": ""`, finalized in one pass. +- Schema flexed across every value kind (`color`, `spacing`, `radius`, `shadow`, `breakpoint`, `motion`, `typography`, `layout-primitive`). +- Token alias chains captured the 3-deep indirection cleanly (`--color-foreground` → `--foreground` → `--text-default` → `--color-gray-900`). + +## What failed + +### 1. Bucket recall ~10–20% + +Massive undercount across every section: + +| Section | Recorded | Reality | Recall | +|---|---|---|---| +| `components` | 6 | 97 UI primitives + 48 AI elements + 5 themes (~150 registry items) | ~4% | +| `values` (distinct hexes) | 8 | 25 distinct hexes in canonical token layer (139 across `src/`) | ~32% | +| `tokens` | 11 | 80+ named tokens in `main.css` alone | ~14% | +| `libraries` | 6 | 27 distinct `@radix-ui/*` packages alone (rolled into 1 row) | OK at the category level | + +The recipe instructed exhaustiveness; the agent sampled. **A 90% undercount is a failed scan** — the interpreter downstream cannot recover what wasn't recorded. + +### 2. Decision-level coverage 7/11 + +Missed four load-bearing decisions named in the prior expression.md (authored under the old single-pass recipe): + +- **`font-sourcing`** — ghost-ui ships zero bundled fonts (`font-faces.css` is one comment). Critical character claim, missed entirely. +- **`interactive-patterns`** — global `*:focus-visible` discipline applied uniformly; missed. +- **`density`** — "compact controls inside generous structural whitespace" composition; missed. +- **Charts as a sub-strategy** — 5-color warm chart palette deliberately departing from the monochromatic discipline; missed. + +### 3. Quantitative errors in named decisions + +- **`shadow-hierarchy: 4-tier`** is wrong. There are **7 named tiers** (`mini`, `btn`, `card`, `elevated`, `popover`, `modal`, `kbd`) plus 2 special-purpose (`mini-inset`, `date-field-focus`). +- **`color-strategy`** overstated "no brand or accent color." `--background-accent` exists (mapping to `gray-900`). The real claim is *monochrome accent* — a deliberate refusal of chromatic accent. + +### 4. Decision naming bias + +The new bucket-grounded recipe produced more literal/technical names (`color-strategy`, `shape-language`, `shadow-hierarchy`) where the existing expression named patterns at a more useful abstraction (`surface-hierarchy`, `elevation`, `theming-architecture`, `interactive-patterns`). The recipe should reinforce "name the pattern, not the value." + +### 5. Bug in self-distance check (separate from the recipe) + +`ghost-drift compare expression.md expression.md` reports 17.5% self-distance because `loadExpression` doesn't backfill `oklch` on palette colors. `comparePalette` then treats every color as fully unmatched (distance 1). Fix shipping in attempt 2's run; not a recipe issue. + +## Lessons for attempt 2 + +1. **Exhaustiveness is the load-bearing rule** — the agent must enumerate every section's source of truth, not sample. Cross-check counts from two independent passes; a divergence > ~10% means re-pass. Already strengthening this in `survey.md`. +2. **No leading repo-specific guidance** in the recipe (e.g. don't say "use registry.json") — Ghost is BYOA-agnostic. The recipe states the discipline; the agent identifies the canonical signal in this repo. +3. **Profile recipe should reinforce "name the pattern, not the value."** Decision dimensions like `font-sourcing`, `interactive-patterns`, `density` are more useful than restated tokens. +4. **Cross-check decision count against prior art when available.** If the old `expression.md` had 11 dimensions and the new has 7, ask why the four absent ones weren't observable — usually a recall gap upstream. diff --git a/dogfood/ghost-ui/attempt-1/bucket.json b/dogfood/ghost-ui/attempt-1/bucket.json new file mode 100644 index 0000000..7338584 --- /dev/null +++ b/dogfood/ghost-ui/attempt-1/bucket.json @@ -0,0 +1,764 @@ +{ + "schema": "ghost.bucket/v1", + "sources": [ + { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + } + ], + "values": [ + { + "id": "07998cc8e99e422d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ffffff", + "raw": "#ffffff", + "spec": { + "space": "srgb", + "hex": "#ffffff", + "rgb": { + "r": 255, + "g": 255, + "b": 255 + } + }, + "occurrences": 24, + "files_count": 4, + "role_hypothesis": "neutral-extreme-light" + }, + { + "id": "f699bc316c60d1df", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#000000", + "raw": "#000000", + "spec": { + "space": "srgb", + "hex": "#000000", + "rgb": { + "r": 0, + "g": 0, + "b": 0 + } + }, + "occurrences": 15, + "files_count": 3, + "role_hypothesis": "neutral-extreme-dark" + }, + { + "id": "b2de7ad8c2837a78", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1a1a1a", + "raw": "#1a1a1a", + "spec": { + "space": "srgb", + "hex": "#1a1a1a" + }, + "occurrences": 9, + "files_count": 3, + "role_hypothesis": "neutral-darkest-step" + }, + { + "id": "4b8ba7755a7b0ad6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f5f5f5", + "raw": "#f5f5f5", + "spec": { + "space": "srgb", + "hex": "#f5f5f5" + }, + "occurrences": 3, + "files_count": 2, + "role_hypothesis": "neutral-lightest-step" + }, + { + "id": "b6756caa068847ff", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#91cb80", + "raw": "#91cb80", + "spec": { + "space": "srgb", + "hex": "#91cb80" + }, + "occurrences": 9, + "files_count": 2, + "role_hypothesis": "semantic-success" + }, + { + "id": "c43f851c1cb8e9bd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f94b4b", + "raw": "#f94b4b", + "spec": { + "space": "srgb", + "hex": "#f94b4b" + }, + "occurrences": 5, + "files_count": 2, + "role_hypothesis": "semantic-danger" + }, + { + "id": "22a9e7619e96133b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#5c98f9", + "raw": "#5c98f9", + "spec": { + "space": "srgb", + "hex": "#5c98f9" + }, + "occurrences": 5, + "files_count": 2, + "role_hypothesis": "semantic-info" + }, + { + "id": "51eea2d9c0c627e3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#fbcd44", + "raw": "#fbcd44", + "spec": { + "space": "srgb", + "hex": "#fbcd44" + }, + "occurrences": 5, + "files_count": 2, + "role_hypothesis": "semantic-warning" + }, + { + "id": "0e913b5f5566586a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "8", + "raw": "8px", + "spec": { + "scalar": 8, + "unit": "px" + }, + "occurrences": 21, + "files_count": 2, + "role_hypothesis": "spacing-base-unit" + }, + { + "id": "fcd6f6793118b2d6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "2", + "raw": "2px", + "spec": { + "scalar": 2, + "unit": "px" + }, + "occurrences": 25, + "files_count": 3 + }, + { + "id": "43e686cb95055b82", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "20", + "raw": "20px", + "spec": { + "scalar": 20, + "unit": "px" + }, + "occurrences": 14, + "files_count": 3, + "role_hypothesis": "spacing-card-radius-unit" + }, + { + "id": "42331c1ca6bbb635", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "100", + "raw": "100px", + "spec": { + "scalar": 100, + "unit": "px" + }, + "occurrences": 3, + "files_count": 1, + "role_hypothesis": "section-padding-vertical" + }, + { + "id": "d77862c3e75ef823", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "radius", + "value": "999", + "raw": "999px", + "spec": { + "scalar": 999, + "unit": "px" + }, + "occurrences": 9, + "files_count": 2, + "role_hypothesis": "radius-pill" + }, + { + "id": "bcf70cb399fa2f2e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "radius", + "value": "20", + "raw": "20px", + "spec": { + "scalar": 20, + "unit": "px" + }, + "occurrences": 4, + "files_count": 1, + "role_hypothesis": "radius-card-default" + }, + { + "id": "13fe983e4d8cad2e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "shadow", + "value": "0 2px 8px rgba(76, 76, 76, 0.15)", + "raw": "0 2px 8px rgba(76, 76, 76, 0.15)", + "spec": { + "offset_x": { + "scalar": 0, + "unit": "px" + }, + "offset_y": { + "scalar": 2, + "unit": "px" + }, + "blur": { + "scalar": 8, + "unit": "px" + }, + "color": "rgba(76, 76, 76, 0.15)" + }, + "occurrences": 4, + "files_count": 1, + "role_hypothesis": "shadow-mini-light" + }, + { + "id": "9a298083f034b5d0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "breakpoint", + "value": "1440", + "raw": "1440px", + "spec": { + "scalar": 1440, + "unit": "px", + "label": "desktop" + }, + "occurrences": 2, + "files_count": 1, + "role_hypothesis": "breakpoint-desktop" + }, + { + "id": "3ac3a9616596e548", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "0.2s", + "raw": "0.2s", + "spec": { + "duration_ms": 200 + }, + "occurrences": 9, + "files_count": 1, + "role_hypothesis": "duration-normal" + }, + { + "id": "79cec1551cea2c94", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "0.15s", + "raw": "0.15s", + "spec": { + "duration_ms": 150 + }, + "occurrences": 3, + "files_count": 1, + "role_hypothesis": "duration-fast" + }, + { + "id": "9b765541bf0e2a42", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "cubic-bezier(0.33, 1, 0.68, 1)", + "raw": "cubic-bezier(0.33, 1, 0.68, 1)", + "spec": { + "easing": "cubic-bezier(0.33, 1, 0.68, 1)" + }, + "occurrences": 1, + "files_count": 1, + "role_hypothesis": "easing-spring" + }, + { + "id": "6cd2a74b3334e115", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "typography", + "value": "Geist Mono", + "raw": "\"Geist Mono\"", + "spec": { + "family": "Geist Mono" + }, + "occurrences": 1, + "files_count": 1, + "role_hypothesis": "font-mono" + }, + { + "id": "7e0c4961e87935f8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "typography", + "value": "system-ui", + "raw": "system-ui", + "spec": { + "family": "system-ui" + }, + "occurrences": 2, + "files_count": 1, + "role_hypothesis": "font-sans" + }, + { + "id": "8f1fb0ee33db31a0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "kind": "layout-primitive", + "value": "1440px", + "raw": "1440px", + "spec": { + "kind": "max-width", + "scalar": 1440, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1, + "role_hypothesis": "page-container-max-width" + } + ], + "tokens": [ + { + "id": "a98934fc1e31ad67", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-900", + "alias_chain": [], + "resolved_value": "#1a1a1a", + "occurrences": 9 + }, + { + "id": "12ea54555a0564cf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-default", + "alias_chain": ["--color-gray-900"], + "resolved_value": "#1a1a1a", + "by_theme": { + "light": "#1a1a1a", + "dark": "#ffffff" + }, + "occurrences": 12 + }, + { + "id": "9e25b641c24e539a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "by_theme": { + "light": "#1a1a1a", + "dark": "#ffffff" + }, + "occurrences": 8 + }, + { + "id": "441b3c286c0261ca", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-foreground", + "alias_chain": ["--foreground", "--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "by_theme": { + "light": "#1a1a1a", + "dark": "#ffffff" + }, + "occurrences": 4 + }, + { + "id": "035086c7634b82d4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-default", + "alias_chain": ["--color-white"], + "resolved_value": "#ffffff", + "by_theme": { + "light": "#ffffff", + "dark": "#000000" + }, + "occurrences": 11 + }, + { + "id": "ec631d558a6a99d1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius", + "alias_chain": [], + "resolved_value": "20px", + "occurrences": 5 + }, + { + "id": "b23396b5f5917ece", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-pill", + "alias_chain": [], + "resolved_value": "999px", + "occurrences": 4 + }, + { + "id": "6495da33b44d2a78", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-button", + "alias_chain": [], + "resolved_value": "999px", + "occurrences": 3 + }, + { + "id": "d440ce2b5a94d053", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-card", + "alias_chain": [], + "resolved_value": "0 2px 8px rgba(76, 76, 76, 0.15)", + "by_theme": { + "light": "0 2px 8px rgba(76, 76, 76, 0.15)", + "dark": "0 2px 8px rgba(0, 0, 0, 0.4)" + }, + "occurrences": 3 + }, + { + "id": "0c2a5e27fb5ab060", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--ease-spring", + "alias_chain": [], + "resolved_value": "cubic-bezier(0.33, 1, 0.68, 1)", + "occurrences": 1 + }, + { + "id": "ea66cc1267da58ae", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "--breakpoint-desktop", + "alias_chain": [], + "resolved_value": "1440px", + "occurrences": 1 + } + ], + "components": [ + { + "id": "08d82e05c20c6443", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "Button", + "discovered_via": "registry.json", + "variants": [ + "default", + "destructive", + "outline", + "secondary", + "ghost", + "link" + ], + "sizes": ["default", "sm", "lg", "icon"] + }, + { + "id": "c0247ab4b8650edd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "Input", + "discovered_via": "registry.json" + }, + { + "id": "a5c90d03f29f5c06", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "Card", + "discovered_via": "registry.json" + }, + { + "id": "3d87605ed68215ef", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "Dialog", + "discovered_via": "registry.json" + }, + { + "id": "60b034a3edf75635", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "DropdownMenu", + "discovered_via": "registry.json" + }, + { + "id": "69c55fea7b09ab3e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "Badge", + "discovered_via": "registry.json" + } + ], + "libraries": [ + { + "id": "6348a4fef3c0b8d4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "lucide-react", + "kind": "icons", + "version": "^1.7.0" + }, + { + "id": "4f8ef2d2b5cd65ee", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-*", + "kind": "primitives", + "version": "^1.x" + }, + { + "id": "b497cbc85c1baf41", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "cmdk", + "kind": "command-palette", + "version": "^1.1.1" + }, + { + "id": "d7dd2e7250f6c2a1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "sonner", + "kind": "toast", + "version": "^2.0.7" + }, + { + "id": "07dcc1e35fef16db", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "class-variance-authority", + "kind": "variant-helper", + "version": "^0.7.1" + }, + { + "id": "396e1af18e2813b3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "5f821cb6f3ef79ca2515091ba1174e6de5cf8227", + "scanned_at": "2026-04-29T13:55:00Z", + "scanner_version": "0.1.0" + }, + "name": "tw-animate-css", + "kind": "animation", + "version": "(imported via main.css)" + } + ] +} diff --git a/dogfood/ghost-ui/attempt-1/expression.md b/dogfood/ghost-ui/attempt-1/expression.md new file mode 100644 index 0000000..d579092 --- /dev/null +++ b/dogfood/ghost-ui/attempt-1/expression.md @@ -0,0 +1,126 @@ +--- +id: ghost-ui +source: llm +timestamp: 2026-04-29T13:55:00Z +sources: + - github:block/ghost#packages/ghost-ui + +observation: + personality: [monochromatic, editorial, generous, restrained, deliberate] + resembles: [vercel-geist, linear] + +decisions: + - dimension: color-strategy + - dimension: shape-language + - dimension: typography-voice + - dimension: token-architecture + - dimension: theming + - dimension: motion + - dimension: shadow-hierarchy + +palette: + dominant: + - { role: ink, value: "#1a1a1a" } + neutrals: + steps: ["#ffffff", "#f5f5f5", "#f0f0f0", "#e8e8e8", "#e5e5e5", "#cccccc", "#999999", "#666666", "#333333", "#232323", "#1a1a1a", "#000000"] + count: 12 + semantic: + - { role: success, value: "#91cb80" } + - { role: danger, value: "#f94b4b" } + - { role: info, value: "#5c98f9" } + - { role: warning, value: "#fbcd44" } + saturationProfile: muted + contrast: high + +spacing: + scale: [2, 8, 20, 100] + baseUnit: 4 + regularity: 0.7 + +typography: + families: ["system-ui", "Geist Mono"] + sizeRamp: [10, 11, 12, 14, 16, 20, 28, 44, 64, 96] + weightDistribution: { "300": 1, "600": 4, "700": 2, "900": 1 } + lineHeightPattern: tight + +surfaces: + borderRadii: [10, 14, 16, 20, 24, 999] + shadowComplexity: layered + borderUsage: minimal +--- + +# Character + +`ghost-ui` is a quietly editorial design language: pure-monochromatic neutrals do almost all of the work, semantic colors light up only for state, and every interactive surface lands on a generous pill or rounded card. Headings move on a magazine scale via fluid `clamp()` sizing; bodies sit on system-ui at a measured 1.65 line-height. The whole system is restrained — no brand color, no decorative anything — and gets its character from shape rhythm and shadow cadence rather than chroma. + +# Signature + +- A 12-step pure-monochromatic gray scale (no chromatic tint at any step) is the entire color system; semantic colors are accents, not primaries. +- Pill-first interactive radii: buttons and inputs are 999px; cards stay rounded but not pilled (20px); modals and dropdowns sit between (10–16px). +- Magazine-scale fluid typography — display heading uses `clamp(64px, 8vw, 96px)`, body reading uses `clamp(1rem, 1.3vw, 1.25rem)` — so the type column responds to viewport rather than living on a fixed ramp. +- Three-deep token alias chains (raw step → semantic alias → shadcn alias) so consumers can opt in at any layer of abstraction; the same `--foreground` resolves through `--text-default` to `--color-gray-900`. +- A four-tier shadow system (mini → btn/card → elevated → popover/modal) whose intensity roughly doubles in dark mode rather than inverting — so depth reads consistently across themes. +- Notable absence: no brand color, no gradient, no decorative ornamentation. The design language is anti-flourish. + +# Decisions + +### color-strategy + +The system is monochromatic by default. A 12-step pure-gray scale (white → black) carries surface, border, and text across the entire UI; semantic colors (success, danger, info, warning) appear only when the UI needs to signal state. There is no brand or accent color — distinction is shape and shadow, not chroma. + +**Evidence:** +- Monochromatic ladder white → black: `#ffffff`, `#f5f5f5`, `#f0f0f0`, `#e8e8e8`, `#e5e5e5`, `#cccccc`, `#999999`, `#666666`, `#333333`, `#232323`, `#1a1a1a`, `#000000` (declared as `--color-gray-50` through `--color-gray-900` plus `--color-white`/`--color-black`) +- Semantic-only utility colors: `#91cb80` (success), `#f94b4b` (danger), `#5c98f9` (info), `#fbcd44` (warning) — bound to `--background-success`, `--background-danger`, `--background-info`, `--background-warning` +- `src/styles/main.css` declares no brand color or accent — `--color-*: initial` resets Tailwind's defaults + +### shape-language + +Pill-first interactive radii: every button and input is fully pilled (`999px`) by default; cards stay distinct as soft squares (`20px`); modals (`16px`) and dropdowns (`10px`) live in between. The shape choice is itself a rhythm — interactive vs. structural surfaces are read by their corner radius before any color cue. + +**Evidence:** +- `--radius-pill: 999px`, `--radius-button: 999px`, `--radius-input: 999px` +- `--radius-card: 20px`, `--radius-modal: 16px`, `--radius-dropdown: 10px` + +### typography-voice + +Headings live on a magazine-scale fluid hierarchy: `clamp()` sizing across display (64–96px), section (44–64px), sub (28–40px), and card (20–28px) tiers, with progressively tightening line-heights (0.88 → 1.1) and decreasing negative letter-spacing. Body copy uses `system-ui` at fluid `1rem–1.25rem`; mono is `Geist Mono`. There is no brand display face — the system inherits the OS sans and lets weight + scale do the work. + +**Evidence:** +- `--heading-display-font-size: clamp(64px, 8vw, 96px)` with line-height `0.88`, weight `900` +- `--heading-card-font-size: clamp(20px, 2vw, 28px)` with line-height `1.1`, weight `600` +- `--font-sans: system-ui, …`, `--font-mono: "Geist Mono"` + +### token-architecture + +Tokens layer three deep. The base layer declares raw stepped values (`--color-gray-900: #1a1a1a`). The semantic layer aliases them to roles (`--text-default: var(--color-gray-900)`). The shadcn alias layer wraps the semantic layer (`--foreground: var(--text-default)`, then `--color-foreground: var(--foreground)`). Consumers reach for any layer that matches their intent — Tailwind utilities pull `--color-foreground`; component CSS pulls `--text-default`; raw needs reach `--color-gray-900` directly. + +**Evidence:** +- chain `--color-foreground` → `--foreground` → `--text-default` → `--color-gray-900` +- chain `--background-default` → `--color-white` +- the `@theme inline` block in `src/styles/main.css` re-exposes every semantic alias as a Tailwind color token + +### theming + +Light and dark themes share token names but route through different cascade values: `.dark` overrides the semantic layer (`--background-default`, `--text-default`, etc.) so the alias-chained downstream tokens automatically pick up the new values. Shadows aren't inverted — they're intensified (alpha doubled in dark mode) so depth reads through the darker background. Alpha utility tokens (`--dark-10`, `--dark-40`, `--dark-04`) flip from gray-900-based to white-based across themes. + +**Evidence:** +- `.dark { --background-default: var(--color-black); --text-default: var(--color-white); … }` +- light `--shadow-card: 0 2px 8px rgba(76, 76, 76, 0.15)` → dark `--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.4)` + +### motion + +Three durations (`0.15s` fast, `0.2s` normal, `0.4s` slow) drive every transition; a single shared cubic-bezier easing (`--ease-spring: cubic-bezier(0.33, 1, 0.68, 1)`) gives interactions a consistent decelerating feel. Animations are short, deliberate, and limited to opacity/transform/blur — no heavy motion. + +**Evidence:** +- `--duration-fast: 0.15s`, `--duration-normal: 0.2s`, `--duration-slow: 0.4s` +- `--ease-spring: cubic-bezier(0.33, 1, 0.68, 1)` +- keyframe library: `fade-in/out`, `scale-in/out`, `enter-from-left/right`, `word-reveal` + +### shadow-hierarchy + +Four tiers organize elevation: mini (cards, buttons, kbd), elevated (raised surfaces), popover (floating menus), modal (overlays). Each tier has fixed offset/blur values; the tier is the noun, not the parameters. A separate `mini-inset` exists for inset shadows, and `date-field-focus` is a special-purpose ring. In dark mode every tier's alpha doubles to maintain perceived depth. + +**Evidence:** +- `--shadow-mini`, `--shadow-btn`, `--shadow-card`, `--shadow-elevated`, `--shadow-popover`, `--shadow-modal`, `--shadow-kbd` +- `--shadow-mini-inset` for inset accents +- `--shadow-date-field-focus: 0 0 0 3px rgba(26, 26, 26, 0.15)` diff --git a/dogfood/ghost-ui/attempt-1/map.md b/dogfood/ghost-ui/attempt-1/map.md new file mode 100644 index 0000000..fd5f572 --- /dev/null +++ b/dogfood/ghost-ui/attempt-1/map.md @@ -0,0 +1,103 @@ +--- +schema: ghost.map/v1 +id: ghost-ui +repo: block/ghost +mapped_at: 2026-04-29 +platform: web +languages: + - { name: typescript, files: 116, share: 0.4696 } + - { name: json, files: 113, share: 0.4575 } + - { name: markdown, files: 10, share: 0.0405 } + - { name: css, files: 4, share: 0.0162 } + - { name: javascript, files: 4, share: 0.0162 } +build_system: [pnpm, vite] +package_manifests: + - package.json + - tsconfig.json + - tsconfig.lib.json + - tsconfig.mcp.json + - vite.lib.config.ts + - components.json +composition: + frameworks: + - { name: react, version: "19.1.0" } + - { name: vite, version: "^6.3.0" } + - { name: tailwindcss, version: "^4.2.2" } + - { name: radix-ui } + - { name: lucide-react, version: "^1.7.0" } + - { name: cmdk, version: "^1.1.1" } + - { name: sonner, version: "^2.0.7" } + - { name: class-variance-authority, version: "^0.7.1" } + rendering: react-spa + styling: + - tailwindcss-v4 + - css-custom-properties +registry: + path: ./registry.json + components: 106 +design_system: + paths: + - src/styles + - src/components/theme + - src/lib + entry_files: + - src/styles/main.css + - src/styles/font-faces.css + - src/lib/theme-presets.ts + - src/lib/theme-defaults.ts + - src/lib/theme-utils.ts + - src/lib/theme-provider.tsx + token_source: inline + status: active +ui_surface: + include: + - "src/components/ui/**" + - "src/components/ai-elements/**" + - "src/components/theme/**" + - "src/styles/**" + - "src/lib/theme-*" + exclude: + - "src/mcp/**" + - "scripts/**" + - "dist/**" + - "dist-lib/**" + - "dist-mcp/**" + - "public/r/**" + - "**/*.test.ts" + - "**/*.test.tsx" +feature_areas: + - name: ui-primitives + paths: ["src/components/ui"] + sub_areas: [input, layout, feedback, display, navigation, overlay] + - name: ai-elements + paths: ["src/components/ai-elements"] + sub_areas: [chat, agent-state, artifacts, audio] + - name: theme + paths: ["src/components/theme", "src/lib/theme-presets.ts", "src/lib/theme-defaults.ts", "src/lib/theme-utils.ts", "src/lib/theme-provider.tsx"] + - name: tokens + paths: ["src/styles"] + - name: hooks + paths: ["src/hooks"] + - name: registry-tooling + paths: ["scripts", "registry.json", "components.json"] + - name: mcp-server + paths: ["src/mcp"] +orientation_files: + - README.md + - registry.json + - src/styles/main.css + - src/lib/theme-presets.ts + - src/lib/theme-defaults.ts +--- + +## Identity + +`ghost-ui` is a private workspace package in the `block/ghost` monorepo. It ships a reference design system — 49 shadcn-style UI primitives plus 48 AI-element components plus a theme layer — distributed via a shadcn `registry.json` rather than npm. It also ships an MCP server (`ghost-mcp` bin) that re-exposes the registry to AI assistants. + +## Topology + +The design system lives across three folders. Tokens are inline CSS custom properties declared in `src/styles/main.css` (Tailwind v4 `@theme` blocks plus `:root` and `.dark` variable layers). Theme presets and defaults are TypeScript modules under `src/lib/theme-*.ts`, surfaced through a `theme-provider.tsx` React context. UI primitives live under `src/components/ui/`; AI-specific elements (chat surfaces, agent-state indicators, artifacts) live under `src/components/ai-elements/`. The `registry.json` at the package root indexes 106 distributable items consumed by `shadcn build`. + +## Conventions + +Tailwind v4 with custom theming via `@theme` blocks, CSS custom properties for runtime token resolution, and a class-variance-authority pattern for variant-heavy primitives. Radix UI underlies most interactive primitives. Components are flat (no nested theme variants) and ship as both source files and a baked `registry.json` plus per-item snapshots under `public/r/` (113 JSON files account for the JSON-heavy histogram). Build splits into a Vite library bundle and a separate TypeScript-built MCP server. diff --git a/packages/ghost-core/src/embedding/colors.ts b/packages/ghost-core/src/embedding/colors.ts index c9931a0..983c498 100644 --- a/packages/ghost-core/src/embedding/colors.ts +++ b/packages/ghost-core/src/embedding/colors.ts @@ -265,6 +265,22 @@ export function colorToSemanticColor( return { role, value, oklch: oklch ?? undefined }; } +/** + * Resolve a color's oklch tuple, computing on-the-fly from `value` if the + * field is missing. Defensive backstop for palette comparisons — without + * this, hex-only colors land in the "unmatched" branch and contribute + * distance 1 even when both sides have the same hex. + * + * `loadExpression` (in ghost-expression) already backfills oklch on read; + * this fallback covers third-party producers that emit hex-only. + */ +export function resolveColorOklch( + c: SemanticColor, +): [number, number, number] | null { + if (c.oklch && c.oklch.length === 3) return c.oklch; + return parseColorToOklch(c.value); +} + export function classifySaturation( colors: SemanticColor[], ): "muted" | "vibrant" | "mixed" { diff --git a/packages/ghost-core/src/embedding/compare.ts b/packages/ghost-core/src/embedding/compare.ts index 0e9a160..705ee90 100644 --- a/packages/ghost-core/src/embedding/compare.ts +++ b/packages/ghost-core/src/embedding/compare.ts @@ -3,6 +3,7 @@ import type { Expression, ExpressionComparison, } from "../types.js"; +import { resolveColorOklch } from "./colors.js"; import { computeDriftVectors } from "./vector.js"; export interface CompareOptions { @@ -92,8 +93,17 @@ function comparePalette(a: Expression, b: Expression): DimensionDelta { for (const role of allDominantRoles) { const ca = aByRole.get(role); const cb = bByRole.get(role); - if (ca?.oklch && cb?.oklch) { - distances.push(oklchDistance(ca.oklch, cb.oklch)); + if (!ca || !cb) continue; + const oa = resolveColorOklch(ca); + const ob = resolveColorOklch(cb); + if (oa && ob) { + distances.push(oklchDistance(oa, ob)); + matchedA.add(role); + matchedB.add(role); + } else if (ca.value === cb.value) { + // Both hex-only on a non-parseable value — but the values match. + // Treat as identical rather than falling through to "unmatched". + distances.push(0); matchedA.add(role); matchedB.add(role); } @@ -106,8 +116,16 @@ function comparePalette(a: Expression, b: Expression): DimensionDelta { for (let i = 0; i < unmatchedCount; i++) { const ca = unmatchedA[i]; const cb = unmatchedB[i]; - if (ca?.oklch && cb?.oklch) { - distances.push(oklchDistance(ca.oklch, cb.oklch)); + if (!ca || !cb) { + distances.push(1); + continue; + } + const oa = resolveColorOklch(ca); + const ob = resolveColorOklch(cb); + if (oa && ob) { + distances.push(oklchDistance(oa, ob)); + } else if (ca.value === cb.value) { + distances.push(0); } else { distances.push(1); } @@ -133,8 +151,13 @@ function comparePalette(a: Expression, b: Expression): DimensionDelta { for (const role of sharedRoles) { const ca = a.palette.semantic.find((c) => c.role === role); const cb = b.palette.semantic.find((c) => c.role === role); - if (ca?.oklch && cb?.oklch) { - distances.push(oklchDistance(ca.oklch, cb.oklch)); + if (!ca || !cb) continue; + const oa = resolveColorOklch(ca); + const ob = resolveColorOklch(cb); + if (oa && ob) { + distances.push(oklchDistance(oa, ob)); + } else if (ca.value === cb.value) { + distances.push(0); } } diff --git a/packages/ghost-core/src/embedding/index.ts b/packages/ghost-core/src/embedding/index.ts index ce7f0a6..62bb309 100644 --- a/packages/ghost-core/src/embedding/index.ts +++ b/packages/ghost-core/src/embedding/index.ts @@ -4,6 +4,7 @@ export { colorToSemanticColor, contrastScore, parseColorToOklch, + resolveColorOklch, saturationScore, } from "./colors.js"; export type { CompareOptions } from "./compare.js"; diff --git a/packages/ghost-drift/test/embedding/compare-oklch-fallback.test.ts b/packages/ghost-drift/test/embedding/compare-oklch-fallback.test.ts new file mode 100644 index 0000000..fbb6543 --- /dev/null +++ b/packages/ghost-drift/test/embedding/compare-oklch-fallback.test.ts @@ -0,0 +1,99 @@ +import type { Expression } from "@ghost/core"; +import { compareExpressions } from "@ghost/core"; +import { describe, expect, it } from "vitest"; + +/** + * Regression coverage for the oklch fallback in `comparePalette`. Authored + * expressions can carry palette colors as bare hex (`{ role, value: "#hex" }`) + * with no `oklch` tuple. Without the fallback, such colors landed in the + * "unmatched" branch and contributed distance 1 — even when comparing the + * same expression to itself. + * + * Two layers of defense: + * - `loadExpression` backfills oklch on read (in ghost-expression). + * - `comparePalette` computes oklch on-the-fly when missing AND falls + * back to hex equality when even on-the-fly compute can't resolve. + */ + +function makeExpression( + paletteOverrides: Partial = {}, +): Expression { + return { + id: "test", + source: "llm", + timestamp: "2026-04-29T00:00:00Z", + palette: { + dominant: [{ role: "primary", value: "#1a1a1a" }], + neutrals: { steps: ["#ffffff", "#1a1a1a"], count: 2 }, + semantic: [ + { role: "danger", value: "#f94b4b" }, + { role: "success", value: "#91cb80" }, + ], + saturationProfile: "muted", + contrast: "high", + ...paletteOverrides, + }, + spacing: { scale: [4, 8, 16], regularity: 1, baseUnit: 4 }, + typography: { + families: ["Inter"], + sizeRamp: [12, 16, 24], + weightDistribution: { 400: 1 }, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [4, 8], + shadowComplexity: "deliberate-none", + borderUsage: "minimal", + }, + embedding: [], + }; +} + +describe("comparePalette — oklch missing fallback", () => { + it("self-comparison of hex-only palette returns distance 0", () => { + const expr = makeExpression(); + const result = compareExpressions(expr, expr); + expect(result.dimensions.palette.distance).toBe(0); + }); + + it("identical hex-only palettes (different objects) return distance 0", () => { + const a = makeExpression(); + const b = makeExpression(); + const result = compareExpressions(a, b); + expect(result.dimensions.palette.distance).toBe(0); + }); + + it("different hex-only palettes return non-zero distance", () => { + const a = makeExpression(); + const b = makeExpression({ + dominant: [{ role: "primary", value: "#0066cc" }], + }); + const result = compareExpressions(a, b); + expect(result.dimensions.palette.distance).toBeGreaterThan(0); + }); + + it("hex-only on one side, oklch-populated on the other still matches when value is identical", () => { + const a = makeExpression(); + const b = makeExpression({ + dominant: [ + { role: "primary", value: "#1a1a1a", oklch: [0.218, 0, 89.9] }, + ], + }); + const result = compareExpressions(a, b); + // The on-the-fly parse should resolve a's hex to roughly the same + // oklch — distance should be near 0, not 1. + expect(result.dimensions.palette.distance).toBeLessThan(0.01); + }); + + it("hex-only colors that are non-parseable but identical strings still match", () => { + const a = makeExpression({ + dominant: [{ role: "primary", value: "var(--upstream-brand)" }], + }); + const b = makeExpression({ + dominant: [{ role: "primary", value: "var(--upstream-brand)" }], + }); + const result = compareExpressions(a, b); + // CSS vars don't parse to oklch — fall through to hex equality. + expect(result.dimensions.palette.distance).toBe(0); + }); +}); diff --git a/packages/ghost-expression/src/core/index.ts b/packages/ghost-expression/src/core/index.ts index 802a7ca..41ce710 100644 --- a/packages/ghost-expression/src/core/index.ts +++ b/packages/ghost-expression/src/core/index.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; -import type { DesignDecision, Expression } from "@ghost/core"; -import { computeEmbedding } from "@ghost/core"; +import type { DesignDecision, Expression, SemanticColor } from "@ghost/core"; +import { computeEmbedding, parseColorToOklch } from "@ghost/core"; import { mergeExpression } from "./compose.js"; import { loadDecisionFragments, @@ -141,6 +141,13 @@ export async function loadExpression( } } + // Backfill `oklch` on palette colors that arrived hex-only. Deterministic + // (same hex → same oklch), so re-parsing the same expression always + // yields the same in-memory shape. Without this, `comparePalette` + // misreads hex-only colors as fully unmatched (distance 1) and even + // self-distance comes out non-zero. + backfillPaletteOklch(parsed.expression); + if (!options.noEmbeddingBackfill) { parsed.expression.embedding = await resolveEmbedding( parsed.expression, @@ -152,6 +159,22 @@ export async function loadExpression( return parsed; } +function backfillPaletteOklch(expression: Expression): void { + if (!expression.palette) return; + if (expression.palette.dominant) { + expression.palette.dominant = expression.palette.dominant.map(ensureOklch); + } + if (expression.palette.semantic) { + expression.palette.semantic = expression.palette.semantic.map(ensureOklch); + } +} + +function ensureOklch(color: SemanticColor): SemanticColor { + if (color.oklch && color.oklch.length === 3) return color; + const oklch = parseColorToOklch(color.value); + return oklch ? { ...color, oklch } : color; +} + /** * Resolve the embedding for an expression.md in order: * 1. Inline `embedding:` in frontmatter (trust as cache). diff --git a/packages/ghost-expression/src/skill-bundle/references/survey.md b/packages/ghost-expression/src/skill-bundle/references/survey.md index 44296b6..0193605 100644 --- a/packages/ghost-expression/src/skill-bundle/references/survey.md +++ b/packages/ghost-expression/src/skill-bundle/references/survey.md @@ -55,10 +55,22 @@ Open `map.md`. Note: - `design_system.entry_files` — start here. These declare the canonical token set. - `design_system.paths` — directories where the design system lives. - `feature_areas[].paths` — surfaces worth sampling for usage counts. -- `registry.path` if present — every component listed there belongs in `components[]`. Decide your extraction strategy from these signals — see Step 2. +## The exhaustiveness rule + +Recall is the failure mode and the only one. A bucket missing 90% of a section's rows is a failed scan, even if every row that *is* there is well-formed — the interpreter downstream cannot recover what you didn't record. + +For every section (`values[]`, `tokens[]`, `components[]`, `libraries[]`): + +1. **Identify the canonical signal in this repo.** Where does the source of truth for this kind of thing actually live? It will be different in every repo — a manifest, a registry, a barrel export, a CSS declaration block, a naming convention. Use the strongest signal the repo offers. +2. **Enumerate, don't sample.** If you can count entries from the canonical signal independently, your row count must match. 6 components when the canonical signal lists 100 is a lie. +3. **Cross-check by a second method when one exists** (e.g., file count by glob vs. enumerated entries vs. import graph). If the two counts disagree by more than ~10%, you're missing entries — investigate before recording. +4. **Honest absence beats partial truth.** If the section has no canonical signal in this repo, leave the array empty rather than recording a sample. Empty is honest; sampled is misleading. + +This applies regardless of dialect. The recipe doesn't tell you what the canonical signal is — it depends on the repo. Your job is to find it and enumerate it. + ### 2. Choose your extraction strategy per dialect **You write your own greps and regexes. There is no pre-built parser.** Adapt to what's actually in the repo: @@ -72,15 +84,22 @@ Decide your extraction strategy from these signals — see Step 2. If the repo mixes dialects (e.g. `swiftui` + `arcade`), run extraction per dialect and merge into one bucket. -### 3. Run extraction passes — be exhaustive +### 3. Run extraction passes — apply the exhaustiveness rule per section -Recall is the failure mode. Sloppy grep undercounts silently. Discipline: +The exhaustiveness rule (above) governs every section. The dialect-specific tactics here are how you implement it for `values[]` and `tokens[]`. For `components[]` and `libraries[]`, the rule is the same: find the canonical signal in *this* repo and enumerate it. -- **Multiple passes per kind.** Don't trust your first regex. After your color pass, run a second pass with a slightly different pattern and check the delta. +For values + tokens, sloppy grep undercounts silently. Discipline: + +- **Multiple passes per kind.** Don't trust your first regex. After your color pass, run a second pass with a slightly different pattern and check the delta. New rows = your first pass missed. - **Cross-check counts.** When you record a row with `occurrences: 47`, run `rg -c '\b#f97316\b' .` against the full repo and verify. If the count differs by more than ~10%, your regex is missing something — refine and re-pass. - **Frequency clustering.** After the first sweep, list candidate values by frequency: `rg -oN '#[0-9a-fA-F]{6}' -g '*.css' | sort | uniq -c | sort -rn`. The top values are almost always real palette entries. Long-tail values are often comments, hashes, or test fixtures — verify before recording. - **Spread check.** If a value appears in `files_count: 1`, it's likely incidental, not part of the design language. Note the count but don't promote with `role_hypothesis`. -- **Resolve aliases.** When you see `var(--brand-primary)`, follow the chain to its literal end. Record the **token row** with the chain, and the **value row** for the resolved literal. Both belong in the bucket. +- **Resolve aliases exhaustively.** Every named token declared in the canonical token source becomes a `tokens[]` row. Don't sample tokens — count the declarations and match the row count. When a token's value is `var(--other)`, follow the chain to the literal; record the **token row** with the chain, and the **value row** for the resolved literal. + +For components + libraries: + +- **Components are countable.** Count them by whatever signal the repo offers (manifest entries, barrel exports, naming pattern under a known directory). If you can count to 50 and your bucket has 6 rows, you've sampled — go back and enumerate. +- **Libraries are countable too.** Read the manifest's dependencies. Each external library that contributes design surface (icons, fonts, motion, charts, primitives) is a row. Don't roll up by family — `@radix-ui/react-dialog` and `@radix-ui/react-popover` are two different surfaces and two different rows. (One row with a count is fine if the manifest groups them; two rows is also fine. A "..." in the name is not.) ### 4. Sample feature areas for usage counts @@ -122,13 +141,16 @@ This recomputes every row's `id` from its content fields. Idempotent — running Fix everything `lint` flags as an error. Warnings (unknown `kind`, `id-mismatch` if you skipped Step 6, etc.) are signals — investigate them, but they don't block. -### 8. Saturation check +### 8. Coverage check (gate before declaring done) + +Before declaring the bucket done, walk each section and confirm exhaustiveness: -The bucket is saturated when **another extraction pass adds fewer than ~2 new rows**. Concretely: +- **`components[]`** — what's the canonical signal in this repo? Count it independently. If your row count is below that count, you've under-recorded. Either add the missing rows or, if the section truly isn't enumerable here, leave the array empty. +- **`tokens[]`** — count the named-token declarations in the canonical token source(s) named in `map.md`. Your row count should match. +- **`values[]`** — frequency-cluster again with a fresh grep. New top-N entries that aren't in your bucket = missed. +- **`libraries[]`** — read the manifest's dependencies. Every external library that contributes design surface (icons, fonts, motion, charts, primitives, command-palette, toast, animation) is a row. -- Run one more grep against a different pattern set or a corner you haven't covered. -- If you find <2 new values across all sections, you're done. -- If you find more, do another pass with the same discipline. +The bucket is **saturated** when another exhaustiveness pass adds fewer than ~2 new rows across all sections AND your component/token row counts match (or come very close to) an independent count of the canonical signal. If exhaustiveness disagrees with what you have, exhaustiveness wins — re-pass. Hard stop conditions: @@ -136,7 +158,7 @@ Hard stop conditions: - ~20 minutes wall, OR - ~200k tokens consumed. -If you hit a hard stop with the soft predicate not yet met, write a `# Coverage` note in your scratchpad explaining what you didn't cover, and surface it in the next stage's interpreter pass — it informs which decisions can be made confidently and which can't. +If you hit a hard stop with exhaustiveness *not* met, write a `# Coverage` note in your scratchpad listing exactly which sections fall short and by how much. Surface it to the interpreter — it tells them which decisions are well-grounded and which aren't. **Do not pad the bucket with sampled rows to look exhaustive.** ## Always @@ -145,12 +167,15 @@ If you hit a hard stop with the soft predicate not yet met, write a `# Coverage` - Resolve token alias chains end-to-end. The `alias_chain` array captures the path. - Validate with `ghost-expression lint bucket.json` before declaring success. - After authoring rows with empty IDs, run `bucket fix-ids` exactly once. +- **Cross-check your component, token, and library counts against an independent count of the canonical signal in this repo.** Disagreement = re-pass. ## Never - **Never write prose.** No `description`, no rationale fields. Prose is the interpreter's job. - **Never invent values.** If you didn't observe it in source, it doesn't go in the bucket. +- **Never sample.** Either enumerate exhaustively or leave the section empty. A bucket with 6 components when the canonical signal has 100 is worse than no `components[]` at all. - **Never assign roles confidently.** `role_hypothesis` is a *hint*, optional, and tentative. The interpreter has the final word. If you're not sure, leave it empty. -- **Never undercount silently.** If your regex coverage is weak (mobile dialects, custom DSLs), surface it in a `# Coverage` scratchpad note and tell the interpreter. +- **Never undercount silently.** If your coverage is weak (mobile dialects, custom DSLs, no canonical signal in this repo), surface it in a `# Coverage` scratchpad note and tell the interpreter. - **Never compute IDs by hand.** Use `bucket fix-ids`. +- **Never use placeholder/glob names.** A library row with `name: "@radix-ui/react-*"` is sampling-disguised-as-a-row. Enumerate or roll up explicitly with a count. - **Never edit a bucket after the interpreter has used it.** If you find a missed value later, re-run survey end-to-end. The bucket is the frozen ground truth between scan and interpretation. diff --git a/packages/ghost-expression/test/expression/load-oklch-backfill.test.ts b/packages/ghost-expression/test/expression/load-oklch-backfill.test.ts new file mode 100644 index 0000000..b08d7b3 --- /dev/null +++ b/packages/ghost-expression/test/expression/load-oklch-backfill.test.ts @@ -0,0 +1,116 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadExpression } from "../../src/core/index.js"; + +const HEX_ONLY_EXPRESSION = `--- +id: hex-only +source: llm +timestamp: 2026-04-29T00:00:00Z +observation: + personality: [restrained] +palette: + dominant: + - { role: ink, value: "#1a1a1a" } + neutrals: { steps: ["#ffffff", "#1a1a1a"], count: 2 } + semantic: + - { role: danger, value: "#f94b4b" } + saturationProfile: muted + contrast: high +spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 } +typography: + families: ["Inter"] + sizeRamp: [12, 16, 24] + weightDistribution: { 400: 1 } + lineHeightPattern: normal +surfaces: + borderRadii: [4, 8] + shadowComplexity: deliberate-none + borderUsage: minimal +--- + +# Character + +Test. + +# Signature + +- Test. + +# Decisions +`; + +describe("loadExpression — backfills palette oklch", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-load-oklch-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("populates oklch on dominant colors when missing from frontmatter", async () => { + const path = join(dir, "expression.md"); + await writeFile(path, HEX_ONLY_EXPRESSION); + + const { expression } = await loadExpression(path, { + noEmbeddingBackfill: true, + }); + + const dominant = expression.palette.dominant[0]; + expect(dominant.value).toBe("#1a1a1a"); + expect(dominant.oklch).toBeDefined(); + expect(dominant.oklch?.length).toBe(3); + }); + + it("populates oklch on semantic colors when missing", async () => { + const path = join(dir, "expression.md"); + await writeFile(path, HEX_ONLY_EXPRESSION); + + const { expression } = await loadExpression(path, { + noEmbeddingBackfill: true, + }); + + const danger = expression.palette.semantic.find((c) => c.role === "danger"); + expect(danger?.value).toBe("#f94b4b"); + expect(danger?.oklch).toBeDefined(); + expect(danger?.oklch?.length).toBe(3); + }); + + it("is deterministic — two loads of the same file produce identical oklch", async () => { + const path = join(dir, "expression.md"); + await writeFile(path, HEX_ONLY_EXPRESSION); + + const a = await loadExpression(path, { noEmbeddingBackfill: true }); + const b = await loadExpression(path, { noEmbeddingBackfill: true }); + + expect(a.expression.palette.dominant[0].oklch).toEqual( + b.expression.palette.dominant[0].oklch, + ); + }); + + it("preserves existing oklch when present (does not recompute)", async () => { + const withExisting = HEX_ONLY_EXPRESSION.replace( + `- { role: ink, value: "#1a1a1a" }`, + `- { role: ink, value: "#1a1a1a", oklch: [0.5, 0.1, 200] }`, + ); + const path = join(dir, "expression.md"); + await writeFile(path, withExisting); + + const { expression } = await loadExpression(path, { + noEmbeddingBackfill: true, + }); + + // The frontmatter-supplied oklch wins, even if it's not the "correct" + // value for the hex. This is the expected contract: backfill only + // when missing. + expect(expression.palette.dominant[0].oklch).toEqual([0.5, 0.1, 200]); + }); +}); From 008e7ea4680c2848e86f50caced5d35cf73a102d Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 29 Apr 2026 16:32:47 -0400 Subject: [PATCH 03/14] dogfood(attempt-2): exhaustive ghost-ui scan post-recipe-tightening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recall delta vs attempt 1: - values: 22 → 190 (10x) - tokens: 11 → 238 (22x, ~99% of 240 declared in main.css) - components: 6 → 97 (16x, 100% of registry:ui items) - libraries: 6 → 42 (7x, every design-surface dep enumerated) Decision count: 7 → 11. New decisions added — chart-strategy, surface-hierarchy, font-sourcing, density, interactive-patterns — and renames adjusted toward pattern-naming (token-architecture → theming-architecture, shadow-hierarchy → elevation, with the count corrected from 4 tiers to 7). Self-distance: 0.0% (was 17.5%). Confirms the oklch backfill fix. Agent followed the recipe by writing build-bucket.mjs (pinned alongside artifacts) — walks main.css for tokens, registry.json for components, package.json for libraries. Reproducible. Co-Authored-By: Claude Opus 4.7 (1M context) --- dogfood/ghost-ui/attempt-2/NOTES.md | 66 + dogfood/ghost-ui/attempt-2/bucket.json | 8293 +++++++++++++++++++ dogfood/ghost-ui/attempt-2/build-bucket.mjs | 316 + dogfood/ghost-ui/attempt-2/expression.md | 190 + dogfood/ghost-ui/attempt-2/map.md | 103 + 5 files changed, 8968 insertions(+) create mode 100644 dogfood/ghost-ui/attempt-2/NOTES.md create mode 100644 dogfood/ghost-ui/attempt-2/bucket.json create mode 100644 dogfood/ghost-ui/attempt-2/build-bucket.mjs create mode 100644 dogfood/ghost-ui/attempt-2/expression.md create mode 100644 dogfood/ghost-ui/attempt-2/map.md diff --git a/dogfood/ghost-ui/attempt-2/NOTES.md b/dogfood/ghost-ui/attempt-2/NOTES.md new file mode 100644 index 0000000..87cbb12 --- /dev/null +++ b/dogfood/ghost-ui/attempt-2/NOTES.md @@ -0,0 +1,66 @@ +# Attempt 2 — ghost-ui scan, 2026-04-29 (post-tighten) + +Second dogfood after the survey-recipe tightening (commit `916e728`) and the oklch backfill bug fix. Same target as attempt 1 (`packages/ghost-ui`). + +## Recall delta vs attempt 1 + +| Section | Attempt 1 | Attempt 2 | Reality | +|---|---|---|---| +| `values[]` | 22 | 190 | ~190 (136 distinct hex + 41 distinct spacing + radii + breakpoints + motion + typography) ✓ | +| `tokens[]` | 11 | 238 | 240 unique CSS custom properties in `main.css` — **99% recall** ✓ | +| `components[]` | 6 | 97 | 97 `registry:ui` items in `registry.json` — **100% recall** ✓ | +| `libraries[]` | 6 | 42 | 42 design-surface deps in `package.json` (27 radix primitives + 15 others) — **100% recall** ✓ | + +The bucket file is 242 KB (vs 21 KB in attempt 1). Exhaustiveness is expensive in disk; honest is what matters. + +## Decision-level coverage + +| Attempt 1 (7 decisions) | Attempt 2 (11 decisions) | +|---|---| +| color-strategy | color-strategy | +| — | **chart-strategy** ← new | +| — | **surface-hierarchy** ← new | +| shape-language | shape-language | +| token-architecture | **theming-architecture** ← renamed (pattern-named) | +| typography-voice | typography-voice | +| — | **font-sourcing** ← new (load-bearing absence) | +| — | **density** ← new | +| — | **interactive-patterns** ← new (focus-ring discipline) | +| theming → folded into theming-architecture | — | +| shadow-hierarchy: 4-tier (WRONG) | **elevation: 7 named tiers** (CORRECT, renamed) | +| motion | motion | + +Coverage went from 7/11 (64%) → 11/11 (100%) of the load-bearing decisions identified in the audit of attempt 1. + +## What the recipe tightening produced + +1. **The agent wrote a script.** Following "use shell tools, identify the canonical signal" the agent generated `build-bucket.mjs` (pinned alongside the artifacts) that: + - Walks `main.css` line-by-line, scope-aware (`@theme`, `:root`, `.dark`), captures every `--name: value` declaration with by_theme cascade. + - Reads `registry.json` and emits one component row per `registry:ui` item. + - Categorizes every `package.json` dependency by design surface (icons, primitives, motion, charts, forms, dates, command, toast, drawer, etc.). + - Frequency-clusters values via `rg -oNI` with proper filename suppression. + +2. **No leading repo-specific guidance was needed.** The recipe just said "find the canonical signal." For ghost-ui that's `registry.json`, `package.json`, and `main.css`. For a different repo it would be different files. Recipe stays agnostic. + +3. **Pattern-naming worked.** `surface-hierarchy`, `theming-architecture`, `font-sourcing`, `density`, `interactive-patterns`, `elevation` all read as patterns rather than restated tokens. That's the prose discipline the existing pre-bucket expression had. + +## Bug fixes verified + +- Self-distance is now 0.0% (was 17.5% in attempt 1) — confirms the oklch backfill fix. +- Lint passes with 0 errors, 0 warnings, 0 info — no unused-palette flags, every palette entry cited in evidence. + +## What's still imperfect + +- **Spacing scale messiness**: attempt 2 records 22 distinct px values in the spacing scale. Real ghost-ui has a coherent rem-based component-height system (2rem, 2.75rem, 3rem, 3.25rem) layered on top of an ad-hoc px scatter (1, 2, 3, 4, 6, 8, 10, 12...). The bucket captured both honestly; the expression flattened them into a single `spacing.scale` array. A future iteration might split rem-component-height from px-utility values explicitly in the spec. +- **No dark-mode-specific rows for tokens that diverge between themes**: each token has a `by_theme` field when light/dark differ, but value-level dark-mode rows aren't separate rows. That's by design but worth noting. +- **The agent's heuristic categorizers** (e.g. "999px → radius, 1440px → breakpoint, others → spacing") are still heuristics. Misclassifications are possible; cross-checking against `map.md` topology would catch them. + +## Follow-up bug found + +`ghost-expression diff` reports `dominant primary: #1a1a1a` as a "+" addition when comparing attempt-2's expression to attempt-1's, even though both have the same dominant color (different role name: `ink` vs `primary`). Worth investigating — diff should match dominant entries by value when role names differ, OR surface "dominant role rename" as a distinct category. Filed as a follow-up; not blocking. + +## Lessons for next iteration + +1. **The script-driven extraction pattern is right.** The recipe should explicitly mention this — "for repos where canonical signals are programmatically enumerable (registry, manifest, named CSS declarations), generate a small extraction script and run it. Don't hand-author hundreds of rows." +2. **Spacing kind heuristics should fall through to `map.md` signals when ambiguous.** A 999px scalar is a radius in this repo because `--radius-pill: 999px`; a 1440px scalar is a breakpoint because `--breakpoint-desktop: 1440px`. The agent caught both; the recipe could codify the heuristic. +3. **The script is pinned alongside artifacts.** Anyone re-running the same target gets the same bucket. Reproducibility is a side-benefit of script-driven extraction. diff --git a/dogfood/ghost-ui/attempt-2/bucket.json b/dogfood/ghost-ui/attempt-2/bucket.json new file mode 100644 index 0000000..c791b82 --- /dev/null +++ b/dogfood/ghost-ui/attempt-2/bucket.json @@ -0,0 +1,8293 @@ +{ + "schema": "ghost.bucket/v1", + "sources": [ + { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + } + ], + "values": [ + { + "id": "19c6f0a7a7c5b4ff", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ffffff", + "raw": "#ffffff", + "spec": { + "space": "srgb", + "hex": "#ffffff" + }, + "occurrences": 24, + "files_count": 3 + }, + { + "id": "eb2504434e5beb8f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#000000", + "raw": "#000000", + "spec": { + "space": "srgb", + "hex": "#000000" + }, + "occurrences": 15, + "files_count": 3 + }, + { + "id": "c9cea4ef32196600", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c5a24d", + "raw": "#C5A24D", + "spec": { + "space": "srgb", + "hex": "#c5a24d" + }, + "occurrences": 9, + "files_count": 1 + }, + { + "id": "780ea03543f7b21b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#91cb80", + "raw": "#91cb80", + "spec": { + "space": "srgb", + "hex": "#91cb80" + }, + "occurrences": 9, + "files_count": 2 + }, + { + "id": "9e6949dfbfe6cd6f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1a1a1a", + "raw": "#1a1a1a", + "spec": { + "space": "srgb", + "hex": "#1a1a1a" + }, + "occurrences": 9, + "files_count": 3 + }, + { + "id": "92257d0742fbd5d1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1e1528", + "raw": "#1E1528", + "spec": { + "space": "srgb", + "hex": "#1e1528" + }, + "occurrences": 8, + "files_count": 1 + }, + { + "id": "ab0da8f6578c1132", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#333333", + "raw": "#333333", + "spec": { + "space": "srgb", + "hex": "#333333" + }, + "occurrences": 7, + "files_count": 3 + }, + { + "id": "227c3853f470d218", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ff3a00", + "raw": "#FF3A00", + "spec": { + "space": "srgb", + "hex": "#ff3a00" + }, + "occurrences": 6, + "files_count": 1 + }, + { + "id": "0cf9c817fd862758", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d4853a", + "raw": "#D4853A", + "spec": { + "space": "srgb", + "hex": "#d4853a" + }, + "occurrences": 6, + "files_count": 1 + }, + { + "id": "88ad01f113746dcb", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ff5722", + "raw": "#FF5722", + "spec": { + "space": "srgb", + "hex": "#ff5722" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "eb6d87708ade9f6b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#fbf5ec", + "raw": "#FBF5EC", + "spec": { + "space": "srgb", + "hex": "#fbf5ec" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "f6b6bdc6b200cf85", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#fbcd44", + "raw": "#fbcd44", + "spec": { + "space": "srgb", + "hex": "#fbcd44" + }, + "occurrences": 5, + "files_count": 2 + }, + { + "id": "8edffee467d4bf04", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#fafafa", + "raw": "#FAFAFA", + "spec": { + "space": "srgb", + "hex": "#fafafa" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "b035bc04c23683e0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f94b4b", + "raw": "#f94b4b", + "spec": { + "space": "srgb", + "hex": "#f94b4b" + }, + "occurrences": 5, + "files_count": 2 + }, + { + "id": "463fafef98c9af2e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f8f6f9", + "raw": "#F8F6F9", + "spec": { + "space": "srgb", + "hex": "#f8f6f9" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "cae6d2e4e2bc5f85", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f4f9fb", + "raw": "#F4F9FB", + "spec": { + "space": "srgb", + "hex": "#f4f9fb" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "847db1c655cb330f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#5c98f9", + "raw": "#5c98f9", + "spec": { + "space": "srgb", + "hex": "#5c98f9" + }, + "occurrences": 5, + "files_count": 2 + }, + { + "id": "3b3a5209515d379e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3d2b1f", + "raw": "#3D2B1F", + "spec": { + "space": "srgb", + "hex": "#3d2b1f" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "ab13b60f418d759d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3ab4d8", + "raw": "#3AB4D8", + "spec": { + "space": "srgb", + "hex": "#3ab4d8" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "f17312b005f7c6b0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3a3552", + "raw": "#3A3552", + "spec": { + "space": "srgb", + "hex": "#3a3552" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "0cea77740231c4c1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#0f3442", + "raw": "#0F3442", + "spec": { + "space": "srgb", + "hex": "#0f3442" + }, + "occurrences": 5, + "files_count": 1 + }, + { + "id": "e7508e1cb473eda9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f6b44a", + "raw": "#f6b44a", + "spec": { + "space": "srgb", + "hex": "#f6b44a" + }, + "occurrences": 4, + "files_count": 2 + }, + { + "id": "54ce6bdaa29e6810", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d76a6a", + "raw": "#d76a6a", + "spec": { + "space": "srgb", + "hex": "#d76a6a" + }, + "occurrences": 4, + "files_count": 2 + }, + { + "id": "dda8f0a3259b940b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d185e0", + "raw": "#d185e0", + "spec": { + "space": "srgb", + "hex": "#d185e0" + }, + "occurrences": 4, + "files_count": 2 + }, + { + "id": "4f4b25b2ecab8943", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a898e0", + "raw": "#A898E0", + "spec": { + "space": "srgb", + "hex": "#a898e0" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "3f674ce0154adc30", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#9c5930", + "raw": "#9C5930", + "spec": { + "space": "srgb", + "hex": "#9c5930" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "bfae2bd55fff6a01", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#999999", + "raw": "#999999", + "spec": { + "space": "srgb", + "hex": "#999999" + }, + "occurrences": 4, + "files_count": 2 + }, + { + "id": "232959ee28f03557", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#8b7ec8", + "raw": "#8B7EC8", + "spec": { + "space": "srgb", + "hex": "#8b7ec8" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "86a115b50556e4a6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7585ff", + "raw": "#7585ff", + "spec": { + "space": "srgb", + "hex": "#7585ff" + }, + "occurrences": 4, + "files_count": 2 + }, + { + "id": "87ca69d152c82d28", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1b6b8a", + "raw": "#1B6B8A", + "spec": { + "space": "srgb", + "hex": "#1b6b8a" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "a8e61d00c667bcd5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ffd966", + "raw": "#ffd966", + "spec": { + "space": "srgb", + "hex": "#ffd966" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "35d85861e84c6542", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ff6b6b", + "raw": "#ff6b6b", + "spec": { + "space": "srgb", + "hex": "#ff6b6b" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "3902eab2dcad405d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f5f5f5", + "raw": "#f5f5f5", + "spec": { + "space": "srgb", + "hex": "#f5f5f5" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "f2d542a0dcb4c7d5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f0f0f0", + "raw": "#F0F0F0", + "spec": { + "space": "srgb", + "hex": "#f0f0f0" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "0568807befafcbbe", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f0f0f0", + "raw": "#f0f0f0", + "spec": { + "space": "srgb", + "hex": "#f0f0f0" + }, + "occurrences": 3, + "files_count": 3 + }, + { + "id": "059b769b5259aee3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f0e4d4", + "raw": "#F0E4D4", + "spec": { + "space": "srgb", + "hex": "#f0e4d4" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "8b19c166ff830ed4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ede8f2", + "raw": "#EDE8F2", + "spec": { + "space": "srgb", + "hex": "#ede8f2" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "05e04926a9ada328", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e8e8e8", + "raw": "#e8e8e8", + "spec": { + "space": "srgb", + "hex": "#e8e8e8" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "046151ed728caee4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e4f0f5", + "raw": "#E4F0F5", + "spec": { + "space": "srgb", + "hex": "#e4f0f5" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "b6ffdaf562b5a3e8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#cccccc", + "raw": "#cccccc", + "spec": { + "space": "srgb", + "hex": "#cccccc" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "7265b8a354ce811d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a3d795", + "raw": "#a3d795", + "spec": { + "space": "srgb", + "hex": "#a3d795" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "e6515f20c05a1858", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a0d8a0", + "raw": "#A0D8A0", + "spec": { + "space": "srgb", + "hex": "#a0d8a0" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "fa75055b08e6244f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a0c0e8", + "raw": "#A0C0E8", + "spec": { + "space": "srgb", + "hex": "#a0c0e8" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "855193a8720b2540", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#8b7355", + "raw": "#8B7355", + "spec": { + "space": "srgb", + "hex": "#8b7355" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "67ce7e085c2ddf59", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#8a82a0", + "raw": "#8A82A0", + "spec": { + "space": "srgb", + "hex": "#8a82a0" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "5911955f073eba51", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7cacff", + "raw": "#7cacff", + "spec": { + "space": "srgb", + "hex": "#7cacff" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "6675f6e6e3870810", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7a6b8a", + "raw": "#7A6B8A", + "spec": { + "space": "srgb", + "hex": "#7a6b8a" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "6ae193e06bc97c41", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#666666", + "raw": "#666666", + "spec": { + "space": "srgb", + "hex": "#666666" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "6df0ca145bec8a86", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#5a8a9c", + "raw": "#5A8A9C", + "spec": { + "space": "srgb", + "hex": "#5a8a9c" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "65330b22927d4453", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3a2c20", + "raw": "#3A2C20", + "spec": { + "space": "srgb", + "hex": "#3a2c20" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "01d7a7ef9c8edd40", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#2a2640", + "raw": "#2A2640", + "spec": { + "space": "srgb", + "hex": "#2a2640" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "9addca8bce9fe4a9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#2a1e3a", + "raw": "#2A1E3A", + "spec": { + "space": "srgb", + "hex": "#2a1e3a" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "5db75079f6c7895f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#232323", + "raw": "#232323", + "spec": { + "space": "srgb", + "hex": "#232323" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "f50f989ca8e8027e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1a3a4a", + "raw": "#1A3A4A", + "spec": { + "space": "srgb", + "hex": "#1a3a4a" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "6a207a25619dbb7f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ffe600", + "raw": "#FFE600", + "spec": { + "space": "srgb", + "hex": "#ffe600" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "0d8323e8d749a2f9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ff00aa", + "raw": "#FF00AA", + "spec": { + "space": "srgb", + "hex": "#ff00aa" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "f1d46ef21d821bf9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f0edf8", + "raw": "#F0EDF8", + "spec": { + "space": "srgb", + "hex": "#f0edf8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "925c19cf5e8b79c1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e8d0a8", + "raw": "#E8D0A8", + "spec": { + "space": "srgb", + "hex": "#e8d0a8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "51108f937d5e272f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e8d0a0", + "raw": "#E8D0A0", + "spec": { + "space": "srgb", + "hex": "#e8d0a0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "35d0e142eb6310ca", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e8b0b0", + "raw": "#E8B0B0", + "spec": { + "space": "srgb", + "hex": "#e8b0b0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "5fa1d2b9d6e07efe", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e8a0a0", + "raw": "#E8A0A0", + "spec": { + "space": "srgb", + "hex": "#e8a0a0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "7414ffac8619cfbd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e5e5e5", + "raw": "#e5e5e5", + "spec": { + "space": "srgb", + "hex": "#e5e5e5" + }, + "occurrences": 2, + "files_count": 2 + }, + { + "id": "49543ffe08c5fb7a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e2d4c0", + "raw": "#E2D4C0", + "spec": { + "space": "srgb", + "hex": "#e2d4c0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "6a0a17fee12b600c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e0daf0", + "raw": "#E0DAF0", + "spec": { + "space": "srgb", + "hex": "#e0daf0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "60025e29a38deb1b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e07a5f", + "raw": "#E07A5F", + "spec": { + "space": "srgb", + "hex": "#e07a5f" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "ad3d06d55b2e9c2f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ddd4e6", + "raw": "#DDD4E6", + "spec": { + "space": "srgb", + "hex": "#ddd4e6" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "ba7b3d12beb7e4a8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d8c080", + "raw": "#D8C080", + "spec": { + "space": "srgb", + "hex": "#d8c080" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "819ce1cb7d5d81b0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d08080", + "raw": "#D08080", + "spec": { + "space": "srgb", + "hex": "#d08080" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "53267606f845454a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c8dee8", + "raw": "#C8DEE8", + "spec": { + "space": "srgb", + "hex": "#c8dee8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "ea32584ff2696f63", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c8bee0", + "raw": "#C8BEE0", + "spec": { + "space": "srgb", + "hex": "#c8bee0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "3ba3ac5218678f50", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c8a8e0", + "raw": "#C8A8E0", + "spec": { + "space": "srgb", + "hex": "#c8a8e0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "a5a54bc4897064df", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c4a882", + "raw": "#C4A882", + "spec": { + "space": "srgb", + "hex": "#c4a882" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "ce97cde5b27f91bf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c46b5a", + "raw": "#C46B5A", + "spec": { + "space": "srgb", + "hex": "#c46b5a" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "2b3753ae2f1596b8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#b8a8c8", + "raw": "#B8A8C8", + "spec": { + "space": "srgb", + "hex": "#b8a8c8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "e87ec23d893119d8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#b0d8b0", + "raw": "#B0D8B0", + "spec": { + "space": "srgb", + "hex": "#b0d8b0" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "556b291123a7ad41", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#aaaaaa", + "raw": "#AAAAAA", + "spec": { + "space": "srgb", + "hex": "#aaaaaa" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "80b81f8563e27180", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a8c8e8", + "raw": "#A8C8E8", + "spec": { + "space": "srgb", + "hex": "#a8c8e8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "241ecacc33e63903", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a8c256", + "raw": "#A8C256", + "spec": { + "space": "srgb", + "hex": "#a8c256" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "6e9a5fe1b9295590", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a8856b", + "raw": "#A8856B", + "spec": { + "space": "srgb", + "hex": "#a8856b" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "c4e5bfb7641524bd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#8bb8ca", + "raw": "#8BB8CA", + "spec": { + "space": "srgb", + "hex": "#8bb8ca" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "19b6f0b70d7c9b7c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#8baa72", + "raw": "#8BAA72", + "spec": { + "space": "srgb", + "hex": "#8baa72" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "2188e08f1e3d01d0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#80c080", + "raw": "#80C080", + "spec": { + "space": "srgb", + "hex": "#80c080" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "7e7b935653f266dd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#80a8d8", + "raw": "#80A8D8", + "spec": { + "space": "srgb", + "hex": "#80a8d8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "b2ccf30094439216", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7bc4b8", + "raw": "#7BC4B8", + "spec": { + "space": "srgb", + "hex": "#7bc4b8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "63111f684b2a6d2f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7b9ea8", + "raw": "#7B9EA8", + "spec": { + "space": "srgb", + "hex": "#7b9ea8" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "e6c812fb93eb0d8c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#2a1f18", + "raw": "#2A1F18", + "spec": { + "space": "srgb", + "hex": "#2a1f18" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "2c97663a7f4c9018", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1e1a2a", + "raw": "#1E1A2A", + "spec": { + "space": "srgb", + "hex": "#1e1a2a" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "a4239710e3533bbd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1c1410", + "raw": "#1C1410", + "spec": { + "space": "srgb", + "hex": "#1c1410" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "8894e10eae2c9e91", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#1a1224", + "raw": "#1A1224", + "spec": { + "space": "srgb", + "hex": "#1a1224" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "9b548e5a304e60ae", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#141414", + "raw": "#141414", + "spec": { + "space": "srgb", + "hex": "#141414" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "89979c3fd52e671b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#14121c", + "raw": "#14121C", + "spec": { + "space": "srgb", + "hex": "#14121c" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "999ef1660677cce0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#112a36", + "raw": "#112A36", + "spec": { + "space": "srgb", + "hex": "#112a36" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "38c643c29595d778", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#0e0a14", + "raw": "#0E0A14", + "spec": { + "space": "srgb", + "hex": "#0e0a14" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "197147f82859ac50", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#0a1e28", + "raw": "#0A1E28", + "spec": { + "space": "srgb", + "hex": "#0a1e28" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "2e2cb806cc4984e1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#00ff66", + "raw": "#00FF66", + "spec": { + "space": "srgb", + "hex": "#00ff66" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "54ac874bd0d0d787", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#00e5ff", + "raw": "#00E5FF", + "spec": { + "space": "srgb", + "hex": "#00e5ff" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "551f4550a36511ca", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f5f2fa", + "raw": "#F5F2FA", + "spec": { + "space": "srgb", + "hex": "#f5f2fa" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "c4f97253730d3825", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f5ecdf", + "raw": "#F5ECDF", + "spec": { + "space": "srgb", + "hex": "#f5ecdf" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "b3ffa060b9c93e9f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#f0ecf4", + "raw": "#F0ECF4", + "spec": { + "space": "srgb", + "hex": "#f0ecf4" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "84e1d552d06565a2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#eaf3f8", + "raw": "#EAF3F8", + "spec": { + "space": "srgb", + "hex": "#eaf3f8" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "9c7bba2b22963597", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e8e4f4", + "raw": "#E8E4F4", + "spec": { + "space": "srgb", + "hex": "#e8e4f4" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "aded0fd258969b40", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e0c880", + "raw": "#E0C880", + "spec": { + "space": "srgb", + "hex": "#e0c880" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "572bb5470b2fe221", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e0a0a0", + "raw": "#E0A0A0", + "spec": { + "space": "srgb", + "hex": "#e0a0a0" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "1a035341d6f5bbe8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#e08090", + "raw": "#E08090", + "spec": { + "space": "srgb", + "hex": "#e08090" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "6a9824cde5818e6b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d76a7a", + "raw": "#D76A7A", + "spec": { + "space": "srgb", + "hex": "#d76a7a" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "e785c7e322562e82", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d4c2aa", + "raw": "#D4C2AA", + "spec": { + "space": "srgb", + "hex": "#d4c2aa" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "16533ebdfa4ea177", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#d2cae4", + "raw": "#D2CAE4", + "spec": { + "space": "srgb", + "hex": "#d2cae4" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "321697b0028fa79c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#cccccc", + "raw": "#CCCCCC", + "spec": { + "space": "srgb", + "hex": "#cccccc" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "02731578fe6457a7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#ccc0d8", + "raw": "#CCC0D8", + "spec": { + "space": "srgb", + "hex": "#ccc0d8" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "05bbd9850580c178", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c0a060", + "raw": "#C0A060", + "spec": { + "space": "srgb", + "hex": "#c0a060" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "6cdcf36f88536052", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#c07070", + "raw": "#C07070", + "spec": { + "space": "srgb", + "hex": "#c07070" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "f4d6726b8205140b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#b0d0de", + "raw": "#B0D0DE", + "spec": { + "space": "srgb", + "hex": "#b0d0de" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "d5f58d0c2067ee47", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#a88de0", + "raw": "#A88DE0", + "spec": { + "space": "srgb", + "hex": "#a88de0" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "107e45ed8d8224b1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#8bd09a", + "raw": "#8BD09A", + "spec": { + "space": "srgb", + "hex": "#8bd09a" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "be6002f69635c0f5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#8b6cc1", + "raw": "#8B6CC1", + "spec": { + "space": "srgb", + "hex": "#8b6cc1" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "2181ccf72319678c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7cb88a", + "raw": "#7CB88A", + "spec": { + "space": "srgb", + "hex": "#7cb88a" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "eedfd511fe5df7b4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7b92e8", + "raw": "#7B92E8", + "spec": { + "space": "srgb", + "hex": "#7b92e8" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "553969977351d418", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7aaee5", + "raw": "#7AAEE5", + "spec": { + "space": "srgb", + "hex": "#7aaee5" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "3755ec248b128cf6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#70a870", + "raw": "#70A870", + "spec": { + "space": "srgb", + "hex": "#70a870" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "67775c09416227f6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#7090c0", + "raw": "#7090C0", + "spec": { + "space": "srgb", + "hex": "#7090c0" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "fca56628cd29e9ad", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#6b5940", + "raw": "#6B5940", + "spec": { + "space": "srgb", + "hex": "#6b5940" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "63b83ac5febec423", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#6a6280", + "raw": "#6A6280", + "spec": { + "space": "srgb", + "hex": "#6a6280" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "585282f198381c47", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#5e8ec5", + "raw": "#5E8EC5", + "spec": { + "space": "srgb", + "hex": "#5e8ec5" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "15338af0b39cd617", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#5b78d8", + "raw": "#5B78D8", + "spec": { + "space": "srgb", + "hex": "#5b78d8" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "6f593af2b46be5c7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#5a4b6a", + "raw": "#5A4B6A", + "spec": { + "space": "srgb", + "hex": "#5a4b6a" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "6911e439b8d87426", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#5a4530", + "raw": "#5A4530", + "spec": { + "space": "srgb", + "hex": "#5a4530" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "2e838cdd9695eb8f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#555555", + "raw": "#555555", + "spec": { + "space": "srgb", + "hex": "#555555" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "16283661e7d78622", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#4a4468", + "raw": "#4A4468", + "spec": { + "space": "srgb", + "hex": "#4a4468" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "80e91911eeee4bc6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#4a3a60", + "raw": "#4A3A60", + "spec": { + "space": "srgb", + "hex": "#4a3a60" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "913fe0e9ce6e566a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#4a382a", + "raw": "#4A382A", + "spec": { + "space": "srgb", + "hex": "#4a382a" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "7abc5834ffe55841", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#444444", + "raw": "#444444", + "spec": { + "space": "srgb", + "hex": "#444444" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "3b279b5f71f9a19f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3a6a7c", + "raw": "#3A6A7C", + "spec": { + "space": "srgb", + "hex": "#3a6a7c" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "73d67c6dd5257942", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3a6070", + "raw": "#3A6070", + "spec": { + "space": "srgb", + "hex": "#3a6070" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "104b94e69ea070cd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3a3452", + "raw": "#3A3452", + "spec": { + "space": "srgb", + "hex": "#3a3452" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "9375bb4401b8da8b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#3a2a50", + "raw": "#3A2A50", + "spec": { + "space": "srgb", + "hex": "#3a2a50" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "07d56eef40fd7967", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#2e9ab8", + "raw": "#2E9AB8", + "spec": { + "space": "srgb", + "hex": "#2e9ab8" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "5340ffc0902b8603", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#2a5060", + "raw": "#2A5060", + "spec": { + "space": "srgb", + "hex": "#2a5060" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "a735c6cadff938d8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#0a0a0a", + "raw": "#0A0A0A", + "spec": { + "space": "srgb", + "hex": "#0a0a0a" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "6ee6a2239705aa9a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "color", + "value": "#0a0a0a", + "raw": "#0a0a0a", + "spec": { + "space": "srgb", + "hex": "#0a0a0a" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "f88ec6215e9e2f44", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "8", + "raw": "8px", + "spec": { + "scalar": 8, + "unit": "px" + }, + "occurrences": 11, + "files_count": 1 + }, + { + "id": "48bcc0086b6d331a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "2", + "raw": "2px", + "spec": { + "scalar": 2, + "unit": "px" + }, + "occurrences": 9, + "files_count": 1 + }, + { + "id": "17609981db70ed1c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "20", + "raw": "20px", + "spec": { + "scalar": 20, + "unit": "px" + }, + "occurrences": 6, + "files_count": 1 + }, + { + "id": "26cb1d9a65ee3afc", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "radius", + "value": "999", + "raw": "999px", + "spec": { + "scalar": 999, + "unit": "px" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "0467bb0de326d5f0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "4", + "raw": "4px", + "spec": { + "scalar": 4, + "unit": "px" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "3fe85b6317b95865", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "3", + "raw": "3px", + "spec": { + "scalar": 3, + "unit": "px" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "d5c420f261f6973b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "200", + "raw": "200px", + "spec": { + "scalar": 200, + "unit": "px" + }, + "occurrences": 4, + "files_count": 1 + }, + { + "id": "ab7276c9e6e8ca10", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "12", + "raw": "12px", + "spec": { + "scalar": 12, + "unit": "px" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "7a150407b58b558c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "10", + "raw": "10px", + "spec": { + "scalar": 10, + "unit": "px" + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "03a8b74377468929", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "6", + "raw": "6px", + "spec": { + "scalar": 6, + "unit": "px" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "fe6e7ab8374e9190", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "64", + "raw": "64px", + "spec": { + "scalar": 64, + "unit": "px" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "8cca12472e29bc05", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "60", + "raw": "60px", + "spec": { + "scalar": 60, + "unit": "px" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "2acd9bfe599f9583", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "30", + "raw": "30px", + "spec": { + "scalar": 30, + "unit": "px" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "0dd21229fa72968b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "28", + "raw": "28px", + "spec": { + "scalar": 28, + "unit": "px" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "d4b1e28865890fd2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "1", + "raw": "1px", + "spec": { + "scalar": 1, + "unit": "px" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "6526e84b7bb2bc0e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "breakpoint", + "value": "1440", + "raw": "1440px", + "spec": { + "scalar": 1440, + "unit": "px", + "label": "desktop" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "ac8952a956684a7c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "96", + "raw": "96px", + "spec": { + "scalar": 96, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "0732c4cb48b5aad7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "75", + "raw": "75px", + "spec": { + "scalar": 75, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "2f37ade927aa362c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "44", + "raw": "44px", + "spec": { + "scalar": 44, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "5f6a00b7bd08f3bb", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "40", + "raw": "40px", + "spec": { + "scalar": 40, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "1e6bd58b860da10e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "24", + "raw": "24px", + "spec": { + "scalar": 24, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "f4b0897a52c84421", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "16", + "raw": "16px", + "spec": { + "scalar": 16, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "b1f6f180edabd98d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "14", + "raw": "14px", + "spec": { + "scalar": 14, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "dcc100963409d134", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "11", + "raw": "11px", + "spec": { + "scalar": 11, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "d2b1f15320a09f0b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "100", + "raw": "100px", + "spec": { + "scalar": 100, + "unit": "px" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "ef05426d706131d3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "8rem", + "raw": "8rem", + "spec": { + "scalar": 8, + "unit": "rem" + }, + "occurrences": 7, + "files_count": 7 + }, + { + "id": "4a89665e88473cbd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "2rem", + "raw": "2rem", + "spec": { + "scalar": 2, + "unit": "rem" + }, + "occurrences": 3, + "files_count": 5 + }, + { + "id": "e44d4f64633fe41d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "1rem", + "raw": "1rem", + "spec": { + "scalar": 1, + "unit": "rem" + }, + "occurrences": 3, + "files_count": 2 + }, + { + "id": "e114143269d04dc1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "3rem", + "raw": "3rem", + "spec": { + "scalar": 3, + "unit": "rem" + }, + "occurrences": 2, + "files_count": 2 + }, + { + "id": "3d43f141d946f406", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "2.75rem", + "raw": "2.75rem", + "spec": { + "scalar": 2.75, + "unit": "rem" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "3554cc7c7aa186f9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "12rem", + "raw": "12rem", + "spec": { + "scalar": 12, + "unit": "rem" + }, + "occurrences": 2, + "files_count": 2 + }, + { + "id": "de007fd1f6d77ba4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "0.35rem", + "raw": "0.35rem", + "spec": { + "scalar": 0.35, + "unit": "rem" + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "e2aaf87eb6be1f6d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "3.25rem", + "raw": "3.25rem", + "spec": { + "scalar": 3.25, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "613d6504ba5c0849", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "22rem", + "raw": "22rem", + "spec": { + "scalar": 22, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "8e1a94f5a893855f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "2.5rem", + "raw": "2.5rem", + "spec": { + "scalar": 2.5, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "3a3e22185485472c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "18rem", + "raw": "18rem", + "spec": { + "scalar": 18, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "be5b44549f78a831", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "16rem", + "raw": "16rem", + "spec": { + "scalar": 16, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "584c9878e3fc9659", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "1.5rem", + "raw": "1.5rem", + "spec": { + "scalar": 1.5, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "aa49aace62129a2b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "1.25rem", + "raw": "1.25rem", + "spec": { + "scalar": 1.25, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "0f74cf2da58aa928", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "1.15rem", + "raw": "1.15rem", + "spec": { + "scalar": 1.15, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "6f1117620b51e086", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "0.8rem", + "raw": "0.8rem", + "spec": { + "scalar": 0.8, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "62f944d2471eb834", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "0.4rem", + "raw": "0.4rem", + "spec": { + "scalar": 0.4, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "07e474ab5bbd6a8b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "spacing", + "value": "0.45rem", + "raw": "0.45rem", + "spec": { + "scalar": 0.45, + "unit": "rem" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "6335ef9d74e4dd51", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "0.2s", + "raw": "0.2s", + "spec": { + "duration_ms": 200 + }, + "occurrences": 9, + "files_count": 1 + }, + { + "id": "ea16f36543cd855d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "0.15s", + "raw": "0.15s", + "spec": { + "duration_ms": 150 + }, + "occurrences": 3, + "files_count": 1 + }, + { + "id": "2974e3e705d96895", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "0.4s", + "raw": "0.4s", + "spec": { + "duration_ms": 400 + }, + "occurrences": 2, + "files_count": 1 + }, + { + "id": "a9d736217da6ce85", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "1s", + "raw": "1s", + "spec": { + "duration_ms": 1000 + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "aa47604665d17f01", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "typography", + "value": "system-ui", + "raw": "\"system-ui\"", + "spec": { + "family": "system-ui" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "dcdaff2a70d84bdc", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "typography", + "value": "Geist Mono", + "raw": "\"Geist Mono\"", + "spec": { + "family": "Geist Mono" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "14cdd5d410cd5ce4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "typography", + "value": "serif", + "raw": "\"serif\"", + "spec": { + "family": "serif" + }, + "occurrences": 1, + "files_count": 1 + }, + { + "id": "ea0413ae97feefb2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "kind": "motion", + "value": "cubic-bezier(0.33, 1, 0.68, 1)", + "raw": "cubic-bezier(0.33, 1, 0.68, 1)", + "spec": { + "easing": "cubic-bezier(0.33, 1, 0.68, 1)" + }, + "occurrences": 1, + "files_count": 1 + } + ], + "tokens": [ + { + "id": "cb0df5de955ad73c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-white", + "alias_chain": [], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "fa8cbe5ea824054a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-black", + "alias_chain": [], + "resolved_value": "#000000", + "occurrences": 1 + }, + { + "id": "8213ca5fa4a4268a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-50", + "alias_chain": [], + "resolved_value": "#f5f5f5", + "occurrences": 1 + }, + { + "id": "541f124f7eb85071", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-100", + "alias_chain": [], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "03d2bcc1bbdc7d28", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-200", + "alias_chain": [], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "97ecf43a5736e8f2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-300", + "alias_chain": [], + "resolved_value": "#e5e5e5", + "occurrences": 1 + }, + { + "id": "8edd4046d074e50f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-400", + "alias_chain": [], + "resolved_value": "#cccccc", + "occurrences": 1 + }, + { + "id": "de44e9c887444944", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-500", + "alias_chain": [], + "resolved_value": "#999999", + "occurrences": 1 + }, + { + "id": "30372bd858f83fee", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-600", + "alias_chain": [], + "resolved_value": "#666666", + "occurrences": 1 + }, + { + "id": "1664b730ed23be81", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-700", + "alias_chain": [], + "resolved_value": "#333333", + "occurrences": 1 + }, + { + "id": "ef061dbeaf5caa16", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-800", + "alias_chain": [], + "resolved_value": "#232323", + "occurrences": 1 + }, + { + "id": "d62a715d0e50f18a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-gray-900", + "alias_chain": [], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "26e50e6bf93448d4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-red-100", + "alias_chain": [], + "resolved_value": "#ff6b6b", + "occurrences": 1 + }, + { + "id": "b7965dbbcc900c46", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-red-200", + "alias_chain": [], + "resolved_value": "#f94b4b", + "occurrences": 1 + }, + { + "id": "88fd00e599db4d4e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-blue-100", + "alias_chain": [], + "resolved_value": "#7cacff", + "occurrences": 1 + }, + { + "id": "88691479ae0d01fd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-blue-200", + "alias_chain": [], + "resolved_value": "#5c98f9", + "occurrences": 1 + }, + { + "id": "26162117e66af8dc", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-green-100", + "alias_chain": [], + "resolved_value": "#a3d795", + "occurrences": 1 + }, + { + "id": "4cae2b881dc01f65", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-green-200", + "alias_chain": [], + "resolved_value": "#91cb80", + "occurrences": 1 + }, + { + "id": "41ef8bfe752c8d36", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-yellow-100", + "alias_chain": [], + "resolved_value": "#ffd966", + "occurrences": 1 + }, + { + "id": "66f38cbf9eeaadd3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-yellow-200", + "alias_chain": [], + "resolved_value": "#fbcd44", + "occurrences": 1 + }, + { + "id": "2a33cb35eaa43967", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius", + "alias_chain": [], + "resolved_value": "20px", + "occurrences": 1 + }, + { + "id": "89d494ebb1262332", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-accent", + "alias_chain": ["--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-900)", + "dark": "var(--color-white)" + } + }, + { + "id": "d9657bca82623241", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-accent", + "alias_chain": ["--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-900)", + "dark": "var(--color-white)" + } + }, + { + "id": "19902eb417500d9d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-accent", + "alias_chain": ["--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-900)", + "dark": "var(--color-white)" + } + }, + { + "id": "d05df9802efeb06e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-default", + "alias_chain": ["--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1, + "by_theme": { + "light": "var(--color-white)", + "dark": "var(--color-black)" + } + }, + { + "id": "355b9a2a4df5856f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-alt", + "alias_chain": ["--color-gray-50"], + "resolved_value": "#f5f5f5", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-50)", + "dark": "var(--color-gray-800)" + } + }, + { + "id": "0f4f1d6b1fe7318e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-medium", + "alias_chain": ["--color-gray-400"], + "resolved_value": "#cccccc", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-400)", + "dark": "var(--color-gray-700)" + } + }, + { + "id": "e0cf37d1da4a10a0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-muted", + "alias_chain": ["--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-100)", + "dark": "var(--color-gray-800)" + } + }, + { + "id": "92b74be249662f38", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-inverse", + "alias_chain": ["--color-black"], + "resolved_value": "#000000", + "occurrences": 1, + "by_theme": { + "light": "var(--color-black)", + "dark": "var(--color-white)" + } + }, + { + "id": "11dfcdb269185b66", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-danger", + "alias_chain": ["--color-red-200"], + "resolved_value": "#f94b4b", + "occurrences": 1, + "by_theme": { + "light": "var(--color-red-200)", + "dark": "var(--color-red-100)" + } + }, + { + "id": "e94c478eeb3cfaf2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-success", + "alias_chain": ["--color-green-200"], + "resolved_value": "#91cb80", + "occurrences": 1, + "by_theme": { + "light": "var(--color-green-200)", + "dark": "var(--color-green-100)" + } + }, + { + "id": "8e33834f0bd6e5e2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-info", + "alias_chain": ["--color-blue-200"], + "resolved_value": "#5c98f9", + "occurrences": 1, + "by_theme": { + "light": "var(--color-blue-200)", + "dark": "var(--color-blue-100)" + } + }, + { + "id": "8ff9f2f4275738bc", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background-warning", + "alias_chain": ["--color-yellow-200"], + "resolved_value": "#fbcd44", + "occurrences": 1, + "by_theme": { + "light": "var(--color-yellow-200)", + "dark": "var(--color-yellow-100)" + } + }, + { + "id": "11472fd804d05770", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-default", + "alias_chain": ["--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-200)", + "dark": "var(--color-gray-700)" + } + }, + { + "id": "bc1fa882feabc1b7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-input", + "alias_chain": ["--color-gray-300"], + "resolved_value": "#e5e5e5", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-300)", + "dark": "var(--color-gray-700)" + } + }, + { + "id": "7f81a8855762e4db", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-input-hover", + "alias_chain": ["--color-gray-400"], + "resolved_value": "#cccccc", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-400)", + "dark": "var(--color-gray-600)" + } + }, + { + "id": "a480eff48092292b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-strong", + "alias_chain": ["--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-900)", + "dark": "var(--color-white)" + } + }, + { + "id": "117eeeaad69b709f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-card", + "alias_chain": ["--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-200)", + "dark": "var(--color-gray-700)" + } + }, + { + "id": "8cf1d6060432038e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-inverse", + "alias_chain": ["--color-black"], + "resolved_value": "#000000", + "occurrences": 1, + "by_theme": { + "light": "var(--color-black)", + "dark": "var(--color-white)" + } + }, + { + "id": "7ce7823ae7307dae", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-danger", + "alias_chain": ["--color-red-200"], + "resolved_value": "#f94b4b", + "occurrences": 1 + }, + { + "id": "ae2c824e34b376aa", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-success", + "alias_chain": ["--color-green-200"], + "resolved_value": "#91cb80", + "occurrences": 1 + }, + { + "id": "03fe4b69ac215996", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-warning", + "alias_chain": ["--color-yellow-200"], + "resolved_value": "#fbcd44", + "occurrences": 1 + }, + { + "id": "0a201ab94de43e6b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border-info", + "alias_chain": ["--color-blue-200"], + "resolved_value": "#5c98f9", + "occurrences": 1 + }, + { + "id": "1a5199cdcf5849d2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-default", + "alias_chain": ["--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-900)", + "dark": "var(--color-white)" + } + }, + { + "id": "3b99b302eb9364f7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-muted", + "alias_chain": ["--color-gray-500"], + "resolved_value": "#999999", + "occurrences": 1 + }, + { + "id": "0bbb574cd3a22a8c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-alt", + "alias_chain": ["--color-gray-600"], + "resolved_value": "#666666", + "occurrences": 1, + "by_theme": { + "light": "var(--color-gray-600)", + "dark": "var(--color-gray-500)" + } + }, + { + "id": "4254b03cc0a95a35", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-inverse", + "alias_chain": ["--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1, + "by_theme": { + "light": "var(--color-white)", + "dark": "var(--color-black)" + } + }, + { + "id": "746df1a3500d78fe", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-danger", + "alias_chain": ["--color-red-200"], + "resolved_value": "#f94b4b", + "occurrences": 1, + "by_theme": { + "light": "var(--color-red-200)", + "dark": "var(--color-red-100)" + } + }, + { + "id": "1d64349b18172cf0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-success", + "alias_chain": ["--color-green-200"], + "resolved_value": "#91cb80", + "occurrences": 1, + "by_theme": { + "light": "var(--color-green-200)", + "dark": "var(--color-green-100)" + } + }, + { + "id": "4d3efb919e0bb7f6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-warning", + "alias_chain": ["--color-yellow-200"], + "resolved_value": "#fbcd44", + "occurrences": 1, + "by_theme": { + "light": "var(--color-yellow-200)", + "dark": "var(--color-yellow-100)" + } + }, + { + "id": "2e1dc0f80458dcca", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-info", + "alias_chain": ["--color-blue-200"], + "resolved_value": "#5c98f9", + "occurrences": 1, + "by_theme": { + "light": "var(--color-blue-200)", + "dark": "var(--color-blue-100)" + } + }, + { + "id": "eb3ac5c681596680", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--ring", + "alias_chain": ["--border-strong", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "9041300d208ee096", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--dark-10", + "alias_chain": [], + "resolved_value": "rgba(26, 26, 26, 0.1)", + "occurrences": 1, + "by_theme": { + "light": "rgba(26, 26, 26, 0.1)", + "dark": "rgba(242, 242, 242, 0.1)" + } + }, + { + "id": "b81ed2736c6b3480", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--dark-40", + "alias_chain": [], + "resolved_value": "rgba(26, 26, 26, 0.4)", + "occurrences": 1, + "by_theme": { + "light": "rgba(26, 26, 26, 0.4)", + "dark": "rgba(242, 242, 242, 0.4)" + } + }, + { + "id": "f55746f6fc8c61f8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--dark-04", + "alias_chain": [], + "resolved_value": "rgba(26, 26, 26, 0.04)", + "occurrences": 1, + "by_theme": { + "light": "rgba(26, 26, 26, 0.04)", + "dark": "rgba(242, 242, 242, 0.04)" + } + }, + { + "id": "9529fad07f81a343", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-mini", + "alias_chain": [], + "resolved_value": "0 2px 8px rgba(76, 76, 76, 0.15)", + "occurrences": 1, + "by_theme": { + "light": "0 2px 8px rgba(76, 76, 76, 0.15)", + "dark": "0 2px 8px rgba(0, 0, 0, 0.4)" + } + }, + { + "id": "3ff2382fa48f5140", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-mini-inset", + "alias_chain": [], + "resolved_value": "0 1px 4px rgba(76, 76, 76, 0.1) inset", + "occurrences": 1, + "by_theme": { + "light": "0 1px 4px rgba(76, 76, 76, 0.1) inset", + "dark": "0 1px 4px rgba(0, 0, 0, 0.5) inset" + } + }, + { + "id": "000205efc8c4b4cd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-btn", + "alias_chain": [], + "resolved_value": "0 2px 8px rgba(76, 76, 76, 0.15)", + "occurrences": 1, + "by_theme": { + "light": "0 2px 8px rgba(76, 76, 76, 0.15)", + "dark": "0 2px 8px rgba(0, 0, 0, 0.3)" + } + }, + { + "id": "ad9824735daec0fc", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-card", + "alias_chain": [], + "resolved_value": "0 2px 8px rgba(76, 76, 76, 0.15)", + "occurrences": 1, + "by_theme": { + "light": "0 2px 8px rgba(76, 76, 76, 0.15)", + "dark": "0 2px 8px rgba(0, 0, 0, 0.4)" + } + }, + { + "id": "472f662bf8aaa623", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-elevated", + "alias_chain": [], + "resolved_value": "0 3px 12px rgba(76, 76, 76, 0.22)", + "occurrences": 1, + "by_theme": { + "light": "0 3px 12px rgba(76, 76, 76, 0.22)", + "dark": "0 3px 12px rgba(0, 0, 0, 0.5)" + } + }, + { + "id": "48611e920d74e1d5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-popover", + "alias_chain": [], + "resolved_value": "0 8px 30px rgba(0, 0, 0, 0.12)", + "occurrences": 1, + "by_theme": { + "light": "0 8px 30px rgba(0, 0, 0, 0.12)", + "dark": "0 8px 30px rgba(0, 0, 0, 0.4)" + } + }, + { + "id": "dc162be1adc241d4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-modal", + "alias_chain": [], + "resolved_value": "0 20px 60px rgba(0, 0, 0, 0.2)", + "occurrences": 1, + "by_theme": { + "light": "0 20px 60px rgba(0, 0, 0, 0.2)", + "dark": "0 20px 60px rgba(0, 0, 0, 0.6)" + } + }, + { + "id": "d658051aeb18fc51", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-kbd", + "alias_chain": [], + "resolved_value": "0 2px 8px rgba(76, 76, 76, 0.15)", + "occurrences": 1, + "by_theme": { + "light": "0 2px 8px rgba(76, 76, 76, 0.15)", + "dark": "0 2px 8px rgba(0, 0, 0, 0.4)" + } + }, + { + "id": "0ff3d81715e7d7eb", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--shadow-date-field-focus", + "alias_chain": [], + "resolved_value": "0 0 0 3px rgba(26, 26, 26, 0.15)", + "occurrences": 1, + "by_theme": { + "light": "0 0 0 3px rgba(26, 26, 26, 0.15)", + "dark": "0 0 0 3px rgba(244, 244, 245, 0.1)" + } + }, + { + "id": "d1debf06e38b290d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--surface-dark", + "alias_chain": [], + "resolved_value": "#0a0a0a", + "occurrences": 1 + }, + { + "id": "112b3131d3f14654", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--surface-dark-text", + "alias_chain": [], + "resolved_value": "#f5f5f5", + "occurrences": 1 + }, + { + "id": "a923fd620357dc73", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--surface-dark-muted", + "alias_chain": [], + "resolved_value": "rgba(255, 255, 255, 0.5)", + "occurrences": 1 + }, + { + "id": "8a465ae1e5bf1c44", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--surface-dark-border", + "alias_chain": [], + "resolved_value": "rgba(255, 255, 255, 0.08)", + "occurrences": 1 + }, + { + "id": "983010c94c6731f9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--chart-1", + "alias_chain": [], + "resolved_value": "#f6b44a", + "occurrences": 1 + }, + { + "id": "5ef2ec5c4825446e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--chart-2", + "alias_chain": [], + "resolved_value": "#7585ff", + "occurrences": 1 + }, + { + "id": "a80dd35c1a205c2f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--chart-3", + "alias_chain": [], + "resolved_value": "#d76a6a", + "occurrences": 1 + }, + { + "id": "36d0a070971af8f0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--chart-4", + "alias_chain": [], + "resolved_value": "#d185e0", + "occurrences": 1 + }, + { + "id": "883d7b69cdf9cb53", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--chart-5", + "alias_chain": [], + "resolved_value": "#91cb80", + "occurrences": 1 + }, + { + "id": "d8d4435b5f3b5746", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--background", + "alias_chain": ["--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "6105084f233c6c53", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "6145c141293a5512", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--card", + "alias_chain": ["--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "5266020d816d9cb0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--card-foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "d5129e2925a69e62", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--popover", + "alias_chain": ["--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "7b4957e2dbb73224", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--popover-foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "098a05bb762e4e97", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--primary", + "alias_chain": ["--background-accent", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "7fab282fbb18b512", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--primary-foreground", + "alias_chain": ["--text-inverse", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "3bf5dce55a0f2b5f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--secondary", + "alias_chain": ["--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "fc38505d2f4a2288", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--secondary-foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "243245a7bf35e886", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--muted", + "alias_chain": ["--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "5db27d0ba94e075c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--muted-foreground", + "alias_chain": ["--text-muted", "--color-gray-500"], + "resolved_value": "#999999", + "occurrences": 1 + }, + { + "id": "87d78b323afb3dd1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--accent", + "alias_chain": ["--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "1fae63f3e9256ec9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--accent-foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "39319932a8a5dc2d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--destructive", + "alias_chain": ["--background-danger", "--color-red-200"], + "resolved_value": "#f94b4b", + "occurrences": 1 + }, + { + "id": "fb90fa581191a837", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--destructive-foreground", + "alias_chain": [], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "8eb09c0bc090bf82", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--border", + "alias_chain": ["--border-default", "--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "fe2f98046ee3c6c4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--input", + "alias_chain": ["--border-input", "--color-gray-300"], + "resolved_value": "#e5e5e5", + "occurrences": 1 + }, + { + "id": "3f8318aca2ab2c3c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar", + "alias_chain": ["--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "88ecacad45734ced", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar-foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "4769e59a81b0a386", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar-primary", + "alias_chain": ["--background-accent", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "43f6f131de162abe", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar-primary-foreground", + "alias_chain": ["--text-inverse", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "c548facc6d30daa6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar-accent", + "alias_chain": ["--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "834547a9fe9a2912", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar-accent-foreground", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "9559d09e51cfa072", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar-border", + "alias_chain": ["--border-default", "--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "135e42097f86b628", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--sidebar-ring", + "alias_chain": ["--border-default", "--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "bc4fc3c0259843a6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-display-font-size", + "alias_chain": [], + "resolved_value": "clamp(64px, 8vw, 96px)", + "occurrences": 1 + }, + { + "id": "bf70c74ab5d7c9d6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-display-line-height", + "alias_chain": [], + "resolved_value": "0.88", + "occurrences": 1 + }, + { + "id": "e64e51adadbdc369", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-display-letter-spacing", + "alias_chain": [], + "resolved_value": "-0.05em", + "occurrences": 1 + }, + { + "id": "551ad94ab6b75a8c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-display-font-weight", + "alias_chain": [], + "resolved_value": "900", + "occurrences": 1 + }, + { + "id": "b37ffe360f471a83", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-section-font-size", + "alias_chain": [], + "resolved_value": "clamp(44px, 5vw, 64px)", + "occurrences": 1 + }, + { + "id": "da67417bf64196e2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-section-line-height", + "alias_chain": [], + "resolved_value": "0.95", + "occurrences": 1 + }, + { + "id": "54b440772a141ec8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-section-letter-spacing", + "alias_chain": [], + "resolved_value": "-0.035em", + "occurrences": 1 + }, + { + "id": "e276ab90d053a3e3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-section-font-weight", + "alias_chain": [], + "resolved_value": "700", + "occurrences": 1 + }, + { + "id": "36c7fd9100309290", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-sub-font-size", + "alias_chain": [], + "resolved_value": "clamp(28px, 3vw, 40px)", + "occurrences": 1 + }, + { + "id": "5dc95c6e6ec16bcf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-sub-line-height", + "alias_chain": [], + "resolved_value": "1", + "occurrences": 1 + }, + { + "id": "f92cd03e60b413ae", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-sub-letter-spacing", + "alias_chain": [], + "resolved_value": "-0.02em", + "occurrences": 1 + }, + { + "id": "161f1f253f80ef66", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-sub-font-weight", + "alias_chain": [], + "resolved_value": "700", + "occurrences": 1 + }, + { + "id": "f24c816af3ae7ad4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-card-font-size", + "alias_chain": [], + "resolved_value": "clamp(20px, 2vw, 28px)", + "occurrences": 1 + }, + { + "id": "3199e634efff3440", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-card-line-height", + "alias_chain": [], + "resolved_value": "1.1", + "occurrences": 1 + }, + { + "id": "aa72e28a8b70df29", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-card-letter-spacing", + "alias_chain": [], + "resolved_value": "-0.01em", + "occurrences": 1 + }, + { + "id": "361cc48210f57061", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--heading-card-font-weight", + "alias_chain": [], + "resolved_value": "600", + "occurrences": 1 + }, + { + "id": "1b05c7cb8a06bb35", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--display-size", + "alias_chain": [], + "resolved_value": "clamp(3rem, 12vw, 12rem)", + "occurrences": 1 + }, + { + "id": "3e6f0ab2d1b542b4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--display-line-height", + "alias_chain": [], + "resolved_value": "0.85", + "occurrences": 1 + }, + { + "id": "0c597fc3e248ef4c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--display-letter-spacing", + "alias_chain": [], + "resolved_value": "-0.05em", + "occurrences": 1 + }, + { + "id": "1527da444af8397f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--body-reading-size", + "alias_chain": [], + "resolved_value": "clamp(1rem, 1.3vw, 1.25rem)", + "occurrences": 1 + }, + { + "id": "70c24c634c52d9a2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--body-reading-line-height", + "alias_chain": [], + "resolved_value": "1.65", + "occurrences": 1 + }, + { + "id": "71e46d03cfad1ee4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--body-reading-letter-spacing", + "alias_chain": [], + "resolved_value": "-0.01em", + "occurrences": 1 + }, + { + "id": "53a79d6d04e55cd2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--label-font-size", + "alias_chain": [], + "resolved_value": "11px", + "occurrences": 1 + }, + { + "id": "3c2391e1e2903976", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--label-letter-spacing", + "alias_chain": [], + "resolved_value": "0.12em", + "occurrences": 1 + }, + { + "id": "0b849e85006c32a9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--label-font-weight", + "alias_chain": [], + "resolved_value": "600", + "occurrences": 1 + }, + { + "id": "4c200e8abf9af09e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--label-line-height", + "alias_chain": [], + "resolved_value": "1.2", + "occurrences": 1 + }, + { + "id": "c5269bdf6889b8cf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--pullquote-size", + "alias_chain": [], + "resolved_value": "clamp(1.5rem, 3vw, 2.5rem)", + "occurrences": 1 + }, + { + "id": "10329852f513a96f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--pullquote-line-height", + "alias_chain": [], + "resolved_value": "1.3", + "occurrences": 1 + }, + { + "id": "3fc0583efc3fa861", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--pullquote-weight", + "alias_chain": [], + "resolved_value": "300", + "occurrences": 1 + }, + { + "id": "249b61c001ad3634", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--pullquote-letter-spacing", + "alias_chain": [], + "resolved_value": "-0.02em", + "occurrences": 1 + }, + { + "id": "862fd726a776cfdf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--page-container-max-width", + "alias_chain": [], + "resolved_value": "1440px", + "occurrences": 1 + }, + { + "id": "908e2d48bf37c420", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--page-container-side-gutter", + "alias_chain": [], + "resolved_value": "20px", + "occurrences": 1 + }, + { + "id": "64b82c560e2c97bb", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--section-padding-vertical", + "alias_chain": [], + "resolved_value": "100px", + "occurrences": 1 + }, + { + "id": "a17e9285e0b94474", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--section-heading-margin-bottom", + "alias_chain": [], + "resolved_value": "75px", + "occurrences": 1 + }, + { + "id": "f64dee4e923913d9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--ease-spring", + "alias_chain": [], + "resolved_value": "cubic-bezier(0.33, 1, 0.68, 1)", + "occurrences": 1 + }, + { + "id": "6d4fbc30ccefe31c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--duration-fast", + "alias_chain": [], + "resolved_value": "0.15s", + "occurrences": 1 + }, + { + "id": "fc10b79ac07b5678", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--duration-normal", + "alias_chain": [], + "resolved_value": "0.2s", + "occurrences": 1 + }, + { + "id": "2d3684d4b61e80d1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--duration-slow", + "alias_chain": [], + "resolved_value": "0.4s", + "occurrences": 1 + }, + { + "id": "2f5a9a0a95255dac", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background", + "alias_chain": ["--background", "--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "d55dd8bb9f8db72f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-foreground", + "alias_chain": ["--foreground", "--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "d47fcc342b506fc2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-card", + "alias_chain": ["--card", "--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "fe1308ed2b42865b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-card-foreground", + "alias_chain": [ + "--card-foreground", + "--text-default", + "--color-gray-900" + ], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "bfa4e1a7ac7d11f6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-popover", + "alias_chain": ["--popover", "--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "f2aa215413a9b752", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-popover-foreground", + "alias_chain": [ + "--popover-foreground", + "--text-default", + "--color-gray-900" + ], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "124c24bd81747864", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-primary", + "alias_chain": ["--primary", "--background-accent", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "6120c1833d1be4df", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-primary-foreground", + "alias_chain": [ + "--primary-foreground", + "--text-inverse", + "--color-white" + ], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "b7a55a2d42e28314", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-secondary", + "alias_chain": ["--secondary", "--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "f004e1aae65a45d2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-secondary-foreground", + "alias_chain": [ + "--secondary-foreground", + "--text-default", + "--color-gray-900" + ], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "1912ee78976cbd5c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-muted", + "alias_chain": ["--muted", "--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "1098dbdb603807e7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-muted-foreground", + "alias_chain": ["--muted-foreground", "--text-muted", "--color-gray-500"], + "resolved_value": "#999999", + "occurrences": 1 + }, + { + "id": "da871c151bf65f6e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-accent", + "alias_chain": ["--accent", "--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "89dc62abf78f41aa", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-accent-foreground", + "alias_chain": [ + "--accent-foreground", + "--text-default", + "--color-gray-900" + ], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "04de6b660bb3368b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-destructive", + "alias_chain": [ + "--destructive", + "--background-danger", + "--color-red-200" + ], + "resolved_value": "#f94b4b", + "occurrences": 1 + }, + { + "id": "1bf4a1e53b1e1f1c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-destructive-foreground", + "alias_chain": ["--destructive-foreground"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "fdeee77bfff0d520", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border", + "alias_chain": ["--border", "--border-default", "--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "f157958260bde4c8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-input", + "alias_chain": ["--input", "--border-input", "--color-gray-300"], + "resolved_value": "#e5e5e5", + "occurrences": 1 + }, + { + "id": "17578aea56422a67", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-default", + "alias_chain": ["--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "4bb6af94eae82ddd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-alt", + "alias_chain": ["--background-alt", "--color-gray-50"], + "resolved_value": "#f5f5f5", + "occurrences": 1 + }, + { + "id": "fba9a3795903c1e6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-medium", + "alias_chain": ["--background-medium", "--color-gray-400"], + "resolved_value": "#cccccc", + "occurrences": 1 + }, + { + "id": "132c998aeeba0459", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-inverse", + "alias_chain": ["--background-inverse", "--color-black"], + "resolved_value": "#000000", + "occurrences": 1 + }, + { + "id": "cc35408daf49c54c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-muted", + "alias_chain": ["--background-muted", "--color-gray-100"], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "146080977cd2d2f8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-danger", + "alias_chain": ["--background-danger", "--color-red-200"], + "resolved_value": "#f94b4b", + "occurrences": 1 + }, + { + "id": "d484c0bf1937e4f3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-success", + "alias_chain": ["--background-success", "--color-green-200"], + "resolved_value": "#91cb80", + "occurrences": 1 + }, + { + "id": "3ff3806e84dea446", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-info", + "alias_chain": ["--background-info", "--color-blue-200"], + "resolved_value": "#5c98f9", + "occurrences": 1 + }, + { + "id": "26c01a376a4604b0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-warning", + "alias_chain": ["--background-warning", "--color-yellow-200"], + "resolved_value": "#fbcd44", + "occurrences": 1 + }, + { + "id": "97e90e168f5c001a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-background-accent", + "alias_chain": ["--background-accent", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "247eb91270339db0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-accent", + "alias_chain": ["--border-accent", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "eb4d5b18c284ea59", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-accent", + "alias_chain": ["--text-accent", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "f867629e002fd0b1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-default", + "alias_chain": ["--border-default", "--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "1843e8391f966c9a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-input", + "alias_chain": ["--border-input", "--color-gray-300"], + "resolved_value": "#e5e5e5", + "occurrences": 1 + }, + { + "id": "e1a84bf222df3be8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-input-hover", + "alias_chain": ["--border-input-hover", "--color-gray-400"], + "resolved_value": "#cccccc", + "occurrences": 1 + }, + { + "id": "46f223bf0f902a7f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-strong", + "alias_chain": ["--border-strong", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "8e470a2735606b23", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-card", + "alias_chain": ["--border-card", "--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "d0f8eb7be75b1a80", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-inverse", + "alias_chain": ["--border-inverse", "--color-black"], + "resolved_value": "#000000", + "occurrences": 1 + }, + { + "id": "35b229488430db7a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-danger", + "alias_chain": ["--border-danger", "--color-red-200"], + "resolved_value": "#f94b4b", + "occurrences": 1 + }, + { + "id": "ca8197eb296733d3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-success", + "alias_chain": ["--border-success", "--color-green-200"], + "resolved_value": "#91cb80", + "occurrences": 1 + }, + { + "id": "96aac5df8697f71e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-warning", + "alias_chain": ["--border-warning", "--color-yellow-200"], + "resolved_value": "#fbcd44", + "occurrences": 1 + }, + { + "id": "3086a21e95a91d0e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-border-info", + "alias_chain": ["--border-info", "--color-blue-200"], + "resolved_value": "#5c98f9", + "occurrences": 1 + }, + { + "id": "4ae94e5ebf5883a0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-default", + "alias_chain": ["--text-default", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "9ee34ec9f755ead1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-muted", + "alias_chain": ["--text-muted", "--color-gray-500"], + "resolved_value": "#999999", + "occurrences": 1 + }, + { + "id": "a986f406d8779b03", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-alt", + "alias_chain": ["--text-alt", "--color-gray-600"], + "resolved_value": "#666666", + "occurrences": 1 + }, + { + "id": "ecede937cb1b7045", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-inverse", + "alias_chain": ["--text-inverse", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "e374daadeb796e74", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-danger", + "alias_chain": ["--text-danger", "--color-red-200"], + "resolved_value": "#f94b4b", + "occurrences": 1 + }, + { + "id": "17d913ce57d61691", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-success", + "alias_chain": ["--text-success", "--color-green-200"], + "resolved_value": "#91cb80", + "occurrences": 1 + }, + { + "id": "47375d24c38b7d67", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-warning", + "alias_chain": ["--text-warning", "--color-yellow-200"], + "resolved_value": "#fbcd44", + "occurrences": 1 + }, + { + "id": "decd88c38537ed08", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-text-info", + "alias_chain": ["--text-info", "--color-blue-200"], + "resolved_value": "#5c98f9", + "occurrences": 1 + }, + { + "id": "a2a3704df5466718", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-dark-10", + "alias_chain": ["--dark-10"], + "resolved_value": "rgba(26, 26, 26, 0.1)", + "occurrences": 1 + }, + { + "id": "4d9cdbc68318c955", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-dark-40", + "alias_chain": ["--dark-40"], + "resolved_value": "rgba(26, 26, 26, 0.4)", + "occurrences": 1 + }, + { + "id": "1ff7670a50a0469f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-dark-04", + "alias_chain": ["--dark-04"], + "resolved_value": "rgba(26, 26, 26, 0.04)", + "occurrences": 1 + }, + { + "id": "918291ab84c5d96b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-surface-dark", + "alias_chain": ["--surface-dark"], + "resolved_value": "#0a0a0a", + "occurrences": 1 + }, + { + "id": "b63a5e70dfb903d9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-surface-dark-text", + "alias_chain": ["--surface-dark-text"], + "resolved_value": "#f5f5f5", + "occurrences": 1 + }, + { + "id": "78e4178e847afd35", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-surface-dark-muted", + "alias_chain": ["--surface-dark-muted"], + "resolved_value": "rgba(255, 255, 255, 0.5)", + "occurrences": 1 + }, + { + "id": "13e3f662de4dcb25", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-surface-dark-border", + "alias_chain": ["--surface-dark-border"], + "resolved_value": "rgba(255, 255, 255, 0.08)", + "occurrences": 1 + }, + { + "id": "d3bd382b85d91bd7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--font-mono", + "alias_chain": [], + "resolved_value": "\"Geist Mono\", monospace", + "occurrences": 1 + }, + { + "id": "2420c9d2714f81f4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--font-serif", + "alias_chain": [], + "resolved_value": "serif", + "occurrences": 1 + }, + { + "id": "21da98d0c3d9411e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-pill", + "alias_chain": [], + "resolved_value": "999px", + "occurrences": 1 + }, + { + "id": "d1ac92a1e9843734", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-button", + "alias_chain": [], + "resolved_value": "999px", + "occurrences": 1 + }, + { + "id": "b945503b5a257f07", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-input", + "alias_chain": [], + "resolved_value": "999px", + "occurrences": 1 + }, + { + "id": "8bcca92bea5231ea", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-card", + "alias_chain": [], + "resolved_value": "20px", + "occurrences": 1 + }, + { + "id": "2821782150c5a4a1", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-card-lg", + "alias_chain": [], + "resolved_value": "24px", + "occurrences": 1 + }, + { + "id": "ac4a8dc675ffaaf2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-card-sm", + "alias_chain": [], + "resolved_value": "14px", + "occurrences": 1 + }, + { + "id": "fa2a7dbad3526104", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-dropdown", + "alias_chain": [], + "resolved_value": "10px", + "occurrences": 1 + }, + { + "id": "cbc6369b605db513", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-modal", + "alias_chain": [], + "resolved_value": "16px", + "occurrences": 1 + }, + { + "id": "37ce03d30c7d193c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-sm", + "alias_chain": [], + "resolved_value": "calc(var(--radius) - 4px)", + "occurrences": 1 + }, + { + "id": "f6d81cb173ac5b89", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-md", + "alias_chain": [], + "resolved_value": "calc(var(--radius) - 2px)", + "occurrences": 1 + }, + { + "id": "92e39c945832caa3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-lg", + "alias_chain": ["--radius"], + "resolved_value": "20px", + "occurrences": 1 + }, + { + "id": "d67c6f689629e889", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--radius-xl", + "alias_chain": [], + "resolved_value": "calc(var(--radius) + 4px)", + "occurrences": 1 + }, + { + "id": "df28f5a6707b3437", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-ring", + "alias_chain": ["--ring", "--border-strong", "--color-gray-900"], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "0ce9081c13f1ca82", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--spacing-input", + "alias_chain": [], + "resolved_value": "3.25rem", + "occurrences": 1 + }, + { + "id": "3fb3e9f3b95501bf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--spacing-input-sm", + "alias_chain": [], + "resolved_value": "2.75rem", + "occurrences": 1 + }, + { + "id": "72d8da27e0578a21", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--spacing-button", + "alias_chain": [], + "resolved_value": "2.75rem", + "occurrences": 1 + }, + { + "id": "f85ad488352a8258", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--spacing-button-sm", + "alias_chain": [], + "resolved_value": "2rem", + "occurrences": 1 + }, + { + "id": "b067a6136af76dff", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--text-xxs", + "alias_chain": [], + "resolved_value": "10px", + "occurrences": 1 + }, + { + "id": "18951054bbfbd351", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-chart-1", + "alias_chain": ["--chart-1"], + "resolved_value": "#f6b44a", + "occurrences": 1 + }, + { + "id": "bf8c7a0fc1e09641", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-chart-2", + "alias_chain": ["--chart-2"], + "resolved_value": "#7585ff", + "occurrences": 1 + }, + { + "id": "b2cf1391a0213ab5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-chart-3", + "alias_chain": ["--chart-3"], + "resolved_value": "#d76a6a", + "occurrences": 1 + }, + { + "id": "c141c9907497b584", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-chart-4", + "alias_chain": ["--chart-4"], + "resolved_value": "#d185e0", + "occurrences": 1 + }, + { + "id": "519224717a35080d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-chart-5", + "alias_chain": ["--chart-5"], + "resolved_value": "#91cb80", + "occurrences": 1 + }, + { + "id": "f92a8bae3de0133c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar", + "alias_chain": ["--sidebar", "--background-default", "--color-white"], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "9eea5d9d33bf7efb", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar-foreground", + "alias_chain": [ + "--sidebar-foreground", + "--text-default", + "--color-gray-900" + ], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "48ffdceb752eb219", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar-primary", + "alias_chain": [ + "--sidebar-primary", + "--background-accent", + "--color-gray-900" + ], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "8519e3a792896662", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar-primary-foreground", + "alias_chain": [ + "--sidebar-primary-foreground", + "--text-inverse", + "--color-white" + ], + "resolved_value": "#ffffff", + "occurrences": 1 + }, + { + "id": "1c65904fc353164c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar-accent", + "alias_chain": [ + "--sidebar-accent", + "--background-muted", + "--color-gray-100" + ], + "resolved_value": "#f0f0f0", + "occurrences": 1 + }, + { + "id": "f7cecc91f460093f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar-accent-foreground", + "alias_chain": [ + "--sidebar-accent-foreground", + "--text-default", + "--color-gray-900" + ], + "resolved_value": "#1a1a1a", + "occurrences": 1 + }, + { + "id": "944c77060111eb66", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar-border", + "alias_chain": [ + "--sidebar-border", + "--border-default", + "--color-gray-200" + ], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "7e059285384c56af", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--color-sidebar-ring", + "alias_chain": ["--sidebar-ring", "--border-default", "--color-gray-200"], + "resolved_value": "#e8e8e8", + "occurrences": 1 + }, + { + "id": "7fe05bd17b9112f6", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--breakpoint-desktop", + "alias_chain": [], + "resolved_value": "1440px", + "occurrences": 1 + }, + { + "id": "b8dee9dd85440ad8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-accordion-down", + "alias_chain": [], + "resolved_value": "accordion-down 0.2s ease-out", + "occurrences": 1 + }, + { + "id": "67bfc0e3b3b985dd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-accordion-up", + "alias_chain": [], + "resolved_value": "accordion-up 0.2s ease-out", + "occurrences": 1 + }, + { + "id": "60cdf505ddf4991c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-caret-blink", + "alias_chain": [], + "resolved_value": "caret-blink 1s ease-out infinite", + "occurrences": 1 + }, + { + "id": "2e09deb7f2b3ab22", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-scale-in", + "alias_chain": [], + "resolved_value": "scale-in 0.2s ease", + "occurrences": 1 + }, + { + "id": "f3b4e8c63d670772", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-scale-out", + "alias_chain": [], + "resolved_value": "scale-out 0.15s ease", + "occurrences": 1 + }, + { + "id": "9ba43f9f8fb98c62", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-fade-in", + "alias_chain": [], + "resolved_value": "fade-in 0.2s ease", + "occurrences": 1 + }, + { + "id": "c646397fffd6efee", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-fade-out", + "alias_chain": [], + "resolved_value": "fade-out 0.15s ease", + "occurrences": 1 + }, + { + "id": "cb69b598d14d802a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-enter-from-left", + "alias_chain": [], + "resolved_value": "enter-from-left 0.2s ease", + "occurrences": 1 + }, + { + "id": "46fc321d9b36fe46", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-enter-from-right", + "alias_chain": [], + "resolved_value": "enter-from-right 0.2s ease", + "occurrences": 1 + }, + { + "id": "a3dbc93ee468a3a9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-exit-to-left", + "alias_chain": [], + "resolved_value": "exit-to-left 0.2s ease", + "occurrences": 1 + }, + { + "id": "df9f5b8dafce8b3e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-exit-to-right", + "alias_chain": [], + "resolved_value": "exit-to-right 0.2s ease", + "occurrences": 1 + }, + { + "id": "da96512485cd3080", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "--animate-word-reveal", + "alias_chain": [], + "resolved_value": "word-reveal 0.4s ease-out", + "occurrences": 1 + } + ], + "components": [ + { + "id": "6d2f12160726c41d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "accordion", + "discovered_via": "registry.json" + }, + { + "id": "979dafb281d630f9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "alert-dialog", + "discovered_via": "registry.json" + }, + { + "id": "a103296963ca9c32", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "alert", + "discovered_via": "registry.json" + }, + { + "id": "7c241aa68487bbbf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "aspect-ratio", + "discovered_via": "registry.json" + }, + { + "id": "276c3ad242788403", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "avatar", + "discovered_via": "registry.json" + }, + { + "id": "90b4746fa86fdb72", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "badge", + "discovered_via": "registry.json" + }, + { + "id": "5455f2e25e8ec7f4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "breadcrumb", + "discovered_via": "registry.json" + }, + { + "id": "35dd4b60a7b42c9b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "button-group", + "discovered_via": "registry.json" + }, + { + "id": "9176e496c60179cd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "button", + "discovered_via": "registry.json" + }, + { + "id": "afd37c648aa0c22e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "calendar", + "discovered_via": "registry.json" + }, + { + "id": "5d193da58fbd809a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "card", + "discovered_via": "registry.json" + }, + { + "id": "b624ff585a15ed20", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "carousel", + "discovered_via": "registry.json" + }, + { + "id": "68401b063c6bf4f9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "chart", + "discovered_via": "registry.json" + }, + { + "id": "b0f2dd5251ea78ad", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "checkbox", + "discovered_via": "registry.json" + }, + { + "id": "e3ee864d9d917916", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "collapsible", + "discovered_via": "registry.json" + }, + { + "id": "3011ac0c599ddee7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "command", + "discovered_via": "registry.json" + }, + { + "id": "c43a0f72a5e34bb0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "context-menu", + "discovered_via": "registry.json" + }, + { + "id": "37230c20c1ce99bf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "dialog", + "discovered_via": "registry.json" + }, + { + "id": "357a10b4cc9729ed", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "drawer", + "discovered_via": "registry.json" + }, + { + "id": "6ca8e64d5d97c13a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "dropdown-menu", + "discovered_via": "registry.json" + }, + { + "id": "2bd39bd7797b6827", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "form", + "discovered_via": "registry.json" + }, + { + "id": "0fb13b7b70402039", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "hover-card", + "discovered_via": "registry.json" + }, + { + "id": "0cd49ce69c55b1bb", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "input-group", + "discovered_via": "registry.json" + }, + { + "id": "f8b4a54676fffd32", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "input-otp", + "discovered_via": "registry.json" + }, + { + "id": "d2b93a51f8251454", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "input", + "discovered_via": "registry.json" + }, + { + "id": "e0c3ab25a876c27b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "label", + "discovered_via": "registry.json" + }, + { + "id": "230fb7b9f6c51e6b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "menubar", + "discovered_via": "registry.json" + }, + { + "id": "be965ddce05f6bae", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "navigation-menu", + "discovered_via": "registry.json" + }, + { + "id": "67073848018858c5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "pagination", + "discovered_via": "registry.json" + }, + { + "id": "9eed09f2d64026bc", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "popover", + "discovered_via": "registry.json" + }, + { + "id": "7520f45b90a7d790", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "progress", + "discovered_via": "registry.json" + }, + { + "id": "a5bc1e1d5eece144", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "radio-group", + "discovered_via": "registry.json" + }, + { + "id": "57f4dbc96133af92", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "resizable", + "discovered_via": "registry.json" + }, + { + "id": "744f55a017613174", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "scroll-area", + "discovered_via": "registry.json" + }, + { + "id": "cbb1b273705b8c3a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "select", + "discovered_via": "registry.json" + }, + { + "id": "a6731007b390a7cb", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "separator", + "discovered_via": "registry.json" + }, + { + "id": "5ac98c3861fd2bbe", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "sheet", + "discovered_via": "registry.json" + }, + { + "id": "f722d6d84cd1d84c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "sidebar", + "discovered_via": "registry.json" + }, + { + "id": "0dd70a30d7933f92", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "skeleton", + "discovered_via": "registry.json" + }, + { + "id": "cff16fe49fb0dc06", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "slider", + "discovered_via": "registry.json" + }, + { + "id": "4a1fc20196bceda8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "sonner", + "discovered_via": "registry.json" + }, + { + "id": "1a2cc13af69b439b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "spinner", + "discovered_via": "registry.json" + }, + { + "id": "6196b5284e21d11d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "switch", + "discovered_via": "registry.json" + }, + { + "id": "951d177329479541", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "table", + "discovered_via": "registry.json" + }, + { + "id": "80278466976164ec", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "tabs", + "discovered_via": "registry.json" + }, + { + "id": "a350560e9254a3d8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "textarea", + "discovered_via": "registry.json" + }, + { + "id": "8df8c5bff7382fa9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "toggle-group", + "discovered_via": "registry.json" + }, + { + "id": "4413cbe20cc2c103", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "toggle", + "discovered_via": "registry.json" + }, + { + "id": "7612061697dea683", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "tooltip", + "discovered_via": "registry.json" + }, + { + "id": "cef5e1893ef71cfa", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "agent", + "discovered_via": "registry.json" + }, + { + "id": "cb2246a1a0a594a0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "artifact", + "discovered_via": "registry.json" + }, + { + "id": "54fcaf7ad9ec7034", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "attachments", + "discovered_via": "registry.json" + }, + { + "id": "347e4b2dd05be0a3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "audio-player", + "discovered_via": "registry.json" + }, + { + "id": "d27fd6c7bd17b6a4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "canvas", + "discovered_via": "registry.json" + }, + { + "id": "fb83ef54dd58def9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "chain-of-thought", + "discovered_via": "registry.json" + }, + { + "id": "4acb9604bfe89289", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "checkpoint", + "discovered_via": "registry.json" + }, + { + "id": "2812a746582e9ab5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "code-block", + "discovered_via": "registry.json" + }, + { + "id": "bbb940137f1828e3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "commit", + "discovered_via": "registry.json" + }, + { + "id": "2d3f67917ed7c328", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "confirmation", + "discovered_via": "registry.json" + }, + { + "id": "8393143ec2de398a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "connection", + "discovered_via": "registry.json" + }, + { + "id": "707f20d3130f35d5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "context", + "discovered_via": "registry.json" + }, + { + "id": "d9318af758610849", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "controls", + "discovered_via": "registry.json" + }, + { + "id": "2d8c76aef78cd232", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "conversation", + "discovered_via": "registry.json" + }, + { + "id": "719fdb1140e927f9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "edge", + "discovered_via": "registry.json" + }, + { + "id": "b682bd4dcb160070", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "environment-variables", + "discovered_via": "registry.json" + }, + { + "id": "ad229aee1410e523", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "file-tree", + "discovered_via": "registry.json" + }, + { + "id": "94beb1c2d9ff2845", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "image", + "discovered_via": "registry.json" + }, + { + "id": "e51180938ec14b8c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "inline-citation", + "discovered_via": "registry.json" + }, + { + "id": "7b90233a11529ce9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "jsx-preview", + "discovered_via": "registry.json" + }, + { + "id": "ef2cb361cccba988", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "message", + "discovered_via": "registry.json" + }, + { + "id": "4be18327f5b08c6c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "mic-selector", + "discovered_via": "registry.json" + }, + { + "id": "e70c3de12bb6d8ea", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "model-selector", + "discovered_via": "registry.json" + }, + { + "id": "32ad3eafd71afa12", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "node", + "discovered_via": "registry.json" + }, + { + "id": "a4dc760565bee6c2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "open-in-chat", + "discovered_via": "registry.json" + }, + { + "id": "5fff8ca1cc7e8a23", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "package-info", + "discovered_via": "registry.json" + }, + { + "id": "2c0fefee5e534ee4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "panel", + "discovered_via": "registry.json" + }, + { + "id": "75c3ac0441dece32", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "persona", + "discovered_via": "registry.json" + }, + { + "id": "9315380db9a8bda5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "plan", + "discovered_via": "registry.json" + }, + { + "id": "c5d442865455dabf", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "prompt-input", + "discovered_via": "registry.json" + }, + { + "id": "751cbf26da331c5e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "queue", + "discovered_via": "registry.json" + }, + { + "id": "51627933795cfd9f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "reasoning", + "discovered_via": "registry.json" + }, + { + "id": "d50f9446f8cf4163", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "sandbox", + "discovered_via": "registry.json" + }, + { + "id": "dca1fcd6caf24479", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "schema-display", + "discovered_via": "registry.json" + }, + { + "id": "0c8f9133335d0f1f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "shimmer", + "discovered_via": "registry.json" + }, + { + "id": "faf08e9917809e42", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "snippet", + "discovered_via": "registry.json" + }, + { + "id": "ac8f38e930abf05d", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "sources", + "discovered_via": "registry.json" + }, + { + "id": "2a1ad587392af5c9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "speech-input", + "discovered_via": "registry.json" + }, + { + "id": "30fea3317b5c23ac", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "stack-trace", + "discovered_via": "registry.json" + }, + { + "id": "3699dfde5fb9bda5", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "suggestion", + "discovered_via": "registry.json" + }, + { + "id": "0be350bee51fb4c3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "task", + "discovered_via": "registry.json" + }, + { + "id": "471c27e53ec79afc", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "terminal", + "discovered_via": "registry.json" + }, + { + "id": "c9a84b2b2c637f82", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "test-results", + "discovered_via": "registry.json" + }, + { + "id": "6b0d88cc6cfbed59", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "tool", + "discovered_via": "registry.json" + }, + { + "id": "a8b9f241eeb744e2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "toolbar", + "discovered_via": "registry.json" + }, + { + "id": "5dba9261593e5451", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "transcription", + "discovered_via": "registry.json" + }, + { + "id": "251644133d18d6c0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "voice-selector", + "discovered_via": "registry.json" + }, + { + "id": "b980390231ee0de8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "web-preview", + "discovered_via": "registry.json" + } + ], + "libraries": [ + { + "id": "955edb1977d13f5b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@hookform/resolvers", + "kind": "forms", + "version": "^5.2.2" + }, + { + "id": "2ab539264bf22bb3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-accordion", + "kind": "primitives", + "version": "^1.2.12" + }, + { + "id": "7870c62c4e9db858", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-alert-dialog", + "kind": "primitives", + "version": "^1.1.15" + }, + { + "id": "a2ec396fadd46b86", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-aspect-ratio", + "kind": "primitives", + "version": "^1.1.8" + }, + { + "id": "d8ba0badfa7f52e7", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-avatar", + "kind": "primitives", + "version": "^1.1.11" + }, + { + "id": "d2a20eec838ad1a9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-checkbox", + "kind": "primitives", + "version": "^1.3.3" + }, + { + "id": "7a0a367b40c09440", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-collapsible", + "kind": "primitives", + "version": "^1.1.12" + }, + { + "id": "f474b047d92d3e5b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-context-menu", + "kind": "primitives", + "version": "^2.2.16" + }, + { + "id": "23b6506604a19c9b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-dialog", + "kind": "primitives", + "version": "^1.1.15" + }, + { + "id": "be43a3944427cb99", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-dropdown-menu", + "kind": "primitives", + "version": "^2.1.16" + }, + { + "id": "996b036e73d549f8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-hover-card", + "kind": "primitives", + "version": "^1.1.15" + }, + { + "id": "329bd4e2ac504f3b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-label", + "kind": "primitives", + "version": "^2.1.8" + }, + { + "id": "b6cb1ae55ed03033", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-menubar", + "kind": "primitives", + "version": "^1.1.16" + }, + { + "id": "37cdb92d1d6bfabd", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-navigation-menu", + "kind": "primitives", + "version": "^1.2.14" + }, + { + "id": "b21ba96ead7c36d0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-popover", + "kind": "primitives", + "version": "^1.1.15" + }, + { + "id": "86cd9fe57015a3e9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-progress", + "kind": "primitives", + "version": "^1.1.8" + }, + { + "id": "be0a890be5ec59e2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-radio-group", + "kind": "primitives", + "version": "^1.3.8" + }, + { + "id": "fe5a84aedcbdc51b", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-scroll-area", + "kind": "primitives", + "version": "^1.2.10" + }, + { + "id": "e9af4efbb2db6972", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-select", + "kind": "primitives", + "version": "^2.2.6" + }, + { + "id": "e40834caa601d650", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-separator", + "kind": "primitives", + "version": "^1.1.8" + }, + { + "id": "93fde33bb4b4e7d8", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-slider", + "kind": "primitives", + "version": "^1.3.6" + }, + { + "id": "1e65491f959715a0", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-slot", + "kind": "primitives", + "version": "^1.2.4" + }, + { + "id": "1389356cc2bb1302", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-switch", + "kind": "primitives", + "version": "^1.2.6" + }, + { + "id": "760326142ce687a9", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-tabs", + "kind": "primitives", + "version": "^1.1.13" + }, + { + "id": "1319a10801a33732", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-toggle", + "kind": "primitives", + "version": "^1.1.10" + }, + { + "id": "f1eeb95b3e63e4d2", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-toggle-group", + "kind": "primitives", + "version": "^1.1.11" + }, + { + "id": "2a3f4691d0aa5e7a", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-tooltip", + "kind": "primitives", + "version": "^1.2.8" + }, + { + "id": "675b993d39faab35", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "@radix-ui/react-use-controllable-state", + "kind": "primitives", + "version": "^1.2.2" + }, + { + "id": "c3b9988c1ecab4ba", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "class-variance-authority", + "kind": "variant-helper", + "version": "^0.7.1" + }, + { + "id": "2bd418c0a6034d0f", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "clsx", + "kind": "class-utils", + "version": "^2.1.1" + }, + { + "id": "5fe7d1a752cdcb60", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "cmdk", + "kind": "command-palette", + "version": "^1.1.1" + }, + { + "id": "a56b700c1acf0781", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "date-fns", + "kind": "date", + "version": "^4.1.0" + }, + { + "id": "1e4e1f78d154a64c", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "lucide-react", + "kind": "icons", + "version": "^1.7.0" + }, + { + "id": "c1113c9292278754", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "motion", + "kind": "motion", + "version": "^12.38.0" + }, + { + "id": "f257b0b1f90ce7f4", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "react-day-picker", + "kind": "date", + "version": "9.14.0" + }, + { + "id": "3ff7b7953dbf64fa", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "react-hook-form", + "kind": "forms", + "version": "^7.72.0" + }, + { + "id": "cacd86649a83786e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "recharts", + "kind": "charts", + "version": "^3.8.1" + }, + { + "id": "b5e83ed0f2d963ed", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "sonner", + "kind": "toast", + "version": "^2.0.7" + }, + { + "id": "248a95b02f4fa83e", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "tailwind-merge", + "kind": "class-utils", + "version": "^3.5.0" + }, + { + "id": "5d67e888930bf3ea", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "tw-animate-css", + "kind": "animation", + "version": "^1.4.0" + }, + { + "id": "45342a3452d38062", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "vaul", + "kind": "drawer", + "version": "^1.1.2" + }, + { + "id": "8351e3f559ab3dd3", + "source": { + "target": "github:block/ghost#packages/ghost-ui", + "commit": "916e728489f603407c6d00b798f49acd3975e7bb", + "scanned_at": "2026-04-29T16:10:00Z", + "scanner_version": "0.1.0" + }, + "name": "zod", + "kind": "validation", + "version": "^4.3.6" + } + ] +} diff --git a/dogfood/ghost-ui/attempt-2/build-bucket.mjs b/dogfood/ghost-ui/attempt-2/build-bucket.mjs new file mode 100644 index 0000000..c77faa6 --- /dev/null +++ b/dogfood/ghost-ui/attempt-2/build-bucket.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +// Survey-recipe extraction script for packages/ghost-ui. +// Enumerates exhaustively: tokens from main.css, components from registry.json, +// libraries from package.json, values from frequency-clustered hex/scalar/etc. +// Writes a bucket.json with empty IDs (fix-ids will populate). + +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const ROOT = "/Users/nahiyan/Development/ghost/packages/ghost-ui"; +const SOURCE = { + target: "github:block/ghost#packages/ghost-ui", + commit: execSync("git rev-parse HEAD", { + cwd: "/Users/nahiyan/Development/ghost", + }) + .toString() + .trim(), + scanned_at: "2026-04-29T16:10:00Z", + scanner_version: "0.1.0", +}; + +// ---- 1. Tokens — every named CSS custom property in main.css ---- + +const mainCss = readFileSync(resolve(ROOT, "src/styles/main.css"), "utf-8"); + +// Walk main.css line-by-line. Each `--name: value;` is a declaration. +// We dedupe by name, prefer the :root / @theme declaration as the canonical +// source, and capture per-theme overrides under by_theme. +const tokens = new Map(); // name -> { name, declarations: { theme -> value } } +let currentScope = "theme"; // "theme" | "root" | "dark" +const lines = mainCss.split("\n"); +for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Update scope on block boundaries. + if (/^\s*@theme\s*\{/.test(line)) currentScope = "theme"; + else if (/^\s*@theme\s+inline\s*\{/.test(line)) currentScope = "theme-inline"; + else if (/^\s*:root\s*\{/.test(line)) currentScope = "root"; + else if (/^\s*\.dark\s*\{/.test(line)) currentScope = "dark"; + else if (/^\s*@layer\s+/.test(line)) currentScope = "layer"; + + // Match a custom property declaration. + const m = line.match(/^\s*(--[a-z0-9-]+)\s*:\s*([^;]+?)\s*;\s*$/i); + if (!m) continue; + const [, name, value] = m; + + if (!tokens.has(name)) { + tokens.set(name, { name, scopes: {} }); + } + const t = tokens.get(name); + if (!t.scopes[currentScope]) t.scopes[currentScope] = value; +} + +// Build token rows. Resolve simple alias chains by following var() refs. +function resolveChain(name, byScope, depth = 0) { + if (depth > 10) + return { chain: [], resolved: byScope[name]?.scopes?.root ?? "" }; + const scoped = + byScope[name]?.scopes?.root ?? + byScope[name]?.scopes?.theme ?? + byScope[name]?.scopes?.["theme-inline"] ?? + ""; + const m = scoped.match(/^var\(\s*(--[a-z0-9-]+)/i); + if (!m) return { chain: [], resolved: scoped }; + const next = m[1]; + const sub = resolveChain(next, byScope, depth + 1); + return { chain: [next, ...sub.chain], resolved: sub.resolved }; +} + +const byName = Object.fromEntries(tokens); +const tokenRows = []; +for (const t of tokens.values()) { + const { chain, resolved } = resolveChain(t.name, byName); + const row = { + id: "", + source: SOURCE, + name: t.name, + alias_chain: chain, + resolved_value: resolved, + occurrences: 1, + }; + // by_theme: capture light vs dark divergence when both scopes have a declaration. + const root = t.scopes.root; + const dark = t.scopes.dark; + if (root && dark && root !== dark) { + row.by_theme = { light: root, dark }; + } + tokenRows.push(row); +} +console.error(`tokens: ${tokenRows.length}`); + +// ---- 2. Components — every registry:ui item ---- + +const registry = JSON.parse( + readFileSync(resolve(ROOT, "registry.json"), "utf-8"), +); +const componentRows = registry.items + .filter((i) => i.type === "registry:ui") + .map((i) => ({ + id: "", + source: SOURCE, + name: i.name, + discovered_via: "registry.json", + })); +console.error(`components: ${componentRows.length}`); + +// ---- 3. Libraries — every external dep that contributes design surface ---- + +const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8")); +const deps = pkg.dependencies || {}; +const LIBRARY_KIND = (name) => { + if (name.startsWith("@radix-ui/react-")) return "primitives"; + if (name === "lucide-react") return "icons"; + if (name === "recharts") return "charts"; + if (name === "tw-animate-css") return "animation"; + if (name === "motion" || name === "framer-motion") return "motion"; + if (name === "@hookform/resolvers" || name === "react-hook-form") + return "forms"; + if (name === "zod") return "validation"; + if (name === "date-fns" || name === "react-day-picker") return "date"; + if (name === "cmdk") return "command-palette"; + if (name === "sonner") return "toast"; + if (name === "vaul") return "drawer"; + if (name === "class-variance-authority") return "variant-helper"; + if (name === "clsx" || name === "tailwind-merge") return "class-utils"; + return null; +}; +const libraryRows = []; +for (const [name, version] of Object.entries(deps)) { + const kind = LIBRARY_KIND(name); + if (!kind) continue; + libraryRows.push({ + id: "", + source: SOURCE, + name, + kind, + version, + }); +} +console.error(`libraries: ${libraryRows.length}`); + +// ---- 4. Values — frequency-clustered literals across the design system ---- + +function rgFreq(pattern, glob) { + try { + const out = execSync( + `rg -oNI '${pattern}' -g '${glob}' src/ 2>/dev/null | sort | uniq -c | sort -rn`, + { cwd: ROOT, encoding: "utf-8" }, + ); + return out + .split("\n") + .filter(Boolean) + .map((l) => { + const m = l.match(/^\s*(\d+)\s+(.+)$/); + return m ? { count: Number(m[1]), value: m[2] } : null; + }) + .filter(Boolean); + } catch { + return []; + } +} + +function rgFilesContaining(pattern, glob) { + try { + const out = execSync(`rg -lN '${pattern}' -g '${glob}' src/ 2>/dev/null`, { + cwd: ROOT, + encoding: "utf-8", + }); + return out.split("\n").filter(Boolean).length; + } catch { + return 0; + } +} + +const valueRows = []; + +// Hex colors +const hexes = rgFreq("#[0-9a-fA-F]{6}", "*.{ts,tsx,css}"); +for (const { count, value } of hexes) { + if (count < 1) continue; + valueRows.push({ + id: "", + source: SOURCE, + kind: "color", + value: value.toLowerCase(), + raw: value, + spec: { space: "srgb", hex: value.toLowerCase() }, + occurrences: count, + files_count: rgFilesContaining(value, "*.{ts,tsx,css}"), + }); +} + +// px scalars (split into spacing / radius / breakpoint / shadow-blur / layout-primitive) +const pxScalars = rgFreq("\\b[0-9]+(\\.[0-9]+)?px\\b", "*.{css}"); +for (const { count, value } of pxScalars) { + const num = parseFloat(value); + // Heuristic kind by value range — agent's role-hypothesis guess + let kind = "spacing"; + if (num === 999 || num === 1440) { + kind = num === 1440 ? "breakpoint" : "radius"; + } else if (num >= 1000) { + kind = "layout-primitive"; + } + valueRows.push({ + id: "", + source: SOURCE, + kind, + value: String(num), + raw: value, + spec: + kind === "breakpoint" + ? { + scalar: num, + unit: "px", + label: num === 1440 ? "desktop" : undefined, + } + : { scalar: num, unit: "px" }, + occurrences: count, + files_count: rgFilesContaining(value, "*.{css}"), + }); +} + +// rem scalars +const remScalars = rgFreq("\\b[0-9]+(\\.[0-9]+)?rem\\b", "*.{ts,tsx,css}"); +for (const { count, value } of remScalars) { + valueRows.push({ + id: "", + source: SOURCE, + kind: "spacing", + value, + raw: value, + spec: { scalar: parseFloat(value), unit: "rem" }, + occurrences: count, + files_count: rgFilesContaining(value, "*.{ts,tsx,css}"), + }); +} + +// motion durations +const durations = rgFreq("\\b[0-9]+(\\.[0-9]+)?s\\b", "*.{css}"); +for (const { count, value } of durations) { + // Skip values likely not durations (e.g. 999s would be junk; capping) + const seconds = parseFloat(value); + if (seconds > 5) continue; + valueRows.push({ + id: "", + source: SOURCE, + kind: "motion", + value, + raw: value, + spec: { duration_ms: Math.round(seconds * 1000) }, + occurrences: count, + files_count: rgFilesContaining(value, "*.{css}"), + }); +} + +// font-family declarations (string literals in @theme + @theme inline) +const families = new Set(); +const fontMatches = mainCss.match(/--font-[a-z-]+:\s*([^;]+);/gi) || []; +for (const m of fontMatches) { + const valueMatch = m.match(/--font-[a-z-]+:\s*([^;]+);/i); + if (!valueMatch) continue; + // Pull primary family name (first comma-separated entry, dequoted) + const primary = valueMatch[1] + .split(",")[0] + .trim() + .replace(/^["']|["']$/g, ""); + if (primary && primary !== "var") families.add(primary); +} +for (const family of families) { + valueRows.push({ + id: "", + source: SOURCE, + kind: "typography", + value: family, + raw: `"${family}"`, + spec: { family }, + occurrences: 1, + files_count: 1, + }); +} + +// easing +const easings = mainCss.match(/cubic-bezier\([^)]+\)/g) || []; +for (const easing of new Set(easings)) { + valueRows.push({ + id: "", + source: SOURCE, + kind: "motion", + value: easing, + raw: easing, + spec: { easing }, + occurrences: 1, + files_count: 1, + }); +} + +console.error(`values: ${valueRows.length}`); + +// ---- 5. Write ---- + +const bucket = { + schema: "ghost.bucket/v1", + sources: [SOURCE], + values: valueRows, + tokens: tokenRows, + components: componentRows, + libraries: libraryRows, +}; + +writeFileSync( + "/tmp/ghost-ui-scan/bucket.json", + JSON.stringify(bucket, null, 2) + "\n", +); +console.error( + `\nbucket totals: values=${valueRows.length} tokens=${tokenRows.length} components=${componentRows.length} libraries=${libraryRows.length}`, +); diff --git a/dogfood/ghost-ui/attempt-2/expression.md b/dogfood/ghost-ui/attempt-2/expression.md new file mode 100644 index 0000000..85b8b9f --- /dev/null +++ b/dogfood/ghost-ui/attempt-2/expression.md @@ -0,0 +1,190 @@ +--- +id: ghost-ui +source: llm +timestamp: 2026-04-29T16:15:00Z +sources: + - github:block/ghost#packages/ghost-ui + +observation: + personality: [monochromatic, editorial, restrained, generous, rhythmic] + resembles: [vercel-geist, linear] + +decisions: + - dimension: color-strategy + - dimension: chart-strategy + - dimension: surface-hierarchy + - dimension: shape-language + - dimension: theming-architecture + - dimension: typography-voice + - dimension: font-sourcing + - dimension: density + - dimension: interactive-patterns + - dimension: elevation + - dimension: motion + +palette: + dominant: + - { role: ink, value: "#1a1a1a" } + neutrals: + steps: ["#ffffff", "#f5f5f5", "#f0f0f0", "#e8e8e8", "#e5e5e5", "#cccccc", "#999999", "#666666", "#333333", "#232323", "#1a1a1a", "#000000"] + count: 12 + semantic: + - { role: success, value: "#91cb80" } + - { role: success-light, value: "#a3d795" } + - { role: danger, value: "#f94b4b" } + - { role: danger-light, value: "#ff6b6b" } + - { role: info, value: "#5c98f9" } + - { role: info-light, value: "#7cacff" } + - { role: warning, value: "#fbcd44" } + - { role: warning-light, value: "#ffd966" } + - { role: chart-1, value: "#f6b44a" } + - { role: chart-2, value: "#7585ff" } + - { role: chart-3, value: "#d76a6a" } + - { role: chart-4, value: "#d185e0" } + - { role: chart-5, value: "#91cb80" } + saturationProfile: muted + contrast: high + +spacing: + scale: [1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 30, 40, 44, 60, 64, 75, 96, 100, 200] + baseUnit: 4 + regularity: 0.6 + +typography: + families: ["system-ui", "Geist Mono", "serif"] + sizeRamp: [10, 11, 12, 14, 16, 20, 28, 44, 64, 96] + weightDistribution: { "300": 1, "600": 4, "700": 2, "900": 1 } + lineHeightPattern: tight + +surfaces: + borderRadii: [10, 14, 16, 20, 24, 999] + shadowComplexity: layered + borderUsage: minimal +--- + +# Character + +`ghost-ui` is an editorial design language: a 12-step pure-monochromatic neutral scale carries surface, border, and text across the whole UI; chroma appears only when the UI signals state or surfaces data. Headings move on a magazine-scale fluid hierarchy and inputs/buttons land on full pills, so the visual rhythm comes from shape and shadow rather than color. The system ships no bundled fonts and no brand color — character is in the discipline, not the ornament. + +# Signature + +- A 12-step pure-gray neutral ladder (`#ffffff` → `#000000`) with no chromatic tint at any step is the dominant color system. Chroma is reserved for state and data. +- Pill-first interactive radii: every button, input, and toggle is fully pilled (`999px`); cards stay rounded but distinct (`14`/`20`/`24px`); modals (`16px`) and dropdowns (`10px`) sit between. +- Magazine-scale fluid typography — headings and body sizes use `clamp()` to scale with viewport, and a four-tier heading hierarchy (`display`/`section`/`sub`/`card`) carries its own line-height + letter-spacing recipe. +- Three-deep token alias chains (raw step → semantic alias → shadcn alias) so the entire visual language can be runtime-swapped via CSS-var injection without touching component code. +- A 7-named-tier shadow system (`mini`/`btn`/`card`/`elevated`/`popover`/`modal`/`kbd`) whose alpha intensifies in dark mode rather than inverts — depth reads consistently across themes. +- Notable absence: zero bundled fonts (`font-faces.css` is one comment), zero gradients, zero brand color, no decorative motion. The design language is anti-flourish. + +# Decisions + +### color-strategy + +Treat hue as opt-in communication, not ambient decoration. A 12-step pure-monochromatic neutral ladder (`#ffffff` through `#000000`) carries surface, border, and text across the entire UI; eight semantic colors light up only when the UI signals state (success, danger, info, warning, each in a default + light variant for theme cascade). There is no brand color, no accent hue. Distinction comes from shape and shadow, not chroma. + +**Evidence:** +- Monochromatic ladder: `#ffffff`, `#f5f5f5`, `#f0f0f0`, `#e8e8e8`, `#e5e5e5`, `#cccccc`, `#999999`, `#666666`, `#333333`, `#232323`, `#1a1a1a`, `#000000` (declared as `--color-gray-50` through `--color-gray-900` plus `--color-white`/`--color-black`) +- Semantic state colors with light/dark cascade: `#91cb80` / `#a3d795` (success), `#f94b4b` / `#ff6b6b` (danger), `#5c98f9` / `#7cacff` (info), `#fbcd44` / `#ffd966` (warning) +- The `@theme` block in `src/styles/main.css` opens with `--color-*: initial` — explicitly resetting Tailwind's default palette so nothing chromatic leaks in. +- `--background-accent: var(--color-gray-900)` — the "accent" maps to the extreme of the gray scale, not a brand hue. The accent slot itself is monochrome. + +### chart-strategy + +Data visualization gets a deliberately separate, warm-leaning 5-color palette (`#f6b44a` orange, `#7585ff` periwinkle, `#d76a6a` coral, `#d185e0` lilac, `#91cb80` green) that intentionally departs from the monochromatic discipline applied everywhere else. Charts need categorical distinction; the rest of the UI doesn't. Naming the chart palette as a separate sub-strategy keeps the discipline gate clear: chroma here is signal, not seepage. + +**Evidence:** +- `--chart-1: #f6b44a`, `--chart-2: #7585ff`, `--chart-3: #d76a6a`, `--chart-4: #d185e0`, `--chart-5: #91cb80` +- Same five chart values declared identically in `:root` and `.dark` — the chart palette is the one thing that doesn't theme-cascade. Cross-theme consistency is the goal for data. + +### surface-hierarchy + +Name surfaces by intent rather than by shade number. Backgrounds, borders, and text each get their own semantic vocabulary (`default`, `alt`, `medium`, `muted`, `inverse`, `accent`, plus `danger`/`success`/`info`/`warning` for state and `strong`/`card`/`input`/`input-hover` for borders). A theme preset can remap every value without touching component logic — the slot names are the contract. + +**Evidence:** +- 9 background slots: `--background-default`, `--background-alt`, `--background-medium`, `--background-muted`, `--background-inverse`, `--background-accent`, `--background-danger`, `--background-success`, `--background-info`, `--background-warning` +- 10 border slots: `--border-default`, `--border-input`, `--border-input-hover`, `--border-strong`, `--border-card`, `--border-inverse`, `--border-accent`, plus state borders +- 8 text slots mirroring the same vocabulary +- A separate `--sidebar-*` namespace (8 slots) lets sidebar UI carry its own surface vocabulary parallel to the main one — intentional surface-zone separation. + +### shape-language + +Apply a pill-first radius philosophy that sorts surfaces by interaction. Buttons, inputs, and toggles are fully pilled (`999px`) — interactive surfaces are circular at the ends. Cards stay rounded but distinct as soft squares (`14`/`20`/`24px`). Modals (`16px`) and dropdowns (`10px`) live in between. The shape choice itself is a rhythm: interactive vs. structural surfaces are read by their corner radius before any color cue. + +**Evidence:** +- `--radius-pill: 999px`, `--radius-button: 999px`, `--radius-input: 999px` +- `--radius-card: 20px`, `--radius-card-lg: 24px`, `--radius-card-sm: 14px` +- `--radius-modal: 16px`, `--radius-dropdown: 10px` +- `--radius: 20px` (the system base) with derived `--radius-sm/md/lg/xl` via `calc()` + +### theming-architecture + +Cascade three layers — raw stepped values → semantic aliases → shadcn aliases — so the entire visual language swaps at runtime through CSS custom property injection without touching component code. Consumers reach for whichever layer matches their intent: Tailwind utilities pull `--color-foreground`; component CSS pulls `--text-default`; raw needs reach `--color-gray-900`. Five bundled theme presets (default plus four overrides under `src/lib/theme-presets.ts`) validate that shape, color, and contrast can all be remapped. + +**Evidence:** +- chain `--color-foreground` → `--foreground` → `--text-default` → `--color-gray-900` +- chain `--background-default` → `--color-white` (light) / `--color-black` (dark) +- The `@theme inline` block in `main.css` re-exposes every semantic alias as a Tailwind color token (`--color-background-default: var(--background-default)`, etc.) +- `src/lib/theme-presets.ts` ships 5 named presets that each remap base + semantic layers without changing the alias contract. + +### typography-voice + +Use a magazine-scale type hierarchy where display headings and body sizes scale with viewport via `clamp()` rather than living on a fixed ramp. Four heading tiers (`display`, `section`, `sub`, `card`) each carry their own progressively-tightening line-height (0.88 → 1.1) and decreasing negative letter-spacing. Editorial helpers — `--label-*` (uppercase kicker), `--pullquote-*` (light contrast voice) — supply visual punctuation for longform content. + +**Evidence:** +- `--heading-display-font-size: clamp(64px, 8vw, 96px)`, line-height `0.88`, letter-spacing `-0.05em`, weight `900` +- `--heading-section-font-size: clamp(44px, 5vw, 64px)`, line-height `0.95`, weight `700` +- `--heading-sub-font-size: clamp(28px, 3vw, 40px)`, line-height `1`, weight `700` +- `--heading-card-font-size: clamp(20px, 2vw, 28px)`, line-height `1.1`, weight `600` +- `--display-size: clamp(3rem, 12vw, 12rem)` — hero-scale fluid display +- `--body-reading-size: clamp(1rem, 1.3vw, 1.25rem)`, line-height `1.65` — longform reading rhythm +- `--label-font-size: 11px`, letter-spacing `0.12em`, weight `600` — uppercase kicker type +- `--pullquote-weight: 300`, line-height `1.3` — light contrast voice for editorial punctuation + +### font-sourcing + +Ship no bundled typefaces. The system declares a `system-ui` sans stack, `Geist Mono` with a monospace fallback, and a generic `serif` — typography responsibility moves to the consumer. The visual language adapts to the host platform's default face, which keeps the library dependency-free and lets hosts impose their own brand font without overriding. + +**Evidence:** +- `src/styles/font-faces.css` is one comment: `/* Design language ships with no bundled fonts — consumers bring their own. */` — zero `@font-face` declarations. +- `--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` +- `--font-mono: "Geist Mono", monospace` +- `--font-serif: serif` +- `--font-display: system-ui, …` (intentionally same as sans — no separate display face) + +### density + +Maintain compact interactive controls inside generous structural whitespace. Buttons and inputs sit in the 32–40px height range (`h-8`/`h-9`/`h-10` Tailwind utilities backed by `--spacing-button: 2.75rem` and `--spacing-input: 3.25rem` tokens), while page sections use lavish padding (`--section-padding-vertical: 100px`, `--section-heading-margin-bottom: 75px`). The result is a publishing-oriented reading rhythm in the structural layer, not a dense tool-UI feel. + +**Evidence:** +- Component-height tokens declared explicitly: `--spacing-button: 2.75rem` (44px), `--spacing-button-sm: 2rem` (32px), `--spacing-input: 3.25rem` (52px), `--spacing-input-sm: 2.75rem` (44px) +- Page-rhythm tokens: `--page-container-max-width: 1440px`, `--page-container-side-gutter: 20px`, `--section-padding-vertical: 100px`, `--section-heading-margin-bottom: 75px` +- Button cva (in `src/components/ui/button.tsx`) declares fixed sizes `default: h-9`, `sm: h-8`, `lg: h-10`, plus three icon sizes — interactive surfaces have committed heights, not derived ones. + +### interactive-patterns + +Standardize focus states as a 2-ring with offset using the `--ring` token, applied uniformly across every component, plus a global `*:focus-visible` fallback for browser-default elements. Browser default outlines are replaced with a consistent, theme-aware focus indicator that works in both light and dark modes. + +**Evidence:** +- Global rule in `main.css`: `*:not(body):not(.focus-override) { outline: none !important; &:focus-visible { @apply focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-hidden; } }` +- `--ring: var(--border-strong)` in light, same alias resolves to white in dark — focus ring intensity follows theme contrast. +- Button cva declares `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px]` — component-level reinforcement. +- Link helper class: `.link { @apply ... focus-visible:ring-ring focus-visible:ring-offset-background ... }` — same pattern applied to inline links. + +### elevation + +Name shadows by structural role rather than by intensity level, and intensify shadow alpha in dark mode rather than removing them. Seven named tiers (`mini`, `btn`, `card`, `elevated`, `popover`, `modal`, `kbd`) plus two special-purpose (`mini-inset`, `date-field-focus`) give designers a vocabulary tied to component context instead of a numeric scale. Depth cues stay legible on dark surfaces because alpha doubles, not because the shadow flips. + +**Evidence:** +- 7 named tiers: `--shadow-mini`, `--shadow-btn`, `--shadow-card`, `--shadow-elevated`, `--shadow-popover`, `--shadow-modal`, `--shadow-kbd` +- 2 special-purpose: `--shadow-mini-inset`, `--shadow-date-field-focus` +- Light: `--shadow-card: 0 2px 8px rgba(76, 76, 76, 0.15)` → Dark: `--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.4)` — alpha intensifies (~3×), shadow stays. +- Light: `--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.2)` → Dark: `--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.6)` — same intensification pattern at the heaviest tier. + +### motion + +Keep transitions functional and brief — three duration tiers (`0.15s` fast, `0.2s` normal, `0.4s` slow) drive every interaction, and a single shared cubic-bezier easing (`cubic-bezier(0.33, 1, 0.68, 1)`) gives the system a consistent decelerating feel. Animations are limited to opacity, transform, and blur; there is no decorative motion, no kinetic ornament. + +**Evidence:** +- `--duration-fast: 0.15s`, `--duration-normal: 0.2s`, `--duration-slow: 0.4s` +- `--ease-spring: cubic-bezier(0.33, 1, 0.68, 1)` +- 12 named animation tokens (`--animate-*`): `accordion-down/up`, `caret-blink`, `enter-from-left/right`, `exit-to-left/right`, `fade-in/out`, `scale-in/out`, `word-reveal` +- `tw-animate-css` is the only motion library imported via `main.css` — `motion` is in `dependencies` but not pulled into the styles entry point. diff --git a/dogfood/ghost-ui/attempt-2/map.md b/dogfood/ghost-ui/attempt-2/map.md new file mode 100644 index 0000000..fd5f572 --- /dev/null +++ b/dogfood/ghost-ui/attempt-2/map.md @@ -0,0 +1,103 @@ +--- +schema: ghost.map/v1 +id: ghost-ui +repo: block/ghost +mapped_at: 2026-04-29 +platform: web +languages: + - { name: typescript, files: 116, share: 0.4696 } + - { name: json, files: 113, share: 0.4575 } + - { name: markdown, files: 10, share: 0.0405 } + - { name: css, files: 4, share: 0.0162 } + - { name: javascript, files: 4, share: 0.0162 } +build_system: [pnpm, vite] +package_manifests: + - package.json + - tsconfig.json + - tsconfig.lib.json + - tsconfig.mcp.json + - vite.lib.config.ts + - components.json +composition: + frameworks: + - { name: react, version: "19.1.0" } + - { name: vite, version: "^6.3.0" } + - { name: tailwindcss, version: "^4.2.2" } + - { name: radix-ui } + - { name: lucide-react, version: "^1.7.0" } + - { name: cmdk, version: "^1.1.1" } + - { name: sonner, version: "^2.0.7" } + - { name: class-variance-authority, version: "^0.7.1" } + rendering: react-spa + styling: + - tailwindcss-v4 + - css-custom-properties +registry: + path: ./registry.json + components: 106 +design_system: + paths: + - src/styles + - src/components/theme + - src/lib + entry_files: + - src/styles/main.css + - src/styles/font-faces.css + - src/lib/theme-presets.ts + - src/lib/theme-defaults.ts + - src/lib/theme-utils.ts + - src/lib/theme-provider.tsx + token_source: inline + status: active +ui_surface: + include: + - "src/components/ui/**" + - "src/components/ai-elements/**" + - "src/components/theme/**" + - "src/styles/**" + - "src/lib/theme-*" + exclude: + - "src/mcp/**" + - "scripts/**" + - "dist/**" + - "dist-lib/**" + - "dist-mcp/**" + - "public/r/**" + - "**/*.test.ts" + - "**/*.test.tsx" +feature_areas: + - name: ui-primitives + paths: ["src/components/ui"] + sub_areas: [input, layout, feedback, display, navigation, overlay] + - name: ai-elements + paths: ["src/components/ai-elements"] + sub_areas: [chat, agent-state, artifacts, audio] + - name: theme + paths: ["src/components/theme", "src/lib/theme-presets.ts", "src/lib/theme-defaults.ts", "src/lib/theme-utils.ts", "src/lib/theme-provider.tsx"] + - name: tokens + paths: ["src/styles"] + - name: hooks + paths: ["src/hooks"] + - name: registry-tooling + paths: ["scripts", "registry.json", "components.json"] + - name: mcp-server + paths: ["src/mcp"] +orientation_files: + - README.md + - registry.json + - src/styles/main.css + - src/lib/theme-presets.ts + - src/lib/theme-defaults.ts +--- + +## Identity + +`ghost-ui` is a private workspace package in the `block/ghost` monorepo. It ships a reference design system — 49 shadcn-style UI primitives plus 48 AI-element components plus a theme layer — distributed via a shadcn `registry.json` rather than npm. It also ships an MCP server (`ghost-mcp` bin) that re-exposes the registry to AI assistants. + +## Topology + +The design system lives across three folders. Tokens are inline CSS custom properties declared in `src/styles/main.css` (Tailwind v4 `@theme` blocks plus `:root` and `.dark` variable layers). Theme presets and defaults are TypeScript modules under `src/lib/theme-*.ts`, surfaced through a `theme-provider.tsx` React context. UI primitives live under `src/components/ui/`; AI-specific elements (chat surfaces, agent-state indicators, artifacts) live under `src/components/ai-elements/`. The `registry.json` at the package root indexes 106 distributable items consumed by `shadcn build`. + +## Conventions + +Tailwind v4 with custom theming via `@theme` blocks, CSS custom properties for runtime token resolution, and a class-variance-authority pattern for variant-heavy primitives. Radix UI underlies most interactive primitives. Components are flat (no nested theme variants) and ship as both source files and a baked `registry.json` plus per-item snapshots under `public/r/` (113 JSON files account for the JSON-heavy histogram). Build splits into a Vite library bundle and a separate TypeScript-built MCP server. From a468f4ffc616a8b1217458b0df49cd70a472794a Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 29 Apr 2026 17:08:53 -0400 Subject: [PATCH 04/14] schema(bucket): drop libraries section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External libraries (icon sets, primitive collections, motion libs, charting) no longer have a top-level bucket section. Whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*; what matters surfaces elsewhere — font families show up as typography tokens, and load-bearing library choices (icon family, font sourcing) belong in interpreter prose under the relevant decision dimension. Bucket sections are now: values, tokens, components. Removed from @ghost/core exports: LibraryRow, LibraryRowSchema, libraryRowId. Lint, merge, and fix-ids no longer touch a libraries section. Skill recipes (survey.md, profile.md) updated — survey.md no longer instructs the agent to enumerate libraries; profile.md tells the agent that load-bearing library choices land in prose, not as structured rows. zod schema stays non-strict, so historical buckets that still carry a libraries field continue to lint clean (the field is simply ignored). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fix-oklch-backfill-and-tighten-survey.md | 4 +++- packages/ghost-core/src/bucket/fix-ids.ts | 16 ++------------ packages/ghost-core/src/bucket/id.ts | 6 ------ packages/ghost-core/src/bucket/index.ts | 9 +------- packages/ghost-core/src/bucket/lint.ts | 20 ++---------------- packages/ghost-core/src/bucket/merge.ts | 11 +--------- packages/ghost-core/src/bucket/schema.ts | 8 ------- packages/ghost-core/src/bucket/types.ts | 11 ---------- packages/ghost-core/src/index.ts | 3 --- .../ghost-core/test/bucket-fix-ids.test.ts | 1 - packages/ghost-core/test/bucket-id.test.ts | 18 +++++----------- packages/ghost-core/test/bucket-lint.test.ts | 1 - packages/ghost-core/test/bucket-merge.test.ts | 3 +-- .../src/skill-bundle/references/profile.md | 13 ++++++------ .../src/skill-bundle/references/survey.md | 21 ++++++++----------- packages/ghost-expression/test/cli.test.ts | 2 -- 16 files changed, 31 insertions(+), 116 deletions(-) diff --git a/.changeset/fix-oklch-backfill-and-tighten-survey.md b/.changeset/fix-oklch-backfill-and-tighten-survey.md index 40289a0..374b9f8 100644 --- a/.changeset/fix-oklch-backfill-and-tighten-survey.md +++ b/.changeset/fix-oklch-backfill-and-tighten-survey.md @@ -9,4 +9,6 @@ Fix self-distance bug + tighten the survey recipe's exhaustiveness rule. **Defensive fallback.** `comparePalette` also now resolves oklch on-the-fly when missing, and falls back to hex-string equality when even on-the-fly compute can't parse the color (CSS variables, opaque external refs). This covers third-party producers that don't backfill on write. -**Recipe tightening.** `survey.md` now states the exhaustiveness rule up front and applies it per section. The rule is repo-agnostic — the recipe doesn't name specific signal sources (no "use registry.json"); the agent identifies the canonical signal in *this* repo, enumerates exhaustively, and cross-checks counts. New `Never sample` rule and explicit guidance against placeholder/glob library names. Triggered by a dogfood scan that produced ~10% recall on `components[]` (6 rows for a 97-component package). +**Recipe tightening.** `survey.md` now states the exhaustiveness rule up front and applies it per section. The rule is repo-agnostic — the recipe doesn't name specific signal sources (no "use registry.json"); the agent identifies the canonical signal in *this* repo, enumerates exhaustively, and cross-checks counts. New `Never sample` rule and explicit guidance against placeholder/glob names. Triggered by a dogfood scan that produced ~10% recall on `components[]` (6 rows for a 97-component package). + +**Schema cut: `bucket.libraries[]` removed.** External libraries (icon sets, primitive collections, motion libs, charting) no longer have a top-level bucket section. Whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*. When a library is load-bearing for the design language (icon family, font sourcing), it surfaces as prose evidence in the interpreter stage under the relevant decision dimension. Bucket sections are now `values`, `tokens`, `components`. `LibraryRow` / `LibraryRowSchema` / `libraryRowId` removed from `@ghost/core` exports. Existing `bucket.json` files with a `libraries` field will fail lint (use a no-op migration: drop the field). diff --git a/packages/ghost-core/src/bucket/fix-ids.ts b/packages/ghost-core/src/bucket/fix-ids.ts index a719eb0..ce22d28 100644 --- a/packages/ghost-core/src/bucket/fix-ids.ts +++ b/packages/ghost-core/src/bucket/fix-ids.ts @@ -1,11 +1,5 @@ -import { componentRowId, libraryRowId, tokenRowId, valueRowId } from "./id.js"; -import type { - Bucket, - ComponentRow, - LibraryRow, - TokenRow, - ValueRow, -} from "./types.js"; +import { componentRowId, tokenRowId, valueRowId } from "./id.js"; +import type { Bucket, ComponentRow, TokenRow, ValueRow } from "./types.js"; /** * Recompute every row's `id` from its content fields, producing a new @@ -40,11 +34,5 @@ export function recomputeBucketIds(bucket: Bucket): Bucket { id: componentRowId(row.source, row.name), }), ), - libraries: bucket.libraries.map( - (row): LibraryRow => ({ - ...row, - id: libraryRowId(row.source, row.name), - }), - ), }; } diff --git a/packages/ghost-core/src/bucket/id.ts b/packages/ghost-core/src/bucket/id.ts index c67a996..87fc449 100644 --- a/packages/ghost-core/src/bucket/id.ts +++ b/packages/ghost-core/src/bucket/id.ts @@ -19,7 +19,6 @@ const ID_LENGTH = 16; const VALUE_TAG = "value"; const TOKEN_TAG = "token"; const COMPONENT_TAG = "component"; -const LIBRARY_TAG = "library"; function digest(...parts: (string | undefined)[]): string { const hash = createHash("sha256"); @@ -53,8 +52,3 @@ export function componentRowId(source: BucketSource, name: string): string { const [target, commit] = sourceKey(source); return digest(target, commit, COMPONENT_TAG, name); } - -export function libraryRowId(source: BucketSource, name: string): string { - const [target, commit] = sourceKey(source); - return digest(target, commit, LIBRARY_TAG, name); -} diff --git a/packages/ghost-core/src/bucket/index.ts b/packages/ghost-core/src/bucket/index.ts index 6178171..3768f2e 100644 --- a/packages/ghost-core/src/bucket/index.ts +++ b/packages/ghost-core/src/bucket/index.ts @@ -5,12 +5,7 @@ */ export { recomputeBucketIds } from "./fix-ids.js"; -export { - componentRowId, - libraryRowId, - tokenRowId, - valueRowId, -} from "./id.js"; +export { componentRowId, tokenRowId, valueRowId } from "./id.js"; export { BUCKET_FILENAME, type BucketLintIssue, @@ -24,7 +19,6 @@ export { BucketSourceSchema, ColorSpecSchema, ComponentRowSchema, - LibraryRowSchema, RECOMMENDED_VALUE_KINDS, TokenRowSchema, ValueRowSchema, @@ -37,7 +31,6 @@ export type { ColorSpec, ComponentRow, LayoutPrimitiveSpec, - LibraryRow, MotionSpec, RadiusSpec, RecommendedValueKind, diff --git a/packages/ghost-core/src/bucket/lint.ts b/packages/ghost-core/src/bucket/lint.ts index ba46375..0cf5ec9 100644 --- a/packages/ghost-core/src/bucket/lint.ts +++ b/packages/ghost-core/src/bucket/lint.ts @@ -1,5 +1,5 @@ import type { ZodIssue } from "zod"; -import { componentRowId, libraryRowId, tokenRowId, valueRowId } from "./id.js"; +import { componentRowId, tokenRowId, valueRowId } from "./id.js"; import { BucketSchema, RECOMMENDED_VALUE_KINDS } from "./schema.js"; import type { Bucket } from "./types.js"; @@ -91,28 +91,12 @@ export function lintBucket(input: unknown): BucketLintReport { }); } }); - bucket.libraries.forEach((row, idx) => { - const expected = libraryRowId(row.source, row.name); - if (row.id !== expected) { - issues.push({ - severity: "warning", - rule: "id-mismatch", - message: `id '${row.id}' does not match generator output '${expected}'`, - path: `libraries[${idx}].id`, - }); - } - }); // Duplicate-id checks within a single section. (Cross-section duplicates // are fine since IDs include a section tag.) Within-bucket duplicates // mean the scanner emitted two rows with the same content, which the // recorder should have merged. - for (const section of [ - "values", - "tokens", - "components", - "libraries", - ] as const) { + for (const section of ["values", "tokens", "components"] as const) { const seen = new Map(); bucket[section].forEach((row, idx) => { const prev = seen.get(row.id); diff --git a/packages/ghost-core/src/bucket/merge.ts b/packages/ghost-core/src/bucket/merge.ts index db80f81..84eea97 100644 --- a/packages/ghost-core/src/bucket/merge.ts +++ b/packages/ghost-core/src/bucket/merge.ts @@ -2,7 +2,6 @@ import type { Bucket, BucketSource, ComponentRow, - LibraryRow, RowBase, TokenRow, ValueRow, @@ -32,7 +31,6 @@ export function mergeBuckets(...buckets: Bucket[]): Bucket { values: dedupRows(buckets.flatMap((b) => b.values)), tokens: dedupRows(buckets.flatMap((b) => b.tokens)), components: dedupRows(buckets.flatMap((b) => b.components)), - libraries: dedupRows(buckets.flatMap((b) => b.libraries)), }; } @@ -61,11 +59,4 @@ function dedupSources(sources: BucketSource[]): BucketSource[] { // Type re-exports kept narrow so consumers don't have to import from `types.js` // just to use `mergeBuckets` results. -export type { - Bucket, - BucketSource, - ComponentRow, - LibraryRow, - TokenRow, - ValueRow, -}; +export type { Bucket, BucketSource, ComponentRow, TokenRow, ValueRow }; diff --git a/packages/ghost-core/src/bucket/schema.ts b/packages/ghost-core/src/bucket/schema.ts index 03ec831..61120d8 100644 --- a/packages/ghost-core/src/bucket/schema.ts +++ b/packages/ghost-core/src/bucket/schema.ts @@ -123,26 +123,18 @@ const ComponentRowSchema = RowBaseSchema.extend({ sizes: z.array(z.string()).optional(), }); -const LibraryRowSchema = RowBaseSchema.extend({ - name: z.string().min(1), - kind: z.string().min(1), - version: z.string().optional(), -}); - export const BucketSchema = z.object({ schema: z.literal("ghost.bucket/v1"), sources: z.array(BucketSourceSchema).min(1), values: z.array(ValueRowSchema), tokens: z.array(TokenRowSchema), components: z.array(ComponentRowSchema), - libraries: z.array(LibraryRowSchema), }); export { BucketSourceSchema, ColorSpecSchema, ComponentRowSchema, - LibraryRowSchema, TokenRowSchema, ValueRowSchema, ValueSpecSchema, diff --git a/packages/ghost-core/src/bucket/types.ts b/packages/ghost-core/src/bucket/types.ts index 266afce..3da0f94 100644 --- a/packages/ghost-core/src/bucket/types.ts +++ b/packages/ghost-core/src/bucket/types.ts @@ -160,16 +160,6 @@ export interface ComponentRow extends RowBase { sizes?: string[]; } -// --- Library rows ------------------------------------------------------- - -export interface LibraryRow extends RowBase { - /** Package name. */ - name: string; - /** Library kind — `icons`, `charts`, `animation`, `motion`, etc. Open. */ - kind: string; - version?: string; -} - // --- Bucket -------------------------------------------------------------- export interface Bucket { @@ -182,5 +172,4 @@ export interface Bucket { values: ValueRow[]; tokens: TokenRow[]; components: ComponentRow[]; - libraries: LibraryRow[]; } diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index b3b5e99..5da9c27 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -17,9 +17,6 @@ export { ComponentRowSchema, componentRowId, type LayoutPrimitiveSpec, - type LibraryRow, - LibraryRowSchema, - libraryRowId, lintBucket, type MotionSpec, mergeBuckets, diff --git a/packages/ghost-core/test/bucket-fix-ids.test.ts b/packages/ghost-core/test/bucket-fix-ids.test.ts index ae4d6fc..589bd9b 100644 --- a/packages/ghost-core/test/bucket-fix-ids.test.ts +++ b/packages/ghost-core/test/bucket-fix-ids.test.ts @@ -44,7 +44,6 @@ function bucket(): Bucket { }, ], components: [], - libraries: [], }; } diff --git a/packages/ghost-core/test/bucket-id.test.ts b/packages/ghost-core/test/bucket-id.test.ts index b83e297..2a5ff9a 100644 --- a/packages/ghost-core/test/bucket-id.test.ts +++ b/packages/ghost-core/test/bucket-id.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - componentRowId, - libraryRowId, - tokenRowId, - valueRowId, -} from "../src/bucket/id.js"; +import { componentRowId, tokenRowId, valueRowId } from "../src/bucket/id.js"; import type { BucketSource } from "../src/bucket/types.js"; const SOURCE_A: BucketSource = { @@ -94,14 +89,14 @@ describe("section-tagged IDs are non-colliding", () => { expect(tokenId).not.toBe(valueId); }); - it("component vs library with same name does not collide", () => { + it("token vs component with same name does not collide", () => { + const tokenId = tokenRowId(SOURCE_A, "Button"); const componentId = componentRowId(SOURCE_A, "Button"); - const libraryId = libraryRowId(SOURCE_A, "Button"); - expect(componentId).not.toBe(libraryId); + expect(tokenId).not.toBe(componentId); }); }); -describe("token / component / library IDs", () => { +describe("token / component IDs", () => { it("are deterministic", () => { expect(tokenRowId(SOURCE_A, "--color-brand-primary")).toBe( tokenRowId(SOURCE_A, "--color-brand-primary"), @@ -109,9 +104,6 @@ describe("token / component / library IDs", () => { expect(componentRowId(SOURCE_A, "Button")).toBe( componentRowId(SOURCE_A, "Button"), ); - expect(libraryRowId(SOURCE_A, "lucide-react")).toBe( - libraryRowId(SOURCE_A, "lucide-react"), - ); }); it("differ across names within a section", () => { diff --git a/packages/ghost-core/test/bucket-lint.test.ts b/packages/ghost-core/test/bucket-lint.test.ts index ffbc864..4e9a9a2 100644 --- a/packages/ghost-core/test/bucket-lint.test.ts +++ b/packages/ghost-core/test/bucket-lint.test.ts @@ -39,7 +39,6 @@ function makeBucket(values: ReturnType[] = []): Bucket { values, tokens: [], components: [], - libraries: [], }; } diff --git a/packages/ghost-core/test/bucket-merge.test.ts b/packages/ghost-core/test/bucket-merge.test.ts index 5a7db39..3dfd967 100644 --- a/packages/ghost-core/test/bucket-merge.test.ts +++ b/packages/ghost-core/test/bucket-merge.test.ts @@ -64,7 +64,6 @@ function makeBucket( values, tokens, components: [], - libraries: [], }; } @@ -110,7 +109,7 @@ describe("mergeBuckets", () => { expect(merged.sources).toHaveLength(1); }); - it("preserves tokens, components, libraries independently", () => { + it("preserves tokens and components independently", () => { const a = makeBucket( SOURCE_A, [], diff --git a/packages/ghost-expression/src/skill-bundle/references/profile.md b/packages/ghost-expression/src/skill-bundle/references/profile.md index 247b7ee..a604025 100644 --- a/packages/ghost-expression/src/skill-bundle/references/profile.md +++ b/packages/ghost-expression/src/skill-bundle/references/profile.md @@ -21,18 +21,19 @@ handoffs: Two artifacts must exist before you start: - `map.md` — `ghost.map/v1`. Read its frontmatter for repo kind signals (`composition.frameworks`, `composition.styling`, `design_system.token_source`, `platform`, `registry`). Read its body for context on identity / topology / conventions. -- `bucket.json` — `ghost.bucket/v1`. Lint-clean. Carries every concrete value, token, component, and library the surveyor observed, with occurrence counts and (for tokens) alias chains. +- `bucket.json` — `ghost.bucket/v1`. Lint-clean. Carries every concrete value, token, and component the surveyor observed, with occurrence counts and (for tokens) alias chains. If either is missing, **stop**. Run topology and survey first. Inventing an expression from incomplete inputs poisons every downstream comparison. ## How to read the bucket -A `bucket.json` has four sections: +A `bucket.json` has three sections: - **`values[]`** — concrete literals shipped in source. Group by `kind`: `color` rows feed `palette`; `spacing` rows feed `spacing.scale` / `spacing.baseUnit`; `typography` rows feed `typography.*`; `radius` rows feed `surfaces.borderRadii`; `shadow` rows feed `surfaces.shadowComplexity` (count + complexity, not literal shadows); `breakpoint` / `motion` / `layout-primitive` rows feed Decisions where they're load-bearing. Each row has `occurrences` (total count) and `files_count` (spread). Higher numbers = stronger signal. - **`tokens[]`** — named declarations with `alias_chain` (path through indirection) and `resolved_value`. Long chains and semantic naming (`--color-brand-primary` → `--color-orange-500`) are evidence of a deliberate token layer. Empty chains everywhere = inline literals = no token discipline. -- **`components[]`** — known components (registry entries or heuristically discovered). Feeds the `roles[]` layer when components carry slot-to-color mappings. -- **`libraries[]`** — external dependencies that contribute design surface (icons, fonts, motion, charts). Feeds Decisions when load-bearing — e.g. an icon library's presence shapes the visual register. +- **`components[]`** — known components (registry entries or heuristically discovered). Feeds the `roles[]` layer when components carry slot-to-color mappings, and contributes count signal to surface-vocabulary decisions. + +External libraries (icon sets, primitive collections, motion libs) deliberately don't have a bucket section — whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*. When a library is load-bearing for the design language (icon family choice, font sourcing), cite it as prose evidence under the relevant decision dimension; don't expect it as structured data. Read `bucket.json` once, fully. Then keep it open while you write. @@ -74,7 +75,7 @@ For each decision: `dimension` (slug), `decision` (prose, body), `evidence` (con Mode-specific framing: -- **Consumer** — overrides are decisions ("App ships its own `@font-face` instead of inheriting upstream sans" — evidence: bucket `libraries[kind=fonts]` row that's not in the upstream). +- **Consumer** — overrides are decisions ("App ships its own `@font-face` instead of inheriting upstream sans" — evidence: a `--font-*` token row whose value differs from the upstream's, plus prose citing the manifest dependency). - **Token-pipeline** — layering choices are decisions ("Component layer never references base tokens directly — always via semantic" — evidence: bucket `tokens[].alias_chain` lengths). - **Ui-library** — registry posture is a decision ("Components ship as a flat library with no theme variants" — evidence: bucket `components[]` shape). - **Multi-platform** — divergence between dialects is a decision when present ("Web and iOS palettes are intentionally different — web is restrained, iOS reuses system colors" — evidence: per-source counts in merged buckets, or noted in the survey scratchpad). @@ -91,7 +92,7 @@ Populate the structured frontmatter fields **from bucket rows**: - `spacing.scale` — sorted distinct scalar values from `kind: spacing` rows. Convert rem/em to px (1rem = 16px) before recording. - `spacing.baseUnit` — the GCD of scale entries, or the smallest scalar that divides most others. - `spacing.regularity` — 1.0 if the scale is a clean modular sequence (4, 8, 16, 24, …), lower as it diverges. -- `typography.families` — distinct `family` values from `kind: typography` rows + bucket `libraries[kind=fonts]`. +- `typography.families` — distinct `family` values from `kind: typography` rows. - `typography.sizeRamp` — distinct font sizes (in px) from `kind: typography` rows. - `typography.weightDistribution` — map of weight → relative frequency from `kind: typography` rows. - `typography.lineHeightPattern` — `tight` / `normal` / `loose` / `mixed`, judged from `line_height` values. diff --git a/packages/ghost-expression/src/skill-bundle/references/survey.md b/packages/ghost-expression/src/skill-bundle/references/survey.md index 0193605..09bf4a5 100644 --- a/packages/ghost-expression/src/skill-bundle/references/survey.md +++ b/packages/ghost-expression/src/skill-bundle/references/survey.md @@ -30,8 +30,7 @@ A `bucket.json` is `ghost.bucket/v1`: "sources": [{ "target": "...", "commit": "...", "scanned_at": "..." }], "values": [...], "tokens": [...], - "components": [...], - "libraries": [...] + "components": [...] } ``` @@ -40,7 +39,8 @@ Each row carries an `id` (deterministic SHA-256 prefix you do **not** compute by - **`values[]`** — every concrete literal that ships in the design language. `kind` is open; recommended values: `color`, `spacing`, `typography`, `radius`, `shadow`, `breakpoint`, `motion`, `layout-primitive`. Other kinds (`z-index`, `opacity`, `cursor`, `gradient`, `iconography`, `aspect-ratio`) get a `value-kind-unknown` warning but are accepted — emit them when they matter. - **`tokens[]`** — every named token declared in source (CSS variables, theme keys, design-token entries). Each row has `name`, `alias_chain` (path through any indirection — `["--button-bg", "--color-brand-primary"]` for a two-step chain; `[]` for a leaf defined inline), `resolved_value` (end-of-chain literal), optional `by_theme` for light/dark variants. - **`components[]`** — every named component you can confidently identify (registry entries, exported PascalCase components with variants/sizes). Loose schema: `name`, `discovered_via` (`registry.json` / `heuristic` / etc.), optional `variants[]` and `sizes[]`. -- **`libraries[]`** — every external dependency that contributes design surface (icon libraries, charting, animation, typography). `kind` is open: `icons`, `charts`, `animation`, `motion`, `fonts`, etc. + +External libraries (icon sets, primitive collections, motion libs, charting, etc.) are intentionally *not* a bucket section. Whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*. When a library is load-bearing (icon family, font sourcing), surface it in the interpreter stage as prose evidence under the relevant decision dimension instead. Every row needs `occurrences` (total count across the scan) and (for values) `files_count` (distinct files that contain the value). Optional `usage` breaks down by context: `{className: 30, css_var: 17}`. Optional `role_hypothesis` is a single tentative role tag (`brand-primary`, `surface-elevated`); **leave it empty if you are not sure** — the interpreter does role assignment, not you. @@ -62,7 +62,7 @@ Decide your extraction strategy from these signals — see Step 2. Recall is the failure mode and the only one. A bucket missing 90% of a section's rows is a failed scan, even if every row that *is* there is well-formed — the interpreter downstream cannot recover what you didn't record. -For every section (`values[]`, `tokens[]`, `components[]`, `libraries[]`): +For every section (`values[]`, `tokens[]`, `components[]`): 1. **Identify the canonical signal in this repo.** Where does the source of truth for this kind of thing actually live? It will be different in every repo — a manifest, a registry, a barrel export, a CSS declaration block, a naming convention. Use the strongest signal the repo offers. 2. **Enumerate, don't sample.** If you can count entries from the canonical signal independently, your row count must match. 6 components when the canonical signal lists 100 is a lie. @@ -86,7 +86,7 @@ If the repo mixes dialects (e.g. `swiftui` + `arcade`), run extraction per diale ### 3. Run extraction passes — apply the exhaustiveness rule per section -The exhaustiveness rule (above) governs every section. The dialect-specific tactics here are how you implement it for `values[]` and `tokens[]`. For `components[]` and `libraries[]`, the rule is the same: find the canonical signal in *this* repo and enumerate it. +The exhaustiveness rule (above) governs every section. The dialect-specific tactics here are how you implement it for `values[]` and `tokens[]`. For `components[]`, the rule is the same: find the canonical signal in *this* repo and enumerate it. For values + tokens, sloppy grep undercounts silently. Discipline: @@ -96,10 +96,9 @@ For values + tokens, sloppy grep undercounts silently. Discipline: - **Spread check.** If a value appears in `files_count: 1`, it's likely incidental, not part of the design language. Note the count but don't promote with `role_hypothesis`. - **Resolve aliases exhaustively.** Every named token declared in the canonical token source becomes a `tokens[]` row. Don't sample tokens — count the declarations and match the row count. When a token's value is `var(--other)`, follow the chain to the literal; record the **token row** with the chain, and the **value row** for the resolved literal. -For components + libraries: +For components: - **Components are countable.** Count them by whatever signal the repo offers (manifest entries, barrel exports, naming pattern under a known directory). If you can count to 50 and your bucket has 6 rows, you've sampled — go back and enumerate. -- **Libraries are countable too.** Read the manifest's dependencies. Each external library that contributes design surface (icons, fonts, motion, charts, primitives) is a row. Don't roll up by family — `@radix-ui/react-dialog` and `@radix-ui/react-popover` are two different surfaces and two different rows. (One row with a count is fine if the manifest groups them; two rows is also fine. A "..." in the name is not.) ### 4. Sample feature areas for usage counts @@ -125,7 +124,7 @@ Build the bucket file. For every row, leave `id` as an empty string `""`. You do } ``` -Same shape per token / component / library row, just different content fields. **Every row gets the same `source` object** (denormalized so the row survives merges with its origin attribution). Fill `sources[]` at the top of the bucket with the same single source. +Same shape per token and component row, just different content fields. **Every row gets the same `source` object** (denormalized so the row survives merges with its origin attribution). Fill `sources[]` at the top of the bucket with the same single source. ### 6. Populate IDs @@ -148,8 +147,6 @@ Before declaring the bucket done, walk each section and confirm exhaustiveness: - **`components[]`** — what's the canonical signal in this repo? Count it independently. If your row count is below that count, you've under-recorded. Either add the missing rows or, if the section truly isn't enumerable here, leave the array empty. - **`tokens[]`** — count the named-token declarations in the canonical token source(s) named in `map.md`. Your row count should match. - **`values[]`** — frequency-cluster again with a fresh grep. New top-N entries that aren't in your bucket = missed. -- **`libraries[]`** — read the manifest's dependencies. Every external library that contributes design surface (icons, fonts, motion, charts, primitives, command-palette, toast, animation) is a row. - The bucket is **saturated** when another exhaustiveness pass adds fewer than ~2 new rows across all sections AND your component/token row counts match (or come very close to) an independent count of the canonical signal. If exhaustiveness disagrees with what you have, exhaustiveness wins — re-pass. Hard stop conditions: @@ -167,7 +164,7 @@ If you hit a hard stop with exhaustiveness *not* met, write a `# Coverage` note - Resolve token alias chains end-to-end. The `alias_chain` array captures the path. - Validate with `ghost-expression lint bucket.json` before declaring success. - After authoring rows with empty IDs, run `bucket fix-ids` exactly once. -- **Cross-check your component, token, and library counts against an independent count of the canonical signal in this repo.** Disagreement = re-pass. +- **Cross-check your component and token counts against an independent count of the canonical signal in this repo.** Disagreement = re-pass. ## Never @@ -177,5 +174,5 @@ If you hit a hard stop with exhaustiveness *not* met, write a `# Coverage` note - **Never assign roles confidently.** `role_hypothesis` is a *hint*, optional, and tentative. The interpreter has the final word. If you're not sure, leave it empty. - **Never undercount silently.** If your coverage is weak (mobile dialects, custom DSLs, no canonical signal in this repo), surface it in a `# Coverage` scratchpad note and tell the interpreter. - **Never compute IDs by hand.** Use `bucket fix-ids`. -- **Never use placeholder/glob names.** A library row with `name: "@radix-ui/react-*"` is sampling-disguised-as-a-row. Enumerate or roll up explicitly with a count. +- **Never use placeholder/glob names.** A component row with `name: "*Button"` or `name: ""` is sampling-disguised-as-a-row. Enumerate concretely. - **Never edit a bucket after the interpreter has used it.** If you find a missed value later, re-run survey end-to-end. The bucket is the frozen ground truth between scan and interpretation. diff --git a/packages/ghost-expression/test/cli.test.ts b/packages/ghost-expression/test/cli.test.ts index ccb72f8..856eacb 100644 --- a/packages/ghost-expression/test/cli.test.ts +++ b/packages/ghost-expression/test/cli.test.ts @@ -206,7 +206,6 @@ function makeBucket(source: BucketSource, hex = "#f97316"): Bucket { }, ], components: [], - libraries: [], }; } @@ -372,7 +371,6 @@ describe("ghost-expression bucket fix-ids", () => { ], tokens: [], components: [], - libraries: [], }; await writeFile(join(dir, "draft.json"), JSON.stringify(draft)); From 469e0bc2dcf89a7505e5fa4cdfab0a90c3652ad1 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 29 Apr 2026 17:49:40 -0400 Subject: [PATCH 05/14] docs: align READMEs and docs site with the four-tool / three-stage topology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six documentation files were carrying stale framing from the five-tool era or hadn't been updated for the bucket pipeline / libraries-cut. - packages/ghost-expression/README.md: full rewrite. Was framed around "four verbs" (lint/describe/diff/emit) with no mention of the scan pipeline. Now leads with the three-stage table (topology → objective → subjective), enumerates all seven verbs (inventory, scan-status, lint, describe, diff, bucket, emit), and points at all four skill recipes (scan, map, survey, profile). - packages/ghost-drift/README.md: dropped "five-tool decomposition" and the ghost-map reference. The "Authoring expression.md?" sidebar now surfaces inventory + scan-status + bucket merge alongside the existing lint/describe/diff/emit verbs. - CLAUDE.md / AGENTS.md (symlinked): bucket description no longer mentions "and libraries", scan-status added to the verbs table, scan recipe added to the workflows list. - apps/docs/src/content/docs/cli-reference.mdx: added scan-status and bucket sections under ghost-expression. Updated overview to seventeen verbs and added the scan recipe to the skill-recipes table. - apps/docs/src/content/docs/getting-started.mdx: tools table grew to include scan-status, added a three-stage diagram, replaced the single-step "Profile Your First System" with a stage-aware "Scan Your First System" walkthrough. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 +- apps/docs/src/content/docs/cli-reference.mdx | 62 ++++++++++++-- .../docs/src/content/docs/getting-started.mdx | 69 ++++++++++----- packages/ghost-drift/README.md | 18 ++-- packages/ghost-expression/README.md | 83 ++++++++++++++----- 5 files changed, 178 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9584237..935d653 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,7 @@ Verbs are scoped to the tool that owns the artifact. The full surface across all | Tool | Command | Description | |------|---------|-------------| | `ghost-expression` | `inventory [path]` | Emit raw repo signals (manifests, language histogram, registry, top-level tree, git remote) as JSON. Feeds the topology recipe. | +| `ghost-expression` | `scan-status [dir]` | Report which scan stages have produced artifacts (`map.md` / `bucket.json` / `expression.md`) and which stage to run next. | | `ghost-expression` | `lint [file]` | Validate `expression.md`, `map.md`, or `bucket.json` — auto-detects the kind from path/content. | | `ghost-expression` | `describe [expression]` | Print section ranges + token estimates (so agents can selectively load). | | `ghost-expression` | `diff ` | Structural prose-level diff between expressions (decisions + palette roles). **Not** vector distance. | @@ -115,9 +116,10 @@ Verbs are scoped to the tool that owns the artifact. The full surface across all **Workflows (agent recipes).** Each tool ships its own skill-bundle references under `packages//src/skill-bundle/references/`. These are the agent's job, not CLI verbs: +- **Scan** (orchestrate topology → survey → profile end-to-end) — `ghost-expression/.../scan.md` - **Map** (write `map.md` from a repo, the topology stage) — `ghost-expression/.../map.md` - **Survey** (write `bucket.json` from a target, the objective stage) — `ghost-expression/.../survey.md` -- **Profile** (write `expression.md` from a bucket, the subjective stage) — `ghost-expression/.../profile.md` +- **Profile** (interpret a `bucket.json` into `expression.md`, the subjective stage) — `ghost-expression/.../profile.md` - **Review** (flag drift in PR changes) — `ghost-drift/.../review.md` - **Verify** (generate → review loop) — `ghost-drift/.../verify.md` - **Compare interpretation** — `ghost-drift/.../compare.md` @@ -142,7 +144,7 @@ Used by `resolveTrackedExpression` (in `ghost-drift`) and legacy library consume Three artifacts produced in sequence by a scan, all owned by `ghost-expression`: - **`map.md`** — the topology card (stage 1). Human-readable answer to "where is the design system, which folders matter?" Schema is `ghost.map/v1` (lives in `@ghost/core`), validated by `ghost-expression lint map.md`. Authored from `ghost-expression inventory` + the `map.md` skill recipe. The repo's own `map.md` lives at the root. -- **`bucket.json`** — the objective scan (stage 2). Catalogues every concrete design value (colors, spacings, typography, radii, shadows, breakpoints, motion, layout primitives) plus tokens, components, and libraries observed in the target. Each row carries occurrence counts and a deterministic content-hashed `id`. Schema is `ghost.bucket/v1` (lives in `@ghost/core`), validated by `ghost-expression lint bucket.json`. Authored via the `survey.md` skill recipe. +- **`bucket.json`** — the objective scan (stage 2). Catalogues every concrete design value (colors, spacings, typography, radii, shadows, breakpoints, motion, layout primitives) plus tokens and components observed in the target. Each row carries occurrence counts and a deterministic content-hashed `id`. Schema is `ghost.bucket/v1` (lives in `@ghost/core`); three sections — `values`, `tokens`, `components`. External libraries (icons, primitives, charting) deliberately *do not* have a bucket section — whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*; load-bearing library choices surface as prose evidence in the interpreter stage. Validated by `ghost-expression lint bucket.json`. Authored via the `survey.md` skill recipe. - **`expression.md`** — the design language (stage 3, terminal). Human-readable, LLM-editable, with YAML frontmatter (machine layer: 49-dim embedding + palette/spacing/typography/surfaces/roles) and a three-section prose body (Character → Signature → Decisions). Authored by interpreting `bucket.json` per the `profile.md` skill recipe. See `docs/expression-format.md` for the full spec; the condensed reference ships at `packages/ghost-expression/src/skill-bundle/references/schema.md`. ## Releasing & Changesets diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 10b465a..1e842b9 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -1,6 +1,6 @@ --- title: CLI Reference -description: Sixteen verbs across three tools. Everything interpretive lives in the skill bundles your host agent runs. +description: Seventeen verbs across three tools. Everything interpretive lives in the skill bundles your host agent runs. kicker: Docs section: guide order: 20 @@ -17,13 +17,13 @@ all owned by `ghost-expression`. Most commands accept a path; they default to Verbs are scoped to the tool that owns the artifact: -- **`ghost-expression`** — the scan pipeline: `inventory`, `lint`, `describe`, `diff`, `bucket `, `emit` +- **`ghost-expression`** — the scan pipeline: `inventory`, `scan-status`, `lint`, `describe`, `diff`, `bucket `, `emit` - **`ghost-drift`** — drift detection + governance: `compare`, `ack`, `track`, `diverge`, `emit skill` - **`ghost-fleet`** — elevation across many members: `members`, `view`, `emit skill` -Workflows like _map_, _survey_, _profile_, _review_, _verify_, and _remediate_ -are skill recipes your host agent runs — not CLI verbs. Install them with -each tool's `emit skill` verb. +Workflows like _scan_, _map_, _survey_, _profile_, _review_, _verify_, and +_remediate_ are skill recipes your host agent runs — not CLI verbs. Install +them with each tool's `emit skill` verb. The tables below are generated from each CLI's source at build time. If a flag changes in any `cli.ts`, the next `pnpm dump:cli-help` run regenerates @@ -55,6 +55,57 @@ ghost-expression inventory ghost-expression inventory ../other-repo ``` +### Pipeline progress — `scan-status` + +Report which scan stages have produced artifacts in a directory: topology +(`map.md`), objective (`bucket.json`), subjective (`expression.md`). Tells +orchestrators which stage to run next. Pure file-presence check today; +hash-keyed freshness is a planned enhancement. + + + +```bash +# Status of the current directory +ghost-expression scan-status + +# Status of a different scan dir +ghost-expression scan-status fleet/members/cash-ios + +# Machine-readable for orchestrators +ghost-expression scan-status . --format json +``` + +Output reports each stage as `present` or `missing` and surfaces the +recommended next stage (or "scan complete" when every artifact is present). + +### Bucket ops — `bucket ` + +Operate on `ghost.bucket/v1` files. Two ops today: + +- **`merge`** — concat with id-based dedup, deterministic and idempotent. + The composition primitive: modular rollups (one repo, N feature modules) + and fleet cohort views (N members merged into one cohort bucket) both + reduce to bucket merge. +- **`fix-ids`** — recompute every row's `id` from its content. The survey + recipe instructs the agent to author rows with `"id": ""` and finalize + with this verb in one pass — agents don't compute SHA-256 hashes by hand. + + + +```bash +# Merge two buckets +ghost-expression bucket merge a.json b.json -o merged.json + +# Merge a directory of bucket files +ghost-expression bucket merge fleet/members/*/bucket.json -o cohort.json + +# Populate IDs after authoring rows with empty id fields +ghost-expression bucket fix-ids draft.json -o draft.json + +# Stream output to stdout if -o is omitted +ghost-expression bucket merge a.json b.json | jq '.values | length' +``` + ### Validation — `lint` Validate `expression.md`, `map.md`, or `bucket.json` against its schema — @@ -293,6 +344,7 @@ once, then ask your agent in plain English: | Recipe | Bundle | Trigger | | ---------- | ------------------ | ------------------------------------------------------ | +| `scan` | `ghost-expression` | "scan this project" / "go end-to-end" — meta-recipe orchestrating topology → survey → profile | | `map` | `ghost-expression` | "map this repo" / "write map.md" (topology stage) | | `survey` | `ghost-expression` | "survey design values" / "extract tokens" (objective) | | `profile` | `ghost-expression` | "profile this design language" / "write expression.md" | diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index c524d24..11ad402 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -9,16 +9,24 @@ slug: getting-started -Ghost is split into **five small tools**, each with one responsibility, plus a -shared library underneath. Your host agent (Claude Code, Cursor, Goose, Codex, …) -does the interpretive work — profile, review, verify, remediate — using -[agentskills.io](https://agentskills.io)-compatible recipe bundles that each -tool ships. The CLIs are the calculator the agent reaches for when it needs -a reproducible answer. +Ghost is split into **four small tools**, each with one responsibility, plus a +shared library (`@ghost/core`) underneath. Your host agent (Claude Code, Cursor, +Goose, Codex, …) does the interpretive work — profile, review, verify, remediate — +using [agentskills.io](https://agentskills.io)-compatible recipe bundles that +each tool ships. The CLIs are the calculator the agent reaches for when it +needs a reproducible answer. + +A scan runs in three stages, all owned by `ghost-expression`: + +``` +topology → objective → subjective +map.md bucket.json expression.md +ghost.map/v1 ghost.bucket/v1 (the canonical artifact) +``` | Tool | Owns | Verbs | | --- | --- | --- | -| `ghost-expression` | the three-stage scan: `map.md` → `bucket.json` → `expression.md` | `inventory`, `lint`, `describe`, `diff`, `bucket `, `emit` | +| `ghost-expression` | the three-stage scan pipeline | `inventory`, `scan-status`, `lint`, `describe`, `diff`, `bucket `, `emit` | | `ghost-drift` | drift detection + governance | `compare`, `ack`, `track`, `diverge`, `emit skill` | | `ghost-fleet` | `fleet.md` (elevation across members) | `members`, `view`, `emit skill` | | `ghost-ui` | reference design system (97 shadcn components) | — | @@ -67,7 +75,7 @@ ghost-expression emit skill # → .claude/skills/ghost-expression Each bundle ships its own `SKILL.md` plus recipes under `references/`: -- **`ghost-expression`** — `map.md` (write `map.md` from a repo, the topology stage), `survey.md` (write `bucket.json`, the objective stage), `profile.md` (interpret a bucket into `expression.md`, the subjective stage), `schema.md` (condensed format reference). +- **`ghost-expression`** — `scan.md` (meta-recipe orchestrating topology → survey → profile end-to-end), `map.md` (write `map.md` from a repo, the topology stage), `survey.md` (write `bucket.json`, the objective stage), `profile.md` (interpret a bucket into `expression.md`, the subjective stage), `schema.md` (condensed format reference). - **`ghost-drift`** — `compare.md` (interpretation), `review.md` (PR review), `verify.md` (generation→review loop), `remediate.md` (suggest minimal fixes). - **`ghost-fleet`** — `target.md` (synthesize fleet narrative from `view` output). @@ -80,21 +88,34 @@ CLI whenever it needs a reproducible answer. - + -Profiling is a skill recipe shipped in `ghost-expression`'s bundle, not a -CLI verb. Open your host agent in the project you want to profile and ask -it something like: +Scanning is the host-agent loop, not a single CLI verb. Open your host +agent in the project you want to profile and ask it something like: ```text -Profile this design language into expression.md +Scan this design language end-to-end ``` -The `profile` recipe walks the agent through finding design sources -(tailwind config, theme CSS, token files, component primitives), resolving -variable chains end-to-end, and writing the expression. The final step is -always `ghost-expression lint` — which validates the result with the same -answer every time. +The `scan` recipe checkpoints between stages with `scan-status` and +dispatches into the per-stage recipes: + +1. **Topology (`map.md`)** — the `map` recipe reads + `ghost-expression inventory` (raw repo signals) and writes a `map.md` + describing where the design system lives. Validated by + `ghost-expression lint map.md`. +2. **Objective (`bucket.json`)** — the `survey` recipe walks the source + exhaustively (writing dialect-specific greps; the recipe forbids + sampling) and writes a `bucket.json` cataloguing every concrete value, + token, and component. Validated by `ghost-expression lint bucket.json`. +3. **Subjective (`expression.md`)** — the `profile` recipe interprets the + bucket as ground truth: assigns roles to values, names design decisions + in prose, and writes the `expression.md`. Cannot fabricate values not + in the bucket. Validated by `ghost-expression lint expression.md`. + +If you only want one stage, ask for it directly: "map this repo", "survey +the design values", "profile this expression from the bucket". The recipes +chain via `handoffs`, so the agent surfaces the next stage when ready. An **expression** is a two-layer Markdown file: YAML frontmatter is the machine layer (49-dim vector + palette, spacing, typography, surfaces, @@ -104,11 +125,13 @@ See [`packages/ghost-ui/expression.md`](https://github.com/block/ghost/blob/main in this repo for a full real-world example. ```bash -# Validate the result (zero-config — reads ./expression.md) -ghost-expression lint +# Check what stage to run next +ghost-expression scan-status -# Or validate a specific file -ghost-expression lint path/to/expression.md --format json +# Validate any artifact (auto-detects the kind) +ghost-expression lint # ./expression.md +ghost-expression lint map.md # ghost.map/v1 +ghost-expression lint bucket.json # ghost.bucket/v1 ``` @@ -249,6 +272,6 @@ export default defineConfig({ Next: [Drift Workflow](/tools/drift/workflow) for the five-move walkthrough — profile, compare, review, evolve, org — with richer examples for each. Or jump to the [CLI Reference](/docs/cli) for every verb across -all four tools. +all three tools. diff --git a/packages/ghost-drift/README.md b/packages/ghost-drift/README.md index 0b708ca..8c63e2f 100644 --- a/packages/ghost-drift/README.md +++ b/packages/ghost-drift/README.md @@ -47,19 +47,21 @@ ghost-drift emit skill # install the agent recip Zero config for every verb. No API key needed. `OPENAI_API_KEY` / `VOYAGE_API_KEY` are optional and only consumed if you ask for a semantic-enriched embedding via the library. -### Authoring `expression.md`? +### Authoring a scan? -Authoring lives in **[`ghost-expression`](../ghost-expression)**. Install it for `lint`, `describe`, `diff`, and `emit review-command` / `emit context-bundle`: +Scans live in **[`ghost-expression`](../ghost-expression)**, which owns the three-stage pipeline (`map.md` topology → `bucket.json` objective → `expression.md` subjective). Install it for `inventory`, `lint`, `describe`, `diff`, `bucket merge` / `fix-ids`, `scan-status`, and `emit review-command` / `emit context-bundle`: ```bash -ghost-expression lint # validate ./expression.md -ghost-expression describe # section ranges + token estimates -ghost-expression diff a.md b.md # structural prose-level diff +ghost-expression inventory # raw repo signals → JSON (feeds map.md) +ghost-expression scan-status # per-stage state + next stage +ghost-expression lint # auto-detects expression.md / map.md / bucket.json +ghost-expression bucket merge a.json b.json # union with id-based dedup +ghost-expression diff a.md b.md # structural prose-level diff between expressions ghost-expression emit review-command # per-project slash command ghost-expression emit context-bundle # generation context bundle ``` -These verbs used to live under `ghost-drift`. They were moved in v0.2.0 — running them on `ghost-drift` now prints a deprecation message pointing here. +The authoring verbs that used to live under `ghost-drift` were moved in v0.2.0; running them on `ghost-drift` now prints a deprecation message pointing here. ## As a library @@ -88,11 +90,11 @@ ghost-drift emit skill The agent runs the recipes; the CLI runs the arithmetic. The CLI never calls an LLM. -(Authoring recipes — `profile` for `expression.md` — ship in `ghost-expression`'s skill bundle. Topology and fleet recipes ship in `ghost-map` and `ghost-fleet` respectively.) +(Authoring recipes — `scan` / `map` / `survey` / `profile` — all ship in `ghost-expression`'s skill bundle, since one tool now owns the whole three-stage scan pipeline. Fleet narrative recipes ship in `ghost-fleet`.) ## Full story -See the [project README](https://github.com/block/ghost#readme) for the philosophy, the five-tool decomposition, the expression format spec, composite comparison, and the reference design language (Ghost UI). +See the [project README](https://github.com/block/ghost#readme) for the philosophy, the four-tool decomposition, the three-stage scan pipeline, the expression format spec, composite comparison, and the reference design language (Ghost UI). ## License diff --git a/packages/ghost-expression/README.md b/packages/ghost-expression/README.md index fd3d3c4..5e70864 100644 --- a/packages/ghost-expression/README.md +++ b/packages/ghost-expression/README.md @@ -1,10 +1,18 @@ # ghost-expression -**Author and validate `expression.md` — Ghost's canonical design-language artifact. Four verbs. No LLM calls.** +**Author the three-stage scan of a project's design language: `map.md` → `bucket.json` → `expression.md`. No LLM calls in any verb.** -`ghost-expression` owns the on-disk format every other Ghost tool reads. It parses, lints, lays out (section ranges + token estimates for selective loading), structurally diffs, and emits derived artifacts (per-project review slash commands, generation context bundles, agentskills.io skill bundles). +`ghost-expression` owns the on-disk artifacts every other Ghost tool reads. A scan runs in three stages, each a separate skill recipe with a deterministic CLI verb as its success gate: -The actual *writing* of an `expression.md` is a host-agent recipe — `profile.md` ships in this package's skill bundle and walks an agent through resolving design sources end-to-end. The CLI here is the success gate at the end of that loop: same answer every time, no LLM in the loop. +| Stage | Artifact | Schema | Authored via | Validated by | +|---|---|---|---|---| +| **Topology** | `map.md` | `ghost.map/v1` | `map.md` skill recipe + `ghost-expression inventory` | `ghost-expression lint map.md` | +| **Objective** | `bucket.json` | `ghost.bucket/v1` | `survey.md` skill recipe + `ghost-expression bucket fix-ids` | `ghost-expression lint bucket.json` | +| **Subjective** | `expression.md` | (unversioned) | `profile.md` skill recipe (reads bucket as ground truth) | `ghost-expression lint expression.md` | + +The CLI parses, lints (auto-detects file kind), inventories raw repo signals, runs deterministic data ops on buckets (`merge`, `fix-ids`), structurally diffs expressions, reports per-stage scan progress, and emits derived artifacts (per-project review slash commands, generation context bundles, agentskills.io skill bundles). + +The actual *writing* of each artifact is a host-agent recipe — the four ship in this package's skill bundle and walk an agent through topology / survey / interpretation / end-to-end orchestration. The CLI here is the success gate. For drift detection, comparison, and stance recording (`compare`, `ack`, `track`, `diverge`), see **[`ghost-drift`](../ghost-drift)**. @@ -23,16 +31,30 @@ pnpm add https://github.com/block/ghost/releases/download/ghost-expression%400.0 ## Use ```bash -ghost-expression lint # validate ./expression.md -ghost-expression lint path/to/expression.md # validate a specific file -ghost-expression lint expression.md --format json # machine-readable output - -ghost-expression describe # section ranges + token estimates for ./expression.md -ghost-expression describe expression.md --format json - -ghost-expression diff a/expression.md b/expression.md # structural prose-level diff - # (NOT vector distance — for that, use `ghost-drift compare`) - +# Topology — emit raw repo signals +ghost-expression inventory # signals for cwd +ghost-expression inventory ../other-repo # signals for another path + +# Validation — auto-detects expression.md / map.md / bucket.json +ghost-expression lint # ./expression.md +ghost-expression lint map.md # validates as ghost.map/v1 +ghost-expression lint bucket.json # validates as ghost.bucket/v1 +ghost-expression lint path/to/file --format json # machine-readable output + +# Pipeline orchestration — what stage to run next +ghost-expression scan-status # checks cwd +ghost-expression scan-status path/to/scan-dir + +# Bucket ops — deterministic +ghost-expression bucket merge a.json b.json -o merged.json +ghost-expression bucket fix-ids draft.json -o final.json + +# Inspection of expressions +ghost-expression describe # section ranges + token estimates +ghost-expression diff a/expression.md b/expression.md # structural prose-level diff + # (NOT vector distance — see `ghost-drift compare`) + +# Emit derived artifacts ghost-expression emit review-command # → .claude/commands/design-review.md ghost-expression emit context-bundle # → ghost-context/ (SKILL.md + tokens.css + prompt.md) ghost-expression emit context-bundle --prompt-only # single prompt.md @@ -49,34 +71,51 @@ import { lintExpression, layoutExpression, diffExpressions, + inventory, + lintMap, + scanStatus, } from "ghost-expression"; +import { + lintBucket, + mergeBuckets, + recomputeBucketIds, + type Bucket, +} from "@ghost/core"; + const { expression } = parseExpression(await readFile("expression.md", "utf8")); const report = lintExpression(source); const layout = layoutExpression(source); // section ranges + token estimates -const diff = diffExpressions(a, b); // structural prose diff +const diff = diffExpressions(a, b); // structural prose diff +const status = await scanStatus("./scan"); // per-stage state + recommended next ``` -All exports are browser-safe. +All exports are browser-safe except `inventory` (reads from disk). ## BYOA — bring your own agent -Install the skill bundle so your agent can author against the schema: +Install the skill bundle so your agent can author against the schemas: ```bash ghost-expression emit skill ``` -The bundle ships: +The bundle ships four recipes: + +- **`scan.md`** — meta-recipe orchestrating topology → survey → profile end-to-end via `scan-status` checkpoints. Use when the user wants a full scan rather than a specific stage. +- **`map.md`** — write `map.md` from a target's `inventory` output. Stage 1. +- **`survey.md`** — write `bucket.json` from a target's source code. Stage 2. The load-bearing exhaustiveness rule lives here: enumerate the canonical signal in *this* repo (registry, manifest, named declarations) and cross-check counts; sampling is forbidden. +- **`profile.md`** — interpret a `bucket.json` into `expression.md`. Stage 3. Cannot fabricate values not in the bucket; cites bucket rows as evidence. + +Plus a condensed schema reference (`schema.md`) for the `expression.md` frontmatter / body partition. -- `profile.md` — recipe for writing `expression.md` from a project (mode-branched: `target` / `module` / `rollup`). -- `schema.md` — condensed reference to the frontmatter schema and three-layer body. +Once installed, ask your agent to "scan this design language end-to-end" (or just "profile this design language") and it'll follow the recipes, ending each stage at the relevant `lint` invocation as the success gate. -Once installed, ask your agent to "profile this design language" and it'll follow the recipe, ending at `ghost-expression lint` as the success gate. +## Canonical artifacts -## Canonical artifact +See [`docs/expression-format.md`](https://github.com/block/ghost/blob/main/docs/expression-format.md) for the full `expression.md` spec, including the 49-dim machine-vector breakdown (palette [0–20], spacing [21–30], typography [31–40], surfaces [41–48]). -See [`docs/expression-format.md`](https://github.com/block/ghost/blob/main/docs/expression-format.md) for the full spec, including the 49-dim machine-vector breakdown (palette [0–20], spacing [21–30], typography [31–40], surfaces [41–48]). +The `ghost.bucket/v1` schema and `ghost.map/v1` schema both live in `@ghost/core`; the condensed authoring references ship in this package's skill bundle. ## License From 14950249c285c7e517009570e23eece46e1ddc7f Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 29 Apr 2026 23:56:06 -0400 Subject: [PATCH 06/14] schema(expression): drop roles[] in favor of decisions + bucket components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slot → token bindings either fall out of decisions[] (pattern consequences) or live in bucket.json components[] (exhaustive catalog). The hybrid roles[] slot was filling neither cleanly, didn't scale to systems with many components, and the schema never committed on whether it was exemplary or exhaustive. Removes roles[] from the zod schema (.strict() now rejects it), Expression type, lint (broken-role-reference rule, slug-binding propagation, and the references.ts token resolver), profile/scan/schema/verify/review recipes, expression.template.md, the docs site, and every fixture (arcade, market, ghost-ui, fleet members, .scratch). unused-palette is simplified to check decision evidence/prose only. Also: ignore .scratch/ for dogfood scans, and ship the ghost-ui expression-fidelity test bundles (arcade + market) as new fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/drop-roles.md | 6 + .gitignore | 3 + docs/expression-format.md | 66 --- packages/ghost-core/src/index.ts | 1 - packages/ghost-core/src/types.ts | 48 --- .../ghost-drift/src/skill-bundle/SKILL.md | 2 +- .../src/skill-bundle/references/review.md | 2 +- .../src/skill-bundle/references/verify.md | 2 +- packages/ghost-expression/src/core/compose.ts | 8 - .../ghost-expression/src/core/frontmatter.ts | 2 - packages/ghost-expression/src/core/index.ts | 2 +- packages/ghost-expression/src/core/layout.ts | 1 - packages/ghost-expression/src/core/lint.ts | 155 +------ .../ghost-expression/src/core/references.ts | 125 ------ packages/ghost-expression/src/core/schema.ts | 65 --- .../src/skill-bundle/SKILL.md | 2 +- .../assets/expression.template.md | 2 - .../src/skill-bundle/references/profile.md | 23 +- .../src/skill-bundle/references/scan.md | 2 +- .../src/skill-bundle/references/schema.md | 33 +- .../test/expression/compose.test.ts | 34 -- .../test/expression/layout.test.ts | 8 +- .../test/expression/lint.test.ts | 201 --------- .../test/expression/load.test.ts | 61 --- .../test/expression/references.test.ts | 148 ------- .../members/cash-android/expression.md | 1 - .../members/cash-web/expression.md | 1 - .../members/ghost-ui/expression.md | 1 - packages/ghost-ui/expression.md | 28 -- .../bundles/arcade/README.md | 18 + .../bundles/arcade/SKILL.md | 26 ++ .../bundles/arcade/expression.md | 380 ++++++++++++++++++ .../bundles/arcade/tokens.css | 69 ++++ .../bundles/market/README.md | 18 + .../bundles/market/SKILL.md | 26 ++ .../bundles/market/expression.md | 227 +++++++++++ .../bundles/market/tokens.css | 67 +++ 37 files changed, 862 insertions(+), 1002 deletions(-) create mode 100644 .changeset/drop-roles.md delete mode 100644 packages/ghost-expression/src/core/references.ts delete mode 100644 packages/ghost-expression/test/expression/references.test.ts create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/arcade/README.md create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/arcade/SKILL.md create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/arcade/expression.md create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/arcade/tokens.css create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/market/README.md create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/market/SKILL.md create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/market/expression.md create mode 100644 packages/ghost-ui/test/expression-fidelity/bundles/market/tokens.css diff --git a/.changeset/drop-roles.md b/.changeset/drop-roles.md new file mode 100644 index 0000000..e7a4cbc --- /dev/null +++ b/.changeset/drop-roles.md @@ -0,0 +1,6 @@ +--- +"ghost-expression": major +"ghost-drift": patch +--- + +Drop `roles[]` from the `expression.md` schema. Slot → token bindings either fall out of decisions[] (pattern consequences) or live in bucket.json components[] (exhaustive catalog). The hybrid `roles[]` slot was filling neither role cleanly and didn't scale to systems with many components. Existing files that carry `roles:` will fail strict lint — drop the section to migrate. Drift skill recipes that referenced `roles[]` as part of the expression frontmatter have been updated. diff --git a/.gitignore b/.gitignore index a11daf6..6da3d47 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ packages/ghost-ui/.ghost/ # Emitted skill bundle — re-generate with `ghost emit skill` .claude/skills/ + +# Scratch dir for dogfood scans and ad-hoc experiments +.scratch/ diff --git a/docs/expression-format.md b/docs/expression-format.md index 9f7842d..e109aa2 100644 --- a/docs/expression-format.md +++ b/docs/expression-format.md @@ -35,7 +35,6 @@ The frontmatter and the body own disjoint fields. The reader unions them into a | `decisions[].decision` (prose rationale) | **Body** | `### dimension` block | | `decisions[].evidence` | **Body** | `**Evidence:**` bullet list under `### dimension` | | `palette`, `spacing`, `typography`, `surfaces` | Frontmatter | top-level | -| `roles[]` (slot → token bindings) | Frontmatter | `roles:` | | `embedding` (49-dim vector) | **Sibling file** | `embedding.md` (referenced from `# Fragments`) | | `metadata` (loose extension bag) | Frontmatter | top-level, open-ended | @@ -115,24 +114,6 @@ surfaces: shadowComplexity: subtle # deliberate-none | subtle | layered borderUsage: moderate # minimal | moderate | heavy -# --- expression: role bindings (optional) --- -# Semantic slot → token bindings. Bridges abstract tokens to rendering: -# a role names a slot (h1, card, button, …) and binds specific tokens -# from the dimensions above. Each sub-block is optional; omit what you -# cannot infer from source. Agents populate these from component files. -roles: - - name: h1 - tokens: - typography: { family: Anthropic Serif, size: 52, weight: 500 } - spacing: { margin: 32 } - evidence: ["components/Heading.tsx:12"] - - name: card - tokens: - surfaces: { borderRadius: 16, shadow: subtle } - spacing: { padding: 24 } - palette: { background: '#f5f4ed' } - evidence: ["components/ui/card.tsx"] - # --- expression: vector layer --- # embedding is OPTIONAL at root. Readers load it from the sibling # `embedding.md` fragment (referenced in the body) or recompute from the @@ -143,7 +124,6 @@ roles: **Required:** `id`, `source`, `timestamp`, `palette`, `spacing`, `typography`, `surfaces`. **Optional:** `embedding` (omit to let readers load from `embedding.md` or recompute), `metadata` (loose key-value extension bag). **Optional narrative tags:** `observation.personality`, `observation.resembles`, `decisions[]`. Omit rather than lie — a missing tag is truer than a fabricated one. -**Optional role bindings:** `roles[]`. Each role requires `name` and `evidence[]` (citations for where the binding was observed); token sub-blocks (`typography`, `spacing`, `surfaces`, `palette`) are independently optional and strict — unknown keys reject. Note: `evidence` belongs *inside* role entries, not on `decisions[]`. **Optional meta:** `name`, `slug`, `generator`, `confidence`, `generated`, `sources`, `extends`. **Forbidden in frontmatter:** `observation.summary`, `observation.distinctiveTraits`, `decisions[].decision`, `decisions[].evidence`, and any unknown root key (e.g. `schema:`). These either live in the body (prose / evidence) or are not part of the schema. @@ -209,52 +189,6 @@ Link rules: --- -## Roles — the slot → token bridge - -Tokens alone are ingredients: "sizes 14, 16, 20, 32, 64 exist." A role is a recipe: "`h1` uses size 64, weight 500." `roles[]` is the layer that names which tokens belong to which semantic slot, so the expression stops being an inventory and becomes something a renderer can act on. - -**Shape.** Each role has three parts: - -- `name` — the slot. Prefer HTML-like or archetype names: `h1`, `h2`, `body`, `caption`, `card`, `button`, `input`, `list-row`. -- `tokens` — the bindings, grouped by dimension. Each sub-block (`typography`, `spacing`, `surfaces`, `palette`) is independently optional and every field inside is optional. A role can be partial when the source only supplies some tokens. -- `evidence` — where the binding was observed. File paths or `path:line` references. - -**Authoring contract.** Only emit roles with direct source evidence. A plausible-but-unobserved role is worse than a missing one. A codebase with no component files may produce no roles at all — that is truthful. - -**Strictness.** The `typography`, `spacing`, and `surfaces` sub-blocks are zod `.strict()` — unknown keys reject, so the schema stays disciplined as it grows. The `palette` sub-block is an open record (Phase 5b widening): slot keys are free-form so consumers can name slots from the conventional vocabulary or extend it. - -### Palette slot vocabulary - -`roles[].tokens.palette` is a `Record`. The recipe should reach for the conventional keys first; add others when they're load-bearing in the codebase. - -**Conventional keys** (use these by default): `background`, `foreground`, `surface`, `border`, `accent`, `muted`, `link`. - -**Extensions** seen in the wild: `ring`, `popover`, `separator`, `input`, `chart-1`, … — fine to use when the codebase justifies them. - -### Token references - -Role palette slot values may be raw hex literals OR token references. The reference syntax is `{.}`: - -```yaml -roles: - - name: button - tokens: - palette: - background: '{palette.dominant.accent}' # resolves to #c96442 - foreground: '{palette.dominant.surface}' # resolves to #f5f4ed - border: '#e8e6dc' # raw hex is fine too - ring: '{base.color.brand.x.light}' # opaque external ref - evidence: ["components/ui/button.tsx:18"] -``` - -**Local namespaces:** `palette.dominant` and `palette.semantic` — the two palette blocks that already carry a `role`. Renames cascade (change the role value in one place, every role that references it updates too), and `ghost-expression lint` reports `broken-role-reference` for references that don't resolve. - -**External / pipeline refs.** Token-pipeline consumers (Style Dictionary, Theo, …) often bind a role to a deeply-nested upstream token like `{base.color.brand.x.light}`. The linter accepts these as opaque passthroughs when the head segment is a recognized external namespace (`base`, `core`, `semantic`, `component`, `tokens`, `ref`, `sys`) or the path has 4+ segments. We don't try to resolve them — that requires walking the upstream package, which is out of scope for the deterministic CLI. - -**What cannot be referenced locally.** `palette.neutrals.steps` is positional (no name). Typography, spacing, and surfaces are inventories, not named vocabularies — role tokens for those dimensions inline raw values. Local refs targeting the `palette.*` namespace beyond `dominant`/`semantic` fire `broken-role-reference`; external refs (heads outside `palette.*`) pass through. - ---- - ## Embedding fragment The 49-dimensional embedding lives in `embedding.md` next to the expression. The file carries only YAML — no prose: diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index 5da9c27..a85985e 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -87,7 +87,6 @@ export type { CSSVarsMap, DesignDecision, DesignObservation, - DesignRole, DetectedFormat, DimensionAck, DimensionDelta, diff --git a/packages/ghost-core/src/types.ts b/packages/ghost-core/src/types.ts index 06abfa9..462135a 100644 --- a/packages/ghost-core/src/types.ts +++ b/packages/ghost-core/src/types.ts @@ -208,46 +208,6 @@ export interface DesignDecision { embedding?: number[]; } -/** - * A semantic slot → token binding. Describes which concrete tokens a - * design language uses for a specific role (h1, body, card, button, …). - * - * This is the bridge between abstract tokens (`typography.sizeRamp: [14, 16, …]`) - * and renderable output: a role tells a renderer *which* ramp step belongs to - * *which* slot. All subfields are optional — the agent populates only what it - * can infer from the source. - */ -export interface DesignRole { - /** Semantic slot name — "h1", "body", "card", "button", "input", "list-row", etc. */ - name: string; - /** Tokens the slot binds, grouped by expression dimension. */ - tokens: { - typography?: { - family?: string; - size?: number; - weight?: number; - lineHeight?: number; - }; - spacing?: { - padding?: number; - gap?: number; - margin?: number; - }; - surfaces?: { - borderRadius?: number; - shadow?: "none" | "subtle" | "layered"; - borderWidth?: number; - }; - palette?: { - background?: string; - foreground?: string; - border?: string; - }; - }; - /** Evidence from the source — file paths or file:line references. */ - evidence: string[]; -} - export interface Expression { id: string; source: "registry" | "extraction" | "llm" | "unknown"; @@ -262,14 +222,6 @@ export interface Expression { /** Layer 2: Abstract design decisions, implementation-agnostic */ decisions?: DesignDecision[]; - /** - * Semantic slot → token bindings. The bridge from abstract tokens to - * renderable output: each role names a slot ("h1", "card", "button") and - * binds tokens from the dimensions below. Optional — agents populate only - * roles they can infer from the source. - */ - roles?: DesignRole[]; - // --- Layer 3: Concrete values --- palette: { diff --git a/packages/ghost-drift/src/skill-bundle/SKILL.md b/packages/ghost-drift/src/skill-bundle/SKILL.md index 0025e8f..84ce775 100644 --- a/packages/ghost-drift/src/skill-bundle/SKILL.md +++ b/packages/ghost-drift/src/skill-bundle/SKILL.md @@ -40,7 +40,7 @@ For authoring or describing an expression itself (write expression.md, lint, des An `expression.md` has: -- **YAML frontmatter (machine layer):** `id`, `source`, `timestamp`, `observation.personality`, `observation.resembles`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`, `roles`. +- **YAML frontmatter (machine layer):** `id`, `source`, `timestamp`, `observation.personality`, `observation.resembles`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`. - **Markdown body (prose layer):** `# Character` (`observation.summary`), `# Signature` (bullets from `distinctiveTraits`), `# Decisions` with `### ` rationale blocks. Validate via `ghost-expression lint` before drawing conclusions from a drift comparison. diff --git a/packages/ghost-drift/src/skill-bundle/references/review.md b/packages/ghost-drift/src/skill-bundle/references/review.md index 8393ed8..63a47f9 100644 --- a/packages/ghost-drift/src/skill-bundle/references/review.md +++ b/packages/ghost-drift/src/skill-bundle/references/review.md @@ -32,7 +32,7 @@ This prints a section map — frontmatter range, body sections (`# Character`, ` Then read selectively: -- **Always read the frontmatter.** It carries the structural budget — `palette`, `spacing.scale`, `typography.families`/`sizeRamp`, `surfaces.borderRadii`, `roles[]` — that you'll match diff values against. +- **Always read the frontmatter.** It carries the structural budget — `palette`, `spacing.scale`, `typography.families`/`sizeRamp`, `surfaces.borderRadii` — that you'll match diff values against. - **Read decision sections by dimension name.** If the diff touches colors, you'll want `### color-strategy` (and any other `color-*` / `palette-*` dimension). If it touches radii, `### shape-language`, `### surface-hierarchy`, `### elevation`. Match on slug. - **If you're not confident which decisions are relevant — or the diff spans more than two partitions — read the entire `# Decisions` block.** It's typically 2–4k tokens; cheaper than missing a constraint. The describe output tells you the exact line range. diff --git a/packages/ghost-drift/src/skill-bundle/references/verify.md b/packages/ghost-drift/src/skill-bundle/references/verify.md index ff7654f..06b5da4 100644 --- a/packages/ghost-drift/src/skill-bundle/references/verify.md +++ b/packages/ghost-drift/src/skill-bundle/references/verify.md @@ -20,7 +20,7 @@ Ghost has no `ghost verify` CLI command. You drive the loop; the expression is t ### 1. Generate -Produce the UI code. Use whatever generator/recipe your harness provides; respect `palette`, `spacing.scale`, `typography`, `surfaces`, `decisions`, `roles` from the expression. The expression is the constraint set — feed it into the generator's system prompt, or load `tokens.css` (via `ghost-expression emit context-bundle`) as grounding. +Produce the UI code. Use whatever generator/recipe your harness provides; respect `palette`, `spacing.scale`, `typography`, `surfaces`, `decisions` from the expression. The expression is the constraint set — feed it into the generator's system prompt, or load `tokens.css` (via `ghost-expression emit context-bundle`) as grounding. ### 2. Self-review diff --git a/packages/ghost-expression/src/core/compose.ts b/packages/ghost-expression/src/core/compose.ts index 593a2b1..3e99699 100644 --- a/packages/ghost-expression/src/core/compose.ts +++ b/packages/ghost-expression/src/core/compose.ts @@ -30,14 +30,6 @@ export function mergeExpression( ); } - if (base.roles || overlay.roles) { - merged.roles = mergeByKey( - base.roles ?? [], - overlay.roles ?? [], - (r) => r.name, - ); - } - if (base.palette || overlay.palette) { const basePalette = base.palette; const overlayPalette = overlay.palette; diff --git a/packages/ghost-expression/src/core/frontmatter.ts b/packages/ghost-expression/src/core/frontmatter.ts index 2866990..1927c1b 100644 --- a/packages/ghost-expression/src/core/frontmatter.ts +++ b/packages/ghost-expression/src/core/frontmatter.ts @@ -41,7 +41,6 @@ const EXPRESSION_KEYS = new Set([ "spacing", "typography", "surfaces", - "roles", "embedding", ]); @@ -115,7 +114,6 @@ export function mergeFrontmatter( "spacing", "typography", "surfaces", - "roles", "embedding", ]; for (const key of ordered) { diff --git a/packages/ghost-expression/src/core/index.ts b/packages/ghost-expression/src/core/index.ts index 41ce710..20cf868 100644 --- a/packages/ghost-expression/src/core/index.ts +++ b/packages/ghost-expression/src/core/index.ts @@ -112,7 +112,7 @@ export interface LoadOptions { * * If the file declares `extends:`, the base expression is loaded recursively and * merged per the rules in compose.ts: overlay wins, decisions merged by - * dimension, palette roles merged by role. + * dimension, palette colors merged by role. * * If a `decisions/` directory sits next to the expression.md, each .md * inside is assembled into the expression's decisions[], merged by diff --git a/packages/ghost-expression/src/core/layout.ts b/packages/ghost-expression/src/core/layout.ts index c2cb6b1..5616029 100644 --- a/packages/ghost-expression/src/core/layout.ts +++ b/packages/ghost-expression/src/core/layout.ts @@ -127,7 +127,6 @@ function detectPartitions(yamlText: string): string[] { "spacing", "typography", "surfaces", - "roles", "observation", "decisions", "embedding", diff --git a/packages/ghost-expression/src/core/lint.ts b/packages/ghost-expression/src/core/lint.ts index 54f64b1..ae74ce3 100644 --- a/packages/ghost-expression/src/core/lint.ts +++ b/packages/ghost-expression/src/core/lint.ts @@ -2,11 +2,6 @@ import type { Expression } from "@ghost/core"; import { parse as parseYaml } from "yaml"; import type { BodyData } from "./body.js"; import { parseExpression, splitRaw } from "./parser.js"; -import { - formatReferenceError, - isTokenReference, - resolveTokenReference, -} from "./references.js"; import { FrontmatterSchema } from "./schema.js"; export type LintSeverity = "error" | "warning" | "info"; @@ -72,7 +67,6 @@ export function lintExpression( checkStrayEvidenceInBody(bodyText, rawIssues); checkEvidenceHexes(expression, rawIssues); checkUnusedPalette(expression, rawIssues); - checkRoleReferences(expression, rawIssues); return finalize(rawIssues, strict, off); } @@ -199,18 +193,10 @@ function checkEvidenceHexes(fp: Expression, issues: LintIssue[]): void { /** * Flag palette colors that don't appear anywhere a reader could justify - * them. The search covers three citation paths: - * 1. Hex literal in a decision body's Evidence bullet text. - * 2. Hex literal directly in a `roles[].tokens.palette.` field - * or in a `roles[].evidence` string. - * 3. Slug-binding propagation: `roles[].tokens.palette.` carrying - * a `{palette.dominant.X}` / `{palette.semantic.X}` reference resolves - * through the palette to a hex, which counts as cited. - * - * Rationale: forcing every neutral step to be name-dropped in decision - * prose was over-citing prose for no reader benefit. Role bindings are - * the load-bearing place a hex earns its keep — and the propagation - * makes named slots equivalent to inline hexes for citation purposes. + * them — i.e. not cited as a hex literal in any decision's body Evidence + * bullets or rationale prose. Severity is `info` (a soft hint, not an + * error) because some palette entries are honestly load-bearing without + * decision-level commentary (every neutral step in a wide ramp). */ function checkUnusedPalette(fp: Expression, issues: LintIssue[]): void { const paletteHexes = collectPaletteHexes(fp); @@ -224,74 +210,18 @@ function checkUnusedPalette(fp: Expression, issues: LintIssue[]): void { .map((d) => d.decision) .join("\n") .toLowerCase(); - const roleText = collectRoleHexCitations(fp); - const slugCitedHexes = collectSlugBoundHexes(fp); - const haystack = `${evidenceText}\n${decisionText}\n${roleText}`; + const haystack = `${evidenceText}\n${decisionText}`; for (const hex of paletteHexes) { if (haystack.includes(hex)) continue; - if (slugCitedHexes.has(hex)) continue; issues.push({ severity: "info", rule: "unused-palette", - message: `Palette color ${hex} is not cited in any decision or role binding.`, + message: `Palette color ${hex} is not cited in any decision.`, }); } } -/** - * Collect every hex citation reachable from `roles[]`: - * - direct hex literals in `roles[].tokens.palette.` for any slot key - * - any hex that appears inline in a `roles[].evidence` bullet - * - * Returns one big lowercase string so the caller can run substring - * checks against it. Local references (`{palette.dominant.accent}`) are - * resolved separately by `collectSlugBoundHexes`. - */ -function collectRoleHexCitations(fp: Expression): string { - const out: string[] = []; - const HEX_LITERAL = /^#[0-9a-f]{3,8}$/i; - for (const role of fp.roles ?? []) { - const palette = role.tokens?.palette; - if (palette) { - for (const value of Object.values(palette)) { - if (typeof value === "string" && HEX_LITERAL.test(value)) { - out.push(value.toLowerCase()); - } - } - } - for (const ev of role.evidence ?? []) { - out.push(ev.toLowerCase()); - } - } - return out.join("\n"); -} - -/** - * Walk `roles[].tokens.palette` for `{palette.dominant.X}` / - * `{palette.semantic.X}` references and return the set of palette hexes - * those references resolve to. Used by `unused-palette` so a hex cited - * only via a slug binding still counts as used. - * - * Unresolvable references are silently skipped — the dedicated - * `broken-role-reference` rule reports those. - */ -function collectSlugBoundHexes(fp: Expression): Set { - const out = new Set(); - for (const role of fp.roles ?? []) { - const palette = role.tokens?.palette; - if (!palette) continue; - for (const value of Object.values(palette)) { - if (!isTokenReference(value)) continue; - const result = resolveTokenReference(fp, value); - if (result.value) { - out.add(result.value.toLowerCase()); - } - } - } - return out; -} - function collectPaletteHexes(fp: Expression): Set { const out = new Set(); for (const c of fp.palette?.dominant ?? []) out.add(c.value.toLowerCase()); @@ -300,76 +230,3 @@ function collectPaletteHexes(fp: Expression): Set { out.add(step.toLowerCase()); return out; } - -/** - * Role palette slots may reference named palette entries via - * `{palette.dominant.}` or `{palette.semantic.}`, or - * opaque external token refs (`{base.color.brand.x}`) for repos - * that pull tokens from a Style-Dictionary-style pipeline. - * - * The slot vocabulary is open (Phase 5b) — any key the consumer - * defines is walked. Local refs that don't resolve fire - * `broken-role-reference`; external refs are accepted as opaque (see - * `isExternalTokenReference`). - */ -function checkRoleReferences(fp: Expression, issues: LintIssue[]): void { - const roles = fp.roles ?? []; - roles.forEach((role, ri) => { - const palette = role.tokens?.palette; - if (!palette) return; - for (const [field, value] of Object.entries(palette)) { - if (!isTokenReference(value)) continue; - // External token refs (Style-Dictionary-style namespaces) are - // accepted as opaque — we can't resolve them without consulting - // the upstream package, and the agent authored them deliberately. - if (isExternalTokenReference(value)) continue; - const result = resolveTokenReference(fp, value); - if (result.error) { - issues.push({ - severity: "error", - rule: "broken-role-reference", - message: formatReferenceError(result.error), - path: `roles[${ri}].tokens.palette.${field}`, - }); - } - } - }); -} - -/** - * Heuristic for "this is a deliberately-opaque external token ref." - * Returns true when the reference clearly targets a foreign namespace — - * either it starts with a recognized Style-Dictionary-style head, or - * it has 4+ dotted segments (deeper than local `palette..`). - * - * Local refs (`{palette.dominant.accent}`, `{palette.semantic.error}`) - * are NOT external — the caller resolves them against the palette and - * fires `broken-role-reference` if they miss. References starting with - * `palette.` but pointing at an unsupported sub-namespace are also - * routed through the resolver so its `unsupported-namespace` error - * surfaces properly. - */ -function isExternalTokenReference(value: string): boolean { - const match = /^\{([^}]+)\}$/.exec(value); - if (!match) return false; - const path = match[1]; - const segments = path.split("."); - // Anything in the local `palette.*` namespace is resolved locally, - // even if the sub-namespace is wrong (the resolver's - // `unsupported-namespace` error is the right diagnostic). - if (segments[0] === "palette") return false; - // External Style-Dictionary-style namespace heads — passthrough. - const externalHeads = new Set([ - "base", - "core", - "semantic", - "component", - "tokens", - "ref", - "sys", - ]); - if (externalHeads.has(segments[0] ?? "")) return true; - // Deeply-nested refs (4+ segments) — heuristic that this is a - // pipeline-generated token, not a flat slug we should resolve. - return segments.length >= 4; -} diff --git a/packages/ghost-expression/src/core/references.ts b/packages/ghost-expression/src/core/references.ts deleted file mode 100644 index 450d9e6..0000000 --- a/packages/ghost-expression/src/core/references.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { Expression } from "@ghost/core"; - -/** - * Role token reference syntax: `{palette.dominant.}` or - * `{palette.semantic.}`. Lets a role bind its palette to a named - * palette slot instead of a raw hex — renames cascade, and the linter - * can flag a role pointing at a palette entry that no longer exists. - * - * Only the named palette slots are referenceable in v1. Positional - * inventories (neutrals.steps, typography.sizeRamp, spacing.scale, - * surfaces.borderRadii) have no names to point at, so role tokens for - * those dimensions stay raw. - */ - -/** Matches `{namespace.role}` where namespace contains one or more segments. */ -const REFERENCE_RE = /^\{([a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+)\}$/i; - -export interface ParsedTokenReference { - /** Full reference string, e.g. `palette.dominant.accent`. */ - path: string; - /** Namespace segment, e.g. `palette.dominant`. */ - namespace: string; - /** Terminal segment — the role name to look up. */ - role: string; -} - -/** True when `value` is wrapped in `{...}` and shaped like a dotted path. */ -export function isTokenReference(value: unknown): value is string { - return typeof value === "string" && REFERENCE_RE.test(value); -} - -/** Parse `{palette.dominant.accent}` → structured form, or null if malformed. */ -export function parseTokenReference( - value: string, -): ParsedTokenReference | null { - const match = REFERENCE_RE.exec(value); - if (!match) return null; - const path = match[1]; - const lastDot = path.lastIndexOf("."); - return { - path, - namespace: path.slice(0, lastDot), - role: path.slice(lastDot + 1), - }; -} - -export type TokenReferenceError = - | { kind: "malformed"; value: string } - | { kind: "unsupported-namespace"; namespace: string; supported: string[] } - | { kind: "unknown-role"; namespace: string; role: string }; - -export interface ResolveResult { - value: string | null; - error: TokenReferenceError | null; -} - -const SUPPORTED_NAMESPACES = ["palette.dominant", "palette.semantic"]; - -/** - * Resolve a `{...}` reference against an expression. Returns the primitive - * value (hex string) when resolvable, plus a structured error describing why - * resolution failed otherwise. Callers that just want the value can ignore - * `error`; the linter uses it to report `broken-role-reference` precisely. - */ -export function resolveTokenReference( - fp: Expression, - value: string, -): ResolveResult { - const parsed = parseTokenReference(value); - if (!parsed) { - return { value: null, error: { kind: "malformed", value } }; - } - - const list = lookupNamespace(fp, parsed.namespace); - if (!list) { - return { - value: null, - error: { - kind: "unsupported-namespace", - namespace: parsed.namespace, - supported: SUPPORTED_NAMESPACES, - }, - }; - } - - const hit = list.find((c) => c.role === parsed.role); - if (!hit) { - return { - value: null, - error: { - kind: "unknown-role", - namespace: parsed.namespace, - role: parsed.role, - }, - }; - } - - return { value: hit.value, error: null }; -} - -function lookupNamespace( - fp: Expression, - namespace: string, -): { role: string; value: string }[] | null { - switch (namespace) { - case "palette.dominant": - return fp.palette?.dominant ?? []; - case "palette.semantic": - return fp.palette?.semantic ?? []; - default: - return null; - } -} - -/** Human-readable message for a resolve error — used by the linter. */ -export function formatReferenceError(error: TokenReferenceError): string { - switch (error.kind) { - case "malformed": - return `\`${error.value}\` is not a valid token reference (expected \`{namespace.role}\`).`; - case "unsupported-namespace": - return `Reference targets \`${error.namespace}\` which is not a valid reference namespace. Supported: ${error.supported.map((n) => `\`${n}\``).join(", ")}.`; - case "unknown-role": - return `Reference \`{${error.namespace}.${error.role}}\` does not resolve — no entry with role \`${error.role}\` in \`${error.namespace}\`.`; - } -} diff --git a/packages/ghost-expression/src/core/schema.ts b/packages/ghost-expression/src/core/schema.ts index fe4d293..5eb85eb 100644 --- a/packages/ghost-expression/src/core/schema.ts +++ b/packages/ghost-expression/src/core/schema.ts @@ -72,63 +72,6 @@ const DesignDecisionSchema = z }) .strict(); -/** - * Semantic slot → token binding. Each role names a slot ("h1", "card", - * "button") and binds tokens from the expression dimensions. Every - * sub-block is optional — a role can be partial when the source only - * supplies some tokens. - */ -const DesignRoleSchema = z - .object({ - name: z.string(), - tokens: z - .object({ - typography: z - .object({ - family: z.string().optional(), - size: z.number().optional(), - weight: z.number().optional(), - lineHeight: z.number().optional(), - }) - .strict() - .optional(), - spacing: z - .object({ - padding: z.number().optional(), - gap: z.number().optional(), - margin: z.number().optional(), - }) - .strict() - .optional(), - surfaces: z - .object({ - borderRadius: z.number().optional(), - shadow: z.enum(["none", "subtle", "layered"]).optional(), - borderWidth: z.number().optional(), - }) - .strict() - .optional(), - /** - * Palette slot bindings. Open-ended record — keys are slot names - * the consumer chooses, values are either raw hex literals - * (`"#1a1a1a"`) or `{palette.dominant.X}` / `{palette.semantic.X}` - * references that resolve through the local palette, or opaque - * external token refs (`{base.color.brand.x}`) for repos that - * pull tokens from a Style-Dictionary-style pipeline. - * - * Conventional keys (the recipe should reach for these first): - * `background`, `foreground`, `surface`, `border`, `accent`, - * `muted`, `link`. Phase 5b widened this from a fixed three-key - * shape to an open record so richer real-world vocabularies - * (separator, ring, popover, …) don't hard-error. - */ - palette: z.record(z.string(), z.string()).optional(), - }) - .strict(), - evidence: z.array(z.string()), - }) - .strict(); - /** * Schema for the YAML frontmatter in an expression.md file. Covers the * machine-layer of Expression plus expression-level metadata. @@ -173,13 +116,6 @@ export const FrontmatterSchema = z typography: TypographySchema, surfaces: SurfacesSchema, - /** - * Semantic slot → token bindings. Optional. The bridge from abstract - * tokens to rendering: each role names a slot and binds tokens from - * the dimensions above. - */ - roles: z.array(DesignRoleSchema).optional(), - /** * Optional at root — loader falls back to sibling `embedding.md` or * recomputes from structured blocks. Present embeddings are trusted @@ -216,7 +152,6 @@ export const PartialFrontmatterSchema = z spacing: SpacingSchema.optional(), typography: TypographySchema.optional(), surfaces: SurfacesSchema.optional(), - roles: z.array(DesignRoleSchema).optional(), embedding: z.array(z.number()).optional(), }) .strict(); diff --git a/packages/ghost-expression/src/skill-bundle/SKILL.md b/packages/ghost-expression/src/skill-bundle/SKILL.md index 72bc056..7f28815 100644 --- a/packages/ghost-expression/src/skill-bundle/SKILL.md +++ b/packages/ghost-expression/src/skill-bundle/SKILL.md @@ -47,7 +47,7 @@ For drift detection (compare under change, ack/track/diverge, review PR diffs ag An `expression.md` has: -- **YAML frontmatter (machine layer):** `id`, `source`, `timestamp`, `observation.personality`, `observation.resembles`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`, `roles`. +- **YAML frontmatter (machine layer):** `id`, `source`, `timestamp`, `observation.personality`, `observation.resembles`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`. - **Markdown body (prose layer):** `# Character` (`observation.summary`), `# Signature` (bullets from `distinctiveTraits`), `# Decisions` with `### ` rationale blocks. Each field lives in exactly one layer — no duplication. Putting prose in frontmatter is a lint error. Full spec: [references/schema.md](references/schema.md). Starting template: [assets/expression.template.md](assets/expression.template.md). diff --git a/packages/ghost-expression/src/skill-bundle/assets/expression.template.md b/packages/ghost-expression/src/skill-bundle/assets/expression.template.md index e60ab65..e599046 100644 --- a/packages/ghost-expression/src/skill-bundle/assets/expression.template.md +++ b/packages/ghost-expression/src/skill-bundle/assets/expression.template.md @@ -47,8 +47,6 @@ surfaces: borderRadii: [4, 8] shadowComplexity: deliberate-none borderUsage: minimal - -roles: [] --- # Character diff --git a/packages/ghost-expression/src/skill-bundle/references/profile.md b/packages/ghost-expression/src/skill-bundle/references/profile.md index a604025..53206a6 100644 --- a/packages/ghost-expression/src/skill-bundle/references/profile.md +++ b/packages/ghost-expression/src/skill-bundle/references/profile.md @@ -12,7 +12,7 @@ handoffs: # Recipe: Profile a project into expression.md -**Goal:** produce a valid `expression.md` that captures the project's design language as an interpretation. **You are the interpreter, not the surveyor.** Read the `bucket.json` as ground truth for what values the project actually ships; assign roles, write decisions, and form the prose body. Do not re-extract values from source — that's the surveyor's job and you'd be doing it twice. +**Goal:** produce a valid `expression.md` that captures the project's design language as an interpretation. **You are the interpreter, not the surveyor.** Read the `bucket.json` as ground truth for what values the project actually ships; write decisions, form the prose body, and fill the structured token blocks. Do not re-extract values from source — that's the surveyor's job and you'd be doing it twice. `expression.md` is the terminal artifact in a three-stage scan: topology (`map.md`) → objective (`bucket.json`) → subjective (`expression.md`). Yours is the third stage. @@ -31,7 +31,7 @@ A `bucket.json` has three sections: - **`values[]`** — concrete literals shipped in source. Group by `kind`: `color` rows feed `palette`; `spacing` rows feed `spacing.scale` / `spacing.baseUnit`; `typography` rows feed `typography.*`; `radius` rows feed `surfaces.borderRadii`; `shadow` rows feed `surfaces.shadowComplexity` (count + complexity, not literal shadows); `breakpoint` / `motion` / `layout-primitive` rows feed Decisions where they're load-bearing. Each row has `occurrences` (total count) and `files_count` (spread). Higher numbers = stronger signal. - **`tokens[]`** — named declarations with `alias_chain` (path through indirection) and `resolved_value`. Long chains and semantic naming (`--color-brand-primary` → `--color-orange-500`) are evidence of a deliberate token layer. Empty chains everywhere = inline literals = no token discipline. -- **`components[]`** — known components (registry entries or heuristically discovered). Feeds the `roles[]` layer when components carry slot-to-color mappings, and contributes count signal to surface-vocabulary decisions. +- **`components[]`** — known components (registry entries or heuristically discovered). Contributes count signal to surface-vocabulary decisions and grounds prose about what the system ships. External libraries (icon sets, primitive collections, motion libs) deliberately don't have a bucket section — whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*. When a library is load-bearing for the design language (icon family choice, font sourcing), cite it as prose evidence under the relevant decision dimension; don't expect it as structured data. @@ -104,25 +104,16 @@ Populate the structured frontmatter fields **from bucket rows**: **Hard rule:** every `palette` entry must be cited in at least one decision's `evidence`, or dropped. Uncited tokens are noise. -### 5. Roles — slot-to-color mappings - -Populate `roles[]` from `bucket.tokens[]` and `bucket.components[]`: - -- For each role you can identify (e.g. `button-primary-bg`, `surface-elevated`, `text-muted`), record its resolved value from the bucket. Use `tokens[].alias_chain` to trace which named token a slot resolves through. -- Skip roles you can't directly observe in the bucket. Empty `roles[]` is fine. - -In **token-pipeline** mode, this is the richest layer — semantic tokens map cleanly to roles. In **consumer** mode, it's typically empty or upstream-slug-only. In **ui-library** mode, registry-based components give you slot mappings. - -### 6. Write the file +### 5. Write the file Copy [../assets/expression.template.md](../assets/expression.template.md). Fill in: -- **Frontmatter:** all structured fields (identity, `observation.personality`/`.resembles`, `decisions[].dimension`, `palette`, `spacing`, `typography`, `surfaces`, `roles`). +- **Frontmatter:** all structured fields (identity, `observation.personality`/`.resembles`, `decisions[].dimension`, `palette`, `spacing`, `typography`, `surfaces`). - **Body:** `# Character` (observation summary), `# Signature` (distinctiveTraits bullets), `# Decisions` (one `### ` block per decision, each ending with `**Evidence:**` bullets citing bucket rows). Partition matters. See [schema.md](schema.md) for which field lives where. -### 7. Validate +### 6. Validate ghost-expression lint expression.md @@ -133,7 +124,7 @@ Fix any errors. Common ones: - Palette entry not cited in any evidence → cite it (from a bucket row) or drop it. - Typography size not in the bucket → drop it; the surveyor missed it or it's not real. -### 8. Provenance check +### 7. Provenance check For every value in your expression's frontmatter, confirm it appears in `bucket.json`. Quick sanity: @@ -142,7 +133,7 @@ For every value in your expression's frontmatter, confirm it appears in `bucket. Any expression value that doesn't trace back is a hallucination. Remove it. -### 9. Self-distance sanity +### 8. Self-distance sanity ghost-drift compare expression.md expression.md diff --git a/packages/ghost-expression/src/skill-bundle/references/scan.md b/packages/ghost-expression/src/skill-bundle/references/scan.md index 6595f45..c05bbce 100644 --- a/packages/ghost-expression/src/skill-bundle/references/scan.md +++ b/packages/ghost-expression/src/skill-bundle/references/scan.md @@ -70,7 +70,7 @@ After validation, re-run `scan-status` and proceed. Run when `scan-status` reports both prior stages `present` and `subjective: missing`. -Recipe: [profile.md](profile.md). The agent reads `map.md` (for repo-kind signals) and `bucket.json` (for ground truth) and writes `expression.md` purely as interpretation: assigns roles, names decisions, writes the prose body, fills frontmatter from bucket rows. Cannot invent values not in the bucket. Validates with `ghost-expression lint expression.md` and a self-distance sanity check (`ghost-drift compare expression.md expression.md` returns 0). +Recipe: [profile.md](profile.md). The agent reads `map.md` (for repo-kind signals) and `bucket.json` (for ground truth) and writes `expression.md` purely as interpretation: names decisions, writes the prose body, fills frontmatter from bucket rows. Cannot invent values not in the bucket. Validates with `ghost-expression lint expression.md` and a self-distance sanity check (`ghost-drift compare expression.md expression.md` returns 0). ### 6. Confirm complete diff --git a/packages/ghost-expression/src/skill-bundle/references/schema.md b/packages/ghost-expression/src/skill-bundle/references/schema.md index d3a732f..187da2f 100644 --- a/packages/ghost-expression/src/skill-bundle/references/schema.md +++ b/packages/ghost-expression/src/skill-bundle/references/schema.md @@ -58,35 +58,6 @@ surfaces: shadowComplexity: subtle # deliberate-none | subtle | layered borderUsage: moderate # minimal | moderate | heavy -# slot → token bindings (optional but strongly recommended) -# -# `roles[].tokens.palette` is an open record — slot keys are free-form. -# Reach for the conventional vocabulary first: `background`, `foreground`, -# `surface`, `border`, `accent`, `muted`, `link`. Add others (`ring`, -# `popover`, `separator`, …) when they're load-bearing in your codebase. -# -# Slot values are either: -# - raw hex literals — `"#1a1a1a"` -# - local refs — `"{palette.dominant.}"` / `"{palette.semantic.}"` -# - opaque external refs — `"{base.color.brand.x}"` for token-pipeline -# consumers; the linter accepts these as deliberate passthroughs and -# does not try to resolve them -# -# Other dimensions (typography, spacing, surfaces) inline raw values. -roles: - - name: h1 - tokens: - typography: { family: "Geist", size: 52, weight: 500 } - evidence: ["src/components/h1.tsx:4"] - - name: button - tokens: - surfaces: { borderRadius: 8 } - palette: - background: "{palette.dominant.accent}" - foreground: "{palette.dominant.surface}" - border: "{palette.semantic.border-default}" - evidence: ["src/components/button.tsx:12"] - # extension bag (optional, opaque to comparisons) metadata: tone: editorial @@ -140,7 +111,7 @@ Every field lives in exactly one layer: | `decisions[].dimension` | Frontmatter | | `decisions[].decision` (prose) | **Body** (`### ` block) | | `decisions[].evidence` | **Body** (`**Evidence:**` bullets under `### `) | -| `palette`, `spacing`, `typography`, `surfaces`, `roles` | Frontmatter | +| `palette`, `spacing`, `typography`, `surfaces` | Frontmatter | | `embedding` | Sibling `embedding.md` | Putting prose into frontmatter is a schema error. The writer and reader both enforce this. When in doubt: structured data → frontmatter; narrative → body. @@ -149,4 +120,4 @@ Putting prose into frontmatter is a schema error. The writer and reader both enf ghost-expression lint expression.md -This catches schema violations, missing required fields, prose-in-frontmatter, orphaned decision blocks (body `### dim` with no matching frontmatter entry, or vice versa), and uncited palette entries. +This catches schema violations, missing required fields, prose-in-frontmatter, orphaned decision blocks (body `### dim` with no matching frontmatter entry, or vice versa), and uncited palette entries (info-level — palette colors not cited in any decision evidence/prose). diff --git a/packages/ghost-expression/test/expression/compose.test.ts b/packages/ghost-expression/test/expression/compose.test.ts index d264b35..08c9647 100644 --- a/packages/ghost-expression/test/expression/compose.test.ts +++ b/packages/ghost-expression/test/expression/compose.test.ts @@ -106,40 +106,6 @@ describe("mergeExpression", () => { expect(merged.values?.do).toEqual(["new-do"]); expect(merged.values?.dont).toEqual([]); }); - - it("roles merge by name: overlay wins per-slot, base-only roles kept", () => { - const baseWithRoles: Expression = { - ...BASE, - roles: [ - { - name: "h1", - tokens: { typography: { family: "Serif", size: 32 } }, - evidence: ["base.tsx"], - }, - { - name: "body", - tokens: { typography: { family: "Sans", size: 16 } }, - evidence: ["base.tsx"], - }, - ], - }; - const overlay: Partial = { - roles: [ - { - name: "h1", - tokens: { typography: { family: "Serif", size: 64 } }, - evidence: ["overlay.tsx"], - }, - ], - }; - const merged = mergeExpression(baseWithRoles, overlay); - expect(merged.roles).toHaveLength(2); - const h1 = merged.roles?.find((r) => r.name === "h1"); - expect(h1?.tokens.typography?.size).toBe(64); - expect(h1?.evidence).toEqual(["overlay.tsx"]); - const body = merged.roles?.find((r) => r.name === "body"); - expect(body?.tokens.typography?.size).toBe(16); - }); }); describe("loadExpression extends resolution", () => { diff --git a/packages/ghost-expression/test/expression/layout.test.ts b/packages/ghost-expression/test/expression/layout.test.ts index ee6262e..413e9d0 100644 --- a/packages/ghost-expression/test/expression/layout.test.ts +++ b/packages/ghost-expression/test/expression/layout.test.ts @@ -180,13 +180,7 @@ x const fm = layout.sections.find((s) => s.kind === "frontmatter"); expect(fm?.start).toBe(1); expect(fm?.partitions).toEqual( - expect.arrayContaining([ - "palette", - "spacing", - "typography", - "surfaces", - "roles", - ]), + expect.arrayContaining(["palette", "spacing", "typography", "surfaces"]), ); const headings = layout.sections diff --git a/packages/ghost-expression/test/expression/lint.test.ts b/packages/ghost-expression/test/expression/lint.test.ts index f99d3dd..8b2d9d6 100644 --- a/packages/ghost-expression/test/expression/lint.test.ts +++ b/packages/ghost-expression/test/expression/lint.test.ts @@ -150,207 +150,6 @@ refers to a ghost color expect(unused.every((i) => i.severity === "error")).toBe(true); }); - it("accepts a role palette reference that resolves", () => { - const md = build( - ` -roles: - - name: button - tokens: - palette: { background: '{palette.dominant.accent}' } - evidence: ["src/ui/button.tsx:12"]`, - "", - ); - const report = lintExpression(md); - expect(report.issues.some((i) => i.rule === "broken-role-reference")).toBe( - false, - ); - }); - - it("flags a role reference that points at a missing palette role", () => { - const md = build( - ` -roles: - - name: button - tokens: - palette: { background: '{palette.dominant.ghost}' } - evidence: ["src/ui/button.tsx:12"]`, - "", - ); - const report = lintExpression(md); - const broken = report.issues.filter( - (i) => i.rule === "broken-role-reference", - ); - expect(broken.length).toBe(1); - expect(broken[0].severity).toBe("error"); - expect(broken[0].path).toBe("roles[0].tokens.palette.background"); - }); - - it("flags a role reference into an unsupported namespace", () => { - const md = build( - ` -roles: - - name: button - tokens: - palette: { foreground: '{typography.families.primary}' } - evidence: ["src/ui/button.tsx:12"]`, - "", - ); - const report = lintExpression(md); - const broken = report.issues.find( - (i) => i.rule === "broken-role-reference", - ); - expect(broken).toBeDefined(); - expect(broken?.message).toMatch(/palette\.dominant.*palette\.semantic/); - }); - - it("leaves raw hex values in role palette alone", () => { - const md = build( - ` -roles: - - name: button - tokens: - palette: { background: '#c96442' } - evidence: ["src/ui/button.tsx:12"]`, - "", - ); - const report = lintExpression(md); - expect(report.issues.some((i) => i.rule === "broken-role-reference")).toBe( - false, - ); - }); - - it("propagates slug-binding citations through `{palette.dominant.X}` references", () => { - // The role binds `background` to `{palette.dominant.accent}` — the - // accent's hex (#c96442) must be treated as cited even though it - // never appears as a literal in any body Evidence bullet. - const md = build( - ` -roles: - - name: button - tokens: - palette: - background: '{palette.dominant.accent}' - evidence: - - "components/button.tsx using #4d4c48 for hover and #b53333 for danger and #141413 muted"`, - "", - ); - const report = lintExpression(md); - const unused = report.issues.filter((i) => i.rule === "unused-palette"); - // #c96442 is cited only via the slug binding — must NOT be flagged. - expect(unused.some((i) => i.message.includes("#c96442"))).toBe(false); - // The other three are name-dropped in role evidence, so the file - // should be fully clean. - expect(unused.length).toBe(0); - }); - - it("counts a hex used in a role's evidence string as cited (no unused-palette info)", () => { - // The PALETTE_BLOCK ships #c96442, #141413, #4d4c48, #b53333. - // A role binding that cites every hex (some in palette field, some - // inline in evidence) should silence unused-palette entirely. - const md = build( - ` -roles: - - name: button - tokens: - palette: { background: '#c96442', foreground: '#141413' } - evidence: - - "components/button.tsx using #4d4c48 for hover and #b53333 for danger"`, - "", - ); - const report = lintExpression(md); - expect(report.issues.some((i) => i.rule === "unused-palette")).toBe(false); - }); - - it("still flags palette colors absent from both decisions and roles", () => { - // Add a role that cites only one of the four palette hexes; the - // other three should still fire unused-palette as info. - const md = build( - ` -roles: - - name: button - tokens: - palette: { background: '#c96442' } - evidence: ["src/ui/button.tsx:12"]`, - "", - ); - const report = lintExpression(md); - const unused = report.issues.filter((i) => i.rule === "unused-palette"); - expect(unused.length).toBeGreaterThan(0); - // #c96442 is now cited via the role binding — must NOT appear in - // the unused list. - expect(unused.some((i) => i.message.includes("#c96442"))).toBe(false); - }); - - it("accepts extended palette slot keys (surface, accent, muted, link, …)", () => { - // Phase 5b widens roles[].tokens.palette from a fixed three-key - // shape (background/foreground/border) to an open record. Slots like - // `surface`, `accent`, `muted`, `link`, `ring`, `popover` are now - // valid and don't trigger schema-invalid. - const md = build( - ` -roles: - - name: card - tokens: - palette: - background: '#c96442' - surface: '#141413' - border: '#4d4c48' - accent: '{palette.dominant.accent}' - muted: '#b53333' - ring: '#141413' - evidence: ["src/ui/card.tsx:1"]`, - "", - ); - const report = lintExpression(md); - expect(report.issues.some((i) => i.rule === "schema-invalid")).toBe(false); - expect(report.issues.some((i) => i.rule === "broken-role-reference")).toBe( - false, - ); - }); - - it("accepts opaque external token refs without flagging broken-role-reference", () => { - // Style-Dictionary-style consumer repos use deeply-nested refs that - // resolve in the upstream package. The linter should treat them as - // opaque rather than rejecting them. - const md = build( - ` -roles: - - name: button - tokens: - palette: - background: '{base.color.brand.x.light}' - foreground: '{semantic.text.on-brand}' - surface: '{component.button.surface.default}' - evidence: ["src/ui/button.tsx:1"]`, - "", - ); - const report = lintExpression(md); - expect(report.issues.some((i) => i.rule === "broken-role-reference")).toBe( - false, - ); - }); - - it("still resolves and validates local palette refs even with extended slots", () => { - // External-ref tolerance must not regress local-ref validation — - // `{palette.dominant.ghost}` (no such role) still fires. - const md = build( - ` -roles: - - name: card - tokens: - palette: - surface: '{palette.dominant.ghost}' - evidence: ["src/ui/card.tsx:1"]`, - "", - ); - const report = lintExpression(md); - const broken = report.issues.find( - (i) => i.rule === "broken-role-reference", - ); - expect(broken).toBeDefined(); - expect(broken?.path).toBe("roles[0].tokens.palette.surface"); - }); - it("accepts shadowComplexity: deliberate-none on the surfaces block", () => { const md = `${HEADER} palette: diff --git a/packages/ghost-expression/test/expression/load.test.ts b/packages/ghost-expression/test/expression/load.test.ts index 90c7d7e..ddfec79 100644 --- a/packages/ghost-expression/test/expression/load.test.ts +++ b/packages/ghost-expression/test/expression/load.test.ts @@ -359,65 +359,4 @@ describe("serializeExpression round-trip", () => { expect(md).toContain("**Evidence:**"); expect(md).toContain("`#141413`"); }); - - it("round-trips roles (slot → token bindings) through serialize → parse", () => { - const fpWithRoles: Expression = { - ...SAMPLE_EXPRESSION, - roles: [ - { - name: "h1", - tokens: { - typography: { family: "Anthropic Serif", size: 64, weight: 500 }, - spacing: { margin: 32 }, - }, - evidence: ["components/Heading.tsx:12"], - }, - { - name: "card", - tokens: { - surfaces: { borderRadius: 16, shadow: "subtle" }, - spacing: { padding: 24 }, - palette: { background: "#f5f4ed" }, - }, - evidence: ["components/ui/card.tsx"], - }, - ], - }; - const md = serializeExpression(fpWithRoles, { extractEmbedding: false }); - const yamlSection = md.slice(md.indexOf("---") + 3, md.lastIndexOf("---")); - expect(yamlSection).toMatch(/^roles:/m); - expect(yamlSection).toContain("name: h1"); - expect(yamlSection).toContain("name: card"); - - const { expression } = parseExpression(md); - expect(expression.roles).toHaveLength(2); - expect(expression.roles?.[0].name).toBe("h1"); - expect(expression.roles?.[0].tokens.typography?.size).toBe(64); - expect(expression.roles?.[0].evidence).toEqual([ - "components/Heading.tsx:12", - ]); - expect(expression.roles?.[1].tokens.surfaces?.borderRadius).toBe(16); - expect(expression.roles?.[1].tokens.surfaces?.shadow).toBe("subtle"); - expect(expression.roles?.[1].tokens.palette?.background).toBe("#f5f4ed"); - }); - - it("rejects unknown keys in role token sub-blocks (strict schema)", () => { - const fpBad = { - ...SAMPLE_EXPRESSION, - roles: [ - { - name: "h1", - tokens: { - // @ts-expect-error — intentional bad input - typography: { family: "Serif", bogus: 42 }, - }, - evidence: [], - }, - ], - } as Expression; - const md = serializeExpression(fpBad, { extractEmbedding: false }); - expect(() => parseExpression(md)).toThrow( - /Invalid expression frontmatter[\s\S]*roles/, - ); - }); }); diff --git a/packages/ghost-expression/test/expression/references.test.ts b/packages/ghost-expression/test/expression/references.test.ts deleted file mode 100644 index 9bc025b..0000000 --- a/packages/ghost-expression/test/expression/references.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Expression } from "@ghost/core"; -import { describe, expect, it } from "vitest"; -import { - formatReferenceError, - isTokenReference, - parseTokenReference, - resolveTokenReference, -} from "../../src/core/references.js"; - -function buildExpression(): Expression { - return { - id: "x", - source: "llm", - timestamp: "2026-04-22T00:00:00Z", - palette: { - dominant: [ - { role: "accent", value: "#c96442" }, - { role: "surface", value: "#f5f4ed" }, - ], - neutrals: { steps: ["#141413", "#4d4c48"], count: 2 }, - semantic: [ - { role: "error", value: "#b53333" }, - { role: "focus", value: "#3898ec" }, - ], - saturationProfile: "muted", - contrast: "moderate", - }, - spacing: { scale: [4, 8], regularity: 1, baseUnit: 8 }, - typography: { - families: ["Serif"], - sizeRamp: [16], - weightDistribution: { 400: 1 }, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [8], - shadowComplexity: "subtle", - borderUsage: "moderate", - }, - embedding: [], - }; -} - -describe("isTokenReference", () => { - it("recognizes well-formed references", () => { - expect(isTokenReference("{palette.dominant.accent}")).toBe(true); - expect(isTokenReference("{palette.semantic.error}")).toBe(true); - }); - - it("rejects raw hex values and plain strings", () => { - expect(isTokenReference("#c96442")).toBe(false); - expect(isTokenReference("Anthropic Serif")).toBe(false); - expect(isTokenReference("")).toBe(false); - }); - - it("rejects single-segment or empty braces", () => { - expect(isTokenReference("{accent}")).toBe(false); - expect(isTokenReference("{}")).toBe(false); - expect(isTokenReference("{palette}")).toBe(false); - }); - - it("rejects non-string input", () => { - expect(isTokenReference(16)).toBe(false); - expect(isTokenReference(null)).toBe(false); - expect(isTokenReference(undefined)).toBe(false); - }); -}); - -describe("parseTokenReference", () => { - it("splits namespace and role", () => { - expect(parseTokenReference("{palette.dominant.accent}")).toEqual({ - path: "palette.dominant.accent", - namespace: "palette.dominant", - role: "accent", - }); - }); - - it("returns null for malformed input", () => { - expect(parseTokenReference("{palette}")).toBeNull(); - expect(parseTokenReference("palette.dominant.accent")).toBeNull(); - }); -}); - -describe("resolveTokenReference", () => { - const fp = buildExpression(); - - it("resolves a dominant role to its hex", () => { - const result = resolveTokenReference(fp, "{palette.dominant.accent}"); - expect(result.value).toBe("#c96442"); - expect(result.error).toBeNull(); - }); - - it("resolves a semantic role to its hex", () => { - const result = resolveTokenReference(fp, "{palette.semantic.error}"); - expect(result.value).toBe("#b53333"); - expect(result.error).toBeNull(); - }); - - it("flags an unknown role in a supported namespace", () => { - const result = resolveTokenReference(fp, "{palette.dominant.ghost}"); - expect(result.value).toBeNull(); - expect(result.error).toEqual({ - kind: "unknown-role", - namespace: "palette.dominant", - role: "ghost", - }); - }); - - it("flags an unsupported namespace with the supported list", () => { - const result = resolveTokenReference(fp, "{typography.families.primary}"); - expect(result.value).toBeNull(); - expect(result.error?.kind).toBe("unsupported-namespace"); - if (result.error?.kind === "unsupported-namespace") { - expect(result.error.supported).toEqual([ - "palette.dominant", - "palette.semantic", - ]); - } - }); - - it("flags a malformed reference string", () => { - const result = resolveTokenReference(fp, "{bogus}"); - expect(result.value).toBeNull(); - expect(result.error?.kind).toBe("malformed"); - }); -}); - -describe("formatReferenceError", () => { - it("produces a readable message for each kind", () => { - expect( - formatReferenceError({ kind: "malformed", value: "{bogus}" }), - ).toMatch(/not a valid token reference/); - expect( - formatReferenceError({ - kind: "unsupported-namespace", - namespace: "typography.sizeRamp", - supported: ["palette.dominant", "palette.semantic"], - }), - ).toMatch(/Supported:.*palette\.dominant.*palette\.semantic/); - expect( - formatReferenceError({ - kind: "unknown-role", - namespace: "palette.dominant", - role: "ghost", - }), - ).toMatch(/does not resolve/); - }); -}); diff --git a/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-android/expression.md b/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-android/expression.md index af49629..e7b0a38 100644 --- a/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-android/expression.md +++ b/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-android/expression.md @@ -35,7 +35,6 @@ surfaces: borderRadii: [4, 12, 28] shadowComplexity: layered borderUsage: minimal -roles: [] --- # Character diff --git a/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-web/expression.md b/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-web/expression.md index 663f72c..2cc64a0 100644 --- a/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-web/expression.md +++ b/packages/ghost-fleet/test/fixtures/small-fleet/members/cash-web/expression.md @@ -35,7 +35,6 @@ surfaces: borderRadii: [8, 12] shadowComplexity: subtle borderUsage: minimal -roles: [] --- # Character diff --git a/packages/ghost-fleet/test/fixtures/small-fleet/members/ghost-ui/expression.md b/packages/ghost-fleet/test/fixtures/small-fleet/members/ghost-ui/expression.md index c8af60f..fd356be 100644 --- a/packages/ghost-fleet/test/fixtures/small-fleet/members/ghost-ui/expression.md +++ b/packages/ghost-fleet/test/fixtures/small-fleet/members/ghost-ui/expression.md @@ -36,7 +36,6 @@ surfaces: borderRadii: [10, 14, 999] shadowComplexity: layered borderUsage: moderate -roles: [] --- # Character diff --git a/packages/ghost-ui/expression.md b/packages/ghost-ui/expression.md index 4f9df48..d1ae288 100644 --- a/packages/ghost-ui/expression.md +++ b/packages/ghost-ui/expression.md @@ -133,34 +133,6 @@ surfaces: borderRadii: [10, 14, 16, 20, 24, 999] shadowComplexity: layered borderUsage: moderate -roles: - - name: button - tokens: - surfaces: { borderRadius: 999 } - palette: { background: "#1a1a1a", foreground: "#ffffff" } - evidence: - - "src/components/ui/button.tsx:7" - - "src/components/ui/button.tsx:11" - - name: input - tokens: - surfaces: { borderRadius: 999 } - evidence: - - "src/components/ui/input.tsx:11" - - name: badge - tokens: - surfaces: { borderRadius: 999 } - evidence: - - "src/components/ui/badge.tsx:8" - - name: card - tokens: - surfaces: { borderRadius: 20 } - evidence: - - "src/components/ui/card.tsx:10" - - name: alert-title - tokens: - typography: { family: "system-ui", weight: 600 } - evidence: - - "src/components/ui/alert.tsx:42" --- # Character diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/arcade/README.md b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/README.md new file mode 100644 index 0000000..c6064d1 --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/README.md @@ -0,0 +1,18 @@ +# arcade — design context bundle + +Generated by `ghost-drift emit context-bundle`. Grounding material for AI UI generation in the **arcade** design language. Signature traits: Cash Green (`#00D64F` / `#00BD46`) is reserved as accent and surface, never as the default fill of a primary button — `prominent.background = semantic.background.inverse`, so heroes are pure black/white., A "mono" appearance is a parallel reality: every brand reference has a `lightMono` / `darkMono` override that strips green to grey, generated as separate output (`colors-mono.ts`, `ArcadeColorMapping+LightMono.swift`) — accessibility/preference is a build target, not a runtime fork., Single typeface (`CashSans`, plus `CashSansMono` for micro-labels), carried by Square's CDN; every typography token also declares its `dynamic-type-style-uikit` and `dynamic-type-style-swiftui` mapping — type is bonded to platform a11y rails.. + +## Files + +- `SKILL.md` — Agent Skill manifest (user-invocable) +- `expression.md` — canonical design language (YAML frontmatter + Character/Signature/Decisions) +- `tokens.css` — CSS custom properties derived from expression tokens +- `README.md` — this file + +## Using this bundle + +**As a Claude Code / MCP skill:** point the client at this directory. The agent will read `SKILL.md` and follow its instructions. + +**As context for any LLM:** load `expression.md` into the system prompt. For more explicit grounding, concatenate with `tokens.css`. + +**Feedback loop:** ask your host agent to review the generated output against this `expression.md` (the `review` recipe, installed via `ghost-drift emit skill`). Drift signals whether the generator honored the system. diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/arcade/SKILL.md b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/SKILL.md new file mode 100644 index 0000000..74f5560 --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/SKILL.md @@ -0,0 +1,26 @@ +--- +name: arcade +description: Use this skill to generate UI in the arcade design language (Cash Green (`#00D64F` / `#00BD46`) is reserved as accent and surface, never as the default fill of a primary button — `prominent.background = semantic.background.inverse`, so heroes are pure black/white., A "mono" appearance is a parallel reality: every brand reference has a `lightMono` / `darkMono` override that strips green to grey, generated as separate output (`colors-mono.ts`, `ArcadeColorMapping+LightMono.swift`) — accessibility/preference is a build target, not a runtime fork., Single typeface (`CashSans`, plus `CashSansMono` for micro-labels), carried by Square's CDN; every typography token also declares its `dynamic-type-style-uikit` and `dynamic-type-style-swiftui` mapping — type is bonded to platform a11y rails.). Contains the canonical expression and token reference. +user-invocable: true +--- + +This skill grounds UI generation in the **arcade** design language. + +Read `expression.md` first — it is the source of truth. It has four layered sections: + +1. **Character** — what this expression is (one-paragraph summary) +2. **Signature** — what makes it distinctive (bullet list of traits) +3. **Decisions** — specific design choices with evidence from the source +4. **Values** — hard Do / Don't rules + +When generating UI in this language: + +- Treat **Values** as non-negotiable gates — never violate a Don't. +- Use **Decisions** as the lookup for specific choices (spacing scale, type ramp, radii). +- Let **Character** and **Signature** shape overall feel, density, and voice. +- Prefer tokens from the YAML frontmatter (palette, spacing, typography, surfaces) over arbitrary values. + +## Files + +- `expression.md` — canonical design language (YAML tokens + Character/Signature/Decisions/Values) +- `tokens.css` — CSS custom properties derived from expression tokens diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/arcade/expression.md b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/expression.md new file mode 100644 index 0000000..6c7f915 --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/expression.md @@ -0,0 +1,380 @@ +--- +id: arcade +source: llm +timestamp: 2026-04-28T00:00:00Z +sources: + - github:squareup/cash-design-system +observation: + personality: + - utilitarian + - monetary + - mode-rich + - high-contrast + - dense + - branded-but-restrained + resembles: + - material-3 + - ios-system +decisions: + - dimension: color-strategy + - dimension: appearance-modes + - dimension: spatial-system + - dimension: surface-hierarchy + - dimension: elevation + - dimension: typography-voice + - dimension: numeric-display + - dimension: semantic-density + - dimension: product-identity + - dimension: token-architecture + - dimension: motion + - dimension: legacy-handling + - dimension: playground-divergence +palette: + dominant: + - role: brand + value: "#00D64F" + oklch: + - 0.763 + - 0.227 + - 147 + - role: brand-dark + value: "#00BD46" + oklch: + - 0.696 + - 0.206 + - 147.1 + - role: ink + value: "#000000" + oklch: + - 0 + - 0 + - 0 + - role: paper + value: "#FFFFFF" + oklch: + - 1 + - 0 + - 89.9 + neutrals: + steps: + - "#FFFFFF" + - "#F0F0F0" + - "#E8E8E8" + - "#CCCCCC" + - "#878787" + - "#000000" + count: 6 + semantic: + - role: danger + value: "#D3040E" + oklch: + - 0.546 + - 0.222 + - 28.2 + - role: danger-dark + value: "#F84752" + oklch: + - 0.657 + - 0.213 + - 22.1 + - role: warning + value: "#CC4B03" + oklch: + - 0.582 + - 0.176 + - 41.4 + - role: success + value: "#00792C" + oklch: + - 0.502 + - 0.147 + - 147.6 + - role: info + value: "#007CC1" + oklch: + - 0.565 + - 0.139 + - 244.1 + - role: link-visited + value: "#660199" + oklch: + - 0.4 + - 0.204 + - 307.8 + - role: notification + value: "#D7040E" + oklch: + - 0.553 + - 0.225 + - 28.3 + - role: bitcoin + value: "#00D4FF" + oklch: + - 0.804 + - 0.146 + - 219.5 + - role: investing + value: "#9013FE" + oklch: + - 0.553 + - 0.289 + - 298.9 + - role: taxes + value: "#5D00E8" + oklch: + - 0.47 + - 0.273 + - 285.7 + - role: borrow + value: "#3399FF" + oklch: + - 0.676 + - 0.176 + - 252.3 + - role: avatar-turquoise + value: "#41EBC1" + oklch: + - 0.845 + - 0.149 + - 172 + - role: avatar-pink + value: "#FB60C4" + oklch: + - 0.718 + - 0.215 + - 344.2 + - role: avatar-sunshine + value: "#FADA3D" + oklch: + - 0.89 + - 0.167 + - 97 + - role: avatar-amber + value: "#F46E38" + oklch: + - 0.693 + - 0.178 + - 41 + - role: avatar-purple + value: "#B141FF" + oklch: + - 0.622 + - 0.265 + - 306.9 + saturationProfile: mixed + contrast: high +spacing: + scale: + - 4 + - 8 + - 16 + - 32 + - 64 + regularity: 1 + baseUnit: 4 +typography: + families: + - CashSans + - CashSansMono + sizeRamp: + - 10 + - 11 + - 12 + - 14 + - 16 + - 24 + - 28 + - 32 + - 44 + - 56 + - 96 + weightDistribution: + "400": 9 + "500": 16 + "600": 2 + lineHeightPattern: tight +surfaces: + borderRadii: + - 6 + - 8 + - 16 + - 24 + - 40 + - 9999 + shadowComplexity: deliberate-none + borderUsage: moderate +--- + +# Character + +Arcade is the visual language Cash App ships across every surface — phones, web, server-rendered email, and CDN — authored once as a YAML graph and fanned out through Style Dictionary. Read end-to-end, the language reads as utilitarian and monetary: a grayscale stage with a single saturated Cash Green for brand, a wide semantic vocabulary of warnings/states/services, and an unusually rich set of *appearance modes* (light, dark, P3 wide-gamut, light-mono, dark-mono) that the system treats as a first-class product axis rather than a theme switch. Heaviness is achieved by inversion (black on white, white on black) instead of by elevation; depth is layered greys, never shadow. The brand is loud where money is happening — keypad, brand surface, toggle-on — and quiet everywhere else. + +# Signature + +- Cash Green (`#00D64F` / `#00BD46`) is reserved as accent and surface, never as the default fill of a primary button — `prominent.background = semantic.background.inverse`, so heroes are pure black/white. +- A "mono" appearance is a parallel reality: every brand reference has a `lightMono` / `darkMono` override that strips green to grey, generated as separate output (`colors-mono.ts`, `ArcadeColorMapping+LightMono.swift`) — accessibility/preference is a build target, not a runtime fork. +- Single typeface (`CashSans`, plus `CashSansMono` for micro-labels), carried by Square's CDN; every typography token also declares its `dynamic-type-style-uikit` and `dynamic-type-style-swiftui` mapping — type is bonded to platform a11y rails. +- Money is its own typographic class: `keypad-total` is 96/96, `numeral-large` 56/56, `numeral-small` 32/32 — large monetary numerals collapse line-height to size for tight optical fit. +- Border radii are categorical, not derived: `xsmall=6, small=8, medium=16, large=24, xlarge=40, pill=9999, circle=50%` — the pill chip is a first-class slug, not a math result. +- No shadow vocabulary. Surface depth is a 5-step grey ladder (`background.app → subtle → standard → prominent → extra-prominent`) and a single `dimmer` rgba; elevation is *not* in the graph. +- Product surfaces have named identity colors at the semantic layer: `service.bitcoin`, `service.investing`, `service.taxes`, `service.borrow` — money lines own their own hue, separate from brand. +- Avatar identity is a fixed nine-chip kaleidoscope (turquoise, sky, ocean, royal, pink, purple, scarlet, amber, sunshine) hardcoded against the base brand palette — identity color is not theme-dependent. +- Components are authored once at `component/mobile/*.yaml` and platform layers `extend:` for additions only — web grafts on hover/focus, android grafts on ripple, iOS grafts on dynamic-type styles. +- Deprecation lives in the graph: deprecated tokens carry `deprecated: { value: true, replacement: }` inline rather than in a changelog. +- The Cabinet playground that ships the system does not eat its own dogfood — `apps/cabinet/app/components/ui/button.tsx` uses default shadcn `bg-neutral-900` / `bg-red-500` Tailwind classes and only the surrounding shell pulls Arcade CSS variables. + +# Decisions + +### Color strategy +Brand-as-accent, inverse-as-prominent. The most prominent surface is not the brand color — `prominent.background` resolves to `semantic.background.inverse` (black in light, white in dark), and Cash Green is reserved for `semantic.background.brand` / `border.brand` / `text.brand` / `icon.brand`, plus the keypad surface and toggle-on state. The rest of the app rides on a deep grey ramp punctuated by saturated semantic states. + +**Evidence:** +- `design-tokens/arcade/component/mobile/button.yaml:6\` — \`prominent.background.default = semantic.color.background.inverse` +- `design-tokens/arcade/semantic/color.yaml:67\` — \`text.brand → base.color.brand.cash-green` +- `design-tokens/arcade/semantic/color.yaml:237\` — \`background.brand → base.color.brand.cash-green` +- `base.color.brand.cash-green.light = #00D64F` +- `base.color.brand.cash-green.dark = #00BD46` +- `base.color.constant.black = #000000` +- `base.color.constant.white = #FFFFFF` + +### Appearance modes +Five appearance modes are first-class outputs, not runtime overlays. Every brand-touching token declares `light`, `dark`, optional `lightP3`, and a parallel `lightMono` / `darkMono` pair. Style Dictionary emits separate artifacts per mode (`colors-mono.ts`, `colors-pre-mono.ts`, `ArcadeColorMapping+LightP3.swift`, `+LightMono.swift`, `+DarkMono.swift`); the mono variant deliberately removes brand color from the system rather than re-tinting it. Modes are a generator concern, not a CSS concern. + +**Evidence:** +- `\`generators/ios/config.yaml:24\` — \`+LightP3.swift\` / \`+LightMono.swift\` / \`+DarkMono.swift\` filtered outputs` +- `\`generators/web/config.yaml:11\` — \`colors-mono.ts\` / \`colors-pre-mono.ts\` web outputs` +- `\`design-tokens/arcade/mono/component-button.yaml:6\` — \`lightMono\` strips brand to grey on prominent button` +- `\`design-tokens/arcade/p3/semantic-color.yaml:1\` — \`lightP3\` overrides for success` +- `\`packages/web/tokens/src/index.ts:2\` — \`semanticMonochrome\` / \`componentMonochrome\` named re-exports` + +### Spatial system +Discrete pixel scale, no fluid grid. Spacing is six categorical slugs (`xsmall=4, small=8, medium=16, large=32, xlarge=64`) plus a fixed `margin=16`. Values are authored as raw integers in YAML and converted to rem only at the web sink; iOS gets `CGFloat`, Android gets `dp`. Radii are categorical too — `pill=9999` and `circle='50%'` are not arithmetic, they are tokens. + +**Evidence:** +- `--space-xsmall: 4` +- `--space-small: 8` +- `--space-medium: 16` +- `--space-large: 32` +- `--space-xlarge: 64` +- `--radius-pill: 9999` +- `--radius-circle: 50%` +- `design-tokens/arcade/semantic/size.yaml:28` +- `\`generators/web/config.yaml:6\` — \`size-rem\` web transform` +- `\`generators/ios/config.yaml:104\` — \`CGFloat+Arcade.swift\` ios sink` + +### Surface hierarchy +Depth is a five-step neutral ramp, not a shadow stack. `background.app → subtle → standard → prominent → extra-prominent` walks the grey ladder (white→grey-95→grey-90→grey-80→grey-65 in light mode; black→grey-15→grey-25→grey-40→grey-45 in dark). Cards and inputs sit on `background.app` with a `border.subtle` hairline; pressed states swap up one step rather than animating elevation. Dimming is a single `#00000073` rgba in `ui.dimmer`. + +**Evidence:** +- `--background-app-light: #FFFFFF` +- `--background-subtle-light: #F0F0F0` +- `--background-standard-light: #E8E8E8` +- `--background-prominent-light: #CCCCCC` +- `--background-extra-prominent-light: #878787` +- `design-tokens/arcade/component/mobile/card.yaml:4` +- `design-tokens/arcade/component/mobile/cell.yaml:4` +- `\`design-tokens/arcade/component/mobile/ui.yaml:19\` — \`dimmer.background\` is a single alpha-encoded black rgba` + +### Elevation +There is no shadow or elevation token in the graph. Shadow appears only as an *illustration* paint (`semantic.color.illustration.shadow.standard`), used for drawing graphics, not for raising surfaces. Z-depth is communicated entirely through the surface ramp and inversion. + +**Evidence:** +- `\`design-tokens/arcade/semantic/color.yaml:402\` — \`illustration.shadow.standard\` (paint, not depth)` +- `No \`shadow\` / \`elevation\` namespace in any base or semantic file` +- `surfaces.shadowComplexity: deliberate-none` + +### Typography voice +Single proprietary family ("CashSans", served from `cash-f.squarecdn.com`), with `CashSansMono` reserved for `body-x-small`, `link-x-small`, and `label-x-small`. Every token declares paired iOS dynamic-type slugs (`dynamic-type-style-uikit`, `dynamic-type-style-swiftui`) so the same role survives the iOS accessibility ramp. Weights are restricted to 400 (regular) and 500 (medium); 600 (semibold) appears only on deprecated tokens, signalling the system is migrating off it. + +**Evidence:** +- `apps/cabinet/app/styles/arcade.css:5\` — \`@font-face CashSans\` from \`cash-f.squarecdn.com` +- `design-tokens/arcade/base/typography.yaml:4\` — \`font.family.default = CashSans` +- `design-tokens/arcade/base/typography.yaml:7\` — \`font.family.mono = CashSansMono` +- `design-tokens/arcade/semantic/typography.yaml:14\` — \`dynamic-type-style-uikit: largeTitle\` paired on \`keypad-total` +- `design-tokens/arcade/semantic/typography.yaml:697\` — semibold (600) only on deprecated \`hero-numerics` +- `design-tokens/arcade/semantic/typography.yaml:411\` — \`body-x-small.font-family = mono` + +### Numeric display +Money has its own typographic stratum. `keypad-total` (96/96), `numeral-large` (56/56), `numeral-small` (32/32) live alongside `headline-large` (44/44) and `page-title` (32/32) but collapse line-height to size for tight numeric stacking. The keypad token is at the apex of the type ramp; nothing else hits 96. + +**Evidence:** +- `--type-keypad-total-size: 96` +- `--type-keypad-total-line-height: 96` +- `--type-numeral-large-size: 56` +- `--type-numeral-small-size: 32` +- `design-tokens/arcade/semantic/typography.yaml:3\` — \`keypad-total` +- `design-tokens/arcade/semantic/typography.yaml:22\` — \`numeral-large` +- `design-tokens/arcade/semantic/typography.yaml:101\` — \`numeral-small` + +### Semantic density +The semantic layer is unusually wide. `text`, `icon`, `border`, `background` each carry `standard / subtle / prominent / disabled / inverse / warning / danger / success / brand` slugs; `text` adds `placeholder / link / link-visited`, `background` adds `notification / keypad / inverse-pressed`, `icon` adds `info / extra-subtle`. Component tokens almost always go through this layer — `base.*` is reached only for transparency hex literals (`#0000004D` for disabled inverse, `#FFFFFF80` for disabled-on-prominent). + +**Evidence:** +- `\`design-tokens/arcade/semantic/color.yaml:1\` — \`semantic.color.text\` declares 12 slugs` +- `design-tokens/arcade/semantic/color.yaml:166\` — \`semantic.color.background\` declares 12 slugs incl. \`keypad\`, \`notification\`, \`inverse-pressed` +- `\`design-tokens/arcade/component/mobile/button.yaml:22\` — \`prominent.background.disabled\` is an alpha-encoded literal, not a semantic reference` +- `--text-danger-light: #D3040E` +- `--text-warning-light: #CC4B03` +- `--text-success-light: #00792C` +- `--icon-info-light: #007CC1` +- `--text-link-visited-light: #660199` +- `--background-notification-light: #D7040E` +- `--text-danger-dark: #F84752` + +### Product identity +Product lines own named identity tokens at the semantic layer, not just brand-tinted accents. `service.bitcoin = #00D4FF`, `service.bitcoin-orange = #F78A2B`, `service.investing = #9013FE`, `service.taxes = violet.50`, `service.borrow = brand.ocean` — each money surface gets its own hue. Avatar identity is a separate, fixed nine-chip palette (turquoise, sky, ocean, royal, pink, purple, scarlet, amber, sunshine) hardcoded across light and dark. + +**Evidence:** +- `design-tokens/arcade/semantic/color.yaml:316\` — \`service.bitcoin = #00D4FF` +- `design-tokens/arcade/semantic/color.yaml:351\` — \`service.investing = #9013FE` +- `design-tokens/arcade/semantic/color.yaml:358\` — \`service.taxes → violet.50 = #5D00E8` +- `design-tokens/arcade/semantic/color.yaml:344\` — \`service.borrow → brand.ocean = #3399FF` +- `\`design-tokens/arcade/component/mobile/avatar.yaml:17\` — nine-chip avatar palette` +- `--avatar-turquoise: #41EBC1` +- `--avatar-pink: #FB60C4` +- `--avatar-sunshine: #FADA3D` +- `--avatar-amber: #F46E38` +- `--avatar-purple: #B141FF` + +### Token architecture +Three layers, strict references. `base/*` declares hex literals; `semantic/*` references base; `component/*/*` references semantic (and base only for transparency edge cases). Components are authored once at `component/mobile/*.yaml` and per-platform files use `extend: '{component.mobile.}'` to layer additions only — `component/web/button.yaml` adds hover/focus, `component/mobile/android/button.yaml` adds ripple, `mono/component-button.yaml` adds `lightMono` / `darkMono`. The architecture composes additively, not destructively. + +**Evidence:** +- `design-tokens/arcade/component/web/button.yaml:4\` — \`extend: '{component.mobile.button}'` +- `design-tokens/arcade/component/mobile/android/button.yaml:5\` — \`extend: '{component.mobile.button}'` +- `design-tokens/arcade/component/web/input.yaml:4\` — \`extend: '{component.mobile.input}'` +- `\`design-tokens/arcade/mono/component-button.yaml:1\` — overlay file targets the same path` +- `\`generators/web/config.yaml:1\` — \`arcade-cti\` + \`name-web\` + \`resolve-object-values\` transform stack` +- `\`generators/ios/config.yaml:1\` — parallel transform stack with \`attributes-ios\`, \`name-ios\`, etc.` + +### Motion +Motion is a named taxonomy on iOS — and only on iOS. `motion.spring.smooth.{gentle, steady, fast, sharp, soft}` plus `motion.spring.bouncy.{error, hint, urgent}` declare stiffness/damping pairs that emit Swift, UIKit, and SwiftUI artifacts. There is no equivalent web/android motion namespace in this token graph; mobile owns the motion vocabulary, and other platforms inherit nothing. + +**Evidence:** +- `design-tokens/arcade/base/ios/motion.yaml:1\` — \`motion.spring.smooth.gentle = stiffness:50, damping:14.182` +- `design-tokens/arcade/base/ios/motion.yaml:24\` — \`motion.spring.bouncy.error = stiffness:1200, damping:8.222` +- `\`generators/ios/config.yaml:114\` — \`Motion/ArcadeMotion.swift\` / \`+UIKit.swift\` / \`+SwiftUI.swift\` outputs` +- `No \`motion\` namespace under \`design-tokens/arcade/base/\` or any web sink` + +### Legacy handling +Deprecation is graph-resident, not changelog-resident. Deprecated typography tokens (`meta-text`, `hero-numerics`, `large-label`, `label`, `body`, `body-link`, `cell-body`, `disclaimer`, `disclaimer-link`) keep their values inline alongside `deprecated: { value: true, replacement: }`, so consumers see the migration target at lookup time. The semibold (600) weight only survives on these deprecated entries. + +**Evidence:** +- `design-tokens/arcade/semantic/typography.yaml:446\` — \`meta-text.deprecated.value: true, replacement: body-xs` +- `design-tokens/arcade/semantic/typography.yaml:687\` — \`hero-numerics.deprecated, replacement: numeral-l` +- `design-tokens/arcade/semantic/typography.yaml:710\` — \`large-label.deprecated, replacement: numeral-s` +- `design-tokens/arcade/semantic/typography.yaml:733\` — \`label.deprecated, replacement: label-medium` +- `design-tokens/arcade/semantic/typography.yaml:756\` — \`body.deprecated, replacement: body-medium` + +### Playground divergence +The Cabinet playground that documents the system runs on default shadcn aesthetics, not Arcade. `apps/cabinet/app/components/ui/button.tsx` ships `bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90` and `bg-red-500` — generic Tailwind classes with no relation to Arcade's brand-as-accent strategy. Only the chrome around the playground (`apps/cabinet/app/styles/arcade.css`) pulls Arcade CSS variables; the showcase components themselves are unrebadged shadcn. The system declares its values with one mouth and exhibits them with another. + +**Evidence:** +- `apps/cabinet/app/components/ui/button.tsx:13\` — \`bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90` +- `\`apps/cabinet/app/components/ui/button.tsx:14\` — \`bg-red-500 ... dark:bg-red-900\` (not \`--background-danger\`)` +- `apps/cabinet/app/components/ui/button.tsx:8\` — \`rounded-md\` literal, not \`--radius-medium` +- `\`apps/cabinet/app/styles/arcade.css:34\` — Arcade CSS variables only at \`:root\` level` + +# Fragments + +- [embedding](embedding.md) — 49-dim vector for compare/composite/viz diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/arcade/tokens.css b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/tokens.css new file mode 100644 index 0000000..3fb959d --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/arcade/tokens.css @@ -0,0 +1,69 @@ +/* + * Generated by ghost from /Users/nahiyan/Development/design-world-model/fleet/members/arcade/expression.md on 2026-04-28T00:00:00Z + * DO NOT EDIT — regenerate with `ghost-drift emit context-bundle`. + */ + +:root { + /* Dominant brand */ + --brand-brand: #00d64f; + --brand-brand-dark: #00bd46; + --brand-ink: #000000; + --brand-paper: #ffffff; + + /* Semantic colors */ + --color-danger: #d3040e; + --color-danger-dark: #f84752; + --color-warning: #cc4b03; + --color-success: #00792c; + --color-info: #007cc1; + --color-link-visited: #660199; + --color-notification: #d7040e; + --color-bitcoin: #00d4ff; + --color-investing: #9013fe; + --color-taxes: #5d00e8; + --color-borrow: #3399ff; + --color-avatar-turquoise: #41ebc1; + --color-avatar-pink: #fb60c4; + --color-avatar-sunshine: #fada3d; + --color-avatar-amber: #f46e38; + --color-avatar-purple: #b141ff; + + /* Neutral ramp */ + --neutral-0: #ffffff; + --neutral-1: #f0f0f0; + --neutral-2: #e8e8e8; + --neutral-3: #cccccc; + --neutral-4: #878787; + --neutral-5: #000000; + + /* Spacing scale */ + --space-0: 4px; + --space-1: 8px; + --space-2: 16px; + --space-3: 32px; + --space-4: 64px; + + /* Typography scale */ + --text-0: 10px; + --text-1: 11px; + --text-2: 12px; + --text-3: 14px; + --text-4: 16px; + --text-5: 24px; + --text-6: 28px; + --text-7: 32px; + --text-8: 44px; + --text-9: 56px; + --text-10: 96px; + + /* Font families */ + --font-sans: CashSans, CashSansMono; + + /* Border radii */ + --radius-0: 6px; + --radius-1: 8px; + --radius-2: 16px; + --radius-3: 24px; + --radius-4: 40px; + --radius-5: 9999px; +} diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/market/README.md b/packages/ghost-ui/test/expression-fidelity/bundles/market/README.md new file mode 100644 index 0000000..c568e6e --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/market/README.md @@ -0,0 +1,18 @@ +# market — design context bundle + +Generated by `ghost-drift emit context-bundle`. Grounding material for AI UI generation in the **market** design language. Signature traits: Monochromatic by default: `core.emphasis-fill` ships as `#101010` light / `#FFFFFF` dark even though the source aliases it to `core.blue-fill` — chromatic identity is opt-in via theme overlays, not a base-system trait., A full chromatic palette is defined and reserved: green / forest / teal / blue / sky / purple / pink / burgundy / red / orange / gold / yellow / taupe / brown each ship as a 6-step set (fill, text, 10, 20, 30, 40) but the base system only consumes them through semantic aliases (`success`, `warning`, `critical`, `emphasis`)., A 17-step grayscale ramp from `#FFFFFF` to `#000000` (with a separate `core.constant.gray-*` track) carries most of the visual weight — surfaces, dividers, fills, and text are all neutrals first.. + +## Files + +- `SKILL.md` — Agent Skill manifest (user-invocable) +- `expression.md` — canonical design language (YAML frontmatter + Character/Signature/Decisions) +- `tokens.css` — CSS custom properties derived from expression tokens +- `README.md` — this file + +## Using this bundle + +**As a Claude Code / MCP skill:** point the client at this directory. The agent will read `SKILL.md` and follow its instructions. + +**As context for any LLM:** load `expression.md` into the system prompt. For more explicit grounding, concatenate with `tokens.css`. + +**Feedback loop:** ask your host agent to review the generated output against this `expression.md` (the `review` recipe, installed via `ghost-drift emit skill`). Drift signals whether the generator honored the system. diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/market/SKILL.md b/packages/ghost-ui/test/expression-fidelity/bundles/market/SKILL.md new file mode 100644 index 0000000..5fd239c --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/market/SKILL.md @@ -0,0 +1,26 @@ +--- +name: market +description: Use this skill to generate UI in the market design language (Monochromatic by default: `core.emphasis-fill` ships as `#101010` light / `#FFFFFF` dark even though the source aliases it to `core.blue-fill` — chromatic identity is opt-in via theme overlays, not a base-system trait., A full chromatic palette is defined and reserved: green / forest / teal / blue / sky / purple / pink / burgundy / red / orange / gold / yellow / taupe / brown each ship as a 6-step set (fill, text, 10, 20, 30, 40) but the base system only consumes them through semantic aliases (`success`, `warning`, `critical`, `emphasis`)., A 17-step grayscale ramp from `#FFFFFF` to `#000000` (with a separate `core.constant.gray-*` track) carries most of the visual weight — surfaces, dividers, fills, and text are all neutrals first.). Contains the canonical expression and token reference. +user-invocable: true +--- + +This skill grounds UI generation in the **market** design language. + +Read `expression.md` first — it is the source of truth. It has four layered sections: + +1. **Character** — what this expression is (one-paragraph summary) +2. **Signature** — what makes it distinctive (bullet list of traits) +3. **Decisions** — specific design choices with evidence from the source +4. **Values** — hard Do / Don't rules + +When generating UI in this language: + +- Treat **Values** as non-negotiable gates — never violate a Don't. +- Use **Decisions** as the lookup for specific choices (spacing scale, type ramp, radii). +- Let **Character** and **Signature** shape overall feel, density, and voice. +- Prefer tokens from the YAML frontmatter (palette, spacing, typography, surfaces) over arbitrary values. + +## Files + +- `expression.md` — canonical design language (YAML tokens + Character/Signature/Decisions/Values) +- `tokens.css` — CSS custom properties derived from expression tokens diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/market/expression.md b/packages/ghost-ui/test/expression-fidelity/bundles/market/expression.md new file mode 100644 index 0000000..d17b47d --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/market/expression.md @@ -0,0 +1,227 @@ +--- +id: market +source: llm +timestamp: 2026-04-28T00:00:00Z +observation: + personality: + - utilitarian + - systematic + - restrained + - dense + - structural + - mode-aware + resembles: + - cash-app + - shopify-polaris + - github-primer +decisions: + - dimension: color-strategy + - dimension: chromatic-reserve + - dimension: spatial-system + - dimension: typography-voice + - dimension: surface-hierarchy + - dimension: density + - dimension: motion + - dimension: state-modeling + - dimension: theming-architecture + - dimension: cross-platform-fidelity +palette: + dominant: + - role: emphasis-fill-light + value: "#101010" + - role: emphasis-fill-dark + value: "#FFFFFF" + - role: surface-light + value: "#FFFFFF" + - role: surface-dark-5 + value: "#080808" + - role: surface-dark-10 + value: "#141414" + - role: surface-dark-20 + value: "#1C1C1C" + - role: surface-dark-30 + value: "#2D2D2D" + neutrals: + steps: + - "#FFFFFF" + - "#101010" + count: 17 + semantic: + - role: critical + value: "#CC0023" + - role: warning + value: "#FF9F40" + - role: success + value: "#00B23B" + - role: info + value: "#006AFF" + - role: purple + value: "#8716D9" + - role: pink + value: "#D936B0" + - role: burgundy + value: "#990838" + saturationProfile: muted + contrast: high +spacing: + scale: + - 2 + - 4 + - 8 + - 12 + - 16 + - 20 + - 24 + - 32 + - 40 + - 48 + - 64 + - 80 + - 120 + - 160 + regularity: 0.85 + baseUnit: 4 +typography: + families: + - Cash Sans Text + - Cash Sans Display + - Cash Sans Mono + sizeRamp: + - 12 + - 14 + - 16 + - 19 + - 25 + - 32 + - 48 + weightDistribution: + "400": 4 + "500": 5 + "600": 4 + "700": 1 + lineHeightPattern: normal +surfaces: + borderRadii: + - 0 + - 2 + - 4 + - 6 + - 12 + - 16 + - 24 + - 32 + - 1000 + shadowComplexity: deliberate-none + borderUsage: minimal +--- + +# Character + +Market is Square's cross-platform design language — a CMPT-structured (Component / Modifier / Part / Type) token graph piped through Style Dictionary into Stencil web components, SwiftUI/UIKit modules, and Jetpack Compose modules. The personality is utilitarian and structural rather than expressive: the default theme renders monochromatically with near-black emphasis on white, a wide neutral ramp does most of the work, and a full chromatic palette sits in reserve for status semantics and theme overlays. Visual decisions are encoded as states-modes-variants tables — every interactive element ships normal/hover/pressed/focus/disabled × light/dark × variant tokens — which produces a system that feels exhaustive and machine-checkable rather than hand-crafted. Identity expression happens at the theme layer (Buyer-Facing, Tidal, Noho, S3, legacy Market Blue), not in the base. + +# Signature + +- Monochromatic by default: `core.emphasis-fill` ships as `#101010` light / `#FFFFFF` dark even though the source aliases it to `core.blue-fill` — chromatic identity is opt-in via theme overlays, not a base-system trait. +- A full chromatic palette is defined and reserved: green / forest / teal / blue / sky / purple / pink / burgundy / red / orange / gold / yellow / taupe / brown each ship as a 6-step set (fill, text, 10, 20, 30, 40) but the base system only consumes them through semantic aliases (`success`, `warning`, `critical`, `emphasis`). +- A 17-step grayscale ramp from `#FFFFFF` to `#000000` (with a separate `core.constant.gray-*` track) carries most of the visual weight — surfaces, dividers, fills, and text are all neutrals first. +- Every interactive token is enumerated as a full state × mode × variant matrix — `state:normal | hover | pressed | focus | disabled` × `mode:light | dark` × `variant:normal | destructive` — rather than computed via opacity or color-mix. +- Three weight tracks (`text`, `display`, `mono`) all use the Cash Sans superfamily; `display` is reserved for headings ≥19px, `text` for body and small heading, `mono` for code. +- Spacing tokens are named numerically in a `25 / 50 / 100 / 150 / 200 / 250 / 300 / 400 / 500 / 600 / 800 / 1000 / 1500 / 2000` system that maps to `2 / 4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48 / 64 / 80 / 120 / 160` px — the names are intentionally decoupled from absolute pixels so themes can rescale. +- Border radii are dual-named as a pixel scale (`33 → 2px`, `66 → 4px`, `100 → 6px`, `200 → 12px`, `266 → 16px`, `400 → 24px`, `533 → 32px`) plus role aliases (`forms`, `modals`, `circle: 1000`); buttons default to `radius.100` (6px), pills to `radius.circle` (∞). +- Shadows are absent from the system — no `shadow` or `elevation` tokens at the core layer; surface separation is achieved with surface-stack tokens (`surface-5/10/20/30`) and dividers, not z-layered shadow. +- Animation is encoded as three named curves (`enter`, `exit`, `move`) each with three speeds (`fast: 100ms`, `moderate: 160-240ms`, `slow: 300-400ms`); easing differs per direction (asymmetric in/out cubic-bezier). +- Themes are diff-overlays, not parallel systems: Buyer-Facing, Monochrome, Tidal, Noho, S3, Starter — each ships as a sparse JSON override package that replaces specific tokens (e.g. button radii, type ramps, emphasis colors) on top of the base graph. +- Component tokens never reference base color primitives directly — they always go through the semantic layer (`{core.emphasis-fill...}`, `{core.critical-text...}`), so swapping `emphasis` to a different hue retones the entire system. + +# Decisions + +### Color strategy +The default Market system is monochromatic-with-status: the action/identity color is grayscale near-black on white (light) or near-white on near-black (dark), and the chromatic palette is reserved for semantic states (success / warning / critical / info). Brand color is treated as a property of the *consuming app's theme*, not of the design system itself — the system ships the structure for color but defers the identity hue to overlays. + +**Evidence:** +- `\`--core-emphasis-fill-light-mode-color: #101010\` (\`common/design-tokens/dist/css/properties.css:144\`)` +- `\`--core-emphasis-fill-dark-mode-color: #FFFFFF\` (\`common/design-tokens/dist/css/properties.css:143\`)` +- `17-step grayscale ramp \`core.constant.gray-10..98\` plus \`black\` and \`white\` (\`common/design-tokens/tokens/core/color.json:635\`)` +- `Semantic aliases (\`success-fill\`, \`warning-fill\`, \`critical-fill\`, \`emphasis-fill\`) all alias to chromatic primitives in source but ship monochrome by default (\`common/design-tokens/tokens/core/color.json:91\`)` + +### Chromatic reserve +The repo declares a complete 14-hue chromatic palette (green, forest, teal, blue, sky, purple, pink, burgundy, red, orange, gold, yellow, taupe, brown) with a uniform 6-step structure (`fill`, `text`, `10`, `20`, `30`, `40`) per hue. None of these are referenced directly by component tokens — components only consume the *semantic* abstractions on top (`emphasis-*`, `success-*`, `warning-*`, `critical-*`). This makes the chromatic palette a reserved vocabulary that themes opt into, rather than a fixed identity the base system asserts. + +**Evidence:** +- `\`core.green-fill = #00B23B\`, \`core.red-fill = #CC0023\`, \`core.gold-fill = #FF9F40\`, \`core.blue-fill = #006AFF\` (\`common/design-tokens/tokens/core/color.json:299..540\`)` +- `\`purple-fill = #8716D9\`, \`pink-fill = #D936B0\`, \`burgundy-fill = #990838\` (\`common/design-tokens/tokens/core/color.json:419..473\`)` +- `Component tokens reference only semantic aliases: \`button.variant:normal.rank:primary.state:normal.background.color = {core.emphasis-fill...}\` (\`common/design-tokens/tokens/components/button.json:243\`)` +- `Banner/icon-button/checkbox component tokens reference \`{core.emphasis-fill...}\`, never \`{core.blue-fill...}\` directly` + +### Spatial system +Spacing is a logarithmic-ish ramp keyed by *intent index* (`25, 50, 100, 150, 200, 250, 300, 400, 500, 600, 800, 1000, 1500, 2000`) rather than by raw pixels — the index maps to px (`25→2`, `100→8`, `200→16`, `400→32`, `1000→80`) but the name carries the meaning. Component tokens always reference the index, not the pixel, which means a theme can rescale density by remapping the index without touching component graphs. + +**Evidence:** +- `\`core.metrics.spacing.{25:2, 50:4, 100:8, 150:12, 200:16, 250:20, 300:24, 400:32, 500:40, 600:48, 800:64, 1000:80, 1500:120, 2000:160}\` (\`common/design-tokens/tokens/core/metrics.json:5\`)` +- `\`button.size:medium.rank:tertiary.vertical.padding = {core.metrics.spacing.150}\` (\`common/design-tokens/tokens/components/button.json:147\`)` +- `\`core.minimum-height.{50:24, 75:32, 100:40, 200:48, 300:64, 400:80}\` for control sizing (\`common/design-tokens/tokens/core/minimum-height.json:5\`)` +- `\`--market-core-base-size: 8px\` exposed as the system base (\`common/design-tokens/dist/css/core/Core.tokens.base.css:21\`)` + +### Typography voice +Three Cash Sans tracks (`text`, `display`, `mono`) form a single typeface family with role separation. The size ramp is asymmetric and slightly compressed (`12 / 14 / 16 / 19 / 25 / 32 / 48`), and `display` is reserved for `heading-20` and above (≥19px) — smaller headings use the `text` family. Weights run `400 / 500 / 600 / 700`; the system prefers `500` (medium) and `600` (semibold) for emphasis rather than `700`. Heading-5 (12px uppercase, +0.05 tracking) is the only labeled use of letter-spacing in the ramp. + +**Evidence:** +- `\`core.type.fontFamily = "Cash Sans Text"\`, fallbacks \`[Helvetica, Arial, sans-serif]\` (\`common/design-tokens/tokens/core/type.json:24\`)` +- `\`core.type.display.fontFamily = "Cash Sans Display"\`, \`core.type.mono.fontFamily = "Cash Sans Mono"\` (\`common/design-tokens/tokens/core/type.json:30\`)` +- `Heading ramp \`5:12 / 10:14 / 20:19 / 30:25\`; display \`10:32 / 20:48\`; paragraph \`10:12 / 20:14 / 30:16\` (\`common/design-tokens/tokens/core/type-styles.json\`)` +- `\`heading.5\` is uppercase with \`tracking: 0.05\`; all other styles use \`tracking: 0\` and \`case: regular\` (\`common/design-tokens/tokens/core/type-styles.json:53\`)` + +### Surface hierarchy +Surface separation uses a stacked-flat-fill model (`surface-5 / 10 / 20 / 30`) — light mode collapses all four to `#FFFFFF` and relies on dividers (`divider-10/20`) and inner fills (`fill-30/40/50` at 3-15% black) for separation; dark mode steps the surfaces (`#080808 / #141414 / #1C1C1C / #2D2D2D`) for elevation. There are no shadow tokens at the core layer — elevation is purely a function of fill opacity and surface stack. + +**Evidence:** +- `\`core.surface-{5,10,20,30}.mode:light = rgba(255,255,255,1)\` (all identical) (\`common/design-tokens/tokens/core/color.json:67..82\`)` +- `\`core.surface-{5,10,20,30}.mode:dark = #080808 / #141414 / #1C1C1C / #2D2D2D\` (\`common/design-tokens/tokens/core/color.json:67..82\`)` +- `\`core.fill-{30,40,50}\` at 15% / 5% / 3% black opacity for layered fills (\`common/design-tokens/tokens/core/color.json:35..46\`)` +- `No \`shadow\`, \`elevation\`, or \`box-shadow\` tokens defined at the core layer (verified by absence in \`tokens/core/*.json\`)` + +### Density +Component sizing is exposed as a three-step `size:small / medium / large` axis driven by `core.minimum-height.{100, 200, 300}` (40 / 48 / 64 px), with vertical padding derived from `core.padding-set.*` rather than computed from text. Buttons explicitly decouple height from content via a `minimum-width.multiplier` of 1.5× — the button is at least 1.5× as wide as it is tall, regardless of label length. This produces a system that holds shape consistency over content density, but with explicit dynamic-type support via `ramp` references on every dimensional token. + +**Evidence:** +- `\`button.size:small.minimum.height = {core.minimum-height.100}\` (40px) (\`common/design-tokens/tokens/components/button.json:50\`)` +- `\`button.size:{small,medium,large}.minimum.width.multiplier.value = 1.5\` (\`common/design-tokens/tokens/components/button.json:45\`)` +- `Every dimensional token carries a \`ramp\` field (\`{core.ramp.spacing-200}\`, \`{core.ramp.min-height-100}\`) for dynamic-type scaling (\`common/design-tokens/tokens/core/metrics.json:28\`)` + +### Motion +Animation is parameterized as a 3×3 matrix: three direction curves (`enter`, `exit`, `move`) with three speeds (`fast: 100ms`, `moderate: 160–240ms`, `slow: 300–400ms`). The easings are asymmetric — enter uses `cubic-bezier(0.26, 0.10, 0.48, 1.0)` (decelerating), exit uses `cubic-bezier(0.52, 0.0, 0.74, 0.0)` (accelerating), move uses `cubic-bezier(0.76, 0.0, 0.24, 1.0)` (in-out). There are no spring tokens, no per-component motion tokens, and no animation-amount tokens — motion is opt-in by reference to one of nine curves. + +**Evidence:** +- `\`core.animation.transition:enter.easing = cubic-bezier(0.26, 0.10, 0.48, 1.0)\` (\`common/design-tokens/tokens/core/animation.json:5\`)` +- `\`transition:exit.speed:moderate = 160ms\` vs \`transition:enter.speed:moderate = 240ms\` — exit is faster than enter (\`common/design-tokens/tokens/core/animation.json:13,33\`)` +- `--transition-duration: var(--core-animation-enter-transition-moderate-speed-duration)\` referenced from \`market-button-base.css:4` + +### State modeling +State, mode, and variant are first-class axes in the token graph: every interactive token explodes as `variant × rank × state × mode` (e.g. `button.variant:destructive.rank:secondary.state:hover.mode:dark.background.color`). This is enumerated, not computed — there is no `darken-by-10%` transform layer; each cell of the matrix has an explicit token, often referencing a sibling in the same matrix to share values. Disabled states use `core.opacity.state:disabled = 0.4` plus an explicit muted color rather than just opacity. + +**Evidence:** +- `Button has 30 background-color tokens (3 ranks × 5 states × 2 modes) per variant, repeated for \`normal\` and \`destructive\` variants (\`common/design-tokens/tokens/components/button.json:238..1337\`)` +- `\`core.opacity.state:disabled = 0.4\`, others (\`normal/hover/pressed = 1.0\`) (\`common/design-tokens/tokens/core/opacity.json:4\`)` +- `Hover states reference normal-state border color rather than shifting hue: \`border.color = {button.variant:normal.rank:primary.state:normal.mode:light.border.color.value}\` (\`common/design-tokens/tokens/components/button.json:284\`)` + +### Theming architecture +Themes are sparse JSON overlay packages keyed off the base token graph. Each of the nine themes (`buyer-facing`, `cxf-large`, `cxf-large-monochrome`, `monochrome`, `noho-tokens`, `s3-monochrome-tokens`, `s3-tokens`, `starter-theme`, `tidal`) ships only the diffs from base — buyer-facing overrides button padding and type ramps; monochrome overrides emphasis-fill and per-component fill colors; tidal overrides only dark-mode opacity-based fills and button corner radii. Style Dictionary then re-runs the build per theme, producing a parallel `dist/{css,js,kotlin,swift,xml}` per theme package. + +**Evidence:** +- `9 theme packages under \`common/themes/*\` each shipping \`tokens/overrides/*.json\` plus generated \`dist/\` (verified by directory listing)` +- `Buyer-facing button-large min-height = 72 (vs base 64) and uses \`heading.20\` font for button text (\`common/themes/buyer-facing/tokens/overrides/components-button.json:40\`)` +- `Monochrome \`core-color.json\` flattens emphasis-fill to \`#101010 / #FFFFFF\` and fill-* to constant grays (\`common/themes/monochrome/tokens/overrides/core-color.json:176\`)` +- `Tidal \`core-color.json\` only ships dark-mode opacity-based fills (\`common/themes/tidal/tokens/core-color.json\`)` + +### Cross platform fidelity +The same token graph generates equivalent artifacts for three platforms — Stencil/CSS variables for web, `MarketDesignTokens` Swift class with `MarketDesignTokens+Component` extensions for iOS, and `MarketStyleDictionary*` Kotlin objects for Compose Android. Token names round-trip across naming conventions (kebab-case CSS → camelCase Swift/Kotlin), and the generator preserves `ramp` references for platforms that support dynamic type. Platform-specific exclusions (e.g. ripple is Android-only) are encoded in the build's matcher/filter system rather than in the token JSON. + +**Evidence:** +- `\`dist/css/properties.css\`, \`dist/swift/MarketDesignTokens.swift\`, \`dist/kotlin/MarketStyleDictionary.kt\` all generated from the same \`tokens/\` graph (\`common/design-tokens/dist/\`)` +- `iOS button surface: \`extension MarketDesignTokens { public final class Button { public var smallSizeMinimumHeight: CGFloat ... } }\` (\`common/design-tokens/dist/swift/components/MarketDesignTokens+Button.swift:14\`)` +- `Android Compose: \`MarketStyleDictionaryColors\`, \`MarketStyleDictionaryDimensions\`, \`MarketStyleDictionaryAnimations\`, \`MarketStyleDictionaryTypographies\` (\`common/design-tokens/dist/kotlin/\`)` +- `Web Stencil components consume CSS vars: \`background-color: var(--button-normal-variant-primary-rank-normal-state-background-color)\` (\`web/web-components/src/components/market-button/styles/market-button-primary.css:2\`)` + +# Fragments + +- [embedding](embedding.md) — 49-dim vector for compare/composite/viz diff --git a/packages/ghost-ui/test/expression-fidelity/bundles/market/tokens.css b/packages/ghost-ui/test/expression-fidelity/bundles/market/tokens.css new file mode 100644 index 0000000..5848ae2 --- /dev/null +++ b/packages/ghost-ui/test/expression-fidelity/bundles/market/tokens.css @@ -0,0 +1,67 @@ +/* + * Generated by ghost from /Users/nahiyan/Development/design-world-model/fleet/members/market/expression.md on 2026-04-28T00:00:00Z + * DO NOT EDIT — regenerate with `ghost-drift emit context-bundle`. + */ + +:root { + /* Dominant brand */ + --brand-emphasis-fill-light: #101010; + --brand-emphasis-fill-dark: #ffffff; + --brand-surface-light: #ffffff; + --brand-surface-dark-5: #080808; + --brand-surface-dark-10: #141414; + --brand-surface-dark-20: #1c1c1c; + --brand-surface-dark-30: #2d2d2d; + + /* Semantic colors */ + --color-critical: #cc0023; + --color-warning: #ff9f40; + --color-success: #00b23b; + --color-info: #006aff; + --color-purple: #8716d9; + --color-pink: #d936b0; + --color-burgundy: #990838; + + /* Neutral ramp */ + --neutral-0: #ffffff; + --neutral-1: #101010; + + /* Spacing scale */ + --space-0: 2px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + --space-8: 40px; + --space-9: 48px; + --space-10: 64px; + --space-11: 80px; + --space-12: 120px; + --space-13: 160px; + + /* Typography scale */ + --text-0: 12px; + --text-1: 14px; + --text-2: 16px; + --text-3: 19px; + --text-4: 25px; + --text-5: 32px; + --text-6: 48px; + + /* Font families */ + --font-sans: Cash Sans Text, Cash Sans Display, Cash Sans Mono; + + /* Border radii */ + --radius-0: 0px; + --radius-1: 2px; + --radius-2: 4px; + --radius-3: 6px; + --radius-4: 12px; + --radius-5: 16px; + --radius-6: 24px; + --radius-7: 32px; + --radius-8: 1000px; +} From 35109ef804117ab7a27a8fc30cc40b986c808fd0 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 30 Apr 2026 08:46:05 -0400 Subject: [PATCH 07/14] feat(expression): canonical vocabulary for decision dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free-form `decisions[].dimension` slugs fragment fleet aggregation — ghost-ui's `color-strategy` and a Cash app's `color-system` describe the same axis under different names, and N-way overlap on incidentally shared labels is not a basis for cross-system distance. Add a 12-slug controlled list in `@ghost/core` (color-strategy, surface-hierarchy, shape-language, typography-voice, spatial-system, density, motion, elevation, theming-architecture, interactive-patterns, token-architecture, font-sourcing) plus `closestCanonical()` and `resolveDecisionKind()` helpers. The frontmatter schema accepts an optional `dimension_kind` on `decisions[]` as the escape hatch for genuinely novel decisions, which fleet-aggregation primitives use to roll up by canonical bucket. New soft `non-canonical-dimension` lint warning suggests the closest match without rejecting authoring freedom. Validated against every expression.md in the repo: the ghost-ui reference is 11/11 canonical with zero warnings; across 9 expressions, 47 of 64 decisions land canonical, and the matcher resolves 4 of the remaining 17 to a clear suggestion (`theming` → `theming-architecture`, `shadow-hierarchy` → `elevation`, `semantic-density` → `density`, `product-area-color-coding` → `color-strategy`). Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/canonical-decision-vocabulary.md | 5 + .../ghost-core/src/decision-vocabulary.ts | 265 ++++++++++++++++++ packages/ghost-core/src/index.ts | 8 + packages/ghost-core/src/types.ts | 12 + .../test/decision-vocabulary.test.ts | 114 ++++++++ .../ghost-expression/src/core/fragments.ts | 11 +- .../ghost-expression/src/core/frontmatter.ts | 1 + packages/ghost-expression/src/core/lint.ts | 45 ++- packages/ghost-expression/src/core/parser.ts | 1 + packages/ghost-expression/src/core/schema.ts | 13 +- .../src/skill-bundle/references/profile.md | 29 +- .../test/expression/lint.test.ts | 83 ++++++ schemas/expression.schema.json | 94 +------ 13 files changed, 584 insertions(+), 97 deletions(-) create mode 100644 .changeset/canonical-decision-vocabulary.md create mode 100644 packages/ghost-core/src/decision-vocabulary.ts create mode 100644 packages/ghost-core/test/decision-vocabulary.test.ts diff --git a/.changeset/canonical-decision-vocabulary.md b/.changeset/canonical-decision-vocabulary.md new file mode 100644 index 0000000..190520b --- /dev/null +++ b/.changeset/canonical-decision-vocabulary.md @@ -0,0 +1,5 @@ +--- +"ghost-expression": minor +--- + +Add a controlled vocabulary of 12 canonical decision dimensions (`color-strategy`, `surface-hierarchy`, `shape-language`, `typography-voice`, `spatial-system`, `density`, `motion`, `elevation`, `theming-architecture`, `interactive-patterns`, `token-architecture`, `font-sourcing`) so fleet-aggregation primitives can group decisions across members. Profile recipe nudges authors toward canonical slugs; novel project-flavored slugs may pair with an optional `dimension_kind` that maps to a canonical bucket. New soft `non-canonical-dimension` lint warning suggests the closest canonical match. The schema accepts the optional `dimension_kind` field on `decisions[]`; existing expressions remain valid. diff --git a/packages/ghost-core/src/decision-vocabulary.ts b/packages/ghost-core/src/decision-vocabulary.ts new file mode 100644 index 0000000..0ede518 --- /dev/null +++ b/packages/ghost-core/src/decision-vocabulary.ts @@ -0,0 +1,265 @@ +/** + * Canonical decision-dimension vocabulary. + * + * Free-form `decisions[].dimension` slugs are great for authoring but bad + * for fleet aggregation: ghost-ui's `color-strategy` and a hypothetical + * Cash app's `color-system` describe the same axis under different names, + * and N-way overlap on incidentally-shared labels is not a basis for + * cross-system distance. + * + * The fix is a small controlled vocabulary. Profilers pick from this list + * first; non-canonical slugs are still permitted (the schema allows any + * string), but the recommended pattern is to pair them with a + * `dimension_kind` that maps to a canonical slug. Lint warns when a + * non-canonical dimension has no canonical kind. Fleet-rollup primitives + * group by `dimension_kind` (or by `dimension` when it's already + * canonical) so the decision-overlap distance axis becomes meaningful. + * + * The list below was derived from the actual decisions produced by + * profiling ghost-ui — these are not invented categories, they are the + * orthogonal-ish axes that one real expression already surfaces. + */ +export const CANONICAL_DECISION_DIMENSIONS = [ + "color-strategy", + "surface-hierarchy", + "shape-language", + "typography-voice", + "spatial-system", + "density", + "motion", + "elevation", + "theming-architecture", + "interactive-patterns", + "token-architecture", + "font-sourcing", +] as const; + +export type CanonicalDecisionDimension = + (typeof CANONICAL_DECISION_DIMENSIONS)[number]; + +const CANONICAL_SET: ReadonlySet = new Set( + CANONICAL_DECISION_DIMENSIONS, +); + +/** + * Direct synonyms — common slug variants we've observed or expect, mapped + * to the canonical dimension. Lookup is exact-match (post-normalization). + */ +const SYNONYMS: Readonly> = { + // color-strategy + "color-system": "color-strategy", + "color-philosophy": "color-strategy", + "color-approach": "color-strategy", + "palette-strategy": "color-strategy", + "palette-system": "color-strategy", + "hue-strategy": "color-strategy", + // surface-hierarchy + "surface-vocabulary": "surface-hierarchy", + "surface-system": "surface-hierarchy", + "background-hierarchy": "surface-hierarchy", + "background-system": "surface-hierarchy", + // shape-language + "radius-philosophy": "shape-language", + "radius-strategy": "shape-language", + "corner-treatment": "shape-language", + "corner-radii": "shape-language", + geometry: "shape-language", + // typography-voice + "type-voice": "typography-voice", + "type-stack": "typography-voice", + "type-hierarchy": "typography-voice", + "typographic-voice": "typography-voice", + "typography-system": "typography-voice", + // spatial-system + spacing: "spatial-system", + "spacing-scale": "spatial-system", + "spacing-system": "spatial-system", + "layout-rhythm": "spatial-system", + // density + compactness: "density", + "control-density": "density", + // motion + animation: "motion", + "motion-language": "motion", + "motion-system": "motion", + "animation-philosophy": "motion", + // elevation + "shadow-system": "elevation", + "shadow-vocabulary": "elevation", + "depth-language": "elevation", + // theming-architecture + theming: "theming-architecture", + "theme-architecture": "theming-architecture", + "theme-system": "theming-architecture", + themeability: "theming-architecture", + // interactive-patterns + "interaction-patterns": "interactive-patterns", + "focus-treatment": "interactive-patterns", + "hover-system": "interactive-patterns", + "interaction-design": "interactive-patterns", + // token-architecture + "token-system": "token-architecture", + "token-cascade": "token-architecture", + "token-layering": "token-architecture", + // font-sourcing + "font-stack": "font-sourcing", + "font-strategy": "font-sourcing", + "font-loading": "font-sourcing", + "font-bundling": "font-sourcing", +}; + +/** + * Token-level affinity — when a slug has no direct synonym, score it by + * how strongly its dash-separated tokens evoke each canonical dimension. + * The token "color" alone is a strong signal for color-strategy; "shadow" + * is strong for elevation. Used by `closestCanonical` as a fallback. + * + * Each entry is `[token, dimension]`. A token may map to multiple + * dimensions (e.g. "font" hints both font-sourcing and typography-voice); + * the scorer sums signals across dimensions and returns the strongest. + */ +const TOKEN_HINTS: ReadonlyArray< + readonly [string, CanonicalDecisionDimension] +> = [ + ["color", "color-strategy"], + ["palette", "color-strategy"], + ["hue", "color-strategy"], + ["chroma", "color-strategy"], + ["surface", "surface-hierarchy"], + ["background", "surface-hierarchy"], + ["bg", "surface-hierarchy"], + ["radius", "shape-language"], + ["radii", "shape-language"], + ["corner", "shape-language"], + ["shape", "shape-language"], + ["pill", "shape-language"], + ["typography", "typography-voice"], + ["type", "typography-voice"], + ["typographic", "typography-voice"], + ["heading", "typography-voice"], + ["spacing", "spatial-system"], + ["space", "spatial-system"], + ["spatial", "spatial-system"], + ["layout", "spatial-system"], + ["rhythm", "spatial-system"], + ["density", "density"], + ["compact", "density"], + ["motion", "motion"], + ["animation", "motion"], + ["transition", "motion"], + ["shadow", "elevation"], + ["elevation", "elevation"], + ["depth", "elevation"], + ["theme", "theming-architecture"], + ["theming", "theming-architecture"], + ["themeable", "theming-architecture"], + ["interaction", "interactive-patterns"], + ["interactive", "interactive-patterns"], + ["focus", "interactive-patterns"], + ["hover", "interactive-patterns"], + ["token", "token-architecture"], + ["alias", "token-architecture"], + ["cascade", "token-architecture"], + ["font", "font-sourcing"], + ["typeface", "font-sourcing"], +]; + +/** + * Normalize a dimension slug for lookup: trim, lowercase, collapse + * separators (`_`, ` `, repeated `-`) into single dashes. + */ +function normalize(slug: string): string { + return slug + .trim() + .toLowerCase() + .replace(/[_\s]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Returns true when `slug` is in the canonical vocabulary (after + * normalization). Use to gate fleet-aggregation paths that require + * commensurable dimension labels across members. + */ +export function isCanonicalDimension( + slug: string, +): slug is CanonicalDecisionDimension { + return CANONICAL_SET.has(normalize(slug)); +} + +/** + * Suggest the closest canonical dimension for a free-form slug. + * + * Resolution order: + * 1. Exact canonical match (after normalization). + * 2. Direct synonym lookup. + * 3. Token-affinity scoring across `TOKEN_HINTS` — wins when a single + * dimension scores strictly higher than all others. + * 4. `null` when there's no clear winner. Callers should treat null as + * "this slug is genuinely novel; lint warns and the profile keeps it + * long-tail." + * + * Pure / deterministic. No I/O. + */ +export function closestCanonical( + slug: string, +): CanonicalDecisionDimension | null { + if (!slug) return null; + const norm = normalize(slug); + if (!norm) return null; + + if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; + + const synonym = SYNONYMS[norm]; + if (synonym) return synonym; + + const tokens = norm.split("-").filter(Boolean); + if (tokens.length === 0) return null; + + const scores = new Map(); + for (const token of tokens) { + for (const [hint, dim] of TOKEN_HINTS) { + if (hint === token) { + scores.set(dim, (scores.get(dim) ?? 0) + 1); + } + } + } + if (scores.size === 0) return null; + + let best: CanonicalDecisionDimension | null = null; + let bestScore = 0; + let tied = false; + for (const [dim, score] of scores) { + if (score > bestScore) { + best = dim; + bestScore = score; + tied = false; + } else if (score === bestScore) { + tied = true; + } + } + return tied ? null : best; +} + +/** + * Resolve a decision's effective canonical dimension for fleet rollup: + * prefer an explicit `dimension_kind` (when it's canonical), otherwise + * fall back to the slug if it's canonical, otherwise null. + * + * The fleet aggregator groups decisions by this resolved value; null + * means the decision lives in the long tail and is reported per-member, + * not aggregated. + */ +export function resolveDecisionKind(decision: { + dimension: string; + dimension_kind?: string; +}): CanonicalDecisionDimension | null { + if (decision.dimension_kind) { + const norm = normalize(decision.dimension_kind); + if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; + } + const norm = normalize(decision.dimension); + if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; + return null; +} diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index a85985e..0228751 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -39,6 +39,14 @@ export { ValueSpecSchema, valueRowId, } from "./bucket/index.js"; +// --- Decision vocabulary (controlled list for fleet aggregation) --- +export { + CANONICAL_DECISION_DIMENSIONS, + type CanonicalDecisionDimension, + closestCanonical, + isCanonicalDimension, + resolveDecisionKind, +} from "./decision-vocabulary.js"; export type { CompareOptions, RoleCandidate } from "./embedding/index.js"; export { classifyContrast, diff --git a/packages/ghost-core/src/types.ts b/packages/ghost-core/src/types.ts index 462135a..1553e53 100644 --- a/packages/ghost-core/src/types.ts +++ b/packages/ghost-core/src/types.ts @@ -196,6 +196,18 @@ export interface DesignObservation { export interface DesignDecision { /** Freeform dimension name — LLM chooses what's relevant (e.g. "color-strategy", "motion", "density") */ dimension: string; + /** + * Optional canonical bucket this decision rolls up under. When present, + * fleet-aggregation primitives group by this value. When absent, they + * fall back to `dimension` if it happens to be canonical, otherwise the + * decision is treated as long-tail. + * + * Authoring rule (see `closestCanonical` in `@ghost/core`): when + * `dimension` itself is one of `CANONICAL_DECISION_DIMENSIONS`, omit + * `dimension_kind`. Set it only when you've chosen a project-flavored + * slug that's better described by an existing canonical bucket. + */ + dimension_kind?: string; /** The decision stated abstractly, implementation-agnostic */ decision: string; /** Evidence from the source code supporting this decision */ diff --git a/packages/ghost-core/test/decision-vocabulary.test.ts b/packages/ghost-core/test/decision-vocabulary.test.ts new file mode 100644 index 0000000..f883d4e --- /dev/null +++ b/packages/ghost-core/test/decision-vocabulary.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + CANONICAL_DECISION_DIMENSIONS, + closestCanonical, + isCanonicalDimension, + resolveDecisionKind, +} from "../src/decision-vocabulary.js"; + +describe("CANONICAL_DECISION_DIMENSIONS", () => { + it("contains the documented 12 dimensions", () => { + expect(CANONICAL_DECISION_DIMENSIONS).toHaveLength(12); + expect(CANONICAL_DECISION_DIMENSIONS).toContain("color-strategy"); + expect(CANONICAL_DECISION_DIMENSIONS).toContain("font-sourcing"); + }); + + it("has no duplicates", () => { + const set = new Set(CANONICAL_DECISION_DIMENSIONS); + expect(set.size).toBe(CANONICAL_DECISION_DIMENSIONS.length); + }); +}); + +describe("isCanonicalDimension", () => { + it("accepts every canonical slug", () => { + for (const slug of CANONICAL_DECISION_DIMENSIONS) { + expect(isCanonicalDimension(slug)).toBe(true); + } + }); + + it("rejects unknown slugs", () => { + expect(isCanonicalDimension("warm-neutrals")).toBe(false); + expect(isCanonicalDimension("color-system")).toBe(false); + }); + + it("normalizes whitespace and underscores before checking", () => { + expect(isCanonicalDimension("color_strategy")).toBe(true); + expect(isCanonicalDimension(" Color-Strategy ")).toBe(true); + }); +}); + +describe("closestCanonical", () => { + it("returns the slug itself when already canonical", () => { + expect(closestCanonical("color-strategy")).toBe("color-strategy"); + expect(closestCanonical("motion")).toBe("motion"); + }); + + it("resolves direct synonyms", () => { + expect(closestCanonical("color-system")).toBe("color-strategy"); + expect(closestCanonical("palette-strategy")).toBe("color-strategy"); + expect(closestCanonical("type-stack")).toBe("typography-voice"); + expect(closestCanonical("radius-philosophy")).toBe("shape-language"); + expect(closestCanonical("corner-treatment")).toBe("shape-language"); + expect(closestCanonical("shadow-system")).toBe("elevation"); + expect(closestCanonical("theme-system")).toBe("theming-architecture"); + }); + + it("falls back to token affinity for novel slugs", () => { + expect(closestCanonical("color-cadence")).toBe("color-strategy"); + expect(closestCanonical("custom-shadow-language")).toBe("elevation"); + expect(closestCanonical("fancy-motion-rules")).toBe("motion"); + }); + + it("returns null when no canonical wins clearly", () => { + expect(closestCanonical("entirely-novel-decision")).toBeNull(); + expect(closestCanonical("")).toBeNull(); + }); + + it("returns null on a tie between dimensions", () => { + // "color" → color-strategy, "shadow" → elevation: tied at 1 each + expect(closestCanonical("color-shadow")).toBeNull(); + }); + + it("normalizes input before matching", () => { + expect(closestCanonical("Color_Strategy")).toBe("color-strategy"); + expect(closestCanonical(" shadow_system ")).toBe("elevation"); + }); +}); + +describe("resolveDecisionKind", () => { + it("prefers explicit dimension_kind when canonical", () => { + expect( + resolveDecisionKind({ + dimension: "system-color-deference", + dimension_kind: "color-strategy", + }), + ).toBe("color-strategy"); + }); + + it("falls back to dimension when canonical and kind absent", () => { + expect(resolveDecisionKind({ dimension: "shape-language" })).toBe( + "shape-language", + ); + }); + + it("returns null when neither is canonical", () => { + expect(resolveDecisionKind({ dimension: "warm-neutrals" })).toBeNull(); + expect( + resolveDecisionKind({ + dimension: "warm-neutrals", + dimension_kind: "also-not-canonical", + }), + ).toBeNull(); + }); + + it("ignores a non-canonical kind even when dimension is canonical", () => { + // dimension_kind is opt-in metadata; if author typoed it, fall through + // to the dimension itself rather than silently failing rollup. + expect( + resolveDecisionKind({ + dimension: "color-strategy", + dimension_kind: "typo-here", + }), + ).toBe("color-strategy"); + }); +}); diff --git a/packages/ghost-expression/src/core/fragments.ts b/packages/ghost-expression/src/core/fragments.ts index 90eb661..841fbf2 100644 --- a/packages/ghost-expression/src/core/fragments.ts +++ b/packages/ghost-expression/src/core/fragments.ts @@ -68,6 +68,10 @@ function parseFragment( const dimension = typeof yamlObj.dimension === "string" ? yamlObj.dimension : filenameSlug; + const dimensionKind = + typeof yamlObj.dimension_kind === "string" + ? yamlObj.dimension_kind + : undefined; const evidence = Array.isArray(yamlObj.evidence) ? yamlObj.evidence.filter((e): e is string => typeof e === "string") : []; @@ -75,7 +79,12 @@ function parseFragment( const decisionText = prose.trim(); if (!dimension || !decisionText) return null; - return { dimension, decision: decisionText, evidence }; + return { + dimension, + decision: decisionText, + evidence, + ...(dimensionKind ? { dimension_kind: dimensionKind } : {}), + }; } /** diff --git a/packages/ghost-expression/src/core/frontmatter.ts b/packages/ghost-expression/src/core/frontmatter.ts index 1927c1b..7724089 100644 --- a/packages/ghost-expression/src/core/frontmatter.ts +++ b/packages/ghost-expression/src/core/frontmatter.ts @@ -152,6 +152,7 @@ function stripDecisionProse( if (!decisions?.length) return undefined; return decisions.map((d) => { const out: Record = { dimension: d.dimension }; + if (d.dimension_kind) out.dimension_kind = d.dimension_kind; if (d.embedding) out.embedding = d.embedding; return out; }); diff --git a/packages/ghost-expression/src/core/lint.ts b/packages/ghost-expression/src/core/lint.ts index ae74ce3..a73f178 100644 --- a/packages/ghost-expression/src/core/lint.ts +++ b/packages/ghost-expression/src/core/lint.ts @@ -1,4 +1,8 @@ -import type { Expression } from "@ghost/core"; +import { + closestCanonical, + type Expression, + isCanonicalDimension, +} from "@ghost/core"; import { parse as parseYaml } from "yaml"; import type { BodyData } from "./body.js"; import { parseExpression, splitRaw } from "./parser.js"; @@ -67,6 +71,7 @@ export function lintExpression( checkStrayEvidenceInBody(bodyText, rawIssues); checkEvidenceHexes(expression, rawIssues); checkUnusedPalette(expression, rawIssues); + checkNonCanonicalDimensions(expression, rawIssues); return finalize(rawIssues, strict, off); } @@ -222,6 +227,44 @@ function checkUnusedPalette(fp: Expression, issues: LintIssue[]): void { } } +/** + * Soft check: a `decisions[].dimension` slug should either be in the + * canonical vocabulary or pair with a canonical `dimension_kind`. Anything + * else lives in the long tail and won't roll up at fleet scale. This + * never errors — it suggests, so authoring stays free-form by default. + */ +function checkNonCanonicalDimensions( + fp: Expression, + issues: LintIssue[], +): void { + const decisions = fp.decisions ?? []; + decisions.forEach((d, idx) => { + if (isCanonicalDimension(d.dimension)) return; + if (d.dimension_kind && isCanonicalDimension(d.dimension_kind)) return; + const suggestion = closestCanonical(d.dimension); + if (d.dimension_kind && !isCanonicalDimension(d.dimension_kind)) { + const fix = closestCanonical(d.dimension_kind) ?? suggestion; + issues.push({ + severity: "warning", + rule: "non-canonical-dimension", + message: `Decision \`${d.dimension}\` has \`dimension_kind: ${d.dimension_kind}\`, which is also not in the canonical vocabulary${ + fix ? ` (closest: \`${fix}\`)` : "" + }. Set \`dimension_kind\` to a canonical slug so fleet aggregation can group this decision.`, + path: `decisions[${idx}].dimension_kind`, + }); + return; + } + issues.push({ + severity: "warning", + rule: "non-canonical-dimension", + message: `Decision \`${d.dimension}\` is not a canonical dimension${ + suggestion ? ` (closest: \`${suggestion}\`)` : "" + }. Either rename, or add \`dimension_kind: \` so fleet aggregation can group this decision.`, + path: `decisions[${idx}].dimension`, + }); + }); +} + function collectPaletteHexes(fp: Expression): Set { const out = new Set(); for (const c of fp.palette?.dominant ?? []) out.add(c.value.toLowerCase()); diff --git a/packages/ghost-expression/src/core/parser.ts b/packages/ghost-expression/src/core/parser.ts index 4f3c85e..ea92118 100644 --- a/packages/ghost-expression/src/core/parser.ts +++ b/packages/ghost-expression/src/core/parser.ts @@ -177,6 +177,7 @@ function mergeDecisions( dimension: y.dimension, decision: b?.decision ?? "", evidence: b?.evidence ?? [], + ...(y.dimension_kind ? { dimension_kind: y.dimension_kind } : {}), ...(y.embedding ? { embedding: y.embedding } : {}), }); } diff --git a/packages/ghost-expression/src/core/schema.ts b/packages/ghost-expression/src/core/schema.ts index 5eb85eb..7b0ffb2 100644 --- a/packages/ghost-expression/src/core/schema.ts +++ b/packages/ghost-expression/src/core/schema.ts @@ -60,14 +60,19 @@ const DesignObservationSchema = z .strict(); /** - * Frontmatter decision: dimension slug + optional embedding only. - * Both the prose rationale AND the evidence bullets live in the body - * under `### dimension` → `**Evidence:**`. Evidence in frontmatter is - * rejected by the strict schema. + * Frontmatter decision: dimension slug + optional kind + optional + * embedding only. Both the prose rationale AND the evidence bullets live + * in the body under `### dimension` → `**Evidence:**`. Evidence in + * frontmatter is rejected by the strict schema. + * + * `dimension_kind` is the optional canonical-vocabulary mapping used by + * fleet aggregation. See `CANONICAL_DECISION_DIMENSIONS` in `@ghost/core` + * and the soft `non-canonical-dimension` lint rule for guidance. */ const DesignDecisionSchema = z .object({ dimension: z.string(), + dimension_kind: z.string().optional(), embedding: z.array(z.number()).optional(), }) .strict(); diff --git a/packages/ghost-expression/src/skill-bundle/references/profile.md b/packages/ghost-expression/src/skill-bundle/references/profile.md index 53206a6..f8a945e 100644 --- a/packages/ghost-expression/src/skill-bundle/references/profile.md +++ b/packages/ghost-expression/src/skill-bundle/references/profile.md @@ -67,7 +67,34 @@ Name the pattern, not the token: - ✗ Weak: "Spacing follows a 4px base grid with Tailwind defaults." (restates a fact already in the bucket) - ✓ Strong: "Prefer explicit component-height tokens over padding arithmetic, so button/input sizing is decoupled from surrounding layout." (names the pattern and its consequence) -Surface whatever dimensions fit. Common ones: `color-strategy`, `spatial-system`, `typography-voice`, `surface-hierarchy`, `density`, `motion`, `elevation`, `interactive-patterns`, `token-architecture`. **Absences are decisions** — "No animation — interactions are immediate and non-kinetic" is valid (evidence: empty `motion` rows in the bucket). +**Pick from the canonical vocabulary first.** Twelve dimensions cover the orthogonal axes a designer makes deliberate calls on, and using canonical slugs is what makes cross-system fleet aggregation possible (otherwise `color-strategy` and `color-system` and `palette-strategy` are three names for one axis and the rollup can't group them): + +| Slug | Captures | +|---|---| +| `color-strategy` | hue as decoration vs. communication; default-mono vs. branded | +| `surface-hierarchy` | named-by-intent vs. named-by-shade; surface vocabulary | +| `shape-language` | radius philosophy (pill, uniform, geometric, organic) | +| `typography-voice` | type-as-instrument; editorial vs. utility; scale rhythm | +| `spatial-system` | spacing scale, base unit, padding philosophy | +| `density` | compact controls vs. spacious containers (paired with spatial, distinct) | +| `motion` | animation as functional vs. decorative; presence vs. absence | +| `elevation` | shadow vocabulary; named-by-role vs. numeric; dark-mode treatment | +| `theming-architecture` | runtime themability; cascade structure; override patterns | +| `interactive-patterns` | focus, hover, active feedback conventions | +| `token-architecture` | alias-chain depth; semantic vs. raw; layering discipline | +| `font-sourcing` | bundled vs. consumer-supplied; preferred families | + +**Absences are decisions** — "No animation — interactions are immediate and non-kinetic" is valid under `motion` (evidence: empty `motion` rows in the bucket). + +**Escape hatch for genuinely novel decisions.** When a project really has a decision that doesn't fit any canonical slug — e.g. an iOS app's `system-color-deference` ("we defer to UIKit's system colors when available"), or a charting library's `chart-archetype` ("we ship four chart families as first-class") — keep the project-flavored slug and add `dimension_kind: ` pointing at the closest canonical bucket. Fleet aggregation rolls up by `dimension_kind` when set: + +```yaml +decisions: + - dimension: system-color-deference # specific, project-flavored + dimension_kind: color-strategy # canonical bucket for fleet rollup +``` + +`ghost-expression lint` warns on non-canonical slugs without a canonical kind (rule: `non-canonical-dimension`); the warning suggests the closest canonical match. The check is soft — long-tail decisions are allowed, just won't roll up. For each decision: `dimension` (slug), `decision` (prose, body), `evidence` (concrete citations from the bucket — preferred form: token definitions like `"--radius-pill: 999px"` or value rows like `"#f97316 (47 occurrences across 12 files)"`). diff --git a/packages/ghost-expression/test/expression/lint.test.ts b/packages/ghost-expression/test/expression/lint.test.ts index 8b2d9d6..40bea5f 100644 --- a/packages/ghost-expression/test/expression/lint.test.ts +++ b/packages/ghost-expression/test/expression/lint.test.ts @@ -179,6 +179,89 @@ embedding: [0] expect(report.issues.some((i) => i.rule === "schema-invalid")).toBe(false); }); + it("warns on a non-canonical decision dimension with no dimension_kind", () => { + const md = build( + `\ndecisions: + - dimension: warm-neutrals`, + `# Decisions + +### warm-neutrals +No cool grays. + +**Evidence:** +- \`#141413\` +`, + ); + const report = lintExpression(md); + const issue = report.issues.find( + (i) => i.rule === "non-canonical-dimension", + ); + expect(issue).toBeDefined(); + expect(issue?.severity).toBe("warning"); + expect(issue?.path).toBe("decisions[0].dimension"); + }); + + it("does not warn when dimension is canonical", () => { + const md = build( + `\ndecisions: + - dimension: color-strategy`, + `# Decisions + +### color-strategy +Hue as opt-in. + +**Evidence:** +- \`#141413\` +`, + ); + const report = lintExpression(md); + expect( + report.issues.some((i) => i.rule === "non-canonical-dimension"), + ).toBe(false); + }); + + it("does not warn when non-canonical dimension has canonical dimension_kind", () => { + const md = build( + `\ndecisions: + - dimension: warm-neutrals + dimension_kind: color-strategy`, + `# Decisions + +### warm-neutrals +No cool grays. + +**Evidence:** +- \`#141413\` +`, + ); + const report = lintExpression(md); + expect( + report.issues.some((i) => i.rule === "non-canonical-dimension"), + ).toBe(false); + }); + + it("warns when dimension_kind itself is not canonical", () => { + const md = build( + `\ndecisions: + - dimension: warm-neutrals + dimension_kind: also-bogus`, + `# Decisions + +### warm-neutrals +No cool grays. + +**Evidence:** +- \`#141413\` +`, + ); + const report = lintExpression(md); + const issue = report.issues.find( + (i) => i.rule === "non-canonical-dimension", + ); + expect(issue).toBeDefined(); + expect(issue?.path).toBe("decisions[0].dimension_kind"); + }); + it("rejects the legacy shadowComplexity: none value", () => { const md = `${HEADER} palette: diff --git a/schemas/expression.schema.json b/schemas/expression.schema.json index be3194b..ba17206 100644 --- a/schemas/expression.schema.json +++ b/schemas/expression.schema.json @@ -69,6 +69,9 @@ "dimension": { "type": "string" }, + "dimension_kind": { + "type": "string" + }, "embedding": { "type": "array", "items": { @@ -251,7 +254,7 @@ }, "shadowComplexity": { "type": "string", - "enum": ["none", "subtle", "layered"] + "enum": ["deliberate-none", "subtle", "layered"] }, "borderUsage": { "type": "string", @@ -264,95 +267,6 @@ "required": ["borderRadii", "shadowComplexity", "borderUsage"], "additionalProperties": false }, - "roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "tokens": { - "type": "object", - "properties": { - "typography": { - "type": "object", - "properties": { - "family": { - "type": "string" - }, - "size": { - "type": "number" - }, - "weight": { - "type": "number" - }, - "lineHeight": { - "type": "number" - } - }, - "additionalProperties": false - }, - "spacing": { - "type": "object", - "properties": { - "padding": { - "type": "number" - }, - "gap": { - "type": "number" - }, - "margin": { - "type": "number" - } - }, - "additionalProperties": false - }, - "surfaces": { - "type": "object", - "properties": { - "borderRadius": { - "type": "number" - }, - "shadow": { - "type": "string", - "enum": ["none", "subtle", "layered"] - }, - "borderWidth": { - "type": "number" - } - }, - "additionalProperties": false - }, - "palette": { - "type": "object", - "properties": { - "background": { - "type": "string" - }, - "foreground": { - "type": "string" - }, - "border": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "evidence": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["name", "tokens", "evidence"], - "additionalProperties": false - } - }, "embedding": { "type": "array", "items": { From 810a107d81048968c2c7ccd65af7f0f0b31ef5d1 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 30 Apr 2026 11:16:25 -0400 Subject: [PATCH 08/14] feat(core): perceptual prior + Rule type for drift severity calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the perceptual prior — Ghost's opinionated stance that drift severity should track how loudly a change registers visually, not just whether it deviates from a recorded value. Three perceptual tiers fix membership for each canonical dimension: loud (color-strategy, font-sourcing → critical), structural (shape, elevation, surface, interactive, typography-voice → serious), and rhythmic (spatial-system, density, motion, theming, token → nit). Match shape is per-RuleKind: color is exact, spacing is band (±2px), type-size is percent (±10%), radius and shadow are structural. Presence/absence escalation lifts a rule one tier when bucket count for its dimension is at or below presence_floor — sparsity is a design decision, and adding to a silent dimension is the loudest possible change. Adds Rule, RuleKind, RuleMatchShape, DriftSeverity types alongside existing Decision (additive — nothing removed). Expression.rules is optional. Exports computeRuleSeverity, resolveMatchShape, resolveTolerance, escalateForPresence as the helpers a v0 emitter threads the prior through. 35 new tests cover tier coverage, severity mapping, escalation boundaries, match defaults, and tolerance defaults. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 20 ++ packages/ghost-core/src/index.ts | 18 ++ packages/ghost-core/src/perceptual-prior.ts | 215 +++++++++++++++++ packages/ghost-core/src/types.ts | 101 ++++++++ .../ghost-core/test/perceptual-prior.test.ts | 227 ++++++++++++++++++ .../src/core/context/review-command.ts | 219 ++++++++++++++++- .../ghost-expression/src/core/frontmatter.ts | 2 + packages/ghost-expression/src/core/schema.ts | 43 ++++ .../src/skill-bundle/SKILL.md | 8 + .../src/skill-bundle/references/map.md | 32 ++- .../src/skill-bundle/references/profile.md | 126 +++++++--- .../src/skill-bundle/references/scan.md | 12 + .../__snapshots__/review-command.test.ts.snap | 122 +++++----- .../test/context/review-command.test.ts | 179 ++++++++++++++ packages/ghost-ui/expression.md | 81 +++++++ 15 files changed, 1302 insertions(+), 103 deletions(-) create mode 100644 packages/ghost-core/src/perceptual-prior.ts create mode 100644 packages/ghost-core/test/perceptual-prior.test.ts diff --git a/README.md b/README.md index e43db37..786d052 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,26 @@ Ghost is a pnpm monorepo. Four tools, one reference design system, one docs site Dependency flow: `@ghost/core` ← everyone. `ghost-expression` ← `ghost-drift`, `ghost-fleet`. No cycles. +## Quick install + +If you just want the design-language scan + emit recipes installed into your host agent — no Node, no pnpm, no build: + +```bash +curl -fsSL https://raw.githubusercontent.com/block/ghost/main/install/install.sh | sh +``` + +The installer detects your agent (`claude` / `cursor` / `codex` / `opencode`), drops the `ghost` skill bundle into the right skills directory (e.g. `~/.claude/skills/ghost/`), and tells you what to do next. Pass `--agent claude` (or `--dest `) to override detection. Re-run with `--force` to upgrade. + +After install, in any repo: + +``` +> Scan this project with ghost +``` + +The agent walks `map.md` → `bucket.json` → `expression.md`, then emits a `/design-review` slash command tuned to your design language. The recipes work without any Ghost CLI on PATH — every CLI-using step has a prose fallback. + +If you want the deterministic CLI helpers (faster lint, embedding math, structural diff, fleet view), install from source instead — see *Getting Started* below. + ## Getting Started ### Prerequisites diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index 0228751..2c3efc2 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -77,6 +77,20 @@ export { type RequiredBodySection, type TopLevelEntry, } from "./map/index.js"; +// --- Perceptual prior (drift severity calibration) --- +export { + computeRuleSeverity, + DEFAULT_MATCH, + DEFAULT_TOLERANCE, + escalateForPresence, + escalateTier, + PERCEPTUAL_TIER, + type PerceptualTier, + resolveMatchShape, + resolveTolerance, + TIER_SEVERITY, + tierForCanonical, +} from "./perceptual-prior.js"; // --- Skill bundle loader --- export type { SkillBundleFile } from "./skill-bundle-loader.js"; export { loadSkillBundle } from "./skill-bundle-loader.js"; @@ -100,6 +114,7 @@ export type { DimensionDelta, DimensionStance, DivergenceClass, + DriftSeverity, DriftVector, DriftVelocity, EmbeddingConfig, @@ -120,6 +135,9 @@ export type { RegistryItem, RegistryItemType, ResolvedRegistry, + Rule, + RuleKind, + RuleMatchShape, RuleSeverity, SampledFile, SampledMaterial, diff --git a/packages/ghost-core/src/perceptual-prior.ts b/packages/ghost-core/src/perceptual-prior.ts new file mode 100644 index 0000000..0949652 --- /dev/null +++ b/packages/ghost-core/src/perceptual-prior.ts @@ -0,0 +1,215 @@ +/** + * Ghost's perceptual prior — the opinionated stance that drift severity + * should track *how loudly a change registers visually*, not just whether + * it deviates from a recorded value. + * + * Three perceptual tiers: + * + * - **loud**: visible at first glance, no inspection required. Color + * and typeface family are loud — a new color or font is the change + * everyone notices. + * - **structural**: visible on inspection or interaction. Radius + * philosophy (pill vs. boxy), elevation vocabulary, focus treatment. + * Pill among boxes screams; the wrong shadow on a flat system jars. + * - **rhythmic**: visible only as a system property. Spacing scale + * adherence, density, motion duration. Individual deviations are + * nearly imperceptible — the rhythm matters in aggregate. + * + * Two cross-cutting rules: + * + * 1. **Match shape** is per-`RuleKind`: color is `exact`, spacing is + * `band`, type-size is `percent`, radius/shadow are `structural`. + * Defaults are sensible; per-rule overrides remain available. + * 2. **Presence/absence escalation**: when bucket count for a + * dimension is ≤ `presence_floor`, escalate the rule one tier. + * Sparsity is the design decision — adding to a silent dimension + * is the loudest possible change regardless of base tier. + * + * Tier membership is a position: projects can override per-rule severity + * but cannot remap a dimension's tier. The tiers are the product. + */ + +import type { CanonicalDecisionDimension } from "./decision-vocabulary.js"; +import type { DriftSeverity, Rule, RuleKind, RuleMatchShape } from "./types.js"; + +// --- Tier table --------------------------------------------------------- + +export type PerceptualTier = "loud" | "structural" | "rhythmic"; + +/** + * Maps each canonical dimension to its perceptual tier. The mapping is a + * position, not configuration — see module docstring. + * + * Notes on a few placements: + * - `typography-voice` is structural at the dimension level; a foreign + * font *family* is loud (handled by `RuleKind: "type-family"`), while + * size-detail drift is rhythmic (handled by `RuleKind: "type-size"`). + * Per-rule kind escalation handles that split. + * - `interactive-patterns` is structural — focus rings register on + * interaction, not at first glance. + * - `theming-architecture` and `token-architecture` are rhythmic — + * they're plumbing, perceptible only via downstream symptoms. + */ +export const PERCEPTUAL_TIER: Readonly< + Record +> = { + "color-strategy": "loud", + "font-sourcing": "loud", + "typography-voice": "structural", + "shape-language": "structural", + elevation: "structural", + "surface-hierarchy": "structural", + "interactive-patterns": "structural", + "spatial-system": "rhythmic", + density: "rhythmic", + motion: "rhythmic", + "theming-architecture": "rhythmic", + "token-architecture": "rhythmic", +}; + +/** + * Per-tier default severity for emitted reviewer rules. The emitter writes + * the resolved severity into the slash command so the reader sees a flat + * Critical / Serious / Nit grouping rather than a per-dimension layout. + */ +export const TIER_SEVERITY: Readonly> = { + loud: "critical", + structural: "serious", + rhythmic: "nit", +}; + +// --- Match shape and tolerance defaults -------------------------------- + +/** + * Default match shape per rule kind. Color demands exact equality (any + * non-allowed hex is drift). Spacing tolerates a small absolute band + * because 7px-vs-8px is invisible. Type size uses a percentage band + * because 14→15px is invisible but 14→24px is loud. Radius and shadow + * are structural — pill vs. non-pill matters more than 999 vs. 998. + */ +export const DEFAULT_MATCH: Readonly> = { + color: "exact", + radius: "structural", + spacing: "band", + "type-size": "percent", + "type-family": "exact", + "type-weight": "exact", + shadow: "structural", + motion: "exact", +}; + +/** + * Default tolerance for each match shape. Absent for `exact` and + * `structural` (no tolerance applies). Used when a rule selects a match + * shape but doesn't specify a tolerance. + */ +export const DEFAULT_TOLERANCE: Readonly< + Record +> = { + exact: undefined, + structural: undefined, + band: 2, // ±2 in source unit (typically px) + percent: 0.1, // ±10% relative +}; + +// --- Severity computation ---------------------------------------------- + +const TIER_ORDER: PerceptualTier[] = ["rhythmic", "structural", "loud"]; + +/** + * Escalate a tier one step toward `loud`. `loud` saturates — escalating + * a loud rule against an absent dimension is still critical. + */ +export function escalateTier(tier: PerceptualTier): PerceptualTier { + const idx = TIER_ORDER.indexOf(tier); + if (idx < 0) return tier; + return TIER_ORDER[Math.min(idx + 1, TIER_ORDER.length - 1)] as PerceptualTier; +} + +/** + * Resolve a canonical dimension to its perceptual tier. Returns + * `structural` for unknown / non-canonical inputs — the conservative + * default. The emitter / lint should warn on non-canonical rules so + * they're caught at authoring time. + */ +export function tierForCanonical( + canonical: string | undefined, +): PerceptualTier { + if (!canonical) return "structural"; + const tier = (PERCEPTUAL_TIER as Record)[ + canonical + ]; + return tier ?? "structural"; +} + +/** + * Apply presence/absence escalation: when `bucketCount <= presenceFloor`, + * the dimension is silent (or near-silent) in the project, so any rule + * guarding it is one tier louder than its base. + * + * `presenceFloor` defaults to 0 — only completely-absent dimensions + * trigger escalation by default. Rules that want softer escalation + * (motion in a system with 1–2 structural transitions, say) can set a + * higher floor. + */ +export function escalateForPresence( + base: PerceptualTier, + bucketCount: number, + presenceFloor = 0, +): PerceptualTier { + if (bucketCount <= presenceFloor) return escalateTier(base); + return base; +} + +/** + * Compute the final severity for a rule, given its canonical dimension + * and the bucket count for that dimension in the current expression. + * + * Resolution order: + * 1. Explicit `rule.severity` wins outright. + * 2. Otherwise, base tier from `rule.canonical` → `tierForCanonical`. + * 3. Apply presence/absence escalation against `rule.presence_floor` + * (default 0) and the supplied `bucketCount`. + * 4. Map tier → severity via `TIER_SEVERITY`. + * + * Pure / deterministic. + */ +export function computeRuleSeverity( + rule: Pick, + bucketCount: number, +): DriftSeverity { + if (rule.severity) return rule.severity; + const baseTier = tierForCanonical(rule.canonical); + const finalTier = escalateForPresence( + baseTier, + bucketCount, + rule.presence_floor ?? 0, + ); + return TIER_SEVERITY[finalTier]; +} + +/** + * Compute the final match shape for a rule. Explicit `rule.match` wins; + * otherwise the default for the rule's kind. Returns `exact` when neither + * is set — the most conservative shape. + */ +export function resolveMatchShape( + rule: Pick, +): RuleMatchShape { + if (rule.match) return rule.match; + if (rule.kind) return DEFAULT_MATCH[rule.kind]; + return "exact"; +} + +/** + * Compute the final tolerance for a rule. Explicit `rule.tolerance` wins; + * otherwise the default for the resolved match shape. Returns `undefined` + * for exact/structural matches, where tolerance doesn't apply. + */ +export function resolveTolerance( + rule: Pick, +): number | undefined { + if (rule.tolerance !== undefined) return rule.tolerance; + const shape = resolveMatchShape(rule); + return DEFAULT_TOLERANCE[shape]; +} diff --git a/packages/ghost-core/src/types.ts b/packages/ghost-core/src/types.ts index 1553e53..40f4979 100644 --- a/packages/ghost-core/src/types.ts +++ b/packages/ghost-core/src/types.ts @@ -180,6 +180,100 @@ export interface ColorRamp { count: number; } +// --- Rule types (v0 reviewer drift rules; perceptual-prior-aware) --- + +/** + * Perceptual severity for a drift violation. Calibrated to how loudly a + * change registers visually, not to engineering hygiene. See + * `perceptual-prior.ts` for the tier table that drives defaults. + * + * Distinct from `RuleSeverity` (`"error" | "warn" | "off"`) which is the + * config-level severity for `GhostConfig.rules`. The two never mix — + * `DriftSeverity` is for emitted reviewer rules; `RuleSeverity` gates lint + * configuration. + */ +export type DriftSeverity = "critical" | "serious" | "nit"; + +/** + * How a rule's pattern is matched against violators. Color is exact; + * spacing tolerates small absolute drift; type-size tolerates relative + * drift; radius/shadow care about structural shape (pill vs. non-pill), + * not exact px. + */ +export type RuleMatchShape = "exact" | "band" | "percent" | "structural"; + +/** + * The dimension-of-value a rule guards. Used to look up default match + * shape and tolerance. Distinct from canonical dimension because one + * canonical dimension (e.g. `typography-voice`) can host multiple rule + * kinds (family, weight, size). + */ +export type RuleKind = + | "color" + | "radius" + | "spacing" + | "type-size" + | "type-family" + | "type-weight" + | "shadow" + | "motion"; + +export interface Rule { + /** Stable id, slug-style. Used as anchor in emitted reviewer + diff. */ + id: string; + /** + * Canonical dimension this rule belongs to. Drives perceptual-tier + * lookup. Optional — non-canonical rules are emitted but don't roll up + * at fleet aggregation. + */ + canonical?: string; + /** What kind of value the rule guards. Drives default match shape. */ + kind?: RuleKind; + /** One-line summary the reviewer surfaces alongside violations. */ + summary?: string; + /** Regex (or fixed string) the reviewer greps for. */ + pattern: string; + /** + * Where the rule is enforced. Drives which file types / contexts the + * reviewer scans. Open vocabulary; common values: `className`, + * `css_var`, `inline_style`, `import`. Empty array = enforce everywhere. + */ + enforce_at?: string[]; + /** + * Optional explicit severity override. When absent, the emitter computes + * severity from `canonical` (perceptual tier) plus `presence_floor` + * (escalation against the bucket). + */ + severity?: DriftSeverity; + /** Optional explicit match-shape override. */ + match?: RuleMatchShape; + /** Tolerance for `band` (px) or `percent` (0–1). Override of default. */ + tolerance?: number; + /** + * Bucket-count threshold below which severity escalates one tier. The + * default is `0` — only when the underlying dimension is wholly absent + * does adding to it cross a presence boundary. Set to `2` (or higher) + * for cases like motion where a couple of structural transitions don't + * count as "this system uses motion." + */ + presence_floor?: number; + /** + * Surveyor-computed support score: fraction of observed cases that + * already conform to this rule. Used by the human curator to triage — + * <0.85 typically indicates the rule isn't yet load-bearing in the + * codebase. Consumed at lint time as a soft warning. + */ + support?: number; + /** + * Provenance: bucket row IDs that motivated this rule. Lets a re-scan + * verify the rule still has a basis in the bucket; lets a reviewer cite + * the exact tokens behind the rule. + */ + based_on?: string[]; + /** Free-form rationale shown above the rule's table in the emitted reviewer. */ + rationale?: string; +} + // --- Observation & decision types (three-layer expression) --- export interface DesignObservation { @@ -233,6 +327,13 @@ export interface Expression { observation?: DesignObservation; /** Layer 2: Abstract design decisions, implementation-agnostic */ decisions?: DesignDecision[]; + /** + * v0 reviewer rules — human-curated, grep-friendly, severity computed + * by the perceptual prior at emit time. Coexists with `decisions[]` + * during the v0 transition; in v1 the parser stops populating + * `decisions[]` and `rules[]` is the only authoring surface. + */ + rules?: Rule[]; // --- Layer 3: Concrete values --- diff --git a/packages/ghost-core/test/perceptual-prior.test.ts b/packages/ghost-core/test/perceptual-prior.test.ts new file mode 100644 index 0000000..0414b69 --- /dev/null +++ b/packages/ghost-core/test/perceptual-prior.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import { CANONICAL_DECISION_DIMENSIONS } from "../src/decision-vocabulary.js"; +import { + computeRuleSeverity, + DEFAULT_MATCH, + DEFAULT_TOLERANCE, + escalateForPresence, + escalateTier, + PERCEPTUAL_TIER, + type PerceptualTier, + resolveMatchShape, + resolveTolerance, + TIER_SEVERITY, + tierForCanonical, +} from "../src/perceptual-prior.js"; + +describe("PERCEPTUAL_TIER", () => { + it("covers every canonical dimension", () => { + for (const dim of CANONICAL_DECISION_DIMENSIONS) { + expect(PERCEPTUAL_TIER[dim]).toBeDefined(); + } + }); + + it("places color-strategy and font-sourcing in loud", () => { + expect(PERCEPTUAL_TIER["color-strategy"]).toBe("loud"); + expect(PERCEPTUAL_TIER["font-sourcing"]).toBe("loud"); + }); + + it("places shape-language and elevation in structural", () => { + expect(PERCEPTUAL_TIER["shape-language"]).toBe("structural"); + expect(PERCEPTUAL_TIER["elevation"]).toBe("structural"); + }); + + it("places spatial-system, density, motion in rhythmic", () => { + expect(PERCEPTUAL_TIER["spatial-system"]).toBe("rhythmic"); + expect(PERCEPTUAL_TIER["density"]).toBe("rhythmic"); + expect(PERCEPTUAL_TIER["motion"]).toBe("rhythmic"); + }); +}); + +describe("TIER_SEVERITY", () => { + it("maps tiers to drift severities in perceptual order", () => { + expect(TIER_SEVERITY.loud).toBe("critical"); + expect(TIER_SEVERITY.structural).toBe("serious"); + expect(TIER_SEVERITY.rhythmic).toBe("nit"); + }); +}); + +describe("escalateTier", () => { + it("rhythmic → structural", () => { + expect(escalateTier("rhythmic")).toBe("structural"); + }); + + it("structural → loud", () => { + expect(escalateTier("structural")).toBe("loud"); + }); + + it("loud saturates at loud", () => { + expect(escalateTier("loud")).toBe("loud"); + }); +}); + +describe("tierForCanonical", () => { + it("returns the canonical tier for a known slug", () => { + expect(tierForCanonical("motion")).toBe("rhythmic"); + expect(tierForCanonical("color-strategy")).toBe("loud"); + }); + + it("returns structural for unknown / undefined", () => { + expect(tierForCanonical(undefined)).toBe("structural"); + expect(tierForCanonical("not-a-real-dimension")).toBe("structural"); + }); +}); + +describe("escalateForPresence", () => { + it("escalates when bucket count is below floor", () => { + expect(escalateForPresence("rhythmic", 0, 0)).toBe("structural"); + expect(escalateForPresence("rhythmic", 1, 2)).toBe("structural"); + }); + + it("does not escalate when bucket count is above floor", () => { + expect(escalateForPresence("rhythmic", 5, 2)).toBe("rhythmic"); + expect(escalateForPresence("structural", 10, 0)).toBe("structural"); + }); + + it("treats count == floor as triggering escalation", () => { + // floor is the boundary at which escalation kicks in (≤ floor → escalate) + expect(escalateForPresence("rhythmic", 2, 2)).toBe("structural"); + }); + + it("defaults presence floor to 0", () => { + expect(escalateForPresence("rhythmic", 0)).toBe("structural"); + expect(escalateForPresence("rhythmic", 1)).toBe("rhythmic"); + }); + + it("loud saturates even with escalation triggered", () => { + expect(escalateForPresence("loud", 0, 0)).toBe("loud"); + }); +}); + +describe("computeRuleSeverity", () => { + it("honors explicit severity override", () => { + expect( + computeRuleSeverity( + { canonical: "spatial-system", severity: "critical" }, + 100, + ), + ).toBe("critical"); + }); + + it("derives from canonical tier when no override", () => { + expect(computeRuleSeverity({ canonical: "color-strategy" }, 50)).toBe( + "critical", + ); + expect(computeRuleSeverity({ canonical: "shape-language" }, 50)).toBe( + "serious", + ); + expect(computeRuleSeverity({ canonical: "spatial-system" }, 50)).toBe( + "nit", + ); + }); + + it("escalates a rhythmic rule when bucket count crosses floor", () => { + // motion at 2 occurrences with floor of 2 → escalates rhythmic → structural → serious + expect( + computeRuleSeverity({ canonical: "motion", presence_floor: 2 }, 2), + ).toBe("serious"); + }); + + it("does not escalate when bucket count exceeds floor", () => { + expect( + computeRuleSeverity({ canonical: "motion", presence_floor: 2 }, 12), + ).toBe("nit"); + }); + + it("escalates structural to loud (critical) at zero presence", () => { + expect( + computeRuleSeverity({ canonical: "elevation", presence_floor: 0 }, 0), + ).toBe("critical"); + }); + + it("treats unknown canonical as structural with conservative escalation", () => { + expect(computeRuleSeverity({ canonical: "novel-dimension" }, 5)).toBe( + "serious", + ); + expect(computeRuleSeverity({ canonical: "novel-dimension" }, 0)).toBe( + "critical", + ); + }); +}); + +describe("DEFAULT_MATCH", () => { + it("color is exact", () => { + expect(DEFAULT_MATCH.color).toBe("exact"); + }); + + it("spacing is band", () => { + expect(DEFAULT_MATCH.spacing).toBe("band"); + }); + + it("type-size is percent; type-family and type-weight are exact", () => { + expect(DEFAULT_MATCH["type-size"]).toBe("percent"); + expect(DEFAULT_MATCH["type-family"]).toBe("exact"); + expect(DEFAULT_MATCH["type-weight"]).toBe("exact"); + }); + + it("radius and shadow are structural", () => { + expect(DEFAULT_MATCH.radius).toBe("structural"); + expect(DEFAULT_MATCH.shadow).toBe("structural"); + }); +}); + +describe("DEFAULT_TOLERANCE", () => { + it("exact and structural have no tolerance", () => { + expect(DEFAULT_TOLERANCE.exact).toBeUndefined(); + expect(DEFAULT_TOLERANCE.structural).toBeUndefined(); + }); + + it("band defaults to ±2", () => { + expect(DEFAULT_TOLERANCE.band).toBe(2); + }); + + it("percent defaults to ±10%", () => { + expect(DEFAULT_TOLERANCE.percent).toBeCloseTo(0.1); + }); +}); + +describe("resolveMatchShape", () => { + it("explicit match wins", () => { + expect(resolveMatchShape({ match: "percent", kind: "color" })).toBe( + "percent", + ); + }); + + it("falls back to kind default", () => { + expect(resolveMatchShape({ kind: "spacing" })).toBe("band"); + }); + + it("returns exact when no signal", () => { + expect(resolveMatchShape({})).toBe("exact"); + }); +}); + +describe("resolveTolerance", () => { + it("explicit tolerance wins", () => { + expect(resolveTolerance({ tolerance: 4, kind: "spacing" })).toBe(4); + }); + + it("derives from match shape default", () => { + expect(resolveTolerance({ kind: "spacing" })).toBe(2); + expect(resolveTolerance({ kind: "type-size" })).toBeCloseTo(0.1); + }); + + it("returns undefined for exact / structural", () => { + expect(resolveTolerance({ kind: "color" })).toBeUndefined(); + expect(resolveTolerance({ kind: "radius" })).toBeUndefined(); + }); +}); + +describe("perceptual-prior tier-coverage invariant", () => { + it("every canonical dimension lands in one of three tiers", () => { + const tiers = new Set(["loud", "structural", "rhythmic"]); + for (const dim of CANONICAL_DECISION_DIMENSIONS) { + expect(tiers.has(PERCEPTUAL_TIER[dim])).toBe(true); + } + }); +}); diff --git a/packages/ghost-expression/src/core/context/review-command.ts b/packages/ghost-expression/src/core/context/review-command.ts index 5cd7b3e..fb5eee0 100644 --- a/packages/ghost-expression/src/core/context/review-command.ts +++ b/packages/ghost-expression/src/core/context/review-command.ts @@ -1,4 +1,10 @@ -import type { Expression } from "@ghost/core"; +import type { DriftSeverity, Expression, Rule } from "@ghost/core"; +import { + computeRuleSeverity, + resolveMatchShape, + resolveTolerance, + tierForCanonical, +} from "@ghost/core"; export interface EmitReviewInput { expression: Expression; @@ -16,6 +22,17 @@ export interface EmitReviewInput { * radii and weights. Universal accessibility rules are out of scope — * those belong in Rams or a sibling a11y skill. * + * Two emission paths: + * - **Rules-driven** (preferred, v0+): when `expression.rules[]` is + * non-empty, group rules by computed perceptual severity and render + * a Critical / Serious / Nit layout. Severity is computed from the + * perceptual prior in `@ghost/core` plus per-rule overrides and + * presence-floor escalation against bucket-proxy counts. + * - **Structured-fallback** (legacy): when no rules[] are present, + * emit the original palette/radius/spacing/typography sections + * derived from frontmatter alone. Preserved verbatim so existing + * expressions keep working through the v0 transition. + * * Pure: deterministic over the same expression. The expression is * expected to be the unioned result of `loadExpression` — body prose * (Character summary, per-decision rationale) is already folded into @@ -23,6 +40,206 @@ export interface EmitReviewInput { */ export function emitReviewCommand(input: EmitReviewInput): string { const { expression: fp } = input; + + if (fp.rules && fp.rules.length > 0) { + return emitRulesDriven(fp); + } + + return emitStructuredFallback(fp); +} + +// --- Rules-driven path (v0+) ------------------------------------------- + +/** + * Render a rules[]-driven slash command. Groups rules by computed + * severity, renders one block per rule with rationale + pattern + match + * shape, then closes with a calibration footer that explains *why* + * severities landed where they did. The calibration footer is what makes + * Ghost's reviewer legibly different from a generic linter — the prior + * is visible, not opaque. + */ +function emitRulesDriven(fp: Expression): string { + const id = fp.id; + const personality = (fp.observation?.personality ?? []).join(", "); + const cousins = (fp.observation?.resembles ?? []).join(", "); + const character = fp.observation?.summary?.trim() ?? ""; + + const resolved = (fp.rules ?? []).map((rule) => ({ + rule, + severity: computeRuleSeverity(rule, bucketCountProxy(rule, fp)), + match: resolveMatchShape(rule), + tolerance: resolveTolerance(rule), + })); + + const grouped: Record = { + critical: [], + serious: [], + nit: [], + }; + for (const r of resolved) grouped[r.severity].push(r); + + const sections: string[] = []; + if (grouped.critical.length) { + sections.push(renderSeverityBlock("Critical", grouped.critical)); + } + if (grouped.serious.length) { + sections.push(renderSeverityBlock("Serious", grouped.serious)); + } + if (grouped.nit.length) { + sections.push(renderSeverityBlock("Nit", grouped.nit)); + } + + const parts = [ + frontmatter(id), + header(id, personality, cousins, character), + modeSection(), + ...sections, + outputTemplate(id), + guidelines(), + calibrationFooter(fp, resolved), + ]; + return `${parts.filter(Boolean).join("\n\n").trim()}\n`; +} + +interface ResolvedRule { + rule: Rule; + severity: DriftSeverity; + match: string; + tolerance: number | undefined; +} + +function renderSeverityBlock(label: string, items: ResolvedRule[]): string { + const lines: string[] = [`## ${label} (${items.length})`]; + for (const item of items) { + lines.push("", renderRule(item)); + } + return lines.join("\n"); +} + +function renderRule(item: ResolvedRule): string { + const { rule, match, tolerance } = item; + const heading = rule.canonical + ? `### \`${rule.id}\` — ${rule.canonical}` + : `### \`${rule.id}\``; + const lines: string[] = [heading]; + if (rule.summary) lines.push("", rule.summary); + if (rule.rationale) lines.push("", `> ${rule.rationale}`); + lines.push("", `**Pattern:** \`${rule.pattern}\``); + + const matchLine = + tolerance !== undefined + ? `**Match:** \`${match}\` (tolerance: \`${tolerance}\`)` + : `**Match:** \`${match}\``; + lines.push(matchLine); + + if (rule.enforce_at?.length) { + const where = rule.enforce_at.map((e) => `\`${e}\``).join(", "); + lines.push(`**Enforce at:** ${where}`); + } + if (rule.based_on?.length) { + const cite = + rule.based_on.length <= 4 + ? rule.based_on.map((id) => `\`${id}\``).join(", ") + : `${rule.based_on + .slice(0, 4) + .map((id) => `\`${id}\``) + .join(", ")}, … (${rule.based_on.length - 4} more)`; + lines.push(`**Based on:** ${cite}`); + } + if (typeof rule.support === "number") { + lines.push(`**Support:** ${(rule.support * 100).toFixed(0)}%`); + } + return lines.join("\n"); +} + +function calibrationFooter(fp: Expression, resolved: ResolvedRule[]): string { + const tierCounts = { loud: 0, structural: 0, rhythmic: 0 }; + const escalated: string[] = []; + + for (const r of resolved) { + const baseTier = tierForCanonical(r.rule.canonical); + tierCounts[baseTier]++; + const finalTierFromSeverity = + r.severity === "critical" + ? "loud" + : r.severity === "serious" + ? "structural" + : "rhythmic"; + if ( + finalTierFromSeverity !== baseTier && + r.rule.severity === undefined // not a manual override + ) { + escalated.push(`\`${r.rule.id}\``); + } + } + + const lines: string[] = [ + "## How this reviewer was calibrated", + "", + `Severity grouping reflects perceptual weight, not arithmetic. \`${fp.id}\` has ${tierCounts.loud} loud-tier, ${tierCounts.structural} structural-tier, and ${tierCounts.rhythmic} rhythmic-tier rules under the canonical perceptual prior.`, + ]; + if (escalated.length) { + lines.push( + "", + `**Presence-floor escalation triggered for:** ${escalated.join(", ")}. These dimensions are silent (or near-silent) in the bucket — adding to them crosses a presence boundary, which is the loudest possible change.`, + ); + } + lines.push( + "", + "Color and font-family rules are loud (critical) by default. Shape, elevation, surface, and interactive-pattern rules are structural (serious). Spacing, density, motion-detail, and theming rules are rhythmic (nit).", + "", + `Generated from \`expression.md\` (${(fp.rules ?? []).length} rules). Re-run \`ghost-expression emit review-command\` after expression updates.`, + ); + return lines.join("\n"); +} + +/** + * Coarse proxy for bucket-count per canonical dimension, derived from the + * structured frontmatter fields. v0 expressions don't carry the bucket + * directly; this proxy lets presence-floor escalation work against the + * derived counts. v1 will replace this with the actual bucket count once + * `loadExpression` returns the bucket alongside the expression. + */ +function bucketCountProxy(rule: Rule, fp: Expression): number { + switch (rule.canonical) { + case "color-strategy": + return ( + fp.palette.dominant.length + + fp.palette.neutrals.count + + fp.palette.semantic.length + ); + case "surface-hierarchy": + return fp.palette.semantic.length + fp.palette.dominant.length; + case "shape-language": + return fp.surfaces.borderRadii.length; + case "elevation": + return fp.surfaces.shadowComplexity === "deliberate-none" + ? 0 + : fp.surfaces.shadowComplexity === "subtle" + ? 2 + : 5; + case "spatial-system": + case "density": + return fp.spacing.scale.length; + case "typography-voice": + return fp.typography.sizeRamp.length; + case "font-sourcing": + return fp.typography.families.length; + case "motion": + // Motion isn't in structured fields; default to a count above + // typical floors so escalation only happens via explicit author + // hint (rule.presence_floor: 2+). + return 100; + default: + // Unknown canonical → leave room above floor 0 so escalation + // doesn't fire incorrectly, but author can override via floor. + return 100; + } +} + +// --- Structured-fallback path (legacy) --------------------------------- + +function emitStructuredFallback(fp: Expression): string { const id = fp.id; const personality = (fp.observation?.personality ?? []).join(", "); const cousins = (fp.observation?.resembles ?? []).join(", "); diff --git a/packages/ghost-expression/src/core/frontmatter.ts b/packages/ghost-expression/src/core/frontmatter.ts index 7724089..d819cec 100644 --- a/packages/ghost-expression/src/core/frontmatter.ts +++ b/packages/ghost-expression/src/core/frontmatter.ts @@ -37,6 +37,7 @@ const EXPRESSION_KEYS = new Set([ "sources", "observation", "decisions", + "rules", "palette", "spacing", "typography", @@ -110,6 +111,7 @@ export function mergeFrontmatter( "sources", "observation", "decisions", + "rules", "palette", "spacing", "typography", diff --git a/packages/ghost-expression/src/core/schema.ts b/packages/ghost-expression/src/core/schema.ts index 7b0ffb2..913dc33 100644 --- a/packages/ghost-expression/src/core/schema.ts +++ b/packages/ghost-expression/src/core/schema.ts @@ -77,6 +77,46 @@ const DesignDecisionSchema = z }) .strict(); +/** + * v0 reviewer rule: a grep-able pattern fitted to this expression's + * design language. Severity, match shape, and tolerance are typically + * computed at emit time from the perceptual prior in `@ghost/core`; + * explicit fields here are overrides. + * + * Rules coexist with `decisions[]` during the v0 transition. Both are + * optional. Lint validation of the perceptual prior (e.g. warning on + * unrealistic tolerances) is not yet wired — the schema permits the + * shape so authors can begin populating rules without lint rejection. + */ +const RuleSchema = z + .object({ + id: z.string(), + canonical: z.string().optional(), + kind: z + .enum([ + "color", + "radius", + "spacing", + "type-size", + "type-family", + "type-weight", + "shadow", + "motion", + ]) + .optional(), + summary: z.string().optional(), + pattern: z.string(), + enforce_at: z.array(z.string()).optional(), + severity: z.enum(["critical", "serious", "nit"]).optional(), + match: z.enum(["exact", "band", "percent", "structural"]).optional(), + tolerance: z.number().optional(), + presence_floor: z.number().int().nonnegative().optional(), + support: z.number().min(0).max(1).optional(), + based_on: z.array(z.string()).optional(), + rationale: z.string().optional(), + }) + .strict(); + /** * Schema for the YAML frontmatter in an expression.md file. Covers the * machine-layer of Expression plus expression-level metadata. @@ -114,6 +154,8 @@ export const FrontmatterSchema = z // expression — narrative tags (optional; prose lives in body) observation: DesignObservationSchema.optional(), decisions: z.array(DesignDecisionSchema).optional(), + /** v0 reviewer rules — optional during the transition. */ + rules: z.array(RuleSchema).optional(), // expression — structured (required) palette: PaletteSchema, @@ -152,6 +194,7 @@ export const PartialFrontmatterSchema = z observation: DesignObservationSchema.optional(), decisions: z.array(DesignDecisionSchema).optional(), + rules: z.array(RuleSchema).optional(), palette: PaletteSchema.optional(), spacing: SpacingSchema.optional(), diff --git a/packages/ghost-expression/src/skill-bundle/SKILL.md b/packages/ghost-expression/src/skill-bundle/SKILL.md index 7f28815..5326316 100644 --- a/packages/ghost-expression/src/skill-bundle/SKILL.md +++ b/packages/ghost-expression/src/skill-bundle/SKILL.md @@ -13,6 +13,14 @@ This skill helps you author the project's design language — its `expression.md You do the synthesis (the profile recipe). The `ghost-expression` CLI is the calculator you reach for when you need a reproducible answer: parsing, schema validation, layout, structural diff. Call it freely; the output is ground truth. +**Two install paths, same recipes.** When the user installed via `curl … | sh` (the no-CLI v0 path) the `ghost-expression` binary is *not* on PATH. The recipes degrade gracefully: every CLI-using step has a prose fallback you can execute via `Read` / `Glob` / `Bash` / `Grep`. Detect availability once, at the start of a workflow: + +```sh +command -v ghost-expression >/dev/null && echo "cli" || echo "prose" +``` + +When the CLI is present, prefer it — the output is deterministic and idempotent. When it isn't, follow the fallback recipes. Don't ask the user to install the CLI mid-workflow; the prose path is real, not a degraded mode. + ## CLI verbs | Verb | Purpose | diff --git a/packages/ghost-expression/src/skill-bundle/references/map.md b/packages/ghost-expression/src/skill-bundle/references/map.md index e7abf94..558d3ae 100644 --- a/packages/ghost-expression/src/skill-bundle/references/map.md +++ b/packages/ghost-expression/src/skill-bundle/references/map.md @@ -20,8 +20,23 @@ This recipe is *your* job. Ghost's CLI provides `ghost-expression inventory` (de ### 1. Gather raw signals +**Preferred (CLI present):** + Run `ghost-expression inventory [path]` from (or pointed at) the target root. It returns deterministic JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote, plus best-effort platform and build-system hints. Read it as the foundation — reproducible from inputs. +**Prose fallback (no CLI):** + +Build the inventory yourself with `Glob` / `Read` / `Bash`: + +- **Package manifests:** `Glob: **/{package.json,pnpm-workspace.yaml,Cargo.toml,go.mod,Package.swift,build.gradle*,pyproject.toml,requirements.txt}` — exclude `node_modules`. Read each; record `name` and top-level dep names. +- **Language histogram:** `Bash: find . -type f -name '*.tsx' -o -name '*.ts' -o -name '*.swift' -o -name '*.kt' …` (extension list per the kinds you care about) and count. Convert to `{name, files, share}` rows where `share = files / total`. +- **Candidate config files:** `Glob` for `tailwind.config.*`, `tsconfig*.json`, `vite.config.*`, `next.config.*`, `tokens/**`, `theme/**`, etc. +- **Registry presence:** check `Read: /registry.json` — if it parses and has `name` + `items[]`, record its path. +- **Top-level tree:** `Bash: ls -la ` for one level deep. +- **Git remote:** `Bash: git -C remote get-url origin` (best-effort, fine if absent). + +Format as a JSON object so the rest of the recipe can quote from it. Skip fields you can't determine; partial inventory is fine. + ### 2. Resolve the schema fields The `ghost.map/v1` frontmatter requires: @@ -60,9 +75,24 @@ If no manifest is provided, derive `feature_areas` from the inventory's `top_lev ### 5. Validate +**Preferred (CLI present):** + ghost-expression lint map.md -Fix any errors. Lint passing is the success gate — do not declare done until it exits 0. Common errors: +Fix any errors. Lint passing is the success gate — do not declare done until it exits 0. + +**Prose fallback (no CLI):** + +Walk the file yourself against the schema in [schema.md](schema.md). Required checks: + +- Frontmatter parses as valid YAML. +- `schema: ghost.map/v1` literal present. +- Required fields populated: `id` (slug), `repo`, `mapped_at`, `platform`, `languages`, `build_system`, `package_manifests`, `composition.frameworks`, `composition.rendering`, `composition.styling`, `design_system.paths`, `design_system.status`, `ui_surface.include`, `feature_areas[]`, `orientation_files[]`. +- `id` matches `^[a-z0-9][a-z0-9._-]*$`. +- Body sections appear in order: `## Identity`, `## Topology`, `## Conventions`. No other `##` headings between them. +- If `design_system.token_source` is `external` or `mixed`, `design_system.upstream` is set. + +Common errors regardless of path: - Body section out of order (`## Identity` must precede `## Topology` etc.) - Missing `entry_files` AND `derived_files` under `design_system` (warning — fine if neither exists, but check) diff --git a/packages/ghost-expression/src/skill-bundle/references/profile.md b/packages/ghost-expression/src/skill-bundle/references/profile.md index f8a945e..0cbdd76 100644 --- a/packages/ghost-expression/src/skill-bundle/references/profile.md +++ b/packages/ghost-expression/src/skill-bundle/references/profile.md @@ -60,52 +60,88 @@ Then in frontmatter: - `distinctiveTraits`: what makes this expression *visually recognizable* — include notable absences ("no decorative elements at all", "no shadows anywhere despite a dark theme") - `resembles`: 1–3 well-known references (Linear, Geist, Material 3, …) — only if genuinely close -### 3. Layer 2 — Design Decisions (abstract prose with evidence) +### 3. Layer 2 — Rules (curated, grep-friendly, perceptual-prior-aware) -Name the pattern, not the token: +This is the load-bearing step. **Your job is to propose 5–15 candidate rules, score each by bucket-derived support, and present the ranked list to the human curator.** The curator promotes the sharpest 5–10 to `rules[]`. You do not author final rules unilaterally — design taste is human-curated, agent-proposed. -- ✗ Weak: "Spacing follows a 4px base grid with Tailwind defaults." (restates a fact already in the bucket) -- ✓ Strong: "Prefer explicit component-height tokens over padding arithmetic, so button/input sizing is decoupled from surrounding layout." (names the pattern and its consequence) +(Legacy: this stage previously authored `decisions[]` — abstract per-dimension prose. That format is preserved during the v0 transition for backward compatibility, but the canonical authoring surface is now `rules[]`. The emitter prefers `rules[]` when present.) -**Pick from the canonical vocabulary first.** Twelve dimensions cover the orthogonal axes a designer makes deliberate calls on, and using canonical slugs is what makes cross-system fleet aggregation possible (otherwise `color-strategy` and `color-system` and `palette-strategy` are three names for one axis and the rollup can't group them): +#### 3a. Propose candidate rules -| Slug | Captures | -|---|---| -| `color-strategy` | hue as decoration vs. communication; default-mono vs. branded | -| `surface-hierarchy` | named-by-intent vs. named-by-shade; surface vocabulary | -| `shape-language` | radius philosophy (pill, uniform, geometric, organic) | -| `typography-voice` | type-as-instrument; editorial vs. utility; scale rhythm | -| `spatial-system` | spacing scale, base unit, padding philosophy | -| `density` | compact controls vs. spacious containers (paired with spatial, distinct) | -| `motion` | animation as functional vs. decorative; presence vs. absence | -| `elevation` | shadow vocabulary; named-by-role vs. numeric; dark-mode treatment | -| `theming-architecture` | runtime themability; cascade structure; override patterns | -| `interactive-patterns` | focus, hover, active feedback conventions | -| `token-architecture` | alias-chain depth; semantic vs. raw; layering discipline | -| `font-sourcing` | bundled vs. consumer-supplied; preferred families | - -**Absences are decisions** — "No animation — interactions are immediate and non-kinetic" is valid under `motion` (evidence: empty `motion` rows in the bucket). - -**Escape hatch for genuinely novel decisions.** When a project really has a decision that doesn't fit any canonical slug — e.g. an iOS app's `system-color-deference` ("we defer to UIKit's system colors when available"), or a charting library's `chart-archetype` ("we ship four chart families as first-class") — keep the project-flavored slug and add `dimension_kind: ` pointing at the closest canonical bucket. Fleet aggregation rolls up by `dimension_kind` when set: +Walk the bucket and pose: *what pattern is this project consistently following that deserves codification?* Each candidate rule has the shape: ```yaml -decisions: - - dimension: system-color-deference # specific, project-flavored - dimension_kind: color-strategy # canonical bucket for fleet rollup +- id: # stable, slug-style + canonical: # optional but strongly preferred (see vocabulary below) + kind: # optional; drives default match shape + summary: # what the rule says in plain English + rationale: # why the rule exists; cites the bucket + pattern: # what the reviewer greps for + enforce_at: [...] # className / css_var / inline_style / import + support: 0.0–1.0 # computed: bucket conformers / total observed + based_on: [bucket-id, ...] # provenance; lets re-scan verify + presence_floor: # optional; default 0 ``` -`ghost-expression lint` warns on non-canonical slugs without a canonical kind (rule: `non-canonical-dimension`); the warning suggests the closest canonical match. The check is soft — long-tail decisions are allowed, just won't roll up. +**Pick `canonical` from the controlled vocabulary first.** Twelve dimensions cover the orthogonal axes a designer makes deliberate calls on: + +| Slug | Captures | Default tier | +|---|---|---| +| `color-strategy` | hue as decoration vs. communication; default-mono vs. branded | loud | +| `font-sourcing` | bundled vs. consumer-supplied; preferred families | loud | +| `surface-hierarchy` | named-by-intent vs. named-by-shade; surface vocabulary | structural | +| `shape-language` | radius philosophy (pill, uniform, geometric, organic) | structural | +| `typography-voice` | type-as-instrument; editorial vs. utility; scale rhythm | structural | +| `elevation` | shadow vocabulary; named-by-role vs. numeric; dark-mode treatment | structural | +| `interactive-patterns` | focus, hover, active feedback conventions | structural | +| `spatial-system` | spacing scale, base unit, padding philosophy | rhythmic | +| `density` | compact controls vs. spacious containers (paired with spatial, distinct) | rhythmic | +| `motion` | animation as functional vs. decorative; presence vs. absence | rhythmic | +| `theming-architecture` | runtime themability; cascade structure; override patterns | rhythmic | +| `token-architecture` | alias-chain depth; semantic vs. raw; layering discipline | rhythmic | + +The **default tier** is the perceptual weight: loud rules render as Critical, structural as Serious, rhythmic as Nit in the emitted reviewer. Severity is computed by the emitter from `canonical` + `presence_floor`; you don't usually set `severity` directly. + +#### 3b. Score support from the bucket + +For each candidate rule, compute support — *the fraction of observed cases that already conform*. Concretely: + +- **`no-off-palette-hex`** (color-strategy) — `support = (bucket color rows with value in palette set) / (total bucket color rows)`. If 31 of 33 colors are in the palette, support is 0.94. +- **`pill-interactives`** (shape-language) — `support = (interactive components using rounded-full) / (interactive components observed)`. Walk `bucket.components` for Button/Input/Badge; check radii. +- **`spacing-on-scale`** (spatial-system) — `support = (spacing rows with value ∈ scale) / (total spacing rows)`. The scale lives in `expression.spacing.scale`. + +Rule of thumb: **drop candidates with support < 0.85.** Below that threshold, the project hasn't actually committed to the pattern — codifying it generates noise. A `support: 0.6` rule looks aspirational, not enforced. + +#### 3c. Identify presence-floor candidates + +The perceptual prior escalates rules one tier when the bucket count for the dimension is ≤ `presence_floor`. Use this to capture *negative space* — what the project deliberately *isn't*: + +- Bucket has 0 motion rows → `no-decorative-motion` rule with `presence_floor: 4` (any addition crosses zero, escalates to critical). +- Bucket has 0 gradient values → `no-gradients` with `presence_floor: 0`. +- Bucket has 0 bundled fonts → `no-foreign-fonts` with `presence_floor: 0`. + +Don't set a presence floor when the dimension is well-populated — the escalation will never trigger and the field becomes noise. + +#### 3d. Cite provenance -For each decision: `dimension` (slug), `decision` (prose, body), `evidence` (concrete citations from the bucket — preferred form: token definitions like `"--radius-pill: 999px"` or value rows like `"#f97316 (47 occurrences across 12 files)"`). +For each rule, list the bucket row IDs that motivated it in `based_on`. This is the rule's *provenance trail* — re-scanning later can verify the rule still has a basis. A rule with empty `based_on` is fine (some rules express absences) but the curator should be able to trace any positive rule back to evidence. -**Evidence belongs in the body markdown under `**Evidence:**` bullets per dimension. Do NOT put `evidence:` arrays in frontmatter — the schema is `.strict()` and will reject.** Each `### ` body block should end with a `**Evidence:**` line followed by bullet citations from the bucket; the parser pulls those back onto `decisions[].evidence` in memory. +#### 3e. Present the ranked list to the curator -Mode-specific framing: +Sort candidates by support, descending. Present each as: id + canonical + summary + support % + 1-line rationale. Mark presence-floor escalations explicitly. Recommend cuts: anything below 0.85, anything redundant with another rule, anything where the pattern is too fuzzy to enforce. -- **Consumer** — overrides are decisions ("App ships its own `@font-face` instead of inheriting upstream sans" — evidence: a `--font-*` token row whose value differs from the upstream's, plus prose citing the manifest dependency). -- **Token-pipeline** — layering choices are decisions ("Component layer never references base tokens directly — always via semantic" — evidence: bucket `tokens[].alias_chain` lengths). -- **Ui-library** — registry posture is a decision ("Components ship as a flat library with no theme variants" — evidence: bucket `components[]` shape). -- **Multi-platform** — divergence between dialects is a decision when present ("Web and iOS palettes are intentionally different — web is restrained, iOS reuses system colors" — evidence: per-source counts in merged buckets, or noted in the survey scratchpad). +The curator picks 5–10. **Do not paste your full candidate list into `rules[]`.** Wait for the human to promote. + +#### Mode-specific framing + +- **Consumer** — overrides are rules. App-side `@font-face` that differs from upstream → a `font-sourcing` rule with `presence_floor: 0` against the upstream's font set. +- **Token-pipeline** — layering posture is a rule. "Component layer never references base tokens directly" → a `token-architecture` rule whose pattern catches `--component-* references --base-*`. +- **Ui-library** — registry shape is a rule. "Components have no theme variants" → an `interactive-patterns` rule against `data-theme=` attributes. +- **Multi-platform** — divergence is rules. "iOS reuses system colors but web doesn't" → two color-strategy rules, one per dialect, each with its own `enforce_at`. + +#### Absences are rules — codify them with `presence_floor` + +Don't try to express "this project has no animation" as prose. Express it as: a motion rule whose `presence_floor` causes any addition to escalate to critical. The emitted reviewer will catch the addition without the prose. ### 4. Layer 3 — Concrete tokens (read from bucket; do not invent) @@ -142,9 +178,23 @@ Partition matters. See [schema.md](schema.md) for which field lives where. ### 6. Validate +**Preferred (CLI present):** + ghost-expression lint expression.md -Fix any errors. Common ones: +Fix any errors. + +**Prose fallback (no CLI):** + +Walk the file against the schema in [schema.md](schema.md). Required checks: + +- Frontmatter parses as valid YAML. +- Required fields: `id`, `source`, `timestamp`, `palette`, `spacing`, `typography`, `surfaces`. +- Body sections appear in order: `# Character`, `# Signature`, `# Decisions` (when decisions are present). No prose in frontmatter. +- For any `### dim` block in the body, a matching `decisions[].dimension` entry exists in frontmatter (and vice versa). +- For any `rules[]` entry: `id` is unique, `pattern` is non-empty, optional `severity` ∈ `{critical, serious, nit}`, optional `match` ∈ `{exact, band, percent, structural}`, optional `support` ∈ `[0, 1]`. + +Common errors regardless of path: - Prose in frontmatter → move to body. - `### dim` with no matching `decisions[]` entry → remove the orphan. @@ -162,10 +212,16 @@ Any expression value that doesn't trace back is a hallucination. Remove it. ### 8. Self-distance sanity +**Preferred (CLI present):** + ghost-drift compare expression.md expression.md Self-distance must be 0. Anything else means the file isn't deterministically loadable. +**Prose fallback (no CLI / no ghost-drift):** + +Re-load the file mentally: parse the frontmatter, normalize whitespace in the body, then verify the file would round-trip through a YAML parser without info loss. If you can't be sure, run the CLI (it's the calculator that exists for exactly this question). The self-distance check is genuinely a "machine math" answer — prose verification is best-effort, not authoritative. + ## When the bucket is incomplete If the surveyor's `bucket.json` has known gaps (a `# Coverage` note in the survey scratchpad, or thin coverage for a dialect), surface them in the expression's `# Character` body or as a Decision (e.g. `### scan-coverage` with evidence "iOS dialect under-sampled — only 23 color sites recorded; web dialect is the dominant signal in this expression"). Do not paper over gaps with invented values. diff --git a/packages/ghost-expression/src/skill-bundle/references/scan.md b/packages/ghost-expression/src/skill-bundle/references/scan.md index c05bbce..2746971 100644 --- a/packages/ghost-expression/src/skill-bundle/references/scan.md +++ b/packages/ghost-expression/src/skill-bundle/references/scan.md @@ -40,6 +40,8 @@ Throughout this recipe, "scan dir" = where artifacts land; "target" = where sour ### 2. Check status +**Preferred (CLI present):** + ghost-expression scan-status [scan-dir] Reports per-stage state (`present` / `missing`) and the recommended next stage. If every stage is `present`, you're done. Otherwise, dispatch to the recipe for the recommended stage. @@ -48,6 +50,16 @@ Use `--format json` if you want to consume the result programmatically: ghost-expression scan-status . --format json +**Prose fallback (no CLI):** + +Check three paths and report what's missing in this order: + +1. `/map.md` — if missing, recommended_next = `topology`. Stop checking. +2. `/bucket.json` — if missing, recommended_next = `objective`. Stop checking. +3. `/expression.md` — if missing, recommended_next = `subjective`. If present, recommended_next = `null` (scan complete). + +Use `Read` (or `Bash: ls `) to verify each file exists. The first missing artifact is the next stage to run. + ### 3. Stage 1 — Topology (`map.md`) Run when `scan-status` reports `topology: missing`. diff --git a/packages/ghost-expression/test/context/__snapshots__/review-command.test.ts.snap b/packages/ghost-expression/test/context/__snapshots__/review-command.test.ts.snap index d689ca5..7f378e0 100644 --- a/packages/ghost-expression/test/context/__snapshots__/review-command.test.ts.snap +++ b/packages/ghost-expression/test/context/__snapshots__/review-command.test.ts.snap @@ -18,100 +18,86 @@ Your job: check code for **drift** from the values below — hardcoded hexes, of If \`$ARGUMENTS\` is provided, analyze that specific file. If \`$ARGUMENTS\` is empty, ask the user which file(s) to review, or offer to scan recently changed components. -## 1. Palette drift +## Critical (2) -> Treat hue as opt-in communication, not ambient decoration — the default theme is pure achromatic, so every bit of chromatic color that appears carries semantic meaning (danger, success, info, warning, chart). Brand personality is expressed through luminance contrast and shape, which makes the system maximally themeable without color conflicts. +### \`no-off-palette-hex\` — color-strategy -**Allowed colors** (21 total — prefer semantic tokens over raw hex): +Hex literals must come from the documented palette -- Dominant: \`#1a1a1a\` (primary), \`#ffffff\` (background), \`#000000\` (inverse) -- Neutrals (ramp): \`#ffffff\`, \`#f5f5f5\`, \`#f0f0f0\`, \`#e8e8e8\`, \`#e5e5e5\`, \`#cccccc\`, \`#999999\`, \`#666666\`, \`#333333\`, \`#232323\`, \`#1a1a1a\`, \`#000000\` -- Semantic hues: \`#f94b4b\` (danger), \`#91cb80\` (success), \`#5c98f9\` (info), \`#fbcd44\` (warning) +> Default theme is achromatic — chromatic colors are reserved for semantic states (danger, success, info, warning) and chart data. Any new hex literal is drift unless it lands in the palette. -### Critical +**Pattern:** \`#[0-9a-fA-F]{3,8}\` +**Match:** \`exact\` +**Enforce at:** \`className\`, \`css_var\`, \`inline_style\` +**Based on:** \`bkt:color:1a1a1a\`, \`bkt:color:ffffff\`, \`bkt:color:f5f5f5\` +**Support:** 94% -| Check | Allowed | What to look for | -|-------|---------|------------------| -| Off-palette hex in JSX/CSS | \`#1a1a1a\`, \`#ffffff\`, \`#000000\`, \`#f5f5f5\`, \`#f0f0f0\`, \`#e8e8e8\`, \`#e5e5e5\`, \`#cccccc\`, \`#999999\`, \`#666666\`, \`#333333\`, … (see list above) | Any \`#[0-9a-fA-F]{3,8}\` literal not in the allowed list | -| Tailwind arbitrary color | use semantic tokens | \`bg-[#...]\`, \`text-[#...]\`, \`border-[#...]\` with arbitrary hex | -| Named Tailwind color for semantic role | use semantic token | \`text-red-500\`, \`bg-green-600\`, etc. when a matching semantic token exists | +### \`no-foreign-fonts\` — font-sourcing -### Serious +Do not bundle additional typefaces -| Check | Allowed | What to look for | -|-------|---------|------------------| -| danger must use the semantic token | \`#f94b4b\` | Raw \`#f94b4b\` or near-equivalent hardcoded; prefer the \`danger\` token | -| success must use the semantic token | \`#91cb80\` | Raw \`#91cb80\` or near-equivalent hardcoded; prefer the \`success\` token | -| info must use the semantic token | \`#5c98f9\` | Raw \`#5c98f9\` or near-equivalent hardcoded; prefer the \`info\` token | -| warning must use the semantic token | \`#fbcd44\` | Raw \`#fbcd44\` or near-equivalent hardcoded; prefer the \`warning\` token | +> Library ships no bundled fonts — system-ui sans, Geist Mono, and a generic serif fallback. Adding @font-face or importing a webfont crosses the font-sourcing decision. -## 2. Shape language (radius) +**Pattern:** \`@import\\s+url\\([^)]*fonts\` +**Match:** \`exact\` +**Enforce at:** \`css_var\`, \`inline_style\` +**Support:** 100% -> Apply a pill-first radius philosophy that visually separates interactive from structural surfaces — buttons, inputs, and badges fully round to 999px, while cards, modals, and dropdowns use moderate radii (10–24px). Users intuit what is tappable versus what is container. +## Serious (3) -**Allowed radii**: \`10px\`, \`14px\`, \`16px\`, \`20px\`, \`24px\`, \`999px (pill)\` +### \`pill-interactives\` — shape-language -### Critical +Buttons, inputs, and badges must be fully rounded -| Check | Allowed | What to look for | -|-------|---------|------------------| -| Custom radius value | \`10px\`, \`14px\`, \`16px\`, \`20px\`, \`24px\`, \`999px (pill)\` | \`rounded-[Npx]\`, \`border-radius: Npx\`, or \`--radius: Npx\` outside the set | -| Interactive element not pill | \`rounded-full\` / \`rounded-pill\` | \`