diff --git a/.agents/skills/boxel-development/SKILL.md b/.agents/skills/boxel-development/SKILL.md index f9d7696f8ed..160ecd24d75 100644 --- a/.agents/skills/boxel-development/SKILL.md +++ b/.agents/skills/boxel-development/SKILL.md @@ -23,6 +23,7 @@ Use this skill for Boxel card and app development. Keep the top-level guidance l These three files establish the data model, the `contains` vs `linksTo` rule, required formats, inherited fields, and common import patterns. + ## Load By Task - Card structure and safe patterns: diff --git a/.github/workflows/mirror-boxel-ui-specs.yaml b/.github/workflows/mirror-boxel-ui-specs.yaml new file mode 100644 index 00000000000..3af61c9f6c3 --- /dev/null +++ b/.github/workflows/mirror-boxel-ui-specs.yaml @@ -0,0 +1,69 @@ +name: Mirror Boxel UI Component Specs + +# Pushes generated boxel-ui component specs into cardstack/boxel-catalog so +# the deployed catalog realm (which is served from that external repo) sees +# them. See CS-10527 for context and packages/boxel-ui/addon/bin/generate-component-specs.mjs +# for the generator. +# +# HUMAN SETUP REQUIRED before this workflow runs successfully: +# 1. Create a deploy key or fine-grained PAT with write access to +# cardstack/boxel-catalog. +# 2. Store it in this repo as the BOXEL_CATALOG_PUSH_TOKEN secret. +# 3. Verify the secret has permissions to push to cardstack/boxel-catalog's +# default branch (or to push branches + open PRs if you prefer the +# PR-based flow — see the commented `gh pr create` step below). +# Until those are set up, this workflow will fail at the push step. + +on: + push: + branches: [main] + paths: + - 'packages/boxel-ui/addon/src/components/**/usage.gts' + - 'packages/boxel-ui/addon/bin/generate-component-specs.mjs' + +permissions: + contents: read + +jobs: + mirror: + name: Mirror specs to cardstack/boxel-catalog + runs-on: ubuntu-latest + concurrency: + group: mirror-boxel-ui-specs + cancel-in-progress: false + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: boxel + - uses: ./boxel/.github/actions/init + + - name: Clone cardstack/boxel-catalog into packages/catalog/contents + # The generator writes into packages/catalog/contents/Spec/ — the working + # tree of the boxel-catalog repo. Cloning it there serves both as the + # write target for the generator AND the working copy we'll commit and + # push at the end of the job. + run: | + git clone "https://x-access-token:${BOXEL_CATALOG_PUSH_TOKEN}@github.com/cardstack/boxel-catalog.git" boxel/packages/catalog/contents + env: + BOXEL_CATALOG_PUSH_TOKEN: ${{ secrets.BOXEL_CATALOG_PUSH_TOKEN }} + + - name: Generate component specs into the catalog working tree + run: pnpm run generate:component-specs + working-directory: boxel/packages/boxel-ui/addon + + - name: Commit and push to cardstack/boxel-catalog + run: | + cd boxel/packages/catalog/contents + git config user.name "boxel-ui-specs-bot" + git config user.email "boxel-ui-specs-bot@users.noreply.github.com" + if git diff --quiet --exit-code -- Spec/; then + echo "No spec changes — nothing to mirror." + exit 0 + fi + git add Spec/ + git commit -m "chore: mirror boxel-ui component specs from boxel@${GITHUB_SHA::7} + + Generated by .github/workflows/mirror-boxel-ui-specs.yaml. + Source: https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA} + " + git push origin HEAD:main diff --git a/docs/spec.md b/docs/spec.md index 8ed143d713c..4765e462021 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -70,12 +70,13 @@ Each spec type has specific characteristics and use cases: ### 3. Component Specs (`specType: 'component'`) -**Purpose**: Document reusable UI components that don't represent data. +**Purpose**: Document reusable UI components that don't represent data, so AI agents and developers can discover them by searching the catalog instead of needing a per-component skill. **Characteristics**: -- No support for examples -- Only when it extends Glimmer Component +- Only when it extends Glimmer Component - Potentially includes reactive data loading resources from ember-resources +- API documentation, an example, and CSS variables live in the `readMe` markdown field +- `cardDescription` is the keyword-rich one-liner the agent matches against — keep it concrete (e.g. "Form text input with validation states") rather than abstract **Example Use Cases**: - `CardsGrid` - Responsive grid layout component for card collections @@ -83,6 +84,67 @@ Each spec type has specific characteristics and use cases: - `Pill` - Badge component for displaying tags and statuses - `LinksToEditor` - Component for editing card relationships +#### Boxel-UI Component Specs + +All `@cardstack/boxel-ui` components ship a generated Spec card. The +generator (`packages/boxel-ui/addon/bin/generate-component-specs.mjs`) +walks each component's `usage.gts` file, extracts the `FreestyleUsage` +metadata (arguments, description, example, CSS variables), and emits a +Spec JSON with: + +- `ref: { module: '@cardstack/boxel-ui/components', name: ComponentName }` +- `cardTitle: ComponentName` +- `cardDescription`: the top-level `@description` attribute on the + primary `` tag, or the first sentence of its + `<:description>` block. **For agent discoverability, add a + keyword-rich `@description` attribute to the primary + `` block in `usage.gts` whenever the synthesized + description is generic.** +- `readMe`: a markdown body with the API table, a usage example, and the + CSS-variable table. + +##### Developer workflow + +The generated specs are **not committed to the boxel repo**. The +`cardstack/boxel-catalog` repo is the source of truth — boxel just owns +the generator and the inputs (`usage.gts`). + +1. Edit the component's `usage.gts`. Make sure the primary + `` block has a `@description='…'` attribute and + complete `` documentation. +2. (Optional, but recommended) Run + `pnpm --dir packages/boxel-ui/addon generate:component-specs` locally + to inspect the resulting spec content and have your local + realm-server reindex it. Requires + `pnpm --dir packages/catalog catalog:setup` to have run at least + once (clones `cardstack/boxel-catalog` into + `packages/catalog/contents/`, which is gitignored from boxel). + Output: `packages/catalog/contents/Spec/boxel-ui-.json`. +3. Commit only your `usage.gts` change to your boxel PR — no spec JSON + needs to land in the boxel repo. +4. On merge to `main`, the + `.github/workflows/mirror-boxel-ui-specs.yaml` job clones + `cardstack/boxel-catalog`, regenerates the specs, and pushes the + diff. That repo is `git pull`ed by realm-server at deploy startup + and full-indexed. + +##### Gotchas + +- `packages/catalog/contents/` is its own git repo (clone of + `cardstack/boxel-catalog`), gitignored from boxel. Edits made + directly there do **not** appear in boxel PRs. The mirror workflow + is the publish path. +- Because the generator regenerates fresh on every mirror run, there + is no in-boxel-repo drift-detection step. If you change `usage.gts` + but don't run the generator locally to eyeball the result, the + catalog still ends up correct — but you only see what the agent will + read at runtime once the mirror PR lands. Running the generator + locally before pushing is the recommended habit. +- The CI mirror job needs a deploy key or fine-grained PAT for + `cardstack/boxel-catalog`, stored as the repo secret + `BOXEL_CATALOG_PUSH_TOKEN`. Until that's wired up, the workflow runs + but fails at the push step. + ### 4. App Specs (`specType: 'app'`) **Purpose**: Document application-level cards that serve as entry points, typically when other cards are queried within them. diff --git a/packages/boxel-ui/addon/bin/generate-component-specs.mjs b/packages/boxel-ui/addon/bin/generate-component-specs.mjs new file mode 100644 index 00000000000..284c2cb1436 --- /dev/null +++ b/packages/boxel-ui/addon/bin/generate-component-specs.mjs @@ -0,0 +1,513 @@ +#!/usr/bin/env node +// Generate Spec card JSON for each boxel-ui component by parsing its usage.gts. +// See CS-10527. +// +// Writes to packages/catalog/contents/Spec/ — the working tree of the +// cardstack/boxel-catalog repo (cloned via `pnpm --dir packages/catalog +// catalog:setup`). The realm-server's file watcher picks the files up and +// reindexes the catalog locally. On merge to boxel main, the CI mirror +// workflow regenerates from a fresh checkout and pushes to +// cardstack/boxel-catalog. +// +// Flags: +// --only X Generate only component X (kebab-case directory name). +// --quiet Suppress non-essential logging. + +import fs from 'fs'; +import path from 'path'; + +const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname); +const ADDON_DIR = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(ADDON_DIR, '..', '..', '..'); + +const COMPONENTS_DIR = path.join(ADDON_DIR, 'src', 'components'); +const CATALOG_DIR = path.join(REPO_ROOT, 'packages', 'catalog', 'contents', 'Spec'); + +const SPEC_MODULE = '@cardstack/boxel-ui/components'; +const SPEC_FILE_PREFIX = 'boxel-ui-'; + +const args = process.argv.slice(2); +const flags = { + only: argValue(args, '--only'), + quiet: args.includes('--quiet'), +}; + +function argValue(argv, name) { + const i = argv.indexOf(name); + if (i < 0 || i + 1 >= argv.length) return null; + return argv[i + 1]; +} + +function log(...m) { + if (!flags.quiet) console.log(...m); +} + +function listComponents() { + const entries = fs.readdirSync(COMPONENTS_DIR, { withFileTypes: true }); + const components = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const usagePath = path.join(COMPONENTS_DIR, entry.name, 'usage.gts'); + if (!fs.existsSync(usagePath)) continue; + components.push({ slug: entry.name, usagePath }); + } + return components.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +function toPascalCase(slug) { + return slug + .split('-') + .filter(Boolean) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +// Match the primary ... in a file. +// Convention across all boxel-ui usage.gts files: the first block documents +// the component itself; subsequent blocks are variant demos. +function extractPrimaryUsageBlock(source) { + const re = /([\s\S]*?)<\/FreestyleUsage>/; + const m = source.match(re); + if (!m) return null; + return { openAttrs: m[1], body: m[2] }; +} + +function extractStringAttr(text, name) { + // @name='value' or @name="value" + const re = new RegExp(`@${name}=(?:'((?:[^'\\\\]|\\\\.)*)'|"((?:[^"\\\\]|\\\\.)*)")`); + const m = text.match(re); + if (!m) return null; + return (m[1] ?? m[2]).replace(/\\'/g, "'").replace(/\\"/g, '"'); +} + +function hasBoolAttr(text, name) { + // @name={{true}} or @name (presence) + if (new RegExp(`@${name}=\\{\\{true\\}\\}`).test(text)) return true; + return false; +} + +function extractDefaultValue(text) { + // @defaultValue='foo' | @defaultValue="foo" | @defaultValue={{true}} | @defaultValue={{this.x}} + const s = extractStringAttr(text, 'defaultValue'); + if (s !== null) return s; + const m = text.match(/@defaultValue=\{\{([^}]+)\}\}/); + if (!m) return null; + const raw = m[1].trim(); + if (raw === 'true') return true; + if (raw === 'false') return false; + // Identifier or member expression (e.g., "this.defaultType") — render as code. + return `\`${raw}\``; +} + +function extractOptions(text, source) { + // @options={{array 'a' 'b' 'c'}} | @options={{this.xVariants}} | @options={{validTypes}} + const arrayMatch = text.match(/@options=\{\{\s*array\s+([^}]+)\}\}/); + if (arrayMatch) { + const inner = arrayMatch[1]; + const opts = []; + const re = /'([^']*)'|"([^"]*)"/g; + let m; + while ((m = re.exec(inner)) !== null) { + opts.push(m[1] ?? m[2]); + } + return opts; + } + const refMatch = text.match(/@options=\{\{([^}]+)\}\}/); + if (!refMatch) return null; + let ref = refMatch[1].trim(); + // Strip leading `this.` so we can look up the class-field declaration. + ref = ref.replace(/^this\./, ''); + // Try to resolve ` = []` in the source file. Catches + // both class-field (`pillKinds = ['button', 'default']`) and top-level + // const (`const validBottomTreatments = [...]`) shapes when they're array + // literals — which is most of the enum cases in usage.gts files. + if (source) { + const re = new RegExp( + `(?:^|\\b)${ref}\\s*=\\s*\\[([^\\]]*)\\]`, + 'm', + ); + const m = source.match(re); + if (m) { + const opts = []; + const valRe = /'([^']*)'|"([^"]*)"/g; + let v; + while ((v = valRe.exec(m[1])) !== null) { + opts.push(v[1] ?? v[2]); + } + if (opts.length) return opts; + } + } + // Fall back to a labelled reference so the reader at least knows the + // identifier they'd grep for if they want the values. + return [`(see ${refMatch[1].trim()})`]; +} + +function extractNamedBlock(body, blockName) { + // <:name>... or <:name as |X|>... + const re = new RegExp( + `<:${blockName}(?:\\s+[^>]*)?>([\\s\\S]*?)<\\/:${blockName}>`, + ); + const m = body.match(re); + return m ? m[1] : null; +} + +function parseArgs(apiBlock, source) { + if (!apiBlock) return []; + const re = //g; + const args = []; + let m; + while ((m = re.exec(apiBlock)) !== null) { + const kind = m[1]; + const attrs = m[2]; + const name = extractStringAttr(attrs, 'name'); + if (!name && kind !== 'Yield') continue; + args.push({ + kind, + name: name ?? '(yield)', + description: extractStringAttr(attrs, 'description'), + required: hasBoolAttr(attrs, 'required'), + optional: hasBoolAttr(attrs, 'optional'), + defaultValue: extractDefaultValue(attrs), + options: extractOptions(attrs, source), + }); + } + return args; +} + +function parseCssVars(cssBlock) { + if (!cssBlock) return []; + const re = //g; + const vars = []; + let m; + while ((m = re.exec(cssBlock)) !== null) { + const attrs = m[1]; + const name = extractStringAttr(attrs, 'name'); + if (!name) continue; + vars.push({ + name, + type: extractStringAttr(attrs, 'type'), + description: extractStringAttr(attrs, 'description'), + }); + } + return vars; +} + +// Strip Glimmer/HTML markup from a description block to plain prose. +// Keeps ... as `...` (markdown inline code). +function htmlToPlainText(html) { + if (!html) return ''; + return html + .replace(/([\s\S]*?)<\/code>/g, '`$1`') + .replace(/<[^>]+>/g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/“|”/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// True if the description block is dominated by structural HTML (tables, lists) +// rather than prose. Those flatten badly to plain text and duplicate the API +// table, so we skip them in the readMe. +function isStructuralDescription(html) { + if (!html) return false; + return /<(table|tbody|thead|tr|td|ul|ol|li)\b/i.test(html); +} + +function trimExample(exampleBlock) { + if (!exampleBlock) return ''; + // Strip leading/trailing whitespace lines but preserve internal indentation. + return exampleBlock.replace(/^[ \t]*\n+/, '').replace(/\n+[ \t]*$/, ''); +} + +// usage.gts files import the component locally as `BoxelButton`, `BoxelInput`, +// `BoxelModal` etc. (aliased from `./index.gts`) so the FreestyleUsage demo +// can co-exist with arg state. But the public export from +// `@cardstack/boxel-ui/components` is the un-prefixed name (`Button`, `Input`, +// `Modal`). The Import section of the readMe correctly shows the public name; +// without rewriting, the Example section would still show the internal alias +// and a reader (human or agent) might copy `BoxelInput` into their imports +// and crash at runtime (CS-10527 second test run did exactly this). +// +// Rewrite ``, ``, and self-closing `` to use +// the public name. Tag-name match is anchored on the leading `<` / ` l.trim().length > 0) + .map((l) => l.match(/^[ \t]*/)[0].length); + if (!indents.length) return text; + const min = Math.min(...indents); + if (min === 0) return text; + return lines.map((l) => l.slice(min)).join('\n'); +} + +function argTypeLabel(arg) { + switch (arg.kind) { + case 'String': + return 'string'; + case 'Bool': + return 'boolean'; + case 'Object': + return 'object'; + case 'Action': + return 'action'; + case 'Yield': + return 'block'; + default: + return arg.kind.toLowerCase(); + } +} + +function argRequirednessLabel(arg) { + if (arg.required) return 'required'; + return 'optional'; +} + +function defaultValueLabel(v) { + if (v === null || v === undefined) return '—'; + if (v === true) return '`true`'; + if (v === false) return '`false`'; + if (typeof v === 'string') { + // Already escaped/coded? + if (v.startsWith('`') && v.endsWith('`')) return v; + return `\`${v}\``; + } + return String(v); +} + +function buildApiTable(args) { + if (!args.length) return '_No documented arguments._'; + let table = '| Arg | Type | Required | Default | Description |\n'; + table += '| --- | --- | --- | --- | --- |\n'; + for (const arg of args) { + const desc = arg.description ?? ''; + const options = arg.options + ? ` Options: ${arg.options.map((o) => (o.startsWith('(') ? o : `\`${o}\``)).join(', ')}.` + : ''; + const cell = (desc + options).replace(/\|/g, '\\|').trim(); + const nameLabel = + arg.kind === 'Yield' ? '`(yield block)`' : `\`@${arg.name}\``; + table += `| ${nameLabel} | ${argTypeLabel(arg)} | ${argRequirednessLabel(arg)} | ${defaultValueLabel(arg.defaultValue)} | ${cell || '—'} |\n`; + } + return table.trim(); +} + +function buildCssTable(vars) { + if (!vars.length) return '_No documented CSS variables._'; + let table = '| Variable | Type | Description |\n'; + table += '| --- | --- | --- |\n'; + for (const v of vars) { + const name = v.name.startsWith('--') ? v.name : `--${v.name}`; + const desc = (v.description ?? '').replace(/\|/g, '\\|').trim(); + table += `| \`${name}\` | ${v.type ?? '—'} | ${desc || '—'} |\n`; + } + return table.trim(); +} + +function synthesizeCardDescription({ + componentName, + topDescription, + descriptionText, + hasStructuralDescription, +}) { + // Order of preference: + // 1. Top-level @description on the FreestyleUsage tag (curated by author). + // 2. First sentence of the <:description> block when it's prose, not a table. + // 3. Generic placeholder — flagged for follow-up. + if (topDescription) { + const lower = topDescription.toLowerCase(); + if (lower.includes(componentName.toLowerCase())) return topDescription; + return `${componentName} — ${topDescription}`; + } + if (descriptionText && !hasStructuralDescription) { + const firstSentence = descriptionText.split(/(?<=[.!?])\s+/)[0]?.trim(); + if (firstSentence && firstSentence.length > 3) { + const lower = firstSentence.toLowerCase(); + if (lower.includes(componentName.toLowerCase())) return firstSentence; + return `${componentName} — ${firstSentence}`; + } + } + // Fallback: keyword-thin. Add a top-level @description='...' on the + // FreestyleUsage tag in usage.gts to override. + return `${componentName} — boxel-ui component (see readMe for API and example).`; +} + +function buildReadme({ + componentName, + topDescription, + descriptionText, + hasStructuralDescription, + example, + args, + cssVars, +}) { + const sections = []; + sections.push(`# ${componentName}`); + // Prefer the curated top-level description; otherwise include the prose + // <:description> block. Skip when it's structural HTML (the API/CSS tables + // below already cover that information). + if (topDescription) { + sections.push(topDescription); + } else if (descriptionText && !hasStructuralDescription) { + sections.push(descriptionText); + } + sections.push('## Import'); + sections.push( + '```ts\n' + + `import { ${componentName} } from '${SPEC_MODULE}';\n` + + '```', + ); + sections.push('## API'); + sections.push(buildApiTable(args)); + if (example) { + sections.push('## Example'); + const trimmed = normalizeExampleTagNames(dedent(trimExample(example))); + sections.push('```gts\n' + trimmed + '\n```'); + } + sections.push('## CSS Variables'); + sections.push(buildCssTable(cssVars)); + return sections.join('\n\n') + '\n'; +} + +function buildSpecJson({ componentName, cardDescription, readMe }) { + return { + data: { + type: 'card', + attributes: { + readMe, + ref: { module: SPEC_MODULE, name: componentName }, + specType: 'component', + containedExamples: [], + cardTitle: componentName, + cardDescription, + cardInfo: { + name: null, + summary: null, + cardThumbnailURL: null, + notes: null, + }, + }, + relationships: { + linkedExamples: { links: { self: null } }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }; +} + +function generateForComponent({ slug, usagePath }) { + const source = fs.readFileSync(usagePath, 'utf8'); + const block = extractPrimaryUsageBlock(source); + if (!block) { + return { slug, error: 'no FreestyleUsage block found' }; + } + const topDescription = extractStringAttr(block.openAttrs, 'description'); + const descriptionRaw = extractNamedBlock(block.body, 'description'); + const descriptionText = htmlToPlainText(descriptionRaw); + const hasStructuralDescription = isStructuralDescription(descriptionRaw); + const example = extractNamedBlock(block.body, 'example'); + const apiBlock = extractNamedBlock(block.body, 'api'); + const cssBlock = extractNamedBlock(block.body, 'cssVars'); + + const args = parseArgs(apiBlock, source); + const cssVars = parseCssVars(cssBlock); + + // Pick the component name from the FreestyleUsage @name if present (preserves + // the maintained casing like "Modal" or "Button"); fall back to PascalCase of + // the directory slug. + const nameAttr = extractStringAttr(block.openAttrs, 'name'); + const componentName = + nameAttr && /^[A-Z][A-Za-z0-9]*$/.test(nameAttr) + ? nameAttr + : toPascalCase(slug); + + const cardDescription = synthesizeCardDescription({ + componentName, + topDescription, + descriptionText, + hasStructuralDescription, + }); + + const readMe = buildReadme({ + componentName, + topDescription, + descriptionText, + hasStructuralDescription, + example, + args, + cssVars, + }); + + const spec = buildSpecJson({ componentName, cardDescription, readMe }); + return { slug, componentName, spec }; +} + +function specFileName(slug) { + return `${SPEC_FILE_PREFIX}${slug}.json`; +} + +function serializeSpec(spec) { + return JSON.stringify(spec, null, 2) + '\n'; +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function writeOutput(slug, spec) { + const json = serializeSpec(spec); + ensureDir(CATALOG_DIR); + fs.writeFileSync(path.join(CATALOG_DIR, specFileName(slug)), json); +} + +function main() { + if (!fs.existsSync(path.dirname(CATALOG_DIR))) { + console.error( + `packages/catalog/contents/ is not present. Run 'pnpm --dir packages/catalog catalog:setup' first, then re-run this command.`, + ); + process.exit(2); + } + + let components = listComponents(); + if (flags.only) { + components = components.filter((c) => c.slug === flags.only); + if (!components.length) { + console.error(`No component named '${flags.only}'`); + process.exit(2); + } + } + + const results = components.map(generateForComponent); + const errors = results.filter((r) => r.error); + if (errors.length) { + for (const e of errors) console.error(`! ${e.slug}: ${e.error}`); + process.exit(2); + } + + for (const r of results) { + writeOutput(r.slug, r.spec); + log(`✓ ${specFileName(r.slug)}`); + } + log( + `Wrote ${results.length} spec(s) to ${path.relative(REPO_ROOT, CATALOG_DIR)}/.`, + ); +} + +main(); diff --git a/packages/boxel-ui/addon/package.json b/packages/boxel-ui/addon/package.json index 7feff1fb4d8..63db9e8e431 100644 --- a/packages/boxel-ui/addon/package.json +++ b/packages/boxel-ui/addon/package.json @@ -25,6 +25,7 @@ "lint:types": "ember-tsc --noEmit", "rebuild:icons": "node bin/rebuild-icons.mjs", "rebuild:usage": "node bin/rebuild-usage.mjs", + "generate:component-specs": "node bin/generate-component-specs.mjs", "prepack": "rollup --config", "start": "concurrently \"pnpm:start:*\" --names \"start:\"", "start:js": "rollup --config --watch --no-watch.clearScreen", diff --git a/packages/boxel-ui/addon/src/components/button/usage.gts b/packages/boxel-ui/addon/src/components/button/usage.gts index e81137f34bd..ab46e374771 100644 --- a/packages/boxel-ui/addon/src/components/button/usage.gts +++ b/packages/boxel-ui/addon/src/components/button/usage.gts @@ -42,7 +42,11 @@ export default class ButtonUsage extends Component { }