From 0717f8e120d3f3f53959ede0acab4ee43165d544 Mon Sep 17 00:00:00 2001 From: garthdb Date: Mon, 6 Apr 2026 12:06:31 -0600 Subject: [PATCH 1/5] feat(tools): add token-name-builder prototype Interactive web tool for building Spectrum design token names. Includes: - Core logic: name-object types, serializer (kebab/camel/CSS var/CONST), and validator (advisory semantic + strict dimension validation) - Registry-driven form UI with Lit web components - Live preview, platform format comparison, JSON output, copy buttons - 25 passing tests for serializer and validator Co-Authored-By: Claude Sonnet 4.6 --- pnpm-lock.yaml | 22 + tools/token-name-builder/ava.config.js | 19 + tools/token-name-builder/index.html | 24 + tools/token-name-builder/moon.yml | 49 ++ tools/token-name-builder/package.json | 35 + tools/token-name-builder/src/main.ts | 13 + tools/token-name-builder/src/name-object.ts | 96 +++ tools/token-name-builder/src/registry-data.ts | 117 ++++ tools/token-name-builder/src/serializer.ts | 89 +++ tools/token-name-builder/src/styles/main.css | 32 + .../src/token-name-builder.ts | 656 ++++++++++++++++++ tools/token-name-builder/src/validator.ts | 106 +++ .../test/serializer.test.js | 206 ++++++ .../token-name-builder/test/validator.test.js | 260 +++++++ tools/token-name-builder/tsconfig.json | 19 + tools/token-name-builder/vite.config.ts | 33 + 16 files changed, 1776 insertions(+) create mode 100644 tools/token-name-builder/ava.config.js create mode 100644 tools/token-name-builder/index.html create mode 100644 tools/token-name-builder/moon.yml create mode 100644 tools/token-name-builder/package.json create mode 100644 tools/token-name-builder/src/main.ts create mode 100644 tools/token-name-builder/src/name-object.ts create mode 100644 tools/token-name-builder/src/registry-data.ts create mode 100644 tools/token-name-builder/src/serializer.ts create mode 100644 tools/token-name-builder/src/styles/main.css create mode 100644 tools/token-name-builder/src/token-name-builder.ts create mode 100644 tools/token-name-builder/src/validator.ts create mode 100644 tools/token-name-builder/test/serializer.test.js create mode 100644 tools/token-name-builder/test/validator.test.js create mode 100644 tools/token-name-builder/tsconfig.json create mode 100644 tools/token-name-builder/vite.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2acbc4f6..19b970c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -672,6 +672,28 @@ importers: tools/token-manifest-builder: {} + tools/token-name-builder: + dependencies: + "@spectrum-css/tokens": + specifier: ^16.0.2 + version: 16.0.2 + lit: + specifier: ^3.1.0 + version: 3.3.0 + devDependencies: + "@ava/typescript": + specifier: ^6.0.0 + version: 6.0.0 + ava: + specifier: ^6.0.1 + version: 6.4.0(@ava/typescript@6.0.0)(rollup@4.44.1) + typescript: + specifier: ^5.3.3 + version: 5.8.3 + vite: + specifier: ^5.4.0 + version: 5.4.19(@types/node@22.15.33)(terser@5.44.1) + tools/transform-tokens-json: dependencies: jsonpath-plus: diff --git a/tools/token-name-builder/ava.config.js b/tools/token-name-builder/ava.config.js new file mode 100644 index 00000000..82b978b1 --- /dev/null +++ b/tools/token-name-builder/ava.config.js @@ -0,0 +1,19 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +export default { + files: ["test/**/*.test.js"], + verbose: true, + environmentVariables: { + NODE_ENV: "test", + }, +}; diff --git a/tools/token-name-builder/index.html b/tools/token-name-builder/index.html new file mode 100644 index 00000000..6fa75326 --- /dev/null +++ b/tools/token-name-builder/index.html @@ -0,0 +1,24 @@ + + + + + + + Token Name Builder — Spectrum Design Data + + + + + + + diff --git a/tools/token-name-builder/moon.yml b/tools/token-name-builder/moon.yml new file mode 100644 index 00000000..f869c64e --- /dev/null +++ b/tools/token-name-builder/moon.yml @@ -0,0 +1,49 @@ +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +$schema: "https://moonrepo.dev/schemas/project.json" +layer: tool +fileGroups: + sources: + - "src/**/*.{ts,js,html,css}" + tests: + - "test/**/*.test.js" + configs: + - "*.config.{js,ts,mjs,cjs}" +tasks: + build: + command: + - pnpm + - run + - build + inputs: + - "@globs(sources)" + - "@globs(configs)" + - "package.json" + - "index.html" + outputs: + - "dist/" + platform: node + dev: + command: + - pnpm + - run + - dev + local: true + platform: node + test: + command: + - pnpm + - test + inputs: + - "@globs(sources)" + - "@globs(tests)" + - "ava.config.js" + platform: node diff --git a/tools/token-name-builder/package.json b/tools/token-name-builder/package.json new file mode 100644 index 00000000..3853db73 --- /dev/null +++ b/tools/token-name-builder/package.json @@ -0,0 +1,35 @@ +{ + "name": "@adobe/token-name-builder", + "version": "0.1.0", + "description": "Interactive web tool for building Spectrum design token names", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "ava" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/adobe/spectrum-design-data.git", + "directory": "tools/token-name-builder" + }, + "author": "Adobe", + "license": "Apache-2.0", + "engines": { + "node": ">=20.12.0", + "pnpm": ">=10.17.1" + }, + "packageManager": "pnpm@10.17.1", + "dependencies": { + "lit": "^3.1.0", + "@spectrum-css/tokens": "^16.0.2" + }, + "devDependencies": { + "@ava/typescript": "^6.0.0", + "ava": "^6.0.1", + "typescript": "^5.3.3", + "vite": "^5.4.0" + } +} diff --git a/tools/token-name-builder/src/main.ts b/tools/token-name-builder/src/main.ts new file mode 100644 index 00000000..eda3fb40 --- /dev/null +++ b/tools/token-name-builder/src/main.ts @@ -0,0 +1,13 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import "./token-name-builder.js"; diff --git a/tools/token-name-builder/src/name-object.ts b/tools/token-name-builder/src/name-object.ts new file mode 100644 index 00000000..b4a21d3f --- /dev/null +++ b/tools/token-name-builder/src/name-object.ts @@ -0,0 +1,96 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Structured token identity. Semantic fields describe identity and structure; + * dimension fields drive cascade resolution. + * See: packages/design-data-spec/spec/taxonomy.md + */ +export interface NameObject { + // Semantic fields (advisory validation) + property: string; // REQUIRED + component?: string; + structure?: string; + substructure?: string; + anatomy?: string; + object?: string; + variant?: string; + state?: string; + orientation?: string; + position?: string; + size?: string; + density?: string; + shape?: string; + // Dimension fields (strict validation) + colorScheme?: string; + scale?: string; + contrast?: string; +} + +/** Semantic fields in default serialization order (from taxonomy.md). */ +export const SERIALIZATION_ORDER = [ + "variant", + "component", + "structure", + "substructure", + "anatomy", + "object", + "property", + "orientation", + "position", + "size", + "density", + "shape", + "state", +] as const; + +/** All semantic field keys. */ +export const SEMANTIC_FIELDS = [ + "property", + "component", + "structure", + "substructure", + "anatomy", + "object", + "variant", + "state", + "orientation", + "position", + "size", + "density", + "shape", +] as const; + +/** Dimension field keys. */ +export const DIMENSION_FIELDS = ["colorScheme", "scale", "contrast"] as const; + +export type SemanticField = (typeof SEMANTIC_FIELDS)[number]; +export type DimensionField = (typeof DIMENSION_FIELDS)[number]; +export type NameField = SemanticField | DimensionField; + +/** + * Build a clean name object, omitting undefined/empty fields. + * Only includes `property` (required) plus any non-empty optional fields. + */ +export function buildNameObject( + fields: Partial & { property: string }, +): NameObject { + const result: NameObject = { property: fields.property }; + for (const key of [...SEMANTIC_FIELDS, ...DIMENSION_FIELDS]) { + if (key === "property") continue; + const value = fields[key as keyof NameObject]; + if (value && value.trim().length > 0) { + (result as Record)[key] = value.trim(); + } + } + return result; +} diff --git a/tools/token-name-builder/src/registry-data.ts b/tools/token-name-builder/src/registry-data.ts new file mode 100644 index 00000000..ffecbe65 --- /dev/null +++ b/tools/token-name-builder/src/registry-data.ts @@ -0,0 +1,117 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import type { SemanticField } from "./name-object.js"; + +// Registry JSON imports (resolved via Vite aliases to ../../packages/design-system-registry/registry/) +import componentsJson from "@registry/components.json"; +import structuresJson from "@registry/structures.json"; +import substructuresJson from "@registry/substructures.json"; +import anatomyTermsJson from "@registry/anatomy-terms.json"; +import tokenObjectsJson from "@registry/token-objects.json"; +import variantsJson from "@registry/variants.json"; +import statesJson from "@registry/states.json"; +import orientationsJson from "@registry/orientations.json"; +import positionsJson from "@registry/positions.json"; +import sizesJson from "@registry/sizes.json"; +import densitiesJson from "@registry/densities.json"; +import shapesJson from "@registry/shapes.json"; + +/** A single value entry from a registry file. */ +export interface RegistryValue { + id: string; + label: string; + description?: string; + aliases?: string[]; + deprecated?: boolean; + default?: boolean; +} + +/** A registry file's top-level shape. */ +export interface Registry { + type: string; + description: string; + values: RegistryValue[]; +} + +/** All loaded registries keyed by their semantic field name. */ +export const registries: Partial> = { + component: componentsJson as Registry, + structure: structuresJson as Registry, + substructure: substructuresJson as Registry, + anatomy: anatomyTermsJson as Registry, + object: tokenObjectsJson as Registry, + variant: variantsJson as Registry, + state: statesJson as Registry, + orientation: orientationsJson as Registry, + position: positionsJson as Registry, + size: sizesJson as Registry, + density: densitiesJson as Registry, + shape: shapesJson as Registry, + // `property` has no registry — free-form input +}; + +/** Dimension modes (hardcoded for now; could be loaded from dimension declarations). */ +export const dimensionModes: Record< + string, + { modes: string[]; defaultMode: string } +> = { + colorScheme: { modes: ["light", "dark", "wireframe"], defaultMode: "light" }, + scale: { modes: ["desktop", "mobile"], defaultMode: "desktop" }, + contrast: { modes: ["regular", "high"], defaultMode: "regular" }, +}; + +/** Common property values extracted from existing tokens for combobox suggestions. */ +export const commonProperties = [ + "color", + "background-color", + "border-color", + "width", + "height", + "min-width", + "min-height", + "max-width", + "padding", + "padding-top", + "padding-bottom", + "margin", + "gap", + "opacity", + "border-width", + "border-radius", + "corner-radius", + "font-size", + "font-weight", + "line-height", + "icon-size", + "animation-duration", +]; + +/** Get all non-deprecated value IDs from a registry. */ +export function getActiveIds(registry: Registry): string[] { + return registry.values.filter((v) => !v.deprecated).map((v) => v.id); +} + +/** Find a value in a registry by ID or alias. */ +export function findValue( + registry: Registry, + searchTerm: string, +): RegistryValue | undefined { + return registry.values.find( + (v) => v.id === searchTerm || v.aliases?.includes(searchTerm), + ); +} + +/** Check if a value exists in a registry. */ +export function hasValue(registry: Registry, searchTerm: string): boolean { + return findValue(registry, searchTerm) !== undefined; +} diff --git a/tools/token-name-builder/src/serializer.ts b/tools/token-name-builder/src/serializer.ts new file mode 100644 index 00000000..97185486 --- /dev/null +++ b/tools/token-name-builder/src/serializer.ts @@ -0,0 +1,89 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { type NameObject, SERIALIZATION_ORDER } from "./name-object.js"; + +export type FormatStyle = "kebab" | "camel" | "cssVar" | "constant"; + +/** + * Collect the non-empty values from a name object in serialization order. + * Dimension fields are excluded — serialization is semantic fields only. + */ +function orderedParts(name: NameObject): string[] { + const parts: string[] = []; + for (const field of SERIALIZATION_ORDER) { + const value = name[field as keyof NameObject]; + if (value && value.trim().length > 0) { + parts.push(value.trim()); + } + } + return parts; +} + +/** Split a kebab-case part into individual words. */ +function words(parts: string[]): string[] { + return parts.flatMap((p) => p.split("-")); +} + +/** Capitalize first letter. */ +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Serialize a name object into a flat string using the given format. + * + * Formats: + * - kebab: accent-button-icon-background-color-hover + * - camel: accentButtonIconBackgroundColorHover + * - cssVar: --spectrum-accent-button-icon-background-color-hover + * - constant: ACCENT_BUTTON_ICON_BACKGROUND_COLOR_HOVER + */ +export function serialize( + name: NameObject, + format: FormatStyle = "kebab", +): string { + const parts = orderedParts(name); + if (parts.length === 0) return ""; + + switch (format) { + case "kebab": + return parts.join("-"); + + case "camel": { + const w = words(parts); + return w[0] + w.slice(1).map(capitalize).join(""); + } + + case "cssVar": + return `--spectrum-${parts.join("-")}`; + + case "constant": + return words(parts).join("_").toUpperCase(); + } +} + +/** All supported format styles for iteration. */ +export const FORMAT_STYLES: FormatStyle[] = [ + "kebab", + "camel", + "cssVar", + "constant", +]; + +/** Human-readable label for each format style. */ +export const FORMAT_LABELS: Record = { + kebab: "kebab-case", + camel: "camelCase", + cssVar: "CSS custom property", + constant: "CONSTANT_CASE", +}; diff --git a/tools/token-name-builder/src/styles/main.css b/tools/token-name-builder/src/styles/main.css new file mode 100644 index 00000000..0df18c42 --- /dev/null +++ b/tools/token-name-builder/src/styles/main.css @@ -0,0 +1,32 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +:root { + color-scheme: light dark; +} + +body { + margin: 0; + padding: 24px; + font-family: 'Adobe Clean', adobe-clean, 'Source Sans Pro', -apple-system, + BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--spectrum-background-layer-1-color, #f8f8f8); + color: var(--spectrum-body-color, #222); + line-height: 1.5; +} + +@media (prefers-color-scheme: dark) { + body { + background: var(--spectrum-background-layer-1-color, #1a1a1a); + color: var(--spectrum-body-color, #e0e0e0); + } +} diff --git a/tools/token-name-builder/src/token-name-builder.ts b/tools/token-name-builder/src/token-name-builder.ts new file mode 100644 index 00000000..4cce5f83 --- /dev/null +++ b/tools/token-name-builder/src/token-name-builder.ts @@ -0,0 +1,656 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { NameObject, SemanticField } from "./name-object.js"; +import { buildNameObject, DIMENSION_FIELDS } from "./name-object.js"; +import { + serialize, + FORMAT_STYLES, + FORMAT_LABELS, + type FormatStyle, +} from "./serializer.js"; +import { validate, isValid, type ValidationMessage } from "./validator.js"; +import { + registries, + dimensionModes, + commonProperties, + getActiveIds, + type Registry, +} from "./registry-data.js"; + +interface FieldConfig { + key: string; + label: string; + group: "identity" | "modifier" | "dimension"; + required?: boolean; + type: "combobox" | "select"; + options: string[]; + placeholder?: string; + description?: string; +} + +@customElement("token-name-builder") +export class TokenNameBuilder extends LitElement { + @state() private fields: Record = { property: "" }; + @state() private messages: ValidationMessage[] = []; + @state() private copyFeedback = ""; + + static override styles = css` + :host { + display: block; + max-width: 720px; + margin: 0 auto; + } + + h1 { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 24px; + } + + /* Live preview */ + .preview { + background: var(--spectrum-background-layer-2-color, #fff); + border: 1px solid var(--spectrum-gray-300, #d0d0d0); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 24px; + position: sticky; + top: 12px; + z-index: 10; + } + + @media (prefers-color-scheme: dark) { + .preview { + background: var(--spectrum-background-layer-2-color, #252525); + border-color: var(--spectrum-gray-600, #555); + } + } + + .preview-name { + font-family: "Source Code Pro", "SFMono-Regular", Consolas, monospace; + font-size: 1.125rem; + font-weight: 600; + word-break: break-all; + min-height: 1.5em; + color: var(--spectrum-accent-color-900, #0054b6); + } + + @media (prefers-color-scheme: dark) { + .preview-name { + color: var(--spectrum-accent-color-400, #5ab2ff); + } + } + + .preview-empty { + opacity: 0.4; + font-style: italic; + } + + .preview-actions { + display: flex; + gap: 8px; + margin-top: 8px; + } + + /* Sections */ + .section { + margin-bottom: 24px; + } + + .section-heading { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--spectrum-gray-600, #6e6e6e); + margin: 0 0 12px; + padding-bottom: 4px; + border-bottom: 1px solid var(--spectrum-gray-200, #e6e6e6); + } + + @media (prefers-color-scheme: dark) { + .section-heading { + color: var(--spectrum-gray-400, #999); + border-color: var(--spectrum-gray-700, #444); + } + } + + /* Field grid */ + .fields { + display: grid; + grid-template-columns: 140px 1fr; + gap: 8px 12px; + align-items: center; + } + + .field-label { + font-size: 0.8125rem; + font-weight: 500; + text-align: right; + color: var(--spectrum-body-color, #333); + } + + .field-label.required::after { + content: " *"; + color: var(--spectrum-negative-color-900, #c9252d); + } + + select, + input[type="text"] { + font-family: inherit; + font-size: 0.875rem; + padding: 6px 10px; + border: 1px solid var(--spectrum-gray-400, #b3b3b3); + border-radius: 4px; + background: var(--spectrum-background-layer-2-color, #fff); + color: var(--spectrum-body-color, #222); + width: 100%; + box-sizing: border-box; + } + + @media (prefers-color-scheme: dark) { + select, + input[type="text"] { + background: var(--spectrum-background-layer-2-color, #2a2a2a); + border-color: var(--spectrum-gray-500, #666); + color: var(--spectrum-body-color, #e0e0e0); + } + } + + select:focus, + input[type="text"]:focus { + outline: 2px solid var(--spectrum-accent-color-700, #0070d1); + outline-offset: -1px; + border-color: transparent; + } + + .field-disabled select { + opacity: 0.4; + pointer-events: none; + } + + /* Platform preview */ + .platform-preview { + background: var(--spectrum-background-layer-2-color, #fff); + border: 1px solid var(--spectrum-gray-200, #e6e6e6); + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 24px; + } + + @media (prefers-color-scheme: dark) { + .platform-preview { + background: var(--spectrum-background-layer-2-color, #252525); + border-color: var(--spectrum-gray-700, #444); + } + } + + .platform-row { + display: flex; + gap: 12px; + padding: 4px 0; + font-size: 0.8125rem; + align-items: baseline; + } + + .platform-label { + min-width: 120px; + color: var(--spectrum-gray-600, #6e6e6e); + flex-shrink: 0; + } + + @media (prefers-color-scheme: dark) { + .platform-label { + color: var(--spectrum-gray-400, #999); + } + } + + .platform-value { + font-family: "Source Code Pro", "SFMono-Regular", Consolas, monospace; + word-break: break-all; + } + + /* Validation */ + .validation { + margin-bottom: 24px; + } + + .validation-msg { + display: flex; + gap: 8px; + padding: 4px 0; + font-size: 0.8125rem; + align-items: baseline; + } + + .validation-msg.error { + color: var(--spectrum-negative-color-900, #c9252d); + } + + .validation-msg.warning { + color: var(--spectrum-notice-color-900, #996100); + } + + @media (prefers-color-scheme: dark) { + .validation-msg.error { + color: var(--spectrum-negative-color-400, #ff6b6b); + } + .validation-msg.warning { + color: var(--spectrum-notice-color-400, #f5c400); + } + } + + .validation-msg.valid { + color: var(--spectrum-positive-color-900, #107c10); + } + + @media (prefers-color-scheme: dark) { + .validation-msg.valid { + color: var(--spectrum-positive-color-400, #4fce4f); + } + } + + /* JSON output */ + .json-output { + background: var(--spectrum-background-layer-2-color, #fff); + border: 1px solid var(--spectrum-gray-200, #e6e6e6); + border-radius: 8px; + padding: 12px 16px; + } + + @media (prefers-color-scheme: dark) { + .json-output { + background: var(--spectrum-background-layer-2-color, #252525); + border-color: var(--spectrum-gray-700, #444); + } + } + + .json-output pre { + margin: 0; + font-family: "Source Code Pro", "SFMono-Regular", Consolas, monospace; + font-size: 0.8125rem; + white-space: pre-wrap; + } + + /* Buttons */ + button { + font-family: inherit; + font-size: 0.75rem; + padding: 4px 12px; + border: 1px solid var(--spectrum-gray-400, #b3b3b3); + border-radius: 4px; + background: var(--spectrum-background-layer-2-color, #fff); + color: var(--spectrum-body-color, #333); + cursor: pointer; + } + + @media (prefers-color-scheme: dark) { + button { + background: var(--spectrum-background-layer-2-color, #333); + border-color: var(--spectrum-gray-500, #666); + color: var(--spectrum-body-color, #e0e0e0); + } + } + + button:hover { + background: var(--spectrum-gray-200, #e6e6e6); + } + + @media (prefers-color-scheme: dark) { + button:hover { + background: var(--spectrum-gray-600, #555); + } + } + + .copy-feedback { + font-size: 0.75rem; + color: var(--spectrum-positive-color-900, #107c10); + align-self: center; + } + + @media (prefers-color-scheme: dark) { + .copy-feedback { + color: var(--spectrum-positive-color-400, #4fce4f); + } + } + `; + + private get nameObject(): NameObject { + return buildNameObject({ + property: this.fields.property || "", + ...this.fields, + } as NameObject & { property: string }); + } + + private get serialized(): string { + return serialize(this.nameObject); + } + + private getFieldConfigs(): FieldConfig[] { + return [ + // Identity + { + key: "property", + label: "Property", + group: "identity", + required: true, + type: "combobox", + options: commonProperties, + placeholder: "e.g. color, width, padding", + }, + { + key: "component", + label: "Component", + group: "identity", + type: "combobox", + options: this.registryIds("component"), + placeholder: "e.g. button, checkbox", + }, + { + key: "structure", + label: "Structure", + group: "identity", + type: "select", + options: this.registryIds("structure"), + }, + { + key: "substructure", + label: "Sub-structure", + group: "identity", + type: "select", + options: this.registryIds("substructure"), + }, + { + key: "anatomy", + label: "Anatomy", + group: "identity", + type: "combobox", + options: this.registryIds("anatomy"), + placeholder: "e.g. icon, label, handle", + }, + { + key: "object", + label: "Object", + group: "identity", + type: "select", + options: this.registryIds("object"), + }, + // Modifiers + { + key: "variant", + label: "Variant", + group: "modifier", + type: "select", + options: this.registryIds("variant"), + }, + { + key: "state", + label: "State", + group: "modifier", + type: "select", + options: this.registryIds("state"), + }, + { + key: "size", + label: "Size", + group: "modifier", + type: "select", + options: this.registryIds("size"), + }, + { + key: "orientation", + label: "Orientation", + group: "modifier", + type: "select", + options: this.registryIds("orientation"), + }, + { + key: "position", + label: "Position", + group: "modifier", + type: "select", + options: this.registryIds("position"), + }, + { + key: "density", + label: "Density", + group: "modifier", + type: "select", + options: this.registryIds("density"), + }, + { + key: "shape", + label: "Shape", + group: "modifier", + type: "select", + options: this.registryIds("shape"), + }, + // Dimensions + ...DIMENSION_FIELDS.map((key) => ({ + key, + label: key, + group: "dimension" as const, + type: "select" as const, + options: dimensionModes[key]?.modes ?? [], + placeholder: `default: ${dimensionModes[key]?.defaultMode ?? ""}`, + })), + ]; + } + + private registryIds(field: SemanticField): string[] { + const reg = registries[field]; + return reg ? getActiveIds(reg) : []; + } + + private handleFieldChange(key: string, value: string) { + this.fields = { ...this.fields, [key]: value }; + this.messages = validate(this.nameObject); + } + + private async copyToClipboard(text: string, label: string) { + try { + await navigator.clipboard.writeText(text); + this.copyFeedback = `Copied ${label}!`; + setTimeout(() => { + this.copyFeedback = ""; + }, 2000); + } catch { + this.copyFeedback = "Copy failed"; + setTimeout(() => { + this.copyFeedback = ""; + }, 2000); + } + } + + private renderField(config: FieldConfig) { + const value = this.fields[config.key] ?? ""; + const disabled = config.key === "substructure" && !this.fields.structure; + + return html` + +
+ ${config.type === "combobox" + ? html` + + this.handleFieldChange( + config.key, + (e.target as HTMLInputElement).value, + )} + /> + + ${config.options.map( + (opt) => html``, + )} + + ` + : html` + + `} +
+ `; + } + + override render() { + const configs = this.getFieldConfigs(); + const identity = configs.filter((c) => c.group === "identity"); + const modifiers = configs.filter((c) => c.group === "modifier"); + const dimensions = configs.filter((c) => c.group === "dimension"); + const nameObj = this.nameObject; + const nameJson = JSON.stringify(nameObj, null, 2); + const errors = this.messages.filter((m) => m.severity === "error"); + const warnings = this.messages.filter((m) => m.severity === "warning"); + const hasContent = this.serialized.length > 0; + + return html` +

Token Name Builder

+ + +
+
+ ${hasContent ? this.serialized : "Start by entering a property..."} +
+ ${hasContent + ? html` +
+ + + ${this.copyFeedback + ? html`${this.copyFeedback}` + : ""} +
+ ` + : ""} +
+ + +
+
Identity
+
${identity.map((c) => this.renderField(c))}
+
+ + +
+
Modifiers
+
${modifiers.map((c) => this.renderField(c))}
+
+ + +
+
Dimensions
+
${dimensions.map((c) => this.renderField(c))}
+
+ + + ${hasContent + ? html` +
+
Platform Preview
+
+ ${FORMAT_STYLES.map( + (fmt: FormatStyle) => html` +
+ ${FORMAT_LABELS[fmt]} + ${serialize(nameObj, fmt)} +
+ `, + )} +
+
+ ` + : ""} + + + ${this.messages.length > 0 + ? html` +
+
Validation
+
+ ${errors.map( + (m) => html` +
+ ✘ ${m.field}: ${m.message} +
+ `, + )} + ${warnings.map( + (m) => html` +
+ ⚠ ${m.field}: ${m.message} +
+ `, + )} +
+
+ ` + : hasContent + ? html` +
+
Validation
+
+
+ ✔ All values are valid. +
+
+
+ ` + : ""} + + + ${hasContent + ? html` +
+
Name Object (JSON)
+
+
${nameJson}
+
+
+ ` + : ""} + `; + } +} diff --git a/tools/token-name-builder/src/validator.ts b/tools/token-name-builder/src/validator.ts new file mode 100644 index 00000000..cf98b439 --- /dev/null +++ b/tools/token-name-builder/src/validator.ts @@ -0,0 +1,106 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import type { NameObject } from "./name-object.js"; +import { SEMANTIC_FIELDS, DIMENSION_FIELDS } from "./name-object.js"; +import { registries, dimensionModes, hasValue } from "./registry-data.js"; + +export type Severity = "error" | "warning" | "info"; + +export interface ValidationMessage { + field: string; + severity: Severity; + message: string; +} + +/** + * Validate a name object against registries and dimension declarations. + * + * - Semantic fields: advisory (warning) if value not in registry + * - Dimension fields: strict (error) if value not in declared modes + * - Structural: error if `property` is empty, warning if `substructure` without `structure` + */ +export function validate(name: NameObject): ValidationMessage[] { + const messages: ValidationMessage[] = []; + + // property is required + if (!name.property || name.property.trim().length === 0) { + messages.push({ + field: "property", + severity: "error", + message: "Property is required.", + }); + } + + // substructure without structure + if (name.substructure && !name.structure) { + messages.push({ + field: "substructure", + severity: "warning", + message: "Substructure is set without a parent structure.", + }); + } + + // Validate semantic fields against registries (advisory) + for (const field of SEMANTIC_FIELDS) { + if (field === "property") continue; // no registry for property + const value = name[field as keyof NameObject]; + if (!value) continue; + + const registry = registries[field]; + if (!registry) continue; + + if (!hasValue(registry, value)) { + messages.push({ + field, + severity: "warning", + message: `"${value}" is not in the ${registry.type} registry. This is allowed but may indicate a typo.`, + }); + } + + // Check for deprecated values + const entry = registry.values.find( + (v) => v.id === value || v.aliases?.includes(value), + ); + if (entry?.deprecated) { + messages.push({ + field, + severity: "warning", + message: `"${value}" is deprecated in the ${registry.type} registry.`, + }); + } + } + + // Validate dimension fields against declared modes (strict) + for (const field of DIMENSION_FIELDS) { + const value = name[field as keyof NameObject]; + if (!value) continue; + + const dim = dimensionModes[field]; + if (!dim) continue; + + if (!dim.modes.includes(value)) { + messages.push({ + field, + severity: "error", + message: `Invalid mode "${value}" for ${field}. Allowed: ${dim.modes.join(", ")}.`, + }); + } + } + + return messages; +} + +/** Check if validation passed with no errors (warnings are OK). */ +export function isValid(messages: ValidationMessage[]): boolean { + return !messages.some((m) => m.severity === "error"); +} diff --git a/tools/token-name-builder/test/serializer.test.js b/tools/token-name-builder/test/serializer.test.js new file mode 100644 index 00000000..64dbf9ca --- /dev/null +++ b/tools/token-name-builder/test/serializer.test.js @@ -0,0 +1,206 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; + +// Inline the serializer logic for testing without Vite/TS compilation. +// This mirrors src/serializer.ts and src/name-object.ts. + +const SERIALIZATION_ORDER = [ + "variant", + "component", + "structure", + "substructure", + "anatomy", + "object", + "property", + "orientation", + "position", + "size", + "density", + "shape", + "state", +]; + +function orderedParts(name) { + const parts = []; + for (const field of SERIALIZATION_ORDER) { + const value = name[field]; + if (value && value.trim().length > 0) { + parts.push(value.trim()); + } + } + return parts; +} + +function words(parts) { + return parts.flatMap((p) => p.split("-")); +} + +function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function serialize(name, format = "kebab") { + const parts = orderedParts(name); + if (parts.length === 0) return ""; + + switch (format) { + case "kebab": + return parts.join("-"); + case "camel": { + const w = words(parts); + return w[0] + w.slice(1).map(capitalize).join(""); + } + case "cssVar": + return `--spectrum-${parts.join("-")}`; + case "constant": + return words(parts).join("_").toUpperCase(); + } +} + +// --- Tests --- + +test("serialize kebab: property only", (t) => { + t.is(serialize({ property: "color" }), "color"); +}); + +test("serialize kebab: component + property", (t) => { + t.is(serialize({ component: "button", property: "color" }), "button-color"); +}); + +test("serialize kebab: full example from taxonomy.md", (t) => { + const name = { + variant: "accent", + component: "button", + anatomy: "icon", + object: "background", + property: "color", + state: "hover", + }; + t.is(serialize(name), "accent-button-icon-background-color-hover"); +}); + +test("serialize kebab: ordering is deterministic regardless of object key order", (t) => { + const a = serialize({ + state: "hover", + property: "color", + component: "button", + variant: "accent", + }); + const b = serialize({ + variant: "accent", + component: "button", + property: "color", + state: "hover", + }); + t.is(a, b); + t.is(a, "accent-button-color-hover"); +}); + +test("serialize kebab: skips empty and undefined fields", (t) => { + t.is( + serialize({ + property: "color", + component: "", + structure: undefined, + state: "hover", + }), + "color-hover", + ); +}); + +test("serialize kebab: all semantic fields populated", (t) => { + const name = { + variant: "accent", + component: "slider", + structure: "base", + substructure: "item", + anatomy: "handle", + object: "background", + property: "color", + orientation: "vertical", + position: "top", + size: "m", + density: "compact", + shape: "uniform", + state: "hover", + }; + t.is( + serialize(name), + "accent-slider-base-item-handle-background-color-vertical-top-m-compact-uniform-hover", + ); +}); + +test("serialize camelCase", (t) => { + const name = { + variant: "accent", + component: "button", + property: "color", + state: "hover", + }; + t.is(serialize(name, "camel"), "accentButtonColorHover"); +}); + +test("serialize camelCase with compound property", (t) => { + const name = { + component: "button", + property: "border-color", + }; + t.is(serialize(name, "camel"), "buttonBorderColor"); +}); + +test("serialize CSS custom property", (t) => { + const name = { + variant: "accent", + component: "button", + property: "color", + state: "hover", + }; + t.is(serialize(name, "cssVar"), "--spectrum-accent-button-color-hover"); +}); + +test("serialize CONSTANT_CASE", (t) => { + const name = { + variant: "accent", + component: "button", + property: "color", + state: "hover", + }; + t.is(serialize(name, "constant"), "ACCENT_BUTTON_COLOR_HOVER"); +}); + +test("serialize CONSTANT_CASE with compound values", (t) => { + const name = { + component: "button", + property: "border-color", + state: "keyboard-focus", + }; + t.is(serialize(name, "constant"), "BUTTON_BORDER_COLOR_KEYBOARD_FOCUS"); +}); + +test("serialize returns empty string for empty name object", (t) => { + t.is(serialize({ property: "" }), ""); + t.is(serialize({ property: " " }), ""); +}); + +test("dimension fields are excluded from serialization", (t) => { + const name = { + component: "button", + property: "color", + colorScheme: "dark", + scale: "mobile", + contrast: "high", + }; + // Dimension fields should not appear in the serialized output + t.is(serialize(name), "button-color"); +}); diff --git a/tools/token-name-builder/test/validator.test.js b/tools/token-name-builder/test/validator.test.js new file mode 100644 index 00000000..4412d3cd --- /dev/null +++ b/tools/token-name-builder/test/validator.test.js @@ -0,0 +1,260 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const registryDir = resolve( + __dirname, + "../../../packages/design-system-registry/registry", +); + +// Load registries directly for testing (avoids Vite alias dependency) +function loadRegistry(filename) { + return JSON.parse(readFileSync(resolve(registryDir, filename), "utf-8")); +} + +const registries = { + component: loadRegistry("components.json"), + structure: loadRegistry("structures.json"), + substructure: loadRegistry("substructures.json"), + anatomy: loadRegistry("anatomy-terms.json"), + object: loadRegistry("token-objects.json"), + variant: loadRegistry("variants.json"), + state: loadRegistry("states.json"), + orientation: loadRegistry("orientations.json"), + position: loadRegistry("positions.json"), + size: loadRegistry("sizes.json"), + density: loadRegistry("densities.json"), + shape: loadRegistry("shapes.json"), +}; + +const dimensionModes = { + colorScheme: { modes: ["light", "dark", "wireframe"], defaultMode: "light" }, + scale: { modes: ["desktop", "mobile"], defaultMode: "desktop" }, + contrast: { modes: ["regular", "high"], defaultMode: "regular" }, +}; + +const SEMANTIC_FIELDS = [ + "property", + "component", + "structure", + "substructure", + "anatomy", + "object", + "variant", + "state", + "orientation", + "position", + "size", + "density", + "shape", +]; + +const DIMENSION_FIELDS = ["colorScheme", "scale", "contrast"]; + +function hasValue(registry, searchTerm) { + return registry.values.some( + (v) => v.id === searchTerm || (v.aliases && v.aliases.includes(searchTerm)), + ); +} + +function validate(name) { + const messages = []; + + if (!name.property || name.property.trim().length === 0) { + messages.push({ + field: "property", + severity: "error", + message: "Property is required.", + }); + } + + if (name.substructure && !name.structure) { + messages.push({ + field: "substructure", + severity: "warning", + message: "Substructure is set without a parent structure.", + }); + } + + for (const field of SEMANTIC_FIELDS) { + if (field === "property") continue; + const value = name[field]; + if (!value) continue; + const registry = registries[field]; + if (!registry) continue; + if (!hasValue(registry, value)) { + messages.push({ + field, + severity: "warning", + message: `"${value}" is not in the ${registry.type} registry. This is allowed but may indicate a typo.`, + }); + } + } + + for (const field of DIMENSION_FIELDS) { + const value = name[field]; + if (!value) continue; + const dim = dimensionModes[field]; + if (!dim) continue; + if (!dim.modes.includes(value)) { + messages.push({ + field, + severity: "error", + message: `Invalid mode "${value}" for ${field}. Allowed: ${dim.modes.join(", ")}.`, + }); + } + } + + return messages; +} + +function isValid(messages) { + return !messages.some((m) => m.severity === "error"); +} + +// --- Tests --- + +test("valid name object produces no errors", (t) => { + const messages = validate({ + property: "color", + component: "button", + variant: "accent", + state: "hover", + }); + t.true(isValid(messages)); + t.is(messages.filter((m) => m.severity === "error").length, 0); +}); + +test("missing property is an error", (t) => { + const messages = validate({ property: "" }); + t.false(isValid(messages)); + t.true( + messages.some((m) => m.field === "property" && m.severity === "error"), + ); +}); + +test("unknown semantic value is a warning, not an error", (t) => { + const messages = validate({ + property: "color", + component: "nonexistent-widget", + }); + t.true(isValid(messages)); + const warning = messages.find((m) => m.field === "component"); + t.truthy(warning); + t.is(warning.severity, "warning"); + t.true(warning.message.includes("nonexistent-widget")); +}); + +test("valid registry values produce no warnings", (t) => { + const messages = validate({ + property: "color", + object: "background", + state: "hover", + structure: "base", + }); + t.is(messages.length, 0); +}); + +test("invalid dimension value is a strict error", (t) => { + const messages = validate({ + property: "color", + colorScheme: "dim", + }); + t.false(isValid(messages)); + const error = messages.find((m) => m.field === "colorScheme"); + t.truthy(error); + t.is(error.severity, "error"); + t.true(error.message.includes("dim")); + t.true(error.message.includes("light, dark, wireframe")); +}); + +test("valid dimension values pass", (t) => { + const messages = validate({ + property: "color", + colorScheme: "dark", + scale: "mobile", + contrast: "high", + }); + t.true(isValid(messages)); +}); + +test("substructure without structure warns", (t) => { + const messages = validate({ + property: "color", + substructure: "item", + }); + const warning = messages.find((m) => m.field === "substructure"); + t.truthy(warning); + t.is(warning.severity, "warning"); +}); + +test("substructure with structure does not warn", (t) => { + const messages = validate({ + property: "color", + structure: "list", + substructure: "item", + }); + const subWarning = messages.find( + (m) => m.field === "substructure" && m.message.includes("without"), + ); + t.falsy(subWarning); +}); + +test("multiple validation issues are all reported", (t) => { + const messages = validate({ + property: "", + component: "fake-component", + colorScheme: "invalid-scheme", + }); + t.true(messages.length >= 3); + t.true( + messages.some((m) => m.field === "property" && m.severity === "error"), + ); + t.true( + messages.some((m) => m.field === "component" && m.severity === "warning"), + ); + t.true( + messages.some((m) => m.field === "colorScheme" && m.severity === "error"), + ); +}); + +test("known anatomy terms pass validation", (t) => { + const messages = validate({ + property: "color", + anatomy: "icon", + }); + const anatomyMsg = messages.find((m) => m.field === "anatomy"); + t.falsy(anatomyMsg); +}); + +test("known variants pass validation", (t) => { + const messages = validate({ + property: "color", + variant: "accent", + }); + const variantMsg = messages.find((m) => m.field === "variant"); + t.falsy(variantMsg); +}); + +test("known sizes pass validation", (t) => { + const messages = validate({ + property: "width", + size: "m", + }); + const sizeMsg = messages.find((m) => m.field === "size"); + t.falsy(sizeMsg); +}); diff --git a/tools/token-name-builder/tsconfig.json b/tools/token-name-builder/tsconfig.json new file mode 100644 index 00000000..7b82c1b4 --- /dev/null +++ b/tools/token-name-builder/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": false, + "experimentalDecorators": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tools/token-name-builder/vite.config.ts b/tools/token-name-builder/vite.config.ts new file mode 100644 index 00000000..33bed329 --- /dev/null +++ b/tools/token-name-builder/vite.config.ts @@ -0,0 +1,33 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { defineConfig } from "vite"; +import { resolve } from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@registry": resolve( + __dirname, + "../../packages/design-system-registry/registry", + ), + "@component-schemas": resolve( + __dirname, + "../../packages/component-schemas/schemas/components", + ), + }, + }, + build: { + outDir: "dist", + target: "es2022", + }, +}); From e9a41c6ad7c475dfa76f9d8b646d8d7a96f79813 Mon Sep 17 00:00:00 2001 From: garthdb Date: Mon, 6 Apr 2026 12:35:11 -0600 Subject: [PATCH 2/5] feat(tools): add component awareness, URL hash state, and a11y to token-name-builder - Add component-awareness.ts: dynamically loads component schemas via import.meta.glob and extracts valid anatomy/variant/state/size options - Update token-name-builder: component-aware optgroups on select fields show component-specific values first when a component is selected - Add URL hash persistence: field state encoded as URLSearchParams in hash for shareable links; restored on page load - Improve accessibility: fieldset/legend for sections, aria-live on preview and validation, aria-labelledby on all form inputs - Add Share button to copy the current URL - Fix tsconfig: add vite/client types for import.meta.glob and path mappings for @registry/* and @component-schemas/* aliases - Add token-name-builder to site web tools config and regenerate tools.md Co-Authored-By: Claude Sonnet 4.6 --- docs/site/scripts/generate-tools-page.js | 7 + docs/site/src/tools.md | 2 + .../src/component-awareness.ts | 78 ++++ tools/token-name-builder/src/name-object.ts | 2 +- .../src/token-name-builder.ts | 372 +++++++++++++----- tools/token-name-builder/tsconfig.json | 9 +- 6 files changed, 362 insertions(+), 108 deletions(-) create mode 100644 tools/token-name-builder/src/component-awareness.ts diff --git a/docs/site/scripts/generate-tools-page.js b/docs/site/scripts/generate-tools-page.js index 7f840d27..8e43f333 100644 --- a/docs/site/scripts/generate-tools-page.js +++ b/docs/site/scripts/generate-tools-page.js @@ -35,6 +35,11 @@ const WEB_TOOLS_CONFIG = [ path: "/release-timeline/", label: "Release Timeline", }, + { + dir: "token-name-builder", + path: "/token-name-builder/", + label: "Token Name Builder", + }, ]; const WEB_TOOLS_FALLBACK_DESCRIPTIONS = { @@ -44,6 +49,8 @@ const WEB_TOOLS_FALLBACK_DESCRIPTIONS = { visualizer: "Interactive dependency graph for Spectrum 1 (legacy) tokens", "release-timeline": "Interactive visualization of Spectrum Tokens release history across legacy, stable, beta, and snapshot formats", + "token-name-builder": + "Interactive tool for building Spectrum design token names using the design data spec taxonomy", }; function readJson(path) { diff --git a/docs/site/src/tools.md b/docs/site/src/tools.md index 7f36261f..9e764c8a 100644 --- a/docs/site/src/tools.md +++ b/docs/site/src/tools.md @@ -16,6 +16,7 @@ Interactive tools deployed with this site: * **[S2 Visualizer](/s2-visualizer/)** — Interactive dependency graph for Spectrum 2 tokens showing ancestor/descendant relationships, value filters, and search * **[S1 Visualizer](/visualizer/)** — Interactive dependency graph for Spectrum 1 (legacy) tokens * **[Release Timeline](/release-timeline/)** — Interactive timeline visualization of Spectrum Tokens release history +* **[Token Name Builder](/token-name-builder/)** — Interactive tool for building Spectrum design token names using the design data spec taxonomy ## Developer Tools @@ -28,6 +29,7 @@ Packages under `tools/` in the repo: * **[@adobe/spectrum-design-data-mcp](https://www.npmjs.com/package/%40adobe%2Fspectrum-design-data-mcp)** — Model Context Protocol server for Spectrum design data including tokens, schemas, and component anatomy * **[@adobe/spectrum-diff-core](https://www.npmjs.com/package/%40adobe%2Fspectrum-diff-core)** — Shared core library for Spectrum diff generation tools (tokens, component schemas, etc.) * **[@adobe/token-diff-generator](https://www.npmjs.com/package/%40adobe%2Ftoken-diff-generator)** — Generate comprehensive diffs between design token sets with support for multiple output formats including CLI, JSON, and Markdown. Detects added, deleted, renamed, deprecated, and updated tokens across different schema versions. +* **[@adobe/token-name-builder](https://github.com/adobe/spectrum-design-data/tree/main/tools/token-name-builder)** — Interactive web tool for building Spectrum design token names * **[component-options-editor](https://github.com/adobe/spectrum-design-data/tree/main/tools/component-options-editor)** — Figma plugin for authoring Spectrum component option schemas * **[markdown-generator](https://github.com/adobe/spectrum-design-data/tree/main/tools/markdown-generator)** — Generate markdown files from tokens, component-schemas, and design-system-registry for docs and chatbot indexing * **[release-analyzer](https://github.com/adobe/spectrum-design-data/tree/main/tools/release-analyzer)** — Analyzes Spectrum Tokens release history and generates data for change frequency visualization diff --git a/tools/token-name-builder/src/component-awareness.ts b/tools/token-name-builder/src/component-awareness.ts new file mode 100644 index 00000000..efbdafe6 --- /dev/null +++ b/tools/token-name-builder/src/component-awareness.ts @@ -0,0 +1,78 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { registries } from "./registry-data.js"; + +/** Options derived from a component's JSON schema. */ +export interface ComponentOptions { + /** Schema property keys that match registered anatomy terms. */ + anatomy: string[]; + /** Allowed variant values for this component. */ + variants: string[]; + /** Allowed state values for this component. */ + states: string[]; + /** Allowed size values for this component. */ + sizes: string[]; +} + +// Set of active anatomy term IDs from the registry, for fast lookup. +const ANATOMY_IDS = new Set( + (registries.anatomy?.values ?? []) + .filter((v) => !v.deprecated) + .map((v) => v.id), +); + +// Lazily loaded component schema modules, keyed by relative path. +// import.meta.glob paths must be literal strings relative to this file. +const schemaModules = import.meta.glob( + "../../../packages/component-schemas/schemas/components/*.json", +); + +interface ComponentSchemaProps { + variant?: { enum?: string[] }; + state?: { enum?: string[] }; + size?: { enum?: string[] }; + [key: string]: unknown; +} + +interface ComponentSchemaModule { + default: { + properties?: ComponentSchemaProps; + }; +} + +/** + * Dynamically load a component schema and extract token-relevant options. + * Returns null if the component has no schema file. + */ +export async function loadComponentOptions( + componentId: string, +): Promise { + if (!componentId) return null; + + // Find the matching module key (ends with `/${componentId}.json`) + const key = Object.keys(schemaModules).find((k) => + k.endsWith(`/${componentId}.json`), + ); + if (!key) return null; + + const mod = (await schemaModules[key]()) as ComponentSchemaModule; + const props = mod.default?.properties ?? {}; + + return { + // Anatomy: schema property keys that are registered anatomy terms + anatomy: Object.keys(props).filter((k) => ANATOMY_IDS.has(k)), + variants: props.variant?.enum ?? [], + states: props.state?.enum ?? [], + sizes: props.size?.enum ?? [], + }; +} diff --git a/tools/token-name-builder/src/name-object.ts b/tools/token-name-builder/src/name-object.ts index b4a21d3f..03cc4937 100644 --- a/tools/token-name-builder/src/name-object.ts +++ b/tools/token-name-builder/src/name-object.ts @@ -89,7 +89,7 @@ export function buildNameObject( if (key === "property") continue; const value = fields[key as keyof NameObject]; if (value && value.trim().length > 0) { - (result as Record)[key] = value.trim(); + (result as unknown as Record)[key] = value.trim(); } } return result; diff --git a/tools/token-name-builder/src/token-name-builder.ts b/tools/token-name-builder/src/token-name-builder.ts index 4cce5f83..0de5c5bb 100644 --- a/tools/token-name-builder/src/token-name-builder.ts +++ b/tools/token-name-builder/src/token-name-builder.ts @@ -13,14 +13,18 @@ governing permissions and limitations under the License. import { LitElement, html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; import type { NameObject, SemanticField } from "./name-object.js"; -import { buildNameObject, DIMENSION_FIELDS } from "./name-object.js"; +import { + buildNameObject, + DIMENSION_FIELDS, + SEMANTIC_FIELDS, +} from "./name-object.js"; import { serialize, FORMAT_STYLES, FORMAT_LABELS, type FormatStyle, } from "./serializer.js"; -import { validate, isValid, type ValidationMessage } from "./validator.js"; +import { validate, type ValidationMessage } from "./validator.js"; import { registries, dimensionModes, @@ -28,6 +32,10 @@ import { getActiveIds, type Registry, } from "./registry-data.js"; +import { + loadComponentOptions, + type ComponentOptions, +} from "./component-awareness.js"; interface FieldConfig { key: string; @@ -35,16 +43,22 @@ interface FieldConfig { group: "identity" | "modifier" | "dimension"; required?: boolean; type: "combobox" | "select"; + /** Full list of registry options. */ options: string[]; + /** Component-specific subset (shown first in optgroup when present). */ + filteredOptions?: string[]; placeholder?: string; - description?: string; } +// All name fields used for URL hash persistence +const ALL_FIELDS: string[] = [...SEMANTIC_FIELDS, ...DIMENSION_FIELDS]; + @customElement("token-name-builder") export class TokenNameBuilder extends LitElement { @state() private fields: Record = { property: "" }; @state() private messages: ValidationMessage[] = []; @state() private copyFeedback = ""; + @state() private componentOptions: ComponentOptions | null = null; static override styles = css` :host { @@ -102,26 +116,30 @@ export class TokenNameBuilder extends LitElement { display: flex; gap: 8px; margin-top: 8px; + align-items: center; } - /* Sections */ - .section { - margin-bottom: 24px; + /* Fieldset sections */ + fieldset { + border: none; + padding: 0; + margin: 0 0 24px; } - .section-heading { + legend { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--spectrum-gray-600, #6e6e6e); - margin: 0 0 12px; + margin-bottom: 12px; padding-bottom: 4px; border-bottom: 1px solid var(--spectrum-gray-200, #e6e6e6); + width: 100%; } @media (prefers-color-scheme: dark) { - .section-heading { + legend { color: var(--spectrum-gray-400, #999); border-color: var(--spectrum-gray-700, #444); } @@ -147,6 +165,12 @@ export class TokenNameBuilder extends LitElement { color: var(--spectrum-negative-color-900, #c9252d); } + @media (prefers-color-scheme: dark) { + .field-label { + color: var(--spectrum-body-color, #e0e0e0); + } + } + select, input[type="text"] { font-family: inherit; @@ -176,7 +200,8 @@ export class TokenNameBuilder extends LitElement { border-color: transparent; } - .field-disabled select { + .field-disabled select, + .field-disabled input { opacity: 0.4; pointer-events: none; } @@ -187,7 +212,6 @@ export class TokenNameBuilder extends LitElement { border: 1px solid var(--spectrum-gray-200, #e6e6e6); border-radius: 8px; padding: 12px 16px; - margin-bottom: 24px; } @media (prefers-color-scheme: dark) { @@ -206,7 +230,7 @@ export class TokenNameBuilder extends LitElement { } .platform-label { - min-width: 120px; + min-width: 160px; color: var(--spectrum-gray-600, #6e6e6e); flex-shrink: 0; } @@ -223,10 +247,6 @@ export class TokenNameBuilder extends LitElement { } /* Validation */ - .validation { - margin-bottom: 24px; - } - .validation-msg { display: flex; gap: 8px; @@ -314,10 +334,14 @@ export class TokenNameBuilder extends LitElement { } } + button:focus-visible { + outline: 2px solid var(--spectrum-accent-color-700, #0070d1); + outline-offset: 2px; + } + .copy-feedback { font-size: 0.75rem; color: var(--spectrum-positive-color-900, #107c10); - align-self: center; } @media (prefers-color-scheme: dark) { @@ -325,8 +349,65 @@ export class TokenNameBuilder extends LitElement { color: var(--spectrum-positive-color-400, #4fce4f); } } + + /* Share link */ + .share-link { + font-size: 0.75rem; + color: var(--spectrum-gray-600, #6e6e6e); + margin-left: auto; + } + + @media (prefers-color-scheme: dark) { + .share-link { + color: var(--spectrum-gray-400, #999); + } + } `; + override connectedCallback() { + super.connectedCallback(); + this.loadFromHash(); + } + + /** Restore field state from the URL hash on load. */ + private loadFromHash() { + const hash = window.location.hash.slice(1); + if (!hash) return; + try { + const params = new URLSearchParams(hash); + const loaded: Record = { property: "" }; + for (const field of ALL_FIELDS) { + const val = params.get(field); + if (val) loaded[field] = val; + } + this.fields = loaded; + this.messages = validate(this.nameObject); + // Trigger component options load if component is set + if (loaded.component) { + void this.updateComponentOptions(loaded.component); + } + } catch { + // Ignore malformed hash + } + } + + /** Persist current field state to the URL hash (no page reload). */ + private persistToHash() { + const params = new URLSearchParams(); + for (const field of ALL_FIELDS) { + const val = this.fields[field]; + if (val && val.trim().length > 0) { + params.set(field, val.trim()); + } + } + const hash = params.toString(); + window.history.replaceState( + null, + "", + hash ? `#${hash}` : window.location.pathname, + ); + } + private get nameObject(): NameObject { return buildNameObject({ property: this.fields.property || "", @@ -338,7 +419,13 @@ export class TokenNameBuilder extends LitElement { return serialize(this.nameObject); } + private async updateComponentOptions(componentId: string) { + this.componentOptions = await loadComponentOptions(componentId); + } + private getFieldConfigs(): FieldConfig[] { + const opts = this.componentOptions; + return [ // Identity { @@ -378,6 +465,7 @@ export class TokenNameBuilder extends LitElement { group: "identity", type: "combobox", options: this.registryIds("anatomy"), + filteredOptions: opts?.anatomy, placeholder: "e.g. icon, label, handle", }, { @@ -394,6 +482,7 @@ export class TokenNameBuilder extends LitElement { group: "modifier", type: "select", options: this.registryIds("variant"), + filteredOptions: opts?.variants, }, { key: "state", @@ -401,6 +490,7 @@ export class TokenNameBuilder extends LitElement { group: "modifier", type: "select", options: this.registryIds("state"), + filteredOptions: opts?.states, }, { key: "size", @@ -408,6 +498,7 @@ export class TokenNameBuilder extends LitElement { group: "modifier", type: "select", options: this.registryIds("size"), + filteredOptions: opts?.sizes, }, { key: "orientation", @@ -450,13 +541,18 @@ export class TokenNameBuilder extends LitElement { } private registryIds(field: SemanticField): string[] { - const reg = registries[field]; + const reg = registries[field] as Registry | undefined; return reg ? getActiveIds(reg) : []; } - private handleFieldChange(key: string, value: string) { + private async handleFieldChange(key: string, value: string) { this.fields = { ...this.fields, [key]: value }; this.messages = validate(this.nameObject); + this.persistToHash(); + + if (key === "component") { + await this.updateComponentOptions(value); + } } private async copyToClipboard(text: string, label: string) { @@ -477,51 +573,109 @@ export class TokenNameBuilder extends LitElement { private renderField(config: FieldConfig) { const value = this.fields[config.key] ?? ""; const disabled = config.key === "substructure" && !this.fields.structure; + const labelId = `label-${config.key}`; return html` -
${config.type === "combobox" + ? this.renderCombobox(config, value, disabled, labelId) + : this.renderSelect(config, value, disabled, labelId)} +
+ `; + } + + private renderCombobox( + config: FieldConfig, + value: string, + disabled: boolean, + labelId: string, + ) { + // For combobox: show filteredOptions first in datalist, then remaining + const filtered = config.filteredOptions ?? []; + const rest = config.options.filter((o) => !filtered.includes(o)); + const ordered = [...filtered, ...rest]; + + return html` + + this.handleFieldChange( + config.key, + (e.target as HTMLInputElement).value, + )} + /> + + ${ordered.map((opt) => html``)} + + `; + } + + private renderSelect( + config: FieldConfig, + value: string, + disabled: boolean, + labelId: string, + ) { + const filtered = config.filteredOptions ?? []; + const component = this.fields.component; + const hasFiltered = filtered.length > 0 && !!component; + + // Options not in the filtered set + const rest = hasFiltered + ? config.options.filter((o) => !filtered.includes(o)) + : config.options; + + return html` + - this.handleFieldChange( - config.key, - (e.target as HTMLInputElement).value, - )} - /> - - ${config.options.map( - (opt) => html``, + + ${filtered.map( + (opt) => html` + + `, )} - - ` - : html` - - `} - + + ` + : rest.map( + (opt) => html` + + `, + )} + `; } @@ -540,9 +694,13 @@ export class TokenNameBuilder extends LitElement {

Token Name Builder

-
-
- ${hasContent ? this.serialized : "Start by entering a property..."} +
+
+ ${hasContent ? this.serialized : "Start by entering a property…"}
${hasContent ? html` @@ -555,8 +713,15 @@ export class TokenNameBuilder extends LitElement { + ${this.copyFeedback - ? html`${this.copyFeedback}` : ""} @@ -566,32 +731,32 @@ export class TokenNameBuilder extends LitElement {
-
-
Identity
+
+ Identity
${identity.map((c) => this.renderField(c))}
-
+ -
-
Modifiers
+
+ Modifiers
${modifiers.map((c) => this.renderField(c))}
-
+ -
-
Dimensions
+
+ Dimensions
${dimensions.map((c) => this.renderField(c))}
-
+ ${hasContent ? html` -
-
Platform Preview
-
+
+ Platform Preview +
${FORMAT_STYLES.map( (fmt: FormatStyle) => html` -
+
${FORMAT_LABELS[fmt]} ${serialize(nameObj, fmt)} -
+
` : ""} - ${this.messages.length > 0 - ? html` -
-
Validation
-
- ${errors.map( - (m) => html` -
- ✘ ${m.field}: ${m.message} -
- `, - )} - ${warnings.map( - (m) => html` -
- ⚠ ${m.field}: ${m.message} -
- `, - )} -
-
- ` - : hasContent - ? html` -
-
Validation
-
-
- ✔ All values are valid. -
-
-
- ` - : ""} +
+ Validation +
+ ${!hasContent + ? html`
+ Fill in a property to see validation. +
` + : errors.length === 0 && warnings.length === 0 + ? html`
+ ✔ All values are valid. +
` + : html` + ${errors.map( + (m) => html` + + `, + )} + ${warnings.map( + (m) => html` +
+ ⚠ ${m.field}: ${m.message} +
+ `, + )} + `} +
+
${hasContent ? html` -
-
Name Object (JSON)
+
+ Name Object (JSON)
-
${nameJson}
+
${nameJson}
-
+ ` : ""} `; diff --git a/tools/token-name-builder/tsconfig.json b/tools/token-name-builder/tsconfig.json index 7b82c1b4..3fb07e55 100644 --- a/tools/token-name-builder/tsconfig.json +++ b/tools/token-name-builder/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], "strict": true, "noEmit": true, "esModuleInterop": true, @@ -12,7 +13,13 @@ "resolveJsonModule": true, "isolatedModules": true, "useDefineForClassFields": false, - "experimentalDecorators": true + "experimentalDecorators": true, + "paths": { + "@registry/*": ["../../packages/design-system-registry/registry/*"], + "@component-schemas/*": [ + "../../packages/component-schemas/schemas/components/*" + ] + } }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] From b3862ad34733bcdb2debaf641bfd85b93106d273 Mon Sep 17 00:00:00 2001 From: garthdb Date: Mon, 6 Apr 2026 12:56:59 -0600 Subject: [PATCH 3/5] feat(registry): add component-anatomy registry extracted from S2 docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add extraction script that parses anatomy sections from all 67 S2 doc markdown files and generates a structured component-anatomy.json mapping 65 components to their 288 anatomy parts. - Parse fenced code blocks under ## Anatomy headings - Handle compound lines (", " / " or " / " and " / " / " separators) - Handle optional annotations, camelCase→kebab conversion, dedup - Wire into token-name-builder: anatomy dropdown now shows real S2 docs anatomy parts per component (was ~4 matches, now 65 components) - 24 new tests for extraction logic + generated output validation Co-Authored-By: Claude Sonnet 4.6 --- packages/design-system-registry/index.js | 4 + .../registry/component-anatomy.json | 1836 +++++++++++++++++ .../scripts/extract-component-anatomy.js | 236 +++ .../test/component-anatomy.test.js | 211 ++ .../src/component-awareness.ts | 66 +- tools/token-name-builder/src/registry-data.ts | 26 + 6 files changed, 2351 insertions(+), 28 deletions(-) create mode 100644 packages/design-system-registry/registry/component-anatomy.json create mode 100644 packages/design-system-registry/scripts/extract-component-anatomy.js create mode 100644 packages/design-system-registry/test/component-anatomy.test.js diff --git a/packages/design-system-registry/index.js b/packages/design-system-registry/index.js index 370b5560..e962ce7c 100644 --- a/packages/design-system-registry/index.js +++ b/packages/design-system-registry/index.js @@ -90,6 +90,10 @@ export const shapes = JSON.parse( readFileSync(join(__dirname, "registry", "shapes.json"), "utf-8"), ); +export const componentAnatomy = JSON.parse( + readFileSync(join(__dirname, "registry", "component-anatomy.json"), "utf-8"), +); + /** * Get all values from a registry by ID * @param {object} registry - The registry object diff --git a/packages/design-system-registry/registry/component-anatomy.json b/packages/design-system-registry/registry/component-anatomy.json new file mode 100644 index 00000000..ccc31598 --- /dev/null +++ b/packages/design-system-registry/registry/component-anatomy.json @@ -0,0 +1,1836 @@ +{ + "type": "component-anatomy", + "description": "Mapping of Spectrum components to their visible anatomy parts, extracted from S2 documentation.", + "components": { + "accordion": { + "label": "accordion", + "source": "docs/s2-docs/components/navigation/accordion.md", + "parts": [ + { + "id": "accordion-items", + "label": "Accordion Items", + "optional": false + }, + { + "id": "small-divider", + "label": "Small Divider", + "optional": false + } + ] + }, + "action-button": { + "label": "action button", + "source": "docs/s2-docs/components/actions/action-button.md", + "parts": [ + { + "id": "icon", + "label": "Icon", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "hold-icon", + "label": "Hold Icon", + "optional": true + } + ] + }, + "action-group": { + "label": "action group", + "source": "docs/s2-docs/components/actions/action-group.md", + "parts": [ + { + "id": "action-button-1", + "label": "Action Button 1", + "optional": false + }, + { + "id": "action-button-2", + "label": "Action Button 2", + "optional": false + }, + { + "id": "action-menu", + "label": "Action Menu", + "optional": true + } + ] + }, + "alert-banner": { + "label": "alert banner", + "source": "docs/s2-docs/components/feedback/alert-banner.md", + "parts": [ + { + "id": "background", + "label": "Background", + "optional": false + }, + { + "id": "icon", + "label": "Icon", + "optional": false + }, + { + "id": "text", + "label": "Text", + "optional": false + }, + { + "id": "button", + "label": "Button", + "optional": false + }, + { + "id": "close-button", + "label": "Close Button", + "optional": false + } + ] + }, + "alert-dialog": { + "label": "alert dialog", + "source": "docs/s2-docs/components/feedback/alert-dialog.md", + "parts": [ + { + "id": "alert-dialog-container", + "label": "Alert Dialog Container", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": false + }, + { + "id": "description", + "label": "Description", + "optional": false + }, + { + "id": "primary-action", + "label": "Primary Action", + "optional": false + }, + { + "id": "secondary-action", + "label": "Secondary Action", + "optional": true + }, + { + "id": "cancel-action", + "label": "Cancel Action", + "optional": true + }, + { + "id": "overlay", + "label": "Overlay", + "optional": false + } + ] + }, + "avatar": { + "label": "avatar", + "source": "docs/s2-docs/components/status/avatar.md", + "parts": [ + { + "id": "container", + "label": "Container", + "optional": false + }, + { + "id": "user-image", + "label": "User Image", + "optional": false + }, + { + "id": "gradient-image", + "label": "Gradient Image", + "optional": false + }, + { + "id": "initials", + "label": "Initials", + "optional": false + }, + { + "id": "guest-icon", + "label": "Guest Icon", + "optional": false + } + ] + }, + "avatar-group": { + "label": "avatar group", + "source": "docs/s2-docs/components/status/avatar-group.md", + "parts": [ + { + "id": "avatar", + "label": "Avatar", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "badge": { + "label": "badge", + "source": "docs/s2-docs/components/status/badge.md", + "parts": [ + { + "id": "icon", + "label": "Icon", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "breadcrumbs": { + "label": "breadcrumbs", + "source": "docs/s2-docs/components/navigation/breadcrumbs.md", + "parts": [ + { + "id": "truncated-menu", + "label": "Truncated Menu", + "optional": false + }, + { + "id": "breadcrumbs-item", + "label": "Breadcrumbs Item", + "optional": false + }, + { + "id": "separator", + "label": "Separator", + "optional": false + }, + { + "id": "breadcrumbs-title", + "label": "Breadcrumbs Title", + "optional": false + } + ] + }, + "button": { + "label": "button", + "source": "docs/s2-docs/components/actions/button.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "button-group": { + "label": "button group", + "source": "docs/s2-docs/components/actions/button-group.md", + "parts": [ + { + "id": "button", + "label": "Button", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "calendar": { + "label": "calendar", + "source": "docs/s2-docs/components/inputs/calendar.md", + "parts": [ + { + "id": "chevron", + "label": "Chevron", + "optional": false + }, + { + "id": "month", + "label": "Month", + "optional": false + }, + { + "id": "year", + "label": "Year", + "optional": false + }, + { + "id": "week", + "label": "Week", + "optional": false + }, + { + "id": "days", + "label": "Days", + "optional": false + } + ] + }, + "cards": { + "label": "card", + "source": "docs/s2-docs/components/containers/cards.md", + "parts": [ + { + "id": "container", + "label": "Container", + "optional": false + }, + { + "id": "checkbox", + "label": "Checkbox", + "optional": true + }, + { + "id": "preview-well", + "label": "Preview Well", + "optional": false + }, + { + "id": "preview", + "label": "Preview", + "optional": false + }, + { + "id": "metadata", + "label": "Metadata", + "optional": false + }, + { + "id": "footer-area", + "label": "Footer Area", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": false + } + ] + }, + "checkbox": { + "label": "checkbox", + "source": "docs/s2-docs/components/inputs/checkbox.md", + "parts": [ + { + "id": "control", + "label": "Control", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "checkbox-group": { + "label": "checkbox group", + "source": "docs/s2-docs/components/inputs/checkbox-group.md", + "parts": [ + { + "id": "field-label", + "label": "Field Label", + "optional": false + }, + { + "id": "checkbox", + "label": "Checkbox", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": false + } + ] + }, + "close-button": { + "label": "close button", + "source": "docs/s2-docs/components/actions/close-button.md", + "parts": [ + { + "id": "cross-ui-icon", + "label": "Cross Ui Icon", + "optional": false + } + ] + }, + "coach-indicator": { + "label": "coach indicator", + "source": "docs/s2-docs/components/feedback/coach-indicator.md", + "parts": [ + { + "id": "inner-stroke", + "label": "Inner Stroke", + "optional": false + }, + { + "id": "outer-stroke", + "label": "Outer Stroke", + "optional": false + } + ] + }, + "coach-mark": { + "label": "coach mark", + "source": "docs/s2-docs/components/feedback/coach-mark.md", + "parts": [ + { + "id": "container", + "label": "Container", + "optional": false + }, + { + "id": "image", + "label": "Image", + "optional": true + }, + { + "id": "title", + "label": "Title", + "optional": false + }, + { + "id": "more-actions-menu", + "label": "More Actions Menu", + "optional": false + }, + { + "id": "keyboard-shortcuts", + "label": "Keyboard Shortcuts", + "optional": false + }, + { + "id": "description", + "label": "Description", + "optional": false + }, + { + "id": "tour-step-counter", + "label": "Tour Step Counter", + "optional": false + }, + { + "id": "button-group", + "label": "Button Group", + "optional": false + } + ] + }, + "color-area": { + "label": "color area", + "source": "docs/s2-docs/components/inputs/color-area.md", + "parts": [ + { + "id": "area", + "label": "Area", + "optional": false + }, + { + "id": "handle", + "label": "Handle", + "optional": false + }, + { + "id": "loupe", + "label": "Loupe", + "optional": false + } + ] + }, + "color-handle-and-loupe": { + "label": "color handle and loupe", + "source": "docs/s2-docs/components/inputs/color-handle-and-loupe.md", + "parts": [ + { + "id": "color-handle", + "label": "Color Handle", + "optional": false + }, + { + "id": "opacity-checkerboard", + "label": "Opacity Checkerboard", + "optional": false + }, + { + "id": "color-loupe", + "label": "Color Loupe", + "optional": true + } + ] + }, + "color-slider": { + "label": "color slider", + "source": "docs/s2-docs/components/inputs/color-slider.md", + "parts": [ + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "handle", + "label": "Handle", + "optional": false + }, + { + "id": "loupe", + "label": "Loupe", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + } + ] + }, + "color-wheel": { + "label": "color wheel", + "source": "docs/s2-docs/components/inputs/color-wheel.md", + "parts": [ + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "handle", + "label": "Handle", + "optional": false + }, + { + "id": "loupe", + "label": "Loupe", + "optional": false + } + ] + }, + "combo-box": { + "label": "combo box", + "source": "docs/s2-docs/components/inputs/combo-box.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "necessity-indicator", + "label": "Necessity Indicator", + "optional": false + }, + { + "id": "field", + "label": "Field", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": false + }, + { + "id": "menu-container", + "label": "Menu Container", + "optional": false + } + ] + }, + "contextual-help": { + "label": "contextual help", + "source": "docs/s2-docs/components/feedback/contextual-help.md", + "parts": [ + { + "id": "action-button", + "label": "Action Button", + "optional": false + }, + { + "id": "popover", + "label": "Popover", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": false + }, + { + "id": "description", + "label": "Description", + "optional": false + }, + { + "id": "link", + "label": "Link", + "optional": false + } + ] + }, + "date-picker": { + "label": "date picker", + "source": "docs/s2-docs/components/inputs/date-picker.md", + "parts": [ + { + "id": "date-field", + "label": "Date Field", + "optional": false + }, + { + "id": "calendar", + "label": "Calendar", + "optional": false + }, + { + "id": "time-field", + "label": "Time Field", + "optional": true + } + ] + }, + "drop-zone": { + "label": "drop zone", + "source": "docs/s2-docs/components/inputs/drop-zone.md", + "parts": [ + { + "id": "illustration", + "label": "Illustration", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": false + }, + { + "id": "body", + "label": "Body", + "optional": false + }, + { + "id": "button", + "label": "Button", + "optional": true + } + ] + }, + "field-label": { + "label": "field label", + "source": "docs/s2-docs/components/inputs/field-label.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "necessity-indicator", + "label": "Necessity Indicator", + "optional": false + }, + { + "id": "input", + "label": "Input", + "optional": false + } + ] + }, + "help-text": { + "label": "help text", + "source": "docs/s2-docs/components/inputs/help-text.md", + "parts": [ + { + "id": "icon", + "label": "Icon", + "optional": true + }, + { + "id": "text", + "label": "Text", + "optional": false + } + ] + }, + "illustrated-message": { + "label": "illustrated message", + "source": "docs/s2-docs/components/feedback/illustrated-message.md", + "parts": [ + { + "id": "illustration", + "label": "Illustration", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": false + }, + { + "id": "description", + "label": "Description", + "optional": false + }, + { + "id": "button-group", + "label": "Button Group", + "optional": true + } + ] + }, + "in-line-alert": { + "label": "in-line alert", + "source": "docs/s2-docs/components/feedback/in-line-alert.md", + "parts": [ + { + "id": "background", + "label": "Background", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": true + }, + { + "id": "icon", + "label": "Icon", + "optional": false + }, + { + "id": "body-area", + "label": "Body Area", + "optional": false + } + ] + }, + "list-view": { + "label": "list view", + "source": "docs/s2-docs/components/actions/list-view.md", + "parts": [ + { + "id": "list-view-section-header", + "label": "List View Section Header", + "optional": false + }, + { + "id": "list-item", + "label": "List Item", + "optional": false + }, + { + "id": "checkbox", + "label": "Checkbox", + "optional": false + }, + { + "id": "icon", + "label": "Icon", + "optional": false + }, + { + "id": "thumbnail", + "label": "Thumbnail", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "description", + "label": "Description", + "optional": true + }, + { + "id": "actions", + "label": "Actions", + "optional": false + }, + { + "id": "trailing-icons", + "label": "Trailing Icons", + "optional": false + } + ] + }, + "menu": { + "label": "menu", + "source": "docs/s2-docs/components/actions/menu.md", + "parts": [ + { + "id": "popover", + "label": "Popover", + "optional": false + }, + { + "id": "menu-section-header", + "label": "Menu Section Header", + "optional": false + }, + { + "id": "menu-section-description", + "label": "Menu Section Description", + "optional": false + }, + { + "id": "menu-items", + "label": "Menu Items", + "optional": false + }, + { + "id": "icon", + "label": "Icon", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "description", + "label": "Description", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + }, + { + "id": "switch", + "label": "Switch", + "optional": false + }, + { + "id": "checkbox", + "label": "Checkbox", + "optional": false + }, + { + "id": "thumbnail", + "label": "Thumbnail", + "optional": false + }, + { + "id": "drill-in-chevron", + "label": "Drill In Chevron", + "optional": false + }, + { + "id": "link-out-icon", + "label": "Link Out Icon", + "optional": false + }, + { + "id": "menu-section-divider", + "label": "Menu Section Divider", + "optional": false + } + ] + }, + "meter": { + "label": "meter", + "source": "docs/s2-docs/components/status/meter.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + }, + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "fill", + "label": "Fill", + "optional": false + } + ] + }, + "number-field": { + "label": "number field", + "source": "docs/s2-docs/components/inputs/number-field.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "required-asterisk", + "label": "Required Asterisk", + "optional": false + }, + { + "id": "required-text", + "label": "Required Text", + "optional": false + }, + { + "id": "optional-text", + "label": "Optional Text", + "optional": false + }, + { + "id": "field", + "label": "Field", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + }, + { + "id": "validation-marker", + "label": "Validation Marker", + "optional": false + }, + { + "id": "error-icon", + "label": "Error Icon", + "optional": false + }, + { + "id": "stepper", + "label": "Stepper", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": false + } + ] + }, + "picker": { + "label": "picker", + "source": "docs/s2-docs/components/inputs/picker.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "required-asterisk", + "label": "Required Asterisk", + "optional": false + }, + { + "id": "required-text", + "label": "Required Text", + "optional": false + }, + { + "id": "optional-text", + "label": "Optional Text", + "optional": false + }, + { + "id": "field", + "label": "Field", + "optional": false + }, + { + "id": "placeholder", + "label": "Placeholder", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + }, + { + "id": "error-icon", + "label": "Error Icon", + "optional": false + }, + { + "id": "chevron", + "label": "Chevron", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": false + }, + { + "id": "menu-container", + "label": "Menu Container", + "optional": false + } + ] + }, + "popover": { + "label": "popover", + "source": "docs/s2-docs/components/containers/popover.md", + "parts": [ + { + "id": "tip", + "label": "Tip", + "optional": true + } + ] + }, + "progress-bar": { + "label": "progress bar", + "source": "docs/s2-docs/components/status/progress-bar.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + }, + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "fill", + "label": "Fill", + "optional": false + } + ] + }, + "progress-circle": { + "label": "progress circle", + "source": "docs/s2-docs/components/status/progress-circle.md", + "parts": [ + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "fill", + "label": "Fill", + "optional": false + } + ] + }, + "radio-button": { + "label": "radio button", + "source": "docs/s2-docs/components/inputs/radio-button.md", + "parts": [ + { + "id": "control", + "label": "Control", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "radio-group": { + "label": "radio group", + "source": "docs/s2-docs/components/inputs/radio-group.md", + "parts": [ + { + "id": "field-label", + "label": "Field Label", + "optional": false + }, + { + "id": "radio-button-area", + "label": "Radio Button Area", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": true + } + ] + }, + "rating": { + "label": "rating", + "source": "docs/s2-docs/components/inputs/rating.md", + "parts": [ + { + "id": "star-workflow-icon", + "label": "Star Workflow Icon", + "optional": false + } + ] + }, + "search-field": { + "label": "search field", + "source": "docs/s2-docs/components/inputs/search-field.md", + "parts": [ + { + "id": "field", + "label": "Field", + "optional": false + }, + { + "id": "leading-icon", + "label": "Leading Icon", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "search-term", + "label": "Search Term", + "optional": false + }, + { + "id": "in-field-button", + "label": "In Field Button", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": false + } + ] + }, + "segmented-control": { + "label": "segmented control", + "source": "docs/s2-docs/components/inputs/segmented-control.md", + "parts": [ + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "segmented-control-item", + "label": "Segmented Control Item", + "optional": false + }, + { + "id": "icon", + "label": "Icon", + "optional": true + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "select-box": { + "label": "select box", + "source": "docs/s2-docs/components/inputs/select-box.md", + "parts": [ + { + "id": "illustration", + "label": "Illustration", + "optional": true + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "body", + "label": "Body", + "optional": true + }, + { + "id": "checkbox", + "label": "Checkbox", + "optional": false + } + ] + }, + "side-navigation": { + "label": "side navigation", + "source": "docs/s2-docs/components/navigation/side-navigation.md", + "parts": [ + { + "id": "header", + "label": "Header", + "optional": false + }, + { + "id": "item", + "label": "Item", + "optional": false + }, + { + "id": "icon", + "label": "Icon", + "optional": true + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "slider": { + "label": "slider", + "source": "docs/s2-docs/components/inputs/slider.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": true + }, + { + "id": "value", + "label": "Value", + "optional": true + }, + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "fill", + "label": "Fill", + "optional": false + }, + { + "id": "handle", + "label": "Handle", + "optional": false + } + ] + }, + "standard-dialog": { + "label": "standard dialog", + "source": "docs/s2-docs/components/feedback/standard-dialog.md", + "parts": [ + { + "id": "standard-dialog-container", + "label": "Standard Dialog Container", + "optional": false + }, + { + "id": "cover-image", + "label": "Cover Image", + "optional": true + }, + { + "id": "close-button", + "label": "Close Button", + "optional": true + }, + { + "id": "header-area", + "label": "Header Area", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": false + }, + { + "id": "body-area", + "label": "Body Area", + "optional": false + }, + { + "id": "description", + "label": "Description", + "optional": true + }, + { + "id": "footer-area", + "label": "Footer Area", + "optional": false + }, + { + "id": "footer-content", + "label": "Footer Content", + "optional": true + }, + { + "id": "button-group", + "label": "Button Group", + "optional": false + }, + { + "id": "overlay", + "label": "Overlay", + "optional": false + } + ] + }, + "standard-panel": { + "label": "standard panel", + "source": "docs/s2-docs/components/containers/standard-panel.md", + "parts": [ + { + "id": "header-content-area", + "label": "Header Content Area", + "optional": false + }, + { + "id": "accordion", + "label": "Accordion", + "optional": false + }, + { + "id": "back-button", + "label": "Back Button", + "optional": false + }, + { + "id": "close-button", + "label": "Close Button", + "optional": true + }, + { + "id": "gripper", + "label": "Gripper", + "optional": true + } + ] + }, + "status-light": { + "label": "status light", + "source": "docs/s2-docs/components/status/status-light.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "dot", + "label": "Dot", + "optional": false + } + ] + }, + "steplist": { + "label": "steplist", + "source": "docs/s2-docs/components/status/steplist.md", + "parts": [ + { + "id": "step-item", + "label": "Step Item", + "optional": false + }, + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": true + } + ] + }, + "swatch": { + "label": "swatch", + "source": "docs/s2-docs/components/inputs/swatch.md", + "parts": [ + { + "id": "container", + "label": "Container", + "optional": false + }, + { + "id": "color", + "label": "Color", + "optional": false + } + ] + }, + "swatch-group": { + "label": "swatch group", + "source": "docs/s2-docs/components/inputs/swatch-group.md", + "parts": [ + { + "id": "swatch", + "label": "Swatch", + "optional": false + } + ] + }, + "switch": { + "label": "switch", + "source": "docs/s2-docs/components/inputs/switch.md", + "parts": [ + { + "id": "track", + "label": "Track", + "optional": false + }, + { + "id": "handle", + "label": "Handle", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + } + ] + }, + "table": { + "label": "table", + "source": "docs/s2-docs/components/containers/table.md", + "parts": [ + { + "id": "column-header", + "label": "Column Header", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "sort-icon", + "label": "Sort Icon", + "optional": false + }, + { + "id": "column-divider", + "label": "Column Divider", + "optional": false + }, + { + "id": "row", + "label": "Row", + "optional": false + }, + { + "id": "cell", + "label": "Cell", + "optional": false + }, + { + "id": "row-divider", + "label": "Row Divider", + "optional": false + } + ] + }, + "tabs": { + "label": "tabs", + "source": "docs/s2-docs/components/navigation/tabs.md", + "parts": [ + { + "id": "tab-item", + "label": "Tab Item", + "optional": false + }, + { + "id": "selection-indicator", + "label": "Selection Indicator", + "optional": false + } + ] + }, + "tag": { + "label": "tag", + "source": "docs/s2-docs/components/inputs/tag.md", + "parts": [ + { + "id": "avatar", + "label": "Avatar", + "optional": true + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "close-button", + "label": "Close Button", + "optional": true + } + ] + }, + "tag-field": { + "label": "tag field", + "source": "docs/s2-docs/components/inputs/tag-field.md", + "parts": [ + { + "id": "field-label", + "label": "Field Label", + "optional": false + }, + { + "id": "text-area", + "label": "Text Area", + "optional": false + }, + { + "id": "tag", + "label": "Tag", + "optional": false + }, + { + "id": "avatar", + "label": "Avatar", + "optional": true + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "close-button", + "label": "Close Button", + "optional": true + } + ] + }, + "tag-group": { + "label": "tag group", + "source": "docs/s2-docs/components/inputs/tag-group.md", + "parts": [ + { + "id": "field-label", + "label": "Field Label", + "optional": false + }, + { + "id": "tag", + "label": "Tag", + "optional": false + }, + { + "id": "action-button", + "label": "Action Button", + "optional": false + } + ] + }, + "takeover-dialog": { + "label": "takeover dialog", + "source": "docs/s2-docs/components/feedback/takeover-dialog.md", + "parts": [ + { + "id": "takeover-dialog-container", + "label": "Takeover Dialog Container", + "optional": false + }, + { + "id": "header-area", + "label": "Header Area", + "optional": false + }, + { + "id": "title", + "label": "Title", + "optional": false + }, + { + "id": "header-content", + "label": "Header Content", + "optional": true + }, + { + "id": "button-group", + "label": "Button Group", + "optional": false + }, + { + "id": "body-area", + "label": "Body Area", + "optional": false + }, + { + "id": "body-content", + "label": "Body Content", + "optional": true + }, + { + "id": "overlay", + "label": "Overlay", + "optional": false + } + ] + }, + "text-area": { + "label": "text area", + "source": "docs/s2-docs/components/inputs/text-area.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "required-asterisk", + "label": "Required Asterisk", + "optional": false + }, + { + "id": "required-text", + "label": "Required Text", + "optional": false + }, + { + "id": "optional-text", + "label": "Optional Text", + "optional": false + }, + { + "id": "character-count", + "label": "Character Count", + "optional": false + }, + { + "id": "field", + "label": "Field", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + }, + { + "id": "validation-marker", + "label": "Validation Marker", + "optional": false + }, + { + "id": "error-icon", + "label": "Error Icon", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": false + } + ] + }, + "text-field": { + "label": "text field", + "source": "docs/s2-docs/components/inputs/text-field.md", + "parts": [ + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "required-asterisk", + "label": "Required Asterisk", + "optional": false + }, + { + "id": "required-text", + "label": "Required Text", + "optional": false + }, + { + "id": "optional-text", + "label": "Optional Text", + "optional": false + }, + { + "id": "character-count", + "label": "Character Count", + "optional": false + }, + { + "id": "field", + "label": "Field", + "optional": false + }, + { + "id": "value", + "label": "Value", + "optional": false + }, + { + "id": "validation-marker", + "label": "Validation Marker", + "optional": false + }, + { + "id": "error-icon", + "label": "Error Icon", + "optional": false + }, + { + "id": "help-text", + "label": "Help Text", + "optional": false + } + ] + }, + "thumbnail": { + "label": "thumbnail", + "source": "docs/s2-docs/components/inputs/thumbnail.md", + "parts": [ + { + "id": "container", + "label": "Container", + "optional": false + }, + { + "id": "image", + "label": "Image", + "optional": false + } + ] + }, + "toast": { + "label": "toast", + "source": "docs/s2-docs/components/feedback/toast.md", + "parts": [ + { + "id": "background", + "label": "Background", + "optional": false + }, + { + "id": "icon", + "label": "Icon", + "optional": false + }, + { + "id": "text", + "label": "Text", + "optional": false + }, + { + "id": "button", + "label": "Button", + "optional": false + }, + { + "id": "close-button", + "label": "Close Button", + "optional": false + } + ] + }, + "tooltip": { + "label": "tooltip", + "source": "docs/s2-docs/components/feedback/tooltip.md", + "parts": [ + { + "id": "background", + "label": "Background", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "tip", + "label": "Tip", + "optional": false + } + ] + }, + "tree-view": { + "label": "tree view", + "source": "docs/s2-docs/components/navigation/tree-view.md", + "parts": [ + { + "id": "header", + "label": "Header", + "optional": true + }, + { + "id": "tree-view-item", + "label": "Tree View Item", + "optional": false + }, + { + "id": "label", + "label": "Label", + "optional": false + }, + { + "id": "collapse", + "label": "Collapse", + "optional": false + }, + { + "id": "expand-button", + "label": "Expand Button", + "optional": false + }, + { + "id": "checkbox", + "label": "Checkbox", + "optional": true + }, + { + "id": "drag-icon", + "label": "Drag Icon", + "optional": true + }, + { + "id": "context-area", + "label": "Context Area", + "optional": true + }, + { + "id": "actions-area", + "label": "Actions Area", + "optional": true + }, + { + "id": "in-field-progress-circle", + "label": "In Field Progress Circle", + "optional": false + } + ] + } + } +} diff --git a/packages/design-system-registry/scripts/extract-component-anatomy.js b/packages/design-system-registry/scripts/extract-component-anatomy.js new file mode 100644 index 00000000..116f4eaf --- /dev/null +++ b/packages/design-system-registry/scripts/extract-component-anatomy.js @@ -0,0 +1,236 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Extract component anatomy data from S2 documentation markdown files + * and write a structured component-anatomy.json registry. + * + * Usage: node packages/design-system-registry/scripts/extract-component-anatomy.js + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { join, basename, dirname, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const REPO_ROOT = join(__dirname, "../../.."); +const S2_DOCS_DIR = join(REPO_ROOT, "docs/s2-docs/components"); +const OUTPUT_PATH = join(__dirname, "../registry/component-anatomy.json"); + +/** + * Convert a display name to a kebab-case ID. + * "Action Button" → "action-button" + * "hold icon" → "hold-icon" + */ +export function toKebabCase(name) { + return name + .replace(/([a-z])([A-Z])/g, "$1-$2") // camelCase → kebab + .toLowerCase() + .replace(/\s+/g, "-"); +} + +/** + * Convert a kebab-case ID to a title-case label. + * "hold-icon" → "Hold Icon" + */ +function toTitleCase(kebab) { + return kebab + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +/** + * Split a text by ", ", " or ", and " and " separators, + * but only when they appear outside parentheses. + * e.g. "icon (optional) and text (description or error message)" + * → ["icon (optional)", "text (description or error message)"] + * e.g. "required asterisk, required text, or optional text" + * → ["required asterisk", "required text", "optional text"] + */ +export function splitOutsideParens(text) { + // Replace content inside parens with placeholders to avoid splitting inside them + const placeholders = []; + const masked = text.replace(/\([^)]*\)/g, (match) => { + placeholders.push(match); + return `\x00${placeholders.length - 1}\x00`; + }); + + // Split on ", or " / ", " / " or " / " and " / " / " (try longest matches first) + const segments = masked.split(/,\s+or\s+|,\s+|\s+or\s+|\s+and\s+|\s+\/\s+/); + + // Restore placeholders + return segments + .map((s) => + s.replace(/\x00(\d+)\x00/g, (_, idx) => placeholders[Number(idx)]).trim(), + ) + .filter((s) => s.length > 0); +} + +/** + * Parse a single part line like "cover image (optional)" or "preview (asset)". + * Returns { name, annotation } where annotation may be null. + */ +export function parsePartLine(line) { + const trimmed = line.replace(/^\s*-\s*/, "").trim(); + if (!trimmed) return null; + + const match = trimmed.match(/^(.+?)\s*\(([^)]+)\)\s*$/); + if (match) { + return { name: match[1].trim(), annotation: match[2].trim() }; + } + return { name: trimmed, annotation: null }; +} + +/** + * Parse the anatomy code block content into structured data. + * + * @param {string} blockContent - The text inside the fenced code block + * @returns {{ componentName: string, parts: Array<{ id: string, label: string, optional: boolean }> }} + */ +export function parseAnatomyBlock(blockContent) { + const lines = blockContent.split("\n").filter((l) => l.trim().length > 0); + if (lines.length === 0) return null; + + // First line is the component name (strip any annotation) + const rootParsed = parsePartLine("- " + lines[0]); + const componentName = rootParsed ? rootParsed.name : lines[0].trim(); + + const partsMap = new Map(); + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + // Skip lines that don't start with - (after stripping whitespace) + if (!line.trim().startsWith("-")) continue; + + // Handle compound/alternative lines: + // " and " = co-existing parts: "icon (optional) and text (description)" + // ", " or " or " = alternatives: "required asterisk, required text, or optional text" + // Split on these separators only when they appear outside parentheses. + const rawText = line.replace(/^\s*-\s*/, ""); + const compoundParts = splitOutsideParens(rawText).map( + (p) => "- " + p.trim(), + ); + + for (const part of compoundParts) { + const parsed = parsePartLine(part); + if (!parsed) continue; + + const id = toKebabCase(parsed.name); + const optional = + parsed.annotation !== null && + parsed.annotation.toLowerCase().includes("optional"); + + // Deduplicate by ID (keep the first occurrence, but mark optional + // only if ALL occurrences are optional) + if (!partsMap.has(id)) { + partsMap.set(id, { + id, + label: toTitleCase(id), + optional, + }); + } + } + } + + return { + componentName, + parts: [...partsMap.values()], + }; +} + +/** + * Extract the anatomy code block from a markdown file's content. + * Returns the block content or null if not found. + */ +export function extractAnatomyBlock(markdown) { + // Match ## Anatomy followed by a fenced code block + const match = markdown.match(/## Anatomy\s*\n+```[^\n]*\n([\s\S]*?)```/); + return match ? match[1] : null; +} + +/** + * Recursively find all .md files in a directory. + */ +function findMarkdownFiles(dir) { + const results = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findMarkdownFiles(fullPath)); + } else if (entry.name.endsWith(".md")) { + results.push(fullPath); + } + } + return results; +} + +function main() { + const files = findMarkdownFiles(S2_DOCS_DIR).sort(); + const components = {}; + const skipped = []; + + for (const filePath of files) { + const markdown = readFileSync(filePath, "utf-8"); + const block = extractAnatomyBlock(markdown); + const componentId = basename(filePath, ".md"); + const relPath = relative(REPO_ROOT, filePath); + + if (!block) { + skipped.push(componentId); + continue; + } + + const parsed = parseAnatomyBlock(block); + if (!parsed || parsed.parts.length === 0) { + skipped.push(componentId); + continue; + } + + components[componentId] = { + label: parsed.componentName, + source: relPath, + parts: parsed.parts, + }; + } + + // Sort by component ID + const sorted = {}; + for (const key of Object.keys(components).sort()) { + sorted[key] = components[key]; + } + + const output = { + type: "component-anatomy", + description: + "Mapping of Spectrum components to their visible anatomy parts, extracted from S2 documentation.", + components: sorted, + }; + + writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2) + "\n", "utf-8"); + + const componentCount = Object.keys(sorted).length; + const partCount = Object.values(sorted).reduce( + (sum, c) => sum + c.parts.length, + 0, + ); + console.log( + `Generated component-anatomy.json: ${componentCount} components, ${partCount} total parts`, + ); + if (skipped.length > 0) { + console.log(`Skipped (no anatomy section): ${skipped.join(", ")}`); + } +} + +main(); diff --git a/packages/design-system-registry/test/component-anatomy.test.js b/packages/design-system-registry/test/component-anatomy.test.js new file mode 100644 index 00000000..ee46cd88 --- /dev/null +++ b/packages/design-system-registry/test/component-anatomy.test.js @@ -0,0 +1,211 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + parseAnatomyBlock, + extractAnatomyBlock, + parsePartLine, + toKebabCase, +} from "../scripts/extract-component-anatomy.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REGISTRY_PATH = join(__dirname, "../registry/component-anatomy.json"); + +// --- Unit tests for parsing helpers --- + +test("toKebabCase converts display names", (t) => { + t.is(toKebabCase("Action Button"), "action-button"); + t.is(toKebabCase("hold icon"), "hold-icon"); + t.is(toKebabCase("label"), "label"); + t.is(toKebabCase("cover image"), "cover-image"); +}); + +test("parsePartLine extracts name and annotation", (t) => { + t.deepEqual(parsePartLine("- label"), { name: "label", annotation: null }); + t.deepEqual(parsePartLine(" - cover image (optional)"), { + name: "cover image", + annotation: "optional", + }); + t.deepEqual(parsePartLine("- preview (asset)"), { + name: "preview", + annotation: "asset", + }); + t.is(parsePartLine("- "), null); +}); + +test("parseAnatomyBlock: button anatomy", (t) => { + const result = parseAnatomyBlock("button\n- label"); + t.is(result.componentName, "button"); + t.is(result.parts.length, 1); + t.is(result.parts[0].id, "label"); + t.is(result.parts[0].optional, false); +}); + +test("parseAnatomyBlock: slider with optional parts", (t) => { + const block = `slider +- label (optional) +- value (optional) +- track +- fill +- handle`; + const result = parseAnatomyBlock(block); + t.is(result.componentName, "slider"); + t.is(result.parts.length, 5); + t.is(result.parts[0].id, "label"); + t.is(result.parts[0].optional, true); + t.is(result.parts[2].id, "track"); + t.is(result.parts[2].optional, false); +}); + +test("parseAnatomyBlock: deduplicates by ID", (t) => { + const block = `tabs +- tab item (selected) +- tab item +- selection indicator`; + const result = parseAnatomyBlock(block); + const ids = result.parts.map((p) => p.id); + t.deepEqual(ids, ["tab-item", "selection-indicator"]); +}); + +test("parseAnatomyBlock: compound line with and", (t) => { + const block = `help text (placed under the input) +- icon (optional) and text (description or error message)`; + const result = parseAnatomyBlock(block); + t.is(result.componentName, "help text"); + t.is(result.parts.length, 2); + t.is(result.parts[0].id, "icon"); + t.is(result.parts[0].optional, true); + t.is(result.parts[1].id, "text"); + t.is(result.parts[1].optional, false); +}); + +test("parseAnatomyBlock: strips root annotation", (t) => { + const block = `help text (placed under the input) +- icon (optional)`; + const result = parseAnatomyBlock(block); + t.is(result.componentName, "help text"); +}); + +test("extractAnatomyBlock: extracts from markdown", (t) => { + const md = `# Button + +## Anatomy + +\`\`\` +button +- label +\`\`\` + +## Component options +`; + const block = extractAnatomyBlock(md); + t.truthy(block); + t.true(block.includes("button")); + t.true(block.includes("- label")); +}); + +test("extractAnatomyBlock: returns null when no anatomy section", (t) => { + const md = `# Link\n\n## Overview\n\nSome text.`; + t.is(extractAnatomyBlock(md), null); +}); + +// --- Tests against the generated registry file --- + +const registry = JSON.parse(readFileSync(REGISTRY_PATH, "utf-8")); + +test("generated registry has type and description", (t) => { + t.is(registry.type, "component-anatomy"); + t.truthy(registry.description); +}); + +test("generated registry has 60+ components", (t) => { + const count = Object.keys(registry.components).length; + t.true(count >= 60, `Expected 60+ components, got ${count}`); +}); + +test("button has label", (t) => { + const parts = registry.components.button.parts.map((p) => p.id); + t.deepEqual(parts, ["label"]); +}); + +test("slider has track, fill, handle", (t) => { + const parts = registry.components.slider.parts.map((p) => p.id); + t.true(parts.includes("track")); + t.true(parts.includes("fill")); + t.true(parts.includes("handle")); +}); + +test("slider label is optional, track is not", (t) => { + const slider = registry.components.slider; + const label = slider.parts.find((p) => p.id === "label"); + const track = slider.parts.find((p) => p.id === "track"); + t.is(label.optional, true); + t.is(track.optional, false); +}); + +test("checkbox has control and label", (t) => { + const parts = registry.components.checkbox.parts.map((p) => p.id); + t.deepEqual(parts, ["control", "label"]); +}); + +test("standard-dialog has 10+ parts", (t) => { + const count = registry.components["standard-dialog"].parts.length; + t.true(count >= 10, `Expected 10+ parts, got ${count}`); +}); + +test("action-button hold-icon is optional", (t) => { + const ab = registry.components["action-button"]; + const holdIcon = ab.parts.find((p) => p.id === "hold-icon"); + t.truthy(holdIcon); + t.is(holdIcon.optional, true); +}); + +test("tabs deduplicated tab-item", (t) => { + const parts = registry.components.tabs.parts.map((p) => p.id); + const tabItemCount = parts.filter((p) => p === "tab-item").length; + t.is(tabItemCount, 1); +}); + +test("all part IDs are kebab-case", (t) => { + for (const [componentId, component] of Object.entries(registry.components)) { + for (const part of component.parts) { + t.regex( + part.id, + /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, + `${componentId} part "${part.id}" is not kebab-case`, + ); + } + } +}); + +test("all components have at least one part", (t) => { + for (const [componentId, component] of Object.entries(registry.components)) { + t.true(component.parts.length > 0, `${componentId} has no anatomy parts`); + } +}); + +test("no duplicate part IDs within a component", (t) => { + for (const [componentId, component] of Object.entries(registry.components)) { + const ids = component.parts.map((p) => p.id); + const unique = new Set(ids); + t.is( + ids.length, + unique.size, + `${componentId} has duplicate part IDs: ${ids}`, + ); + } +}); diff --git a/tools/token-name-builder/src/component-awareness.ts b/tools/token-name-builder/src/component-awareness.ts index efbdafe6..94f9bcdd 100644 --- a/tools/token-name-builder/src/component-awareness.ts +++ b/tools/token-name-builder/src/component-awareness.ts @@ -10,29 +10,21 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { registries } from "./registry-data.js"; +import { componentAnatomy } from "./registry-data.js"; -/** Options derived from a component's JSON schema. */ +/** Options derived from a component's anatomy registry and JSON schema. */ export interface ComponentOptions { - /** Schema property keys that match registered anatomy terms. */ + /** Anatomy parts for this component (from S2 docs via component-anatomy.json). */ anatomy: string[]; - /** Allowed variant values for this component. */ + /** Allowed variant values (from component schema). */ variants: string[]; - /** Allowed state values for this component. */ + /** Allowed state values (from component schema). */ states: string[]; - /** Allowed size values for this component. */ + /** Allowed size values (from component schema). */ sizes: string[]; } -// Set of active anatomy term IDs from the registry, for fast lookup. -const ANATOMY_IDS = new Set( - (registries.anatomy?.values ?? []) - .filter((v) => !v.deprecated) - .map((v) => v.id), -); - -// Lazily loaded component schema modules, keyed by relative path. -// import.meta.glob paths must be literal strings relative to this file. +// Lazily loaded component schema modules for variant/state/size extraction. const schemaModules = import.meta.glob( "../../../packages/component-schemas/schemas/components/*.json", ); @@ -51,28 +43,46 @@ interface ComponentSchemaModule { } /** - * Dynamically load a component schema and extract token-relevant options. - * Returns null if the component has no schema file. + * Get component options by combining: + * - Anatomy from the component-anatomy registry (extracted from S2 docs) + * - Variant/state/size from the component JSON schema + * + * Returns null only if neither data source has data for this component. */ export async function loadComponentOptions( componentId: string, ): Promise { if (!componentId) return null; - // Find the matching module key (ends with `/${componentId}.json`) + // Anatomy from the component-anatomy registry (synchronous lookup) + const anatomyEntry = componentAnatomy.components[componentId]; + const anatomy = anatomyEntry ? anatomyEntry.parts.map((p) => p.id) : []; + + // Variant/state/size from component schema (async, lazy loaded) + let variants: string[] = []; + let states: string[] = []; + let sizes: string[] = []; + const key = Object.keys(schemaModules).find((k) => k.endsWith(`/${componentId}.json`), ); - if (!key) return null; + if (key) { + const mod = (await schemaModules[key]()) as ComponentSchemaModule; + const props = mod.default?.properties ?? {}; + variants = props.variant?.enum ?? []; + states = props.state?.enum ?? []; + sizes = props.size?.enum ?? []; + } - const mod = (await schemaModules[key]()) as ComponentSchemaModule; - const props = mod.default?.properties ?? {}; + // Return null only if we have no data at all + if ( + anatomy.length === 0 && + variants.length === 0 && + states.length === 0 && + sizes.length === 0 + ) { + return null; + } - return { - // Anatomy: schema property keys that are registered anatomy terms - anatomy: Object.keys(props).filter((k) => ANATOMY_IDS.has(k)), - variants: props.variant?.enum ?? [], - states: props.state?.enum ?? [], - sizes: props.size?.enum ?? [], - }; + return { anatomy, variants, states, sizes }; } diff --git a/tools/token-name-builder/src/registry-data.ts b/tools/token-name-builder/src/registry-data.ts index ffecbe65..ff2b8240 100644 --- a/tools/token-name-builder/src/registry-data.ts +++ b/tools/token-name-builder/src/registry-data.ts @@ -25,6 +25,7 @@ import positionsJson from "@registry/positions.json"; import sizesJson from "@registry/sizes.json"; import densitiesJson from "@registry/densities.json"; import shapesJson from "@registry/shapes.json"; +import componentAnatomyJson from "@registry/component-anatomy.json"; /** A single value entry from a registry file. */ export interface RegistryValue { @@ -115,3 +116,28 @@ export function findValue( export function hasValue(registry: Registry, searchTerm: string): boolean { return findValue(registry, searchTerm) !== undefined; } + +/** A single anatomy part for a component. */ +export interface AnatomyPart { + id: string; + label: string; + optional: boolean; +} + +/** A component's anatomy entry. */ +export interface ComponentAnatomyEntry { + label: string; + source: string; + parts: AnatomyPart[]; +} + +/** The component-anatomy registry shape. */ +export interface ComponentAnatomyRegistry { + type: string; + description: string; + components: Record; +} + +/** Component → anatomy parts mapping, extracted from S2 docs. */ +export const componentAnatomy = + componentAnatomyJson as unknown as ComponentAnatomyRegistry; From 5c79887fa4556803a29f72939d60cca87cbac567 Mon Sep 17 00:00:00 2001 From: garthdb Date: Mon, 6 Apr 2026 13:03:17 -0600 Subject: [PATCH 4/5] feat(registry): add curation layer for component-anatomy registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add component-anatomy-curation.json that documents and applies rules for divergences between raw S2 docs extraction and the curated registry: - Removals: action-button-1/2 (numbering artifacts), background (token object, not anatomy) - Renames: body-area→body, header-area→header, footer-area→footer, small-divider→divider (align with existing anatomy-terms registry) - Tags: 17 composite anatomy parts (components used as named parts) tagged with tier: "composite" per spec anatomy tier 2 The curation config is the single source of truth for why the registry diverges from the S2 docs. Re-running the extraction script applies all rules automatically. Result: 65 components, 282 parts (was 288 before curation) Co-Authored-By: Claude Sonnet 4.6 --- .../registry/component-anatomy-curation.json | 99 ++++++++ .../registry/component-anatomy.json | 211 ++++++++++-------- .../scripts/extract-component-anatomy.js | 89 +++++++- .../test/component-anatomy.test.js | 44 ++++ 4 files changed, 344 insertions(+), 99 deletions(-) create mode 100644 packages/design-system-registry/registry/component-anatomy-curation.json diff --git a/packages/design-system-registry/registry/component-anatomy-curation.json b/packages/design-system-registry/registry/component-anatomy-curation.json new file mode 100644 index 00000000..7e760ec9 --- /dev/null +++ b/packages/design-system-registry/registry/component-anatomy-curation.json @@ -0,0 +1,99 @@ +{ + "$comment": "Curation rules applied after extracting raw anatomy data from S2 docs. Documents why the registry diverges from the source markdown. Re-run the extraction script to apply.", + + "removals": { + "action-button-1": "Numbering artifact from action-group diagram; not a distinct anatomy term. Use 'action-button' instead.", + "action-button-2": "Numbering artifact from action-group diagram; not a distinct anatomy term. Use 'action-button' instead.", + "background": "This is a token object / styling surface (belongs in the 'object' field of the name object, not 'anatomy'). See token-objects.json." + }, + + "renames": { + "body-area": { + "to": "body", + "reason": "Redundant '-area' suffix; aligns with existing anatomy-terms registry term 'body'." + }, + "header-area": { + "to": "header", + "reason": "Redundant '-area' suffix; aligns with existing anatomy-terms registry term 'header'." + }, + "footer-area": { + "to": "footer", + "reason": "Redundant '-area' suffix; aligns with existing anatomy-terms registry term 'footer'." + }, + "small-divider": { + "to": "divider", + "reason": "Size qualifier is not an anatomy distinction; aligns with existing anatomy-terms registry term 'divider'." + } + }, + + "tags": { + "accordion": { + "tier": "composite", + "note": "Component used as a named part in standard-panel (spec anatomy tier 2)." + }, + "action-button": { + "tier": "composite", + "note": "Component used as a named part in action-group and contextual-help." + }, + "button": { + "tier": "composite", + "note": "Component used as a named part in alert-banner, coach-mark, drop-zone, illustrated-message." + }, + "button-group": { + "tier": "composite", + "note": "Component used as a named part in alert-dialog, coach-mark, standard-dialog, takeover-dialog." + }, + "calendar": { + "tier": "composite", + "note": "Component used as a named part in date-picker." + }, + "checkbox": { + "tier": "composite", + "note": "Component used as a named part in cards, list-view, menu, select-box, table, tree-view." + }, + "close-button": { + "tier": "composite", + "note": "Component used as a named part in alert-banner, coach-mark, standard-dialog, takeover-dialog, toast, in-line-alert." + }, + "field-label": { + "tier": "composite", + "note": "Component used as a named part in combo-box, date-picker, radio-group, slider." + }, + "help-text": { + "tier": "composite", + "note": "Component used as a named part in combo-box, number-field, picker, search-field, text-area, text-field, checkbox-group, radio-group." + }, + "link": { + "tier": "composite", + "note": "Component used as a named part in contextual-help." + }, + "popover": { + "tier": "composite", + "note": "Component used as a named part in contextual-help, menu." + }, + "swatch": { + "tier": "composite", + "note": "Component used as a named part in swatch-group." + }, + "switch": { + "tier": "composite", + "note": "Component used as a named part in menu." + }, + "tag": { + "tier": "composite", + "note": "Component used as a named part in tag-field, tag-group." + }, + "text-area": { + "tier": "composite", + "note": "Component used as a named part in tag-field." + }, + "avatar": { + "tier": "composite", + "note": "Component used as a named part in avatar-group, list-view, tag." + }, + "thumbnail": { + "tier": "composite", + "note": "Component used as a named part in list-view, menu." + } + } +} diff --git a/packages/design-system-registry/registry/component-anatomy.json b/packages/design-system-registry/registry/component-anatomy.json index ccc31598..e97a1e5f 100644 --- a/packages/design-system-registry/registry/component-anatomy.json +++ b/packages/design-system-registry/registry/component-anatomy.json @@ -1,6 +1,6 @@ { "type": "component-anatomy", - "description": "Mapping of Spectrum components to their visible anatomy parts, extracted from S2 documentation.", + "description": "Mapping of Spectrum components to their visible anatomy parts, extracted from S2 documentation and curated via component-anatomy-curation.json.", "components": { "accordion": { "label": "accordion", @@ -12,8 +12,8 @@ "optional": false }, { - "id": "small-divider", - "label": "Small Divider", + "id": "divider", + "label": "Divider", "optional": false } ] @@ -43,16 +43,6 @@ "label": "action group", "source": "docs/s2-docs/components/actions/action-group.md", "parts": [ - { - "id": "action-button-1", - "label": "Action Button 1", - "optional": false - }, - { - "id": "action-button-2", - "label": "Action Button 2", - "optional": false - }, { "id": "action-menu", "label": "Action Menu", @@ -64,11 +54,6 @@ "label": "alert banner", "source": "docs/s2-docs/components/feedback/alert-banner.md", "parts": [ - { - "id": "background", - "label": "Background", - "optional": false - }, { "id": "icon", "label": "Icon", @@ -82,12 +67,14 @@ { "id": "button", "label": "Button", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "close-button", "label": "Close Button", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -170,7 +157,8 @@ { "id": "avatar", "label": "Avatar", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "label", @@ -239,7 +227,8 @@ { "id": "button", "label": "Button", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "label", @@ -291,7 +280,8 @@ { "id": "checkbox", "label": "Checkbox", - "optional": true + "optional": true, + "tier": "composite" }, { "id": "preview-well", @@ -309,8 +299,8 @@ "optional": false }, { - "id": "footer-area", - "label": "Footer Area", + "id": "footer", + "label": "Footer", "optional": false }, { @@ -343,17 +333,20 @@ { "id": "field-label", "label": "Field Label", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "checkbox", "label": "Checkbox", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "help-text", "label": "Help Text", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -426,7 +419,8 @@ { "id": "button-group", "label": "Button Group", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -546,7 +540,8 @@ { "id": "help-text", "label": "Help Text", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "menu-container", @@ -562,12 +557,14 @@ { "id": "action-button", "label": "Action Button", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "popover", "label": "Popover", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "title", @@ -582,7 +579,8 @@ { "id": "link", "label": "Link", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -598,7 +596,8 @@ { "id": "calendar", "label": "Calendar", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "time-field", @@ -629,7 +628,8 @@ { "id": "button", "label": "Button", - "optional": true + "optional": true, + "tier": "composite" } ] }, @@ -692,7 +692,8 @@ { "id": "button-group", "label": "Button Group", - "optional": true + "optional": true, + "tier": "composite" } ] }, @@ -700,11 +701,6 @@ "label": "in-line alert", "source": "docs/s2-docs/components/feedback/in-line-alert.md", "parts": [ - { - "id": "background", - "label": "Background", - "optional": false - }, { "id": "title", "label": "Title", @@ -716,8 +712,8 @@ "optional": false }, { - "id": "body-area", - "label": "Body Area", + "id": "body", + "label": "Body", "optional": false } ] @@ -739,7 +735,8 @@ { "id": "checkbox", "label": "Checkbox", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "icon", @@ -749,7 +746,8 @@ { "id": "thumbnail", "label": "Thumbnail", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "label", @@ -780,7 +778,8 @@ { "id": "popover", "label": "Popover", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "menu-section-header", @@ -820,17 +819,20 @@ { "id": "switch", "label": "Switch", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "checkbox", "label": "Checkbox", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "thumbnail", "label": "Thumbnail", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "drill-in-chevron", @@ -927,7 +929,8 @@ { "id": "help-text", "label": "Help Text", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -983,7 +986,8 @@ { "id": "help-text", "label": "Help Text", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "menu-container", @@ -1068,7 +1072,8 @@ { "id": "field-label", "label": "Field Label", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "radio-button-area", @@ -1078,7 +1083,8 @@ { "id": "help-text", "label": "Help Text", - "optional": true + "optional": true, + "tier": "composite" } ] }, @@ -1125,7 +1131,8 @@ { "id": "help-text", "label": "Help Text", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -1177,7 +1184,8 @@ { "id": "checkbox", "label": "Checkbox", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -1255,11 +1263,12 @@ { "id": "close-button", "label": "Close Button", - "optional": true + "optional": true, + "tier": "composite" }, { - "id": "header-area", - "label": "Header Area", + "id": "header", + "label": "Header", "optional": false }, { @@ -1268,8 +1277,8 @@ "optional": false }, { - "id": "body-area", - "label": "Body Area", + "id": "body", + "label": "Body", "optional": false }, { @@ -1278,8 +1287,8 @@ "optional": true }, { - "id": "footer-area", - "label": "Footer Area", + "id": "footer", + "label": "Footer", "optional": false }, { @@ -1290,7 +1299,8 @@ { "id": "button-group", "label": "Button Group", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "overlay", @@ -1311,7 +1321,8 @@ { "id": "accordion", "label": "Accordion", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "back-button", @@ -1321,7 +1332,8 @@ { "id": "close-button", "label": "Close Button", - "optional": true + "optional": true, + "tier": "composite" }, { "id": "gripper", @@ -1390,7 +1402,8 @@ { "id": "swatch", "label": "Swatch", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -1479,7 +1492,8 @@ { "id": "avatar", "label": "Avatar", - "optional": true + "optional": true, + "tier": "composite" }, { "id": "label", @@ -1489,7 +1503,8 @@ { "id": "close-button", "label": "Close Button", - "optional": true + "optional": true, + "tier": "composite" } ] }, @@ -1500,22 +1515,26 @@ { "id": "field-label", "label": "Field Label", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "text-area", "label": "Text Area", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "tag", "label": "Tag", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "avatar", "label": "Avatar", - "optional": true + "optional": true, + "tier": "composite" }, { "id": "label", @@ -1525,7 +1544,8 @@ { "id": "close-button", "label": "Close Button", - "optional": true + "optional": true, + "tier": "composite" } ] }, @@ -1536,17 +1556,20 @@ { "id": "field-label", "label": "Field Label", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "tag", "label": "Tag", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "action-button", "label": "Action Button", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -1560,8 +1583,8 @@ "optional": false }, { - "id": "header-area", - "label": "Header Area", + "id": "header", + "label": "Header", "optional": false }, { @@ -1577,11 +1600,12 @@ { "id": "button-group", "label": "Button Group", - "optional": false + "optional": false, + "tier": "composite" }, { - "id": "body-area", - "label": "Body Area", + "id": "body", + "label": "Body", "optional": false }, { @@ -1648,7 +1672,8 @@ { "id": "help-text", "label": "Help Text", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -1704,7 +1729,8 @@ { "id": "help-text", "label": "Help Text", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -1728,11 +1754,6 @@ "label": "toast", "source": "docs/s2-docs/components/feedback/toast.md", "parts": [ - { - "id": "background", - "label": "Background", - "optional": false - }, { "id": "icon", "label": "Icon", @@ -1746,12 +1767,14 @@ { "id": "button", "label": "Button", - "optional": false + "optional": false, + "tier": "composite" }, { "id": "close-button", "label": "Close Button", - "optional": false + "optional": false, + "tier": "composite" } ] }, @@ -1759,11 +1782,6 @@ "label": "tooltip", "source": "docs/s2-docs/components/feedback/tooltip.md", "parts": [ - { - "id": "background", - "label": "Background", - "optional": false - }, { "id": "label", "label": "Label", @@ -1808,7 +1826,8 @@ { "id": "checkbox", "label": "Checkbox", - "optional": true + "optional": true, + "tier": "composite" }, { "id": "drag-icon", diff --git a/packages/design-system-registry/scripts/extract-component-anatomy.js b/packages/design-system-registry/scripts/extract-component-anatomy.js index 116f4eaf..9bc3464c 100644 --- a/packages/design-system-registry/scripts/extract-component-anatomy.js +++ b/packages/design-system-registry/scripts/extract-component-anatomy.js @@ -27,6 +27,10 @@ const __dirname = dirname(__filename); const REPO_ROOT = join(__dirname, "../../.."); const S2_DOCS_DIR = join(REPO_ROOT, "docs/s2-docs/components"); const OUTPUT_PATH = join(__dirname, "../registry/component-anatomy.json"); +const CURATION_PATH = join( + __dirname, + "../registry/component-anatomy-curation.json", +); /** * Convert a display name to a kebab-case ID. @@ -176,6 +180,74 @@ function findMarkdownFiles(dir) { return results; } +/** + * Load the curation config and apply removals, renames, and tags + * to the extracted component data. + */ +export function applyCuration(components, curationPath) { + let curation; + try { + curation = JSON.parse(readFileSync(curationPath, "utf-8")); + } catch { + // No curation file — return as-is + return { components, stats: { removed: 0, renamed: 0, tagged: 0 } }; + } + + const removals = curation.removals ?? {}; + const renames = curation.renames ?? {}; + const tags = curation.tags ?? {}; + let removedCount = 0; + let renamedCount = 0; + let taggedCount = 0; + + for (const [componentId, component] of Object.entries(components)) { + const curated = []; + const seenIds = new Set(); + + for (const part of component.parts) { + let { id } = part; + + // Apply removals + if (id in removals) { + removedCount++; + continue; + } + + // Apply renames + if (id in renames) { + const newId = renames[id].to; + id = newId; + part.id = newId; + part.label = toTitleCase(newId); + renamedCount++; + } + + // Apply tags + if (id in tags) { + part.tier = tags[id].tier; + taggedCount++; + } + + // Deduplicate after rename (e.g., footer-area→footer may collide) + if (!seenIds.has(id)) { + seenIds.add(id); + curated.push(part); + } + } + + component.parts = curated; + } + + return { + components, + stats: { + removed: removedCount, + renamed: renamedCount, + tagged: taggedCount, + }, + }; +} + function main() { const files = findMarkdownFiles(S2_DOCS_DIR).sort(); const components = {}; @@ -205,16 +277,22 @@ function main() { }; } + // Apply curation rules (removals, renames, tags) + const { components: curated, stats } = applyCuration( + components, + CURATION_PATH, + ); + // Sort by component ID const sorted = {}; - for (const key of Object.keys(components).sort()) { - sorted[key] = components[key]; + for (const key of Object.keys(curated).sort()) { + sorted[key] = curated[key]; } const output = { type: "component-anatomy", description: - "Mapping of Spectrum components to their visible anatomy parts, extracted from S2 documentation.", + "Mapping of Spectrum components to their visible anatomy parts, extracted from S2 documentation and curated via component-anatomy-curation.json.", components: sorted, }; @@ -228,6 +306,11 @@ function main() { console.log( `Generated component-anatomy.json: ${componentCount} components, ${partCount} total parts`, ); + if (stats.removed || stats.renamed || stats.tagged) { + console.log( + `Curation applied: ${stats.removed} removed, ${stats.renamed} renamed, ${stats.tagged} tagged`, + ); + } if (skipped.length > 0) { console.log(`Skipped (no anatomy section): ${skipped.join(", ")}`); } diff --git a/packages/design-system-registry/test/component-anatomy.test.js b/packages/design-system-registry/test/component-anatomy.test.js index ee46cd88..6f482415 100644 --- a/packages/design-system-registry/test/component-anatomy.test.js +++ b/packages/design-system-registry/test/component-anatomy.test.js @@ -180,6 +180,50 @@ test("tabs deduplicated tab-item", (t) => { t.is(tabItemCount, 1); }); +// --- Curation tests --- + +test("numbering artifacts are removed", (t) => { + const ag = registry.components["action-group"]; + const ids = ag.parts.map((p) => p.id); + t.false(ids.includes("action-button-1")); + t.false(ids.includes("action-button-2")); +}); + +test("background (token object) is removed from anatomy", (t) => { + for (const [, component] of Object.entries(registry.components)) { + const ids = component.parts.map((p) => p.id); + t.false( + ids.includes("background"), + "background should be removed (it is a token object, not anatomy)", + ); + } +}); + +test("-area suffixes are renamed to base terms", (t) => { + const dialog = registry.components["standard-dialog"]; + const ids = dialog.parts.map((p) => p.id); + t.true(ids.includes("header")); + t.true(ids.includes("body")); + t.true(ids.includes("footer")); + t.false(ids.includes("header-area")); + t.false(ids.includes("body-area")); + t.false(ids.includes("footer-area")); +}); + +test("small-divider renamed to divider", (t) => { + const accordion = registry.components["accordion"]; + const ids = accordion.parts.map((p) => p.id); + t.true(ids.includes("divider")); + t.false(ids.includes("small-divider")); +}); + +test("composite parts are tagged with tier", (t) => { + const cards = registry.components["cards"]; + const checkbox = cards.parts.find((p) => p.id === "checkbox"); + t.truthy(checkbox); + t.is(checkbox.tier, "composite"); +}); + test("all part IDs are kebab-case", (t) => { for (const [componentId, component] of Object.entries(registry.components)) { for (const part of component.parts) { From 05772f513b5b4a379212d63715c0ed5888032188 Mon Sep 17 00:00:00 2001 From: garthdb Date: Mon, 6 Apr 2026 13:19:38 -0600 Subject: [PATCH 5/5] fix(tools): validator accepts component-anatomy parts, normalize action-group numbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [P1] Anatomy validation now checks both anatomy-terms.json and the component-anatomy registry. Values like "fill", "title", "value" that come from S2 docs no longer trigger false "not in registry" warnings. [P2] Curation config changed action-button-1/2 from removals to renames (→ action-button), so action-group retains its anatomy data instead of being left with only action-menu. Co-Authored-By: Claude Sonnet 4.6 --- .../registry/component-anatomy-curation.json | 10 +++- .../registry/component-anatomy.json | 6 +++ .../test/component-anatomy.test.js | 3 +- tools/token-name-builder/src/validator.ts | 31 ++++++++++--- .../token-name-builder/test/validator.test.js | 46 +++++++++++++++++-- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/packages/design-system-registry/registry/component-anatomy-curation.json b/packages/design-system-registry/registry/component-anatomy-curation.json index 7e760ec9..38e84783 100644 --- a/packages/design-system-registry/registry/component-anatomy-curation.json +++ b/packages/design-system-registry/registry/component-anatomy-curation.json @@ -2,12 +2,18 @@ "$comment": "Curation rules applied after extracting raw anatomy data from S2 docs. Documents why the registry diverges from the source markdown. Re-run the extraction script to apply.", "removals": { - "action-button-1": "Numbering artifact from action-group diagram; not a distinct anatomy term. Use 'action-button' instead.", - "action-button-2": "Numbering artifact from action-group diagram; not a distinct anatomy term. Use 'action-button' instead.", "background": "This is a token object / styling surface (belongs in the 'object' field of the name object, not 'anatomy'). See token-objects.json." }, "renames": { + "action-button-1": { + "to": "action-button", + "reason": "Numbering artifact from action-group diagram; normalize to the component name." + }, + "action-button-2": { + "to": "action-button", + "reason": "Numbering artifact from action-group diagram; normalize to the component name." + }, "body-area": { "to": "body", "reason": "Redundant '-area' suffix; aligns with existing anatomy-terms registry term 'body'." diff --git a/packages/design-system-registry/registry/component-anatomy.json b/packages/design-system-registry/registry/component-anatomy.json index e97a1e5f..0482f78e 100644 --- a/packages/design-system-registry/registry/component-anatomy.json +++ b/packages/design-system-registry/registry/component-anatomy.json @@ -43,6 +43,12 @@ "label": "action group", "source": "docs/s2-docs/components/actions/action-group.md", "parts": [ + { + "id": "action-button", + "label": "Action Button", + "optional": false, + "tier": "composite" + }, { "id": "action-menu", "label": "Action Menu", diff --git a/packages/design-system-registry/test/component-anatomy.test.js b/packages/design-system-registry/test/component-anatomy.test.js index 6f482415..9d11b1fc 100644 --- a/packages/design-system-registry/test/component-anatomy.test.js +++ b/packages/design-system-registry/test/component-anatomy.test.js @@ -182,11 +182,12 @@ test("tabs deduplicated tab-item", (t) => { // --- Curation tests --- -test("numbering artifacts are removed", (t) => { +test("numbering artifacts are normalized to base term", (t) => { const ag = registry.components["action-group"]; const ids = ag.parts.map((p) => p.id); t.false(ids.includes("action-button-1")); t.false(ids.includes("action-button-2")); + t.true(ids.includes("action-button")); }); test("background (token object) is removed from anatomy", (t) => { diff --git a/tools/token-name-builder/src/validator.ts b/tools/token-name-builder/src/validator.ts index cf98b439..2ad7d93e 100644 --- a/tools/token-name-builder/src/validator.ts +++ b/tools/token-name-builder/src/validator.ts @@ -12,7 +12,20 @@ governing permissions and limitations under the License. import type { NameObject } from "./name-object.js"; import { SEMANTIC_FIELDS, DIMENSION_FIELDS } from "./name-object.js"; -import { registries, dimensionModes, hasValue } from "./registry-data.js"; +import { + registries, + dimensionModes, + hasValue, + componentAnatomy, +} from "./registry-data.js"; + +// Collect all unique part IDs from the component-anatomy registry so anatomy +// values sourced from S2 docs don't trigger false "not in registry" warnings. +const allAnatomyPartIds = new Set( + Object.values(componentAnatomy.components).flatMap((c) => + c.parts.map((p) => p.id), + ), +); export type Severity = "error" | "warning" | "info"; @@ -60,11 +73,17 @@ export function validate(name: NameObject): ValidationMessage[] { if (!registry) continue; if (!hasValue(registry, value)) { - messages.push({ - field, - severity: "warning", - message: `"${value}" is not in the ${registry.type} registry. This is allowed but may indicate a typo.`, - }); + // For the anatomy field, also accept any part ID from the + // component-anatomy registry (extracted from S2 docs). + const knownAnatomyPart = + field === "anatomy" && allAnatomyPartIds.has(value); + if (!knownAnatomyPart) { + messages.push({ + field, + severity: "warning", + message: `"${value}" is not in the ${registry.type} registry. This is allowed but may indicate a typo.`, + }); + } } // Check for deprecated values diff --git a/tools/token-name-builder/test/validator.test.js b/tools/token-name-builder/test/validator.test.js index 4412d3cd..a12fe25c 100644 --- a/tools/token-name-builder/test/validator.test.js +++ b/tools/token-name-builder/test/validator.test.js @@ -26,6 +26,14 @@ function loadRegistry(filename) { return JSON.parse(readFileSync(resolve(registryDir, filename), "utf-8")); } +// Load component-anatomy registry and collect all known part IDs +const componentAnatomy = loadRegistry("component-anatomy.json"); +const allAnatomyPartIds = new Set( + Object.values(componentAnatomy.components).flatMap((c) => + c.parts.map((p) => p.id), + ), +); + const registries = { component: loadRegistry("components.json"), structure: loadRegistry("structures.json"), @@ -97,11 +105,16 @@ function validate(name) { const registry = registries[field]; if (!registry) continue; if (!hasValue(registry, value)) { - messages.push({ - field, - severity: "warning", - message: `"${value}" is not in the ${registry.type} registry. This is allowed but may indicate a typo.`, - }); + // For anatomy, also accept part IDs from the component-anatomy registry + const knownAnatomyPart = + field === "anatomy" && allAnatomyPartIds.has(value); + if (!knownAnatomyPart) { + messages.push({ + field, + severity: "warning", + message: `"${value}" is not in the ${registry.type} registry. This is allowed but may indicate a typo.`, + }); + } } } @@ -241,6 +254,29 @@ test("known anatomy terms pass validation", (t) => { t.falsy(anatomyMsg); }); +test("component-anatomy part IDs do not trigger warnings", (t) => { + // "fill" is in component-anatomy.json (slider) but NOT in anatomy-terms.json + const messages = validate({ + property: "color", + anatomy: "fill", + }); + const anatomyMsg = messages.find((m) => m.field === "anatomy"); + t.falsy( + anatomyMsg, + '"fill" is a known component-anatomy part and should not warn', + ); +}); + +test("truly unknown anatomy values still warn", (t) => { + const messages = validate({ + property: "color", + anatomy: "nonexistent-part", + }); + const anatomyMsg = messages.find((m) => m.field === "anatomy"); + t.truthy(anatomyMsg); + t.is(anatomyMsg.severity, "warning"); +}); + test("known variants pass validation", (t) => { const messages = validate({ property: "color",