④段整套同步(六节 + P/S/F/J + 模板)+ open-design 移到技能根
一、④段整套同步(用户选候选 A)
- 新增 `references/stage-proto-doc/assets/产品文档模板.html`(16 KB/247 行,从备份拷、转 LF)。
- ④段 SKILL.md 换成备份 15:28 那版,并按模板口径改齐:
· 「产品文档框架」从「四块+一附录」改成**六节** —— 页面结构/功能清单/使用场景/操作流程/
跨页与界面状态/待确认与偏离;与模板同构,⛔ 不从空白页重写结构。
· 编号从 R/S/F 改成 **P 页面/S 场景/F 功能/J 二级功能**,各自唯一、按展示顺序通排。
· 保留旧版有价值的要求:每项功能补齐 做什么/入口在哪/输入边界/异常与恢复;重点功能三处标红;
横向流程图每步写清页面/功能/操作/完成判据。
· 完成标准「七节」→「六节」,自查清单同步换掉「需求」那批条目,补模板与配图两条。
· 路径 `designs/<项目>/` → `docs/pm/<项目>/ui/`(2 处)。
· 补回本机那条「每份产出文档开头都要有一张图」的引用(指向主 skill,段内不复述)。
- 主 skill 同步:四段结构表④行、④段沿革、画图小节(④段画页面结构图 + 用户路径)。
二、open-design 移到技能根(用户选候选 A)
- `git mv product-planning/references/stage-delivery/assets/open-design → open-design/`(4178 文件)。
- `.gitignore` 白名单加 `!/open-design/` —— 否则移出去就不再纳入版本控制。
- 引用改 11 处:路径形态 `assets/open-design/...` → `<技能根>/open-design/...`;
「随段携带的自带件」改称「同根技能」;「路径怎么找」那条改成自带件(段内相对路径)与
同根技能(`<技能根>/<技能名>/`)两类分开写。
- open-design 自己的 ATTRIBUTION.md 两处同步(它现在与 oil-ui-pro 同层)。
- 副作用(有益):命名闸门读数由 2063 降到 225 —— 此前它把 open-design 里 919 个第三方 md
当成规则一起扫了;现在只扫本包 23 个 md。已确认④段的 H1 与 4a/4b/4c 子步仍在检查范围内。
三、验收
- check_naming.py --ws E:/ProgramData/AIProject/contentm_agent = 通过 225 / 失败 0 / 提示 27。
- 活文件里 `assets/open-design` 残留 0;工作区 CRLF 文件 0。
This commit is contained in:
1 parent
fa0a9026c8
commit
5afeb6b78a
4184 files changed
+326
-70
No files matched your search
@@ -0,0 +1,219 @@
|
||||
# `_schema/` — design-system contracts
|
||||
|
||||
This directory codifies the structural contracts for design systems.
|
||||
`tokens.schema.ts` re-exports the token contract that every tokenized brand under
|
||||
`design-systems/<brand>/` must satisfy. The canonical runtime copy lives in
|
||||
`packages/contracts/src/design-systems/token-schema.ts` so daemon importers and
|
||||
repo guards consume one schema. `manifest.schema.ts` is the
|
||||
project contract for Design System Project packages. Every bundled catalog
|
||||
entry now ships `manifest.json`, `DESIGN.md`, and `tokens.css`; the daemon keeps
|
||||
`DESIGN.md`-only discovery solely as a compatibility path for older or
|
||||
user-installed folders.
|
||||
|
||||
```
|
||||
_schema/
|
||||
├── manifest.schema.ts ← project manifest schema (TS, machine-enforced when present)
|
||||
├── tokens.schema.ts ← token schema re-export (TS, machine-enforced)
|
||||
├── defaults.css ← A2 fallback values (CSS, human reference)
|
||||
└── AGENTS.md ← this file
|
||||
```
|
||||
|
||||
The TypeScript schemas are the source of truth. `defaults.css` is a
|
||||
human-readable mirror of the A2 `fallback` fields in the token schema
|
||||
and exists so that reviewers can scan real CSS without parsing a TS
|
||||
array — drift between the two is enforced by the `design-system: A2
|
||||
defaults parity` guard. Manifest shape is enforced by
|
||||
`scripts/check-design-system-manifests.ts` for any
|
||||
`design-systems/<brand>/manifest.json` that exists.
|
||||
|
||||
## Project manifest contract
|
||||
|
||||
Design System Project folders use fixed v1 file names:
|
||||
|
||||
- `manifest.json` — machine-readable project entry.
|
||||
- `DESIGN.md` — canonical design prose.
|
||||
- `tokens.css` — canonical compiled tokens.
|
||||
- `design-tokens.json` — optional Design Tokens JSON derived from
|
||||
`tokens.css` + `source/token-contract.report.json`.
|
||||
- `tailwind-v4.css` — optional Tailwind v4 `@theme` CSS derived from
|
||||
`tokens.css`; it must not redefine source values independently.
|
||||
- `components.html` — optional standalone component fixture.
|
||||
- `assets/` — optional brand assets.
|
||||
- `preview/` — optional static preview pages.
|
||||
- `USAGE.md` — optional agent-facing package guide.
|
||||
- `components.manifest.json` — optional rebuildable cache derived from
|
||||
`components.html` and `tokens.css`.
|
||||
- `fonts/` — optional webfont files.
|
||||
- `source/` — optional importer evidence (`scanned-files.json`,
|
||||
`evidence.md`, `tokens.source.json`, `token-contract.report.json`,
|
||||
and `snippets/INDEX.json`).
|
||||
|
||||
The manifest guard validates every bundled package through its `manifest.json`.
|
||||
When rich fields are declared, paths must be safe and present, JSON indexes
|
||||
must parse, and committed `components.manifest.json` files must match a fresh
|
||||
derivation from `components.html` plus `tokens.css`. These fields are also
|
||||
runtime inputs: catalog discovery consumes manifest metadata; prompt
|
||||
composition consumes usage, tokens, component information, import mode, craft
|
||||
bindings, and the manifest-derived pull index; package/static-file routes expose
|
||||
only declared preview and source files. Legacy `DESIGN.md`-only folders remain
|
||||
outside this manifest guard because they have no manifest to validate.
|
||||
|
||||
## Four layers, two questions
|
||||
|
||||
Every shared token answers two questions:
|
||||
|
||||
1. **Who decides the value?** — the brand author (Layer A) or the
|
||||
schema author (Layer B-slot, when the brand has no opinion).
|
||||
2. **What happens if the brand omits it?** — required, fallback, or
|
||||
alias.
|
||||
|
||||
The four layers fall out of those answers:
|
||||
|
||||
| Layer | Who decides | If omitted | Examples |
|
||||
| --- | --- | --- | --- |
|
||||
| **A1-identity** | brand | guard fails | `--bg`, `--fg`, `--accent`, `--font-display` |
|
||||
| **A1-structure** | brand | guard fails | type scale, `--container-max`, `--section-y-*` |
|
||||
| **A2** | brand (with fallback) | guard fails today; derive script fills tomorrow | `--motion-fast`, `--success`, `--space-4`, `--font-mono` |
|
||||
| **B-slot** | brand or schema-suggested alias | guard fails — brand must declare, either as `var(--sibling)` (collapsed) or independent value (richer) | `--fg-2 → var(--fg)`, `--surface-warm → var(--surface)` |
|
||||
|
||||
Brand-specific tokens that fall outside the shared schema are tracked
|
||||
as **C-extensions** in `BRAND_EXTENSIONS` (per-brand allowlist) or
|
||||
`BRAND_EXTENSION_PREFIXES` (global prefix allowlist for whole families
|
||||
like `--tag-bg-*`).
|
||||
|
||||
## Why A2 fails the guard today (and might not later)
|
||||
|
||||
A2 conceptually means "optional with fallback" — but artifacts are
|
||||
generated by agents pasting one brand's `:root` block into a single
|
||||
`<style>`. There is no global stylesheet that loads alongside the
|
||||
brand, so a missing `--motion-fast` resolves to nothing inside the
|
||||
artifact and any `transition: var(--motion-fast)` rule silently breaks.
|
||||
|
||||
Until a future derive script lands and inlines `defaults.css` values
|
||||
into every brand's `tokens.css`, the only safe contract is "every
|
||||
brand must declare every A2 token". The `design-system: A2 required
|
||||
tokens` guard enforces that strictly.
|
||||
|
||||
After the derive script ships, brand authors only need to write the
|
||||
A1 tokens (and any A2 they want to override); the script populates A2
|
||||
slots from `defaults.css`. The guard contract does not change — every
|
||||
final `tokens.css` still contains every A2 token — but the work
|
||||
shifts from human author to script.
|
||||
|
||||
## Why B-slot is required (and what the alias is for)
|
||||
|
||||
Same artifact-paste constraint applies to B-slot tokens. Shared
|
||||
components reference richer tiers via `var(--fg-2)`, `var(--meta)`,
|
||||
`var(--surface-warm)`, `var(--border-soft)` — if a brand omits the
|
||||
slot, those references resolve to nothing and the artifact silently
|
||||
breaks.
|
||||
|
||||
The `aliasTo` field on each B-slot entry is the **schema-suggested
|
||||
default**, not a runtime fallback. A brand with no opinion on the
|
||||
richer tier copies the alias verbatim into its `:root`:
|
||||
|
||||
```
|
||||
--fg-2: var(--fg); /* default brand: 2-level fg */
|
||||
--surface-warm: var(--surface); /* default brand: 2-level surface */
|
||||
```
|
||||
|
||||
A brand that does have the richer tier binds an independent value:
|
||||
|
||||
```
|
||||
--fg-2: #3d3d3a; /* kami brand: dark warm */
|
||||
--surface-warm: #e8e6dc; /* kami brand: warm sand */
|
||||
```
|
||||
|
||||
Either form satisfies the `design-system: B-slot required tokens`
|
||||
guard. The pre-derive-script contract is identical to A2: every
|
||||
brand's `:root` declares every shared slot.
|
||||
|
||||
## C → B-slot → A2 promotion path
|
||||
|
||||
Brand-specific tokens start in `BRAND_EXTENSIONS[brand]`. They earn
|
||||
promotion when a second brand needs the same name:
|
||||
|
||||
```
|
||||
C-extension B-slot A2
|
||||
(one brand declares it) (multiple brands declare, (every brand declares
|
||||
some alias to a sibling) with a sensible default)
|
||||
|
||||
kami: --leading-display → schema: --leading-display → schema: --leading-display
|
||||
aliasTo: var(--leading-tight) fallback: 1.1
|
||||
```
|
||||
|
||||
Concrete promotion rules:
|
||||
|
||||
1. **C → B-slot** when **≥2 brands** declare a token of the same name
|
||||
*and* there is a meaningful sibling to alias to for brands that
|
||||
lack the richer tier. Move the entry from `BRAND_EXTENSIONS` to
|
||||
`TOKEN_SCHEMA` with `layer: "B-slot"` and `aliasTo: "var(--sibling)"`.
|
||||
2. **C → A2** when **≥2 brands** declare a token of the same name
|
||||
*and* a defensible cross-brand fallback exists (no aliasing
|
||||
needed). Move to `TOKEN_SCHEMA` with `layer: "A2"` and a `fallback`,
|
||||
then mirror the value in `defaults.css`.
|
||||
3. **B-slot → A2** when a B-slot starts being independently bound by
|
||||
≥2 brands (instead of aliasing). Replace `aliasTo` with `fallback`
|
||||
and add a defaults.css declaration.
|
||||
4. **A2 → A1** is rare. It happens when the previously-defaultable
|
||||
value turns out to be brand-determining — e.g. if a future brand
|
||||
redefines `--motion-base` from 200ms to 50ms because its identity
|
||||
is "instant", and that change ripples meaningfully through the
|
||||
brand voice. Drop the `fallback` and reclassify.
|
||||
|
||||
Demotion (A → B → C) is not currently supported. A token that is
|
||||
genuinely no longer needed should be marked `@deprecated` in the
|
||||
schema for one release and then deleted from every brand's
|
||||
`tokens.css` in the same PR.
|
||||
|
||||
## When **not** to add a token
|
||||
|
||||
Schema growth has a cost — every new entry forces every bundled brand to
|
||||
declare or alias the new name when the derive script next runs.
|
||||
Resist adding tokens that are:
|
||||
|
||||
- **Component-internal**: a `.btn-primary` background offset that no
|
||||
other component will ever read. Inline the value in the component
|
||||
rule.
|
||||
- **One-off**: a single layout's hero crop ratio. Not a token.
|
||||
- **Speculative**: "we might want a `--motion-slow` someday." Add it
|
||||
the first time a real interaction needs it, not before.
|
||||
- **Already expressible**: a `--accent-tint-50` that resolves to
|
||||
`color-mix(in oklab, var(--accent), transparent 50%)`. Inline the
|
||||
`color-mix(...)` call until ≥2 components need the same tint with
|
||||
the same alpha, then promote to a token.
|
||||
|
||||
## Editing this directory
|
||||
|
||||
When you change the token schema:
|
||||
|
||||
- Run `pnpm guard` and confirm the bundled catalog still passes every
|
||||
design-system sub-check.
|
||||
- If you added an A2 entry: also update `defaults.css` with the
|
||||
matching declaration, byte-equivalent to the `fallback` field.
|
||||
- If you renamed a token: bump every brand's `tokens.css` and the
|
||||
matching `components.html` `:root` paste in the same commit.
|
||||
Otherwise the drift guard will fail.
|
||||
- If you removed a token from `TOKEN_SCHEMA` and the same name now
|
||||
appears in only one brand: add it to that brand's
|
||||
`BRAND_EXTENSIONS` entry so the unknown-token guard does not fail.
|
||||
|
||||
## Open questions for the future derive script
|
||||
|
||||
The checked-in schema and guards enforce the final token contract today, but
|
||||
the following authoring-automation questions are intentionally not answered:
|
||||
|
||||
- **How does the derive script source A1 values from `DESIGN.md`?**
|
||||
Some sections (color palette, type scale) parse cleanly; others
|
||||
(visual atmosphere, do's and don'ts) do not. A frontmatter or
|
||||
fenced-block convention will likely emerge.
|
||||
- **What happens when a brand's `DESIGN.md` contradicts itself?**
|
||||
e.g. accents listed as both cobalt and indigo. The derive script
|
||||
will need a deterministic resolution (last-wins, manual override
|
||||
flag, or hard fail).
|
||||
- **Are A2 fallback formulas stable when re-derived?** Bit-for-bit
|
||||
reproducibility of the script's output is required so that running
|
||||
the script twice on the same input does not churn the brand token files.
|
||||
|
||||
These remain future work for a change that introduces
|
||||
`scripts/derive-tokens-css.ts`; that script does not exist yet.
|
||||
@@ -0,0 +1,100 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* design-systems/_schema/defaults.css
|
||||
*
|
||||
* Fallback values for every Layer A2 token in the shared schema.
|
||||
*
|
||||
* What this file is FOR:
|
||||
* - A human-readable mirror of the `fallback` field on every A2
|
||||
* entry in `tokens.schema.ts`. Reviewers can scan real CSS and
|
||||
* spot weird defaults faster than they can read a TS array.
|
||||
* - The future input to the derive script (PR-B): when a brand's
|
||||
* DESIGN.md does not specify an A2 token, the script copies the
|
||||
* declaration from this file into the brand's tokens.css.
|
||||
*
|
||||
* What this file is NOT FOR:
|
||||
* - Runtime cascade. Artifacts are generated by agents pasting one
|
||||
* brand's :root block into a single <style>. There is no global
|
||||
* stylesheet that loads alongside the brand. A brand's tokens.css
|
||||
* must therefore declare every A2 token directly — this file
|
||||
* never reaches the browser.
|
||||
*
|
||||
* Drift contract:
|
||||
* The `design-system: A2 defaults parity` guard check asserts that
|
||||
* each declaration here matches the `fallback` field on the
|
||||
* corresponding entry in `tokens.schema.ts`. Update both together.
|
||||
*
|
||||
* Tokens absent from this file:
|
||||
* - A1-identity tokens (--bg, --fg, --accent, font stacks) have no
|
||||
* defensible cross-brand default; brands must author them.
|
||||
* - A1-structure tokens (type scale, container, section-y) are
|
||||
* structural decisions that vary per brand by design.
|
||||
* - B-slot tokens (--fg-2, --meta, --surface-warm, --border-soft)
|
||||
* resolve via `var()` aliasing inside each brand's tokens.css,
|
||||
* not via this file.
|
||||
* - C-extension tokens are brand-specific and have no shared
|
||||
* default by definition.
|
||||
* ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* Accent states (over --accent bg). The black-mix formulas work for
|
||||
mid-luminance accents; brands with very dark or very light accents
|
||||
should override the value with a hand-picked one (e.g. kami binds
|
||||
--accent-hover to var(--accent) because ink-blue cannot darken
|
||||
further visibly). */
|
||||
--accent-on: #ffffff;
|
||||
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
|
||||
--accent-active: color-mix(in oklab, var(--accent), black 14%);
|
||||
|
||||
/* Semantic state. Reserve under 5% of any surface area; not all
|
||||
brands need these — print-first kami inherits defaults rather than
|
||||
designing custom warm equivalents. */
|
||||
--success: #16a34a;
|
||||
--warn: #eab308;
|
||||
--danger: #dc2626;
|
||||
|
||||
/* Monospace. Every brand uses kbd / tabular-nums / code somewhere;
|
||||
CJK brands override with a stack that includes their preferred
|
||||
CJK monospace face (kami adds TsangerJinKai02 / Source Han Serif). */
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Base spacing scale on a 4px grid. Identical across default and
|
||||
kami today. Brands with a print-rooted rhythm (different physical
|
||||
paper sizes) may rebind these wholesale; keep the names stable. */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-12: 48px;
|
||||
|
||||
/* Radius scale. Pill is functionally fixed at 9999px; the small /
|
||||
medium / large tiers are defaults that brands routinely override
|
||||
to express softness or sharpness as part of brand mood. */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* Elevation. Three sanctioned levels (no fourth — that is
|
||||
neumorphism territory). Brands forbidding blur shadows (kami,
|
||||
paper, editorial) override --elev-raised with a whisper or
|
||||
ring-only treatment. */
|
||||
--elev-flat: none;
|
||||
--elev-ring: 0 0 0 1px var(--border);
|
||||
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
|
||||
|
||||
/* Focus. Implemented as a box-shadow so it layers outside the
|
||||
element without affecting layout. Brands forbidding cool-blue
|
||||
glows must override; rebind via accent or a hand-picked ring. */
|
||||
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
|
||||
|
||||
/* Motion. Two durations + one easing curve, per the anti-ai-slop
|
||||
"short, purposeful transitions (150–250ms) with stable easing"
|
||||
contract. Add a third duration only when a real interaction
|
||||
needs it; do not invent --motion-slow speculatively. */
|
||||
--motion-fast: 150ms;
|
||||
--motion-base: 200ms;
|
||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* design-systems/_schema/manifest.schema.ts
|
||||
*
|
||||
* Canonical contract for an OpenDesign Design System Project.
|
||||
*
|
||||
* `DESIGN.md` remains the prose source that agents read. The project
|
||||
* manifest is the stable discovery layer around it: picker / daemon /
|
||||
* importer code can find the canonical design prose, compiled tokens,
|
||||
* optional component fixtures, and optional preview/assets directories
|
||||
* without guessing from folder contents.
|
||||
*
|
||||
* PR0 for the import-project structure also defines optional index fields
|
||||
* for richer imported systems (`USAGE.md`, preview pages, source evidence,
|
||||
* and a rebuildable component manifest cache). These fields are structural
|
||||
* only in PR0: guards validate their paths and JSON shape, but runtime
|
||||
* behavior remains unchanged until later PRs consume them.
|
||||
*
|
||||
* PR1 deliberately defines the contract without changing runtime
|
||||
* discovery. Existing DESIGN.md-only systems stay valid; this schema is
|
||||
* enforced only for folders that choose to ship `manifest.json`.
|
||||
* ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION = "od-design-system-project/v1" as const;
|
||||
|
||||
export type DesignSystemProjectSource =
|
||||
| {
|
||||
readonly type: "bundled";
|
||||
/** Human-readable origin, e.g. upstream repo/package, when known. */
|
||||
readonly origin?: string;
|
||||
}
|
||||
| {
|
||||
readonly type: "local";
|
||||
/** Absolute path selected by the user at import time. */
|
||||
readonly path: string;
|
||||
readonly importedAt?: string;
|
||||
}
|
||||
| {
|
||||
readonly type: "github";
|
||||
readonly url: string;
|
||||
readonly branch?: string;
|
||||
readonly commit?: string;
|
||||
readonly importedAt?: string;
|
||||
}
|
||||
| {
|
||||
readonly type: "shadcn";
|
||||
/** Original user-supplied reference (CLI shorthand or item URL). */
|
||||
readonly reference: string;
|
||||
/** Resolved registry-item JSON URL that was fetched. */
|
||||
readonly registryUrl?: string;
|
||||
/** Registry item name within the source registry. */
|
||||
readonly item?: string;
|
||||
/** Registry homepage, when advertised by registry.json. */
|
||||
readonly homepage?: string;
|
||||
readonly importedAt?: string;
|
||||
};
|
||||
|
||||
export type DesignSystemProjectFiles = {
|
||||
/**
|
||||
* Canonical design prose for agent prompts. V1 keeps this fixed so
|
||||
* DESIGN.md-only fallback and project manifests share the same source.
|
||||
*/
|
||||
readonly design: "DESIGN.md";
|
||||
/**
|
||||
* Canonical compiled token stylesheet. New project manifests require
|
||||
* it; legacy folders without a manifest may still be DESIGN.md-only.
|
||||
*/
|
||||
readonly tokens: "tokens.css";
|
||||
/** Optional Design Tokens JSON derived from the final TOKEN_SCHEMA contract. */
|
||||
readonly designTokens?: "design-tokens.json";
|
||||
/** Optional Tailwind v4 @theme CSS derived from the final TOKEN_SCHEMA contract. */
|
||||
readonly tailwind?: "tailwind-v4.css";
|
||||
/**
|
||||
* Optional standalone component fixture. First-class in the contract,
|
||||
* but optional for MVP imports and prose-only brands.
|
||||
*/
|
||||
readonly components?: "components.html";
|
||||
};
|
||||
|
||||
export type DesignSystemProjectImportMode = "normalized" | "hybrid" | "verbatim";
|
||||
|
||||
export type DesignSystemProjectCraft = {
|
||||
readonly applies: readonly string[];
|
||||
readonly suggested: readonly string[];
|
||||
readonly exemptions: readonly string[];
|
||||
};
|
||||
|
||||
export type DesignSystemProjectFont = {
|
||||
readonly family: string;
|
||||
readonly file: string;
|
||||
readonly weight?: number | string;
|
||||
readonly style?: string;
|
||||
};
|
||||
|
||||
export type DesignSystemProjectPreviewPage = {
|
||||
readonly path: string;
|
||||
readonly role?: string;
|
||||
readonly title?: string;
|
||||
};
|
||||
|
||||
export type DesignSystemProjectPreview = {
|
||||
readonly dir: string;
|
||||
readonly pages: readonly DesignSystemProjectPreviewPage[];
|
||||
};
|
||||
|
||||
export type DesignSystemProjectSourceFiles = {
|
||||
readonly scanned?: string;
|
||||
readonly evidence?: string;
|
||||
readonly tokens?: string;
|
||||
readonly report?: string;
|
||||
readonly snippets?: string;
|
||||
};
|
||||
|
||||
export type DesignSystemProjectManifest = {
|
||||
readonly schemaVersion: typeof DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION;
|
||||
/** Folder slug and stable picker id. Must match /^[a-z0-9-]+$/. */
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly category: string;
|
||||
readonly description?: string;
|
||||
readonly source: DesignSystemProjectSource;
|
||||
readonly files: DesignSystemProjectFiles;
|
||||
/** Optional static assets root. V1 fixes the directory name. */
|
||||
readonly assetsDir?: "assets";
|
||||
/** Optional legacy preview root. V1 fixes the directory name. */
|
||||
readonly previewDir?: "preview";
|
||||
/** Optional agent-facing router for richer imported packages. */
|
||||
readonly usage?: string;
|
||||
/** Optional rebuildable cache derived from components.html + tokens.css. */
|
||||
readonly componentsManifest?: string;
|
||||
/** Importer mode metadata. Defaults to hybrid for imported packages. */
|
||||
readonly importMode?: DesignSystemProjectImportMode;
|
||||
/** Optional craft metadata consumed by prompt assembly and guard checks. */
|
||||
readonly craft?: DesignSystemProjectCraft;
|
||||
/** Optional webfont files copied into the package. */
|
||||
readonly fonts?: readonly DesignSystemProjectFont[];
|
||||
/** Optional indexed preview pages for pull-channel and human review. */
|
||||
readonly preview?: DesignSystemProjectPreview;
|
||||
/** Optional imported-source evidence indexes. */
|
||||
readonly sourceFiles?: DesignSystemProjectSourceFiles;
|
||||
};
|
||||
|
||||
export type DesignSystemManifestValidationResult =
|
||||
| { readonly ok: true; readonly manifest: DesignSystemProjectManifest }
|
||||
| { readonly ok: false; readonly errors: readonly string[] };
|
||||
|
||||
const ALLOWED_TOP_LEVEL_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"name",
|
||||
"category",
|
||||
"description",
|
||||
"source",
|
||||
"files",
|
||||
"assetsDir",
|
||||
"previewDir",
|
||||
"usage",
|
||||
"componentsManifest",
|
||||
"importMode",
|
||||
"craft",
|
||||
"fonts",
|
||||
"preview",
|
||||
"sourceFiles",
|
||||
]);
|
||||
|
||||
const ALLOWED_SOURCE_KEYS: Record<DesignSystemProjectSource["type"], ReadonlySet<string>> = {
|
||||
bundled: new Set(["type", "origin"]),
|
||||
local: new Set(["type", "path", "importedAt"]),
|
||||
github: new Set(["type", "url", "branch", "commit", "importedAt"]),
|
||||
shadcn: new Set(["type", "reference", "registryUrl", "item", "homepage", "importedAt"]),
|
||||
};
|
||||
|
||||
const ALLOWED_FILES_KEYS = new Set(["design", "tokens", "designTokens", "tailwind", "components"]);
|
||||
const ALLOWED_CRAFT_KEYS = new Set(["applies", "suggested", "exemptions"]);
|
||||
const ALLOWED_FONT_KEYS = new Set(["family", "file", "weight", "style"]);
|
||||
const ALLOWED_PREVIEW_KEYS = new Set(["dir", "pages"]);
|
||||
const ALLOWED_PREVIEW_PAGE_KEYS = new Set(["path", "role", "title"]);
|
||||
const ALLOWED_SOURCE_FILES_KEYS = new Set(["scanned", "evidence", "tokens", "report", "snippets"]);
|
||||
|
||||
export function parseDesignSystemProjectManifest(
|
||||
raw: string,
|
||||
): DesignSystemManifestValidationResult {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [`manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`],
|
||||
};
|
||||
}
|
||||
|
||||
return validateDesignSystemProjectManifest(value);
|
||||
}
|
||||
|
||||
export function validateDesignSystemProjectManifest(
|
||||
value: unknown,
|
||||
): DesignSystemManifestValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return { ok: false, errors: ["manifest must be a JSON object"] };
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, "$", value, ALLOWED_TOP_LEVEL_KEYS);
|
||||
|
||||
expectLiteral(errors, "$.schemaVersion", value.schemaVersion, DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION);
|
||||
expectSlug(errors, "$.id", value.id);
|
||||
expectNonEmptyString(errors, "$.name", value.name);
|
||||
expectNonEmptyString(errors, "$.category", value.category);
|
||||
if (value.description !== undefined) expectNonEmptyString(errors, "$.description", value.description);
|
||||
|
||||
validateSource(errors, value.source);
|
||||
validateFiles(errors, value.files);
|
||||
|
||||
if (value.assetsDir !== undefined) expectLiteral(errors, "$.assetsDir", value.assetsDir, "assets");
|
||||
if (value.previewDir !== undefined) expectLiteral(errors, "$.previewDir", value.previewDir, "preview");
|
||||
if (value.usage !== undefined) expectSafeRelativePath(errors, "$.usage", value.usage);
|
||||
if (value.componentsManifest !== undefined) {
|
||||
expectSafeRelativePath(errors, "$.componentsManifest", value.componentsManifest);
|
||||
}
|
||||
if (value.importMode !== undefined) validateImportMode(errors, value.importMode);
|
||||
if (value.craft !== undefined) validateCraft(errors, value.craft);
|
||||
if (value.fonts !== undefined) validateFonts(errors, value.fonts);
|
||||
if (value.preview !== undefined) validatePreview(errors, value.preview);
|
||||
if (value.sourceFiles !== undefined) validateSourceFiles(errors, value.sourceFiles);
|
||||
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
return { ok: true, manifest: value as DesignSystemProjectManifest };
|
||||
}
|
||||
|
||||
function validateSource(errors: string[], value: unknown): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push("$.source must be an object");
|
||||
return;
|
||||
}
|
||||
|
||||
const type = value.type;
|
||||
if (type !== "bundled" && type !== "local" && type !== "github" && type !== "shadcn") {
|
||||
errors.push("$.source.type must be one of bundled, local, github, shadcn");
|
||||
return;
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, "$.source", value, ALLOWED_SOURCE_KEYS[type]);
|
||||
|
||||
if (type === "bundled") {
|
||||
if (value.origin !== undefined) expectNonEmptyString(errors, "$.source.origin", value.origin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "local") {
|
||||
expectNonEmptyString(errors, "$.source.path", value.path);
|
||||
if (value.importedAt !== undefined) expectIsoDateTime(errors, "$.source.importedAt", value.importedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "github") {
|
||||
expectNonEmptyString(errors, "$.source.url", value.url);
|
||||
if (value.branch !== undefined) expectNonEmptyString(errors, "$.source.branch", value.branch);
|
||||
if (value.commit !== undefined) expectNonEmptyString(errors, "$.source.commit", value.commit);
|
||||
if (value.importedAt !== undefined) expectIsoDateTime(errors, "$.source.importedAt", value.importedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
// shadcn registry import.
|
||||
expectNonEmptyString(errors, "$.source.reference", value.reference);
|
||||
if (value.registryUrl !== undefined) expectNonEmptyString(errors, "$.source.registryUrl", value.registryUrl);
|
||||
if (value.item !== undefined) expectNonEmptyString(errors, "$.source.item", value.item);
|
||||
if (value.homepage !== undefined) expectNonEmptyString(errors, "$.source.homepage", value.homepage);
|
||||
if (value.importedAt !== undefined) expectIsoDateTime(errors, "$.source.importedAt", value.importedAt);
|
||||
}
|
||||
|
||||
function validateFiles(errors: string[], value: unknown): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push("$.files must be an object");
|
||||
return;
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, "$.files", value, ALLOWED_FILES_KEYS);
|
||||
expectLiteral(errors, "$.files.design", value.design, "DESIGN.md");
|
||||
expectLiteral(errors, "$.files.tokens", value.tokens, "tokens.css");
|
||||
if (value.designTokens !== undefined) {
|
||||
expectLiteral(errors, "$.files.designTokens", value.designTokens, "design-tokens.json");
|
||||
}
|
||||
if (value.tailwind !== undefined) {
|
||||
expectLiteral(errors, "$.files.tailwind", value.tailwind, "tailwind-v4.css");
|
||||
}
|
||||
if (value.components !== undefined) {
|
||||
expectLiteral(errors, "$.files.components", value.components, "components.html");
|
||||
}
|
||||
}
|
||||
|
||||
function validateImportMode(errors: string[], value: unknown): void {
|
||||
if (value !== "normalized" && value !== "hybrid" && value !== "verbatim") {
|
||||
errors.push("$.importMode must be one of normalized, hybrid, verbatim");
|
||||
}
|
||||
}
|
||||
|
||||
function validateCraft(errors: string[], value: unknown): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push("$.craft must be an object");
|
||||
return;
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, "$.craft", value, ALLOWED_CRAFT_KEYS);
|
||||
expectSlugArray(errors, "$.craft.applies", value.applies);
|
||||
expectSlugArray(errors, "$.craft.suggested", value.suggested);
|
||||
expectSlugArray(errors, "$.craft.exemptions", value.exemptions);
|
||||
}
|
||||
|
||||
function validateFonts(errors: string[], value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push("$.fonts must be an array");
|
||||
return;
|
||||
}
|
||||
|
||||
value.forEach((font, index) => {
|
||||
const pathLabel = `$.fonts[${index}]`;
|
||||
if (!isRecord(font)) {
|
||||
errors.push(`${pathLabel} must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, pathLabel, font, ALLOWED_FONT_KEYS);
|
||||
expectNonEmptyString(errors, `${pathLabel}.family`, font.family);
|
||||
expectSafeRelativePath(errors, `${pathLabel}.file`, font.file);
|
||||
if (font.weight !== undefined && typeof font.weight !== "number" && typeof font.weight !== "string") {
|
||||
errors.push(`${pathLabel}.weight must be a number or string`);
|
||||
}
|
||||
if (font.style !== undefined) expectNonEmptyString(errors, `${pathLabel}.style`, font.style);
|
||||
});
|
||||
}
|
||||
|
||||
function validatePreview(errors: string[], value: unknown): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push("$.preview must be an object");
|
||||
return;
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, "$.preview", value, ALLOWED_PREVIEW_KEYS);
|
||||
expectSafeRelativePath(errors, "$.preview.dir", value.dir);
|
||||
if (!Array.isArray(value.pages)) {
|
||||
errors.push("$.preview.pages must be an array");
|
||||
return;
|
||||
}
|
||||
|
||||
value.pages.forEach((page, index) => {
|
||||
const pathLabel = `$.preview.pages[${index}]`;
|
||||
if (!isRecord(page)) {
|
||||
errors.push(`${pathLabel} must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, pathLabel, page, ALLOWED_PREVIEW_PAGE_KEYS);
|
||||
expectSafeRelativePath(errors, `${pathLabel}.path`, page.path);
|
||||
if (page.role !== undefined) expectNonEmptyString(errors, `${pathLabel}.role`, page.role);
|
||||
if (page.title !== undefined) expectNonEmptyString(errors, `${pathLabel}.title`, page.title);
|
||||
});
|
||||
}
|
||||
|
||||
function validateSourceFiles(errors: string[], value: unknown): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push("$.sourceFiles must be an object");
|
||||
return;
|
||||
}
|
||||
|
||||
rejectUnknownKeys(errors, "$.sourceFiles", value, ALLOWED_SOURCE_FILES_KEYS);
|
||||
for (const key of ALLOWED_SOURCE_FILES_KEYS) {
|
||||
const sourcePath = value[key];
|
||||
if (sourcePath !== undefined) expectSafeRelativePath(errors, `$.sourceFiles.${key}`, sourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(
|
||||
errors: string[],
|
||||
pathLabel: string,
|
||||
value: Record<string, unknown>,
|
||||
allowed: ReadonlySet<string>,
|
||||
): void {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) errors.push(`${pathLabel}.${key} is not part of the v1 design-system project schema`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectLiteral(
|
||||
errors: string[],
|
||||
pathLabel: string,
|
||||
value: unknown,
|
||||
expected: string,
|
||||
): void {
|
||||
if (value !== expected) errors.push(`${pathLabel} must be ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
function expectNonEmptyString(errors: string[], pathLabel: string, value: unknown): void {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
errors.push(`${pathLabel} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectSlugArray(errors: string[], pathLabel: string, value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${pathLabel} must be an array of lowercase slugs`);
|
||||
return;
|
||||
}
|
||||
|
||||
value.forEach((entry, index) => {
|
||||
if (typeof entry !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry)) {
|
||||
errors.push(`${pathLabel}[${index}] must be a lowercase slug matching /^[a-z0-9]+(?:-[a-z0-9]+)*$/`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function expectSafeRelativePath(errors: string[], pathLabel: string, value: unknown): void {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
errors.push(`${pathLabel} must be a non-empty relative path`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\")) {
|
||||
errors.push(`${pathLabel} must be a safe relative path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = value.split("/");
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
errors.push(`${pathLabel} must be a safe relative path without empty, "." or ".." segments`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectSlug(errors: string[], pathLabel: string, value: unknown): void {
|
||||
if (typeof value !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
|
||||
errors.push(`${pathLabel} must be a lowercase slug matching /^[a-z0-9]+(?:-[a-z0-9]+)*$/`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectIsoDateTime(errors: string[], pathLabel: string, value: unknown): void {
|
||||
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
|
||||
errors.push(`${pathLabel} must be an ISO-like datetime string`);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../packages/contracts/src/design-systems/token-schema.ts";
|
||||
Reference in new issue
Block a user