themelia-ui 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/.agents/skills/themelia-ui/SKILL.md +38 -9
  2. package/.agents/skills/themelia-ui/manifest.json +23 -0
  3. package/.agents/skills/themelia-ui/references/api-compatibility.md +3 -2
  4. package/.agents/skills/themelia-ui/references/blocks.md +123 -0
  5. package/.agents/skills/themelia-ui/references/composition-ladder.md +8 -8
  6. package/.agents/skills/themelia-ui/references/composition.md +98 -1
  7. package/.agents/skills/themelia-ui/references/forms.md +23 -3
  8. package/.agents/skills/themelia-ui/references/framework-wiring.md +34 -6
  9. package/.agents/skills/themelia-ui/references/i18n.md +4 -2
  10. package/.agents/skills/themelia-ui/references/imports.md +1 -1
  11. package/.agents/skills/themelia-ui/references/installation.md +5 -4
  12. package/.agents/skills/themelia-ui/references/migration.md +22 -2
  13. package/.agents/skills/themelia-ui/references/provider-and-scoping.md +27 -9
  14. package/.agents/skills/themelia-ui/references/reference-workflows.md +43 -0
  15. package/.agents/skills/themelia-ui/references/structure-and-spacing.md +147 -0
  16. package/.agents/skills/themelia-ui/references/theming.md +6 -2
  17. package/.agents/skills/themelia-ui/references/troubleshooting.md +28 -5
  18. package/.agents/skills/themelia-ui/references/verification.md +58 -2
  19. package/CHANGELOG.md +25 -0
  20. package/README.md +3 -0
  21. package/dist/{alert-dialog-Bmo1YrTm.js → alert-dialog-Cg2HhuBe.js} +1 -1
  22. package/dist/base/alert-dialog.js +1 -1
  23. package/dist/base/cards.js +1 -1
  24. package/dist/base/choice-inputs.js +1 -1
  25. package/dist/base/combobox.js +1 -1
  26. package/dist/base/command.js +1 -1
  27. package/dist/base/date-pickers.js +1 -1
  28. package/dist/base/dialog.js +1 -1
  29. package/dist/base/display.js +1 -1
  30. package/dist/base/forms-numeric.js +1 -1
  31. package/dist/base/forms.js +1 -1
  32. package/dist/base/item.js +1 -1
  33. package/dist/base/navigation.js +1 -1
  34. package/dist/base/otp-input.js +1 -1
  35. package/dist/base/overlay.js +1 -1
  36. package/dist/base/popover-menu.js +1 -1
  37. package/dist/base/repeaters.js +1 -1
  38. package/dist/base/sheet.js +1 -1
  39. package/dist/base/sidebar.js +1 -1
  40. package/dist/base/skeleton.js +1 -1
  41. package/dist/base/structure.js +1 -1
  42. package/dist/base/timeline.js +1 -1
  43. package/dist/base/upload.js +1 -1
  44. package/dist/base/value-inputs.js +1 -1
  45. package/dist/blocks/admin/access.js +2 -2
  46. package/dist/blocks/admin/commerce.js +9 -9
  47. package/dist/blocks/analytics.js +3 -3
  48. package/dist/blocks/onboarding.js +1 -1
  49. package/dist/blocks/timelines.js +1 -1
  50. package/dist/{cards-DdiICdMM.js → cards-Ddhz43Bz.js} +2 -2
  51. package/dist/{choice-inputs-CCw-0gD6.js → choice-inputs-NwRxvmZN.js} +2 -2
  52. package/dist/combobox-519ubijc.js +292 -0
  53. package/dist/{command-CnB_IZSo.js → command-JjApYFds.js} +2 -2
  54. package/dist/{comments-C6Y_ddMt.js → comments-CCQ2QRaU.js} +4 -4
  55. package/dist/components/base/display/icon-badge.d.ts +2 -2
  56. package/dist/components/base/forms/form-field.d.ts +2 -2
  57. package/dist/components/base/structure/bleed.d.ts +1 -0
  58. package/dist/components/base/structure/grid.d.ts +8 -2
  59. package/dist/components/base/structure/split.d.ts +1 -0
  60. package/dist/components/features/combobox/fetching-combobox.d.ts +2 -1
  61. package/dist/components/features/combobox/resource-combobox.d.ts +3 -0
  62. package/dist/components/features/combobox/use-suggestions.d.ts +7 -0
  63. package/dist/css/structure.css +1 -1
  64. package/dist/{date-pickers-C2HgjpFU.js → date-pickers-BwCwKIVM.js} +1 -1
  65. package/dist/{dialog-0nRFg5Zs.js → dialog-CAnwRNrm.js} +1 -1
  66. package/dist/{display-BGN834xV.js → display-a7AoX_u7.js} +1 -1
  67. package/dist/features/actions.js +1 -1
  68. package/dist/features/activities.js +3 -3
  69. package/dist/features/ai-chat.js +2 -2
  70. package/dist/features/combobox.js +285 -273
  71. package/dist/features/comments.js +1 -1
  72. package/dist/features/data-view.js +5 -5
  73. package/dist/features/event-calendar.js +3 -3
  74. package/dist/features/filters.js +1 -1
  75. package/dist/features/global-search.js +4 -4
  76. package/dist/features/map.js +2 -2
  77. package/dist/features/media-library.js +8 -8
  78. package/dist/features/mentions.js +1 -1
  79. package/dist/features/overlays.js +1 -1
  80. package/dist/features/products.js +9 -9
  81. package/dist/features/resource-assignment.js +3 -3
  82. package/dist/features/resource.js +3 -3
  83. package/dist/features/schema-form.js +6 -6
  84. package/dist/features/sync.js +2 -2
  85. package/dist/features/table.js +1 -1
  86. package/dist/features/theme-tweaker.js +10 -10
  87. package/dist/{filters-geLuSlFL.js → filters-BhsTkWpZ.js} +9 -9
  88. package/dist/{forms-NxbJK4Mh.js → forms-C0WeNPFF.js} +1 -1
  89. package/dist/{forms-numeric-C6NMdj6G.js → forms-numeric-IwQ1ltNf.js} +1 -1
  90. package/dist/hooks/use-native-dialog.d.ts +1 -1
  91. package/dist/{item-CuUJyETl.js → item-M2pmpWXh.js} +1 -1
  92. package/dist/layout/app-shell.js +1 -1
  93. package/dist/layout/auth.js +1 -1
  94. package/dist/layout/header.js +3 -3
  95. package/dist/layout/navigation.js +1 -1
  96. package/dist/layout/page.js +2 -2
  97. package/dist/layout/settings.js +2 -2
  98. package/dist/layout/sidebar.js +10 -6
  99. package/dist/layout/workspace.js +3 -3
  100. package/dist/lib/ui-provider/ui-scope.d.ts +1 -1
  101. package/dist/{mentions-oeYNv_j1.js → mentions-BOxbigwp.js} +2 -2
  102. package/dist/{navigation-BTX5Ti0Z.js → navigation-Bgu7Vc0M.js} +2 -2
  103. package/dist/{navigation-WYKjDhZq.js → navigation-DiYACqpE.js} +2 -2
  104. package/dist/{overlay-C8RAgi2_.js → overlay-BuQOl8aP.js} +5 -5
  105. package/dist/{overlays-9BxwFTou.js → overlays-1sULyytH.js} +5 -5
  106. package/dist/{popover-menu-DcLeiw9r.js → popover-menu-QXgQeWeG.js} +1 -1
  107. package/dist/{repeaters-DfptvbGA.js → repeaters-CeH20dvr.js} +4 -4
  108. package/dist/{sheet-D-DVZH_b.js → sheet-CJ1-OAla.js} +1 -1
  109. package/dist/{sidebar-DXQOy_Cb.js → sidebar-D8uGqxio.js} +4 -4
  110. package/dist/{skeleton-CubvHel9.js → skeleton-BTKNKTdj.js} +1 -1
  111. package/dist/{structure-B9Y5NkP_.js → structure-CynEZ-OY.js} +1 -2
  112. package/dist/style.css +1 -1
  113. package/dist/styles/TOKENS.md +4 -3
  114. package/dist/{table-DT_20ity.js → table-B7dtIFUx.js} +4 -4
  115. package/dist/{timeline-BRZbSdhf.js → timeline-BR_jNgvz.js} +1 -1
  116. package/dist/{timelines-DczZKW5p.js → timelines-DXG1BfIN.js} +1 -1
  117. package/dist/{upload-C_6mlbfl.js → upload-CYuoJykZ.js} +1 -1
  118. package/dist/{value-inputs-CnO6RZeT.js → value-inputs-ng8aVJJZ.js} +4 -4
  119. package/docs/README.md +11 -1
  120. package/docs/build/browser-composition-fixture.ts +28 -0
  121. package/docs/build/recipes.md +398 -1
  122. package/docs/generated/component-index.json +1993 -862
  123. package/docs/generated/components/base--display.md +2 -2
  124. package/docs/generated/components/base--forms.md +72 -1
  125. package/docs/generated/components/base--structure.md +56 -5
  126. package/docs/generated/components/base--typography.md +38 -0
  127. package/docs/generated/components/blocks--admin--access.md +10 -0
  128. package/docs/generated/components/blocks--analytics.md +44 -0
  129. package/docs/generated/components/features--combobox.md +67 -0
  130. package/docs/generated/components/features--data-view.md +59 -3
  131. package/docs/generated/components/features--filters.md +10 -0
  132. package/docs/generated/components/features--media-library.md +68 -0
  133. package/docs/generated/components/features--overlays.md +54 -0
  134. package/docs/generated/components/features--rich-text-editor.md +38 -0
  135. package/docs/generated/components/layout--app-shell.md +31 -0
  136. package/docs/generated/components/layout--containers.md +40 -0
  137. package/docs/generated/components/layout--page.md +30 -1
  138. package/docs/generated/components/layout--sidebar.md +41 -0
  139. package/docs/generated/components/ui-provider.md +1 -1
  140. package/docs/generated/composition-ladder.md +8 -8
  141. package/docs/generated/consumer-contract.json +5736 -0
  142. package/docs/generated/imports.md +1 -1
  143. package/docs/generated/recipes.json +171 -1
  144. package/docs/generated/workflows/communications-overview/analytics/communications-reference.tsx +19 -0
  145. package/docs/generated/workflows/index.json +691 -0
  146. package/docs/generated/workflows/media-picking/media-library/media-picking-reference.tsx +63 -0
  147. package/docs/generated/workflows/media-picking/media-library/reference-data.ts +3 -0
  148. package/docs/generated/workflows/multi-section-form/form-workflow/multi-section-reference.tsx +46 -0
  149. package/docs/generated/workflows/nested-action-flow/action-overlays/nested-action-reference.tsx +39 -0
  150. package/docs/generated/workflows/record-detail/page-layout/record-detail-reference.tsx +24 -0
  151. package/docs/generated/workflows/resource-index/data-view/resource-index-reference.tsx +51 -0
  152. package/docs/generated/workflows/scoped-resource-selection/combobox/scoped-resource-reference.tsx +50 -0
  153. package/docs/generated/workflows/website-content-editor/media-library/reference-data.ts +3 -0
  154. package/docs/generated/workflows/website-content-editor/rich-text-editor/website-content-reference.tsx +33 -0
  155. package/docs/generated/workflows/workspace-navigation/app-shell/workspace-navigation-reference.tsx +26 -0
  156. package/docs/learn/api-compatibility.md +3 -2
  157. package/docs/learn/blocks.md +123 -0
  158. package/docs/learn/composition.md +98 -1
  159. package/docs/learn/forms.md +23 -3
  160. package/docs/learn/framework-wiring.md +34 -6
  161. package/docs/learn/i18n.md +4 -2
  162. package/docs/learn/installation.md +5 -4
  163. package/docs/learn/migration.md +22 -2
  164. package/docs/learn/provider-and-scoping.md +27 -9
  165. package/docs/learn/reference-workflows.md +43 -0
  166. package/docs/learn/structure-and-spacing.md +147 -0
  167. package/docs/learn/theming.md +6 -2
  168. package/docs/learn/troubleshooting.md +28 -5
  169. package/docs/learn/verification.md +58 -2
  170. package/package.json +4 -1
  171. package/scripts/consumer/codemod.mjs +3 -24
  172. package/scripts/consumer/contract-reader.mjs +42 -0
  173. package/scripts/consumer/css-scan.mjs +51 -0
  174. package/scripts/consumer/diagnose.mjs +136 -0
  175. package/scripts/consumer/find-component.mjs +28 -13
  176. package/scripts/consumer/install-skill.mjs +93 -11
  177. package/src/styles/TOKENS.md +4 -3
  178. package/dist/combobox-Byu8N-Oe.js +0 -274
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "themelia-ui",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "private": false,
5
5
  "description": "Themelia UI is a production-ready, composable React component system with CSS-first theming.",
6
6
  "keywords": [
@@ -38,6 +38,9 @@
38
38
  "scripts/consumer/install-skill.mjs",
39
39
  "scripts/consumer/find-component.mjs",
40
40
  "scripts/consumer/codemod.mjs",
41
+ "scripts/consumer/diagnose.mjs",
42
+ "scripts/consumer/contract-reader.mjs",
43
+ "scripts/consumer/css-scan.mjs",
41
44
  "docs/README.md",
42
45
  "docs/learn",
43
46
  "docs/generated",
@@ -10,8 +10,9 @@
10
10
  * stylesheet references requiring manual review.
11
11
  */
12
12
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
13
- import { dirname, join, relative, resolve } from 'node:path'
13
+ import { dirname, join, resolve } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
+ import { loadMigrationMaps } from './contract-reader.mjs'
15
16
 
16
17
  const HERE = dirname(fileURLToPath(import.meta.url))
17
18
  const GENERATED = resolve(HERE, '..', '..', 'docs', 'generated')
@@ -23,29 +24,7 @@ const MARKUP = /\.(?:html|vue|svelte|astro|mdx)$/
23
24
  const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
24
25
 
25
26
  /** The two published maps, indexed for the rewrites below. */
26
- export function loadMaps(dir = GENERATED) {
27
- const read = (file) => {
28
- const path = resolve(dir, file)
29
- if (!existsSync(path)) throw new Error(`the packaged migration map is missing at ${path}`)
30
- return JSON.parse(readFileSync(path, 'utf8'))
31
- }
32
- const codemod = read('migration-codemod.json')
33
- const broad = read('migration-broad-imports.json')
34
- const root = resolve(dir, '..', '..')
35
- const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
36
- return {
37
- /* Where a reader finds the rest, as a path from wherever the codemod was run. */
38
- doc: relative(process.cwd(), resolve(dir, 'migration.md')),
39
- name: pkg.name,
40
- moves: new Map(Object.entries(codemod.moves ?? {})),
41
- tokens: new Map(Object.entries(codemod.tokens ?? {})),
42
- classes: new Map(Object.entries(codemod.classes ?? {})),
43
- reviewClasses: new Map(Object.entries(codemod.reviewClasses ?? {})),
44
- broad: new Set([...(broad.removedSubpaths ?? [])]),
45
- owner: broad.symbols ?? {},
46
- ambiguous: new Set(Object.keys(broad.ambiguous ?? {})),
47
- }
48
- }
27
+ export const loadMaps = (dir = GENERATED) => loadMigrationMaps(dir)
49
28
 
50
29
  /*
51
30
  * A whole property name: the lookbehind keeps `i--` and `a--b` out; greedy, so a name
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { relative, resolve } from 'node:path'
3
+
4
+ export function loadMigrationMaps(dir) {
5
+ const read = (file) => {
6
+ const path = resolve(dir, file)
7
+ if (!existsSync(path)) throw new Error(`the packaged migration map is missing at ${path}`)
8
+ return JSON.parse(readFileSync(path, 'utf8'))
9
+ }
10
+ const codemod = read('migration-codemod.json')
11
+ const broad = read('migration-broad-imports.json')
12
+ const root = resolve(dir, '..', '..')
13
+ const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
14
+ return {
15
+ /* Where a reader finds the rest, as a path from wherever the codemod was run. */
16
+ doc: relative(process.cwd(), resolve(dir, 'migration.md')),
17
+ name: pkg.name,
18
+ moves: new Map(Object.entries(codemod.moves ?? {})),
19
+ tokens: new Map(Object.entries(codemod.tokens ?? {})),
20
+ classes: new Map(Object.entries(codemod.classes ?? {})),
21
+ reviewClasses: new Map(Object.entries(codemod.reviewClasses ?? {})),
22
+ broad: new Set([...(broad.removedSubpaths ?? [])]),
23
+ owner: broad.symbols ?? {},
24
+ ambiguous: new Set(Object.keys(broad.ambiguous ?? {})),
25
+ }
26
+ }
27
+
28
+
29
+ /** Read generated package facts; absent or incompatible metadata is an incomplete analysis. */
30
+ export function loadConsumerContract(packageRoot) {
31
+ const pkg=JSON.parse(readFileSync(resolve(packageRoot,'package.json'),'utf8'))
32
+ const metadata=resolve(packageRoot,'docs/generated/consumer-contract.json')
33
+ if(!existsSync(metadata)) throw new Error(`consumer contract is missing at ${metadata}`)
34
+ const contract=JSON.parse(readFileSync(metadata,'utf8'))
35
+ if(contract.schemaVersion!==1 || !contract.publicExports || !contract.privateStyleNames || !Array.isArray(contract.privateStyleNames.variables) || !Array.isArray(contract.privateStyleNames.classes)) throw new Error('unsupported consumer contract schema')
36
+ if(contract.packageVersion!==pkg.version) throw new Error('consumer contract version differs from installed package')
37
+ for(const [specifier,entry] of Object.entries(contract.publicExports)) {
38
+ if(!(specifier===pkg.name||specifier.startsWith(`${pkg.name}/`)) || !['javascript','stylesheet','json'].includes(entry.kind) || !Array.isArray(entry.symbols)) throw new Error('invalid public export metadata')
39
+ if(entry.targets?.some(target=>!target.startsWith('./')||target.split('/').includes('..')||!existsSync(resolve(packageRoot,target)))) throw new Error(`installed package export target is missing: ${specifier}`)
40
+ }
41
+ return {...contract,name:pkg.name,packageRoot,migration:loadMigrationMaps(resolve(packageRoot,'docs/generated'))}
42
+ }
@@ -0,0 +1,51 @@
1
+ /** Bounded plain-CSS observations. Unsupported input is explicit, never a regex pass. */
2
+ const decodeEscapes = value => value
3
+ .replace(/\\(?:\r\n|[\n\r\f])/g, '')
4
+ .replace(/\\([0-9a-fA-F]{1,6})(?:\r\n|[ \n\r\t\f])?|\\(.)/g, (_, hex, char) => {
5
+ if (!hex) return char
6
+ const point = parseInt(hex, 16)
7
+ return point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff) ? '\uFFFD' : String.fromCodePoint(point)
8
+ })
9
+ export function scanCss(source, file = 'input.css') {
10
+ const classes = [], variables = [], imports = [], unsupported = []
11
+ const locate = at => { const before=source.slice(0,at); return { line:before.split('\n').length,column:at-(before.lastIndexOf('\n')+1)+1 } }
12
+ if (!file.endsWith('.css')) return { classes, variables, imports, unsupported:[{...locate(0),reason:'Only plain CSS is supported'}] }
13
+ const chars=source.split('')
14
+ for(let i=0;i<source.length;i++) {
15
+ if(source.slice(i,i+2)==='/*') {
16
+ const end=source.indexOf('*/',i+2)
17
+ if(end<0) { unsupported.push({...locate(i),reason:'Unclosed comment'}); break }
18
+ for(let j=i;j<end+2;j++) if(chars[j]!=='\n') chars[j]=' '
19
+ i=end+1
20
+ } else if(source[i]==='"'||source[i]==="'") {
21
+ const start=i, quote=source[i]; let closed=false
22
+ for(i++;i<source.length;i++) { if(source[i]==='\\') {i++;continue} if(source[i]===quote) {closed=true;break} }
23
+ if(!closed) unsupported.push({...locate(start),reason:'Unclosed string'})
24
+ const value=source.slice(start+1,i)
25
+ if(/@import\s+(?:url\(\s*)?$/i.test(chars.slice(0,start).join(''))) imports.push({specifier:decodeEscapes(value),...locate(start)})
26
+ for(let j=start;j<=i && j<chars.length;j++) if(chars[j]!=='\n') chars[j]=' '
27
+ }
28
+ }
29
+ const clean=chars.join(''); let from=0, depth=0
30
+ for (const hit of clean.matchAll(/@import\s+url\(\s*([^\s)][^)]*?)\s*\)/gi)) {
31
+ const value=decodeEscapes(hit[1])
32
+ imports.push({specifier:value,...locate(hit.index)})
33
+ }
34
+ for(let i=0;i<clean.length;i++) {
35
+ if(clean[i]==='{') {
36
+ const prelude=clean.slice(from,i)
37
+ for(const hit of prelude.matchAll(/\.((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w-])+)/g)) {
38
+ const name=decodeEscapes(hit[1])
39
+ classes.push({name,...locate(from+hit.index)})
40
+ }
41
+ depth++;from=i+1
42
+ } else if(clean[i]===';'||clean[i]==='}') { if(clean[i]==='}') depth--;from=i+1 }
43
+ }
44
+ if(depth!==0) unsupported.push({...locate(source.length),reason:'Unbalanced CSS blocks'})
45
+ for(const hit of clean.matchAll(/--[A-Za-z_][\w-]*/g)) {
46
+ const after=clean.slice(hit.index+hit[0].length), before=clean.slice(0,hit.index)
47
+ if(/^\s*:/.test(after)||/var\(\s*$/.test(before)) variables.push({name:hit[0],kind:/^\s*:/.test(after)?'declaration':'read',...locate(hit.index)})
48
+ }
49
+ if(/(?:^|[;{}])\s*\$|@(?:mixin|include|extend)\b/.test(clean)) unsupported.push({...locate(0),reason:'Preprocessor syntax is unsupported'})
50
+ return { classes, variables, imports, unsupported }
51
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /** Read-only observations of explicit consumer files against the installed package. */
3
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
5
+ import { dirname, relative, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { loadConsumerContract } from './contract-reader.mjs'
8
+ import { scanCss } from './css-scan.mjs'
9
+ import { checkSkill } from './install-skill.mjs'
10
+
11
+ const DOC='node_modules/themelia-ui/docs/learn/'
12
+ const SCRIPT=/\.(?:[cm]?[jt]sx?)$/
13
+ const EXCLUDED=new Set(['node_modules','.git','dist','build'])
14
+ const inside=(root,path)=>{const rel=relative(root,path);return rel!== '..'&&!rel.startsWith('../')&&!rel.startsWith('..\\')}
15
+ export async function diagnoseProject({project='.',paths=[],contract,checkSkills=true}={}) {
16
+ const root=realpathSync(resolve(project)), require=createRequire(resolve(root,'package.json'))
17
+ const report={schemaVersion:1,packageVersion:null,checkedRules:[],skippedChecks:[],checkedFiles:[],skippedFiles:[],findings:[]}
18
+ const skip=(ruleId,reason)=>report.skippedChecks.push({ruleId,reason})
19
+ if(!contract) {
20
+ try {contract=loadConsumerContract(dirname(require.resolve('themelia-ui/package.json')))}
21
+ catch(error) {skip('package-contract',error.message);return {...report,exitCode:2}}
22
+ }
23
+ report.packageVersion=contract.packageVersion
24
+ const files=new Set()
25
+ const skipped=(file,reason)=>report.skippedFiles.push({file:relative(root,file),reason})
26
+ const walk=path=>{
27
+ if(!inside(root,path)||relative(root,path).split(/[\\/]/).some(part=>EXCLUDED.has(part))) {skipped(path,'Outside explicit project source boundary');return}
28
+ if(!existsSync(path)) {skipped(path,'Missing path');return}
29
+ if(!inside(root,realpathSync(path))) {skipped(path,'Resolved path escapes the project through a symlink');return}
30
+ const stat=lstatSync(path)
31
+ if(stat.isSymbolicLink()) {skipped(path,'Symlinks are not followed');return}
32
+ if(stat.isDirectory()) {for(const entry of readdirSync(path).sort()) if(!EXCLUDED.has(entry)) walk(resolve(path,entry));return}
33
+ if(SCRIPT.test(path)||/\.(?:css|scss|sass|less|vue|svelte|astro|mdx)$/.test(path)) files.add(path)
34
+ else skipped(path,'Unsupported source format')
35
+ }
36
+ if(!paths.length) skip('explicit-paths','Supply explicit source paths to analyze')
37
+ for(const path of paths) walk(resolve(root,path))
38
+ const scripts=[...files].filter(path=>SCRIPT.test(path))
39
+ let ts, program, checker
40
+ if(scripts.length) {
41
+ try {ts=require('typescript');program=ts.createProgram(scripts,{noResolve:true,noLib:true,types:[],target:ts.ScriptTarget.Latest,jsx:ts.JsxEmit.Preserve});checker=program.getTypeChecker()}
42
+ catch(error) {skip('script-parser',`Install TypeScript in this consumer project: ${error.message}`)}
43
+ }
44
+ const finding=(ruleId,severity,file,location,evidence,explanation,documentation='troubleshooting.md')=>report.findings.push({ruleId,severity,file:relative(root,file),line:location.line,column:location.column,evidence,explanation,documentation:DOC+documentation})
45
+ const exportEntry=specifier=>contract.publicExports[specifier]
46
+ const imports=(specifier,names,file,loc)=>{
47
+ if(!(specifier===contract.name||specifier.startsWith(`${contract.name}/`))) return
48
+ const entry=exportEntry(specifier)
49
+ if(!entry) {
50
+ const moved=contract.migration?.moves?.get(specifier)
51
+ const alternatives=names.map(name=>contract.migration?.owner?.[name]).filter(Boolean)
52
+ finding('unpublished-import','error',file,loc,specifier,`This exact import is not published.${moved?` Use ${moved}.`:alternatives.length?` Use ${[...new Set(alternatives)].join(', ')}.`:/\.css$/.test(specifier)?' Components bring their own CSS; remove obsolete module stylesheet imports.':' Consult the exact import catalogue.'}`,'composition.md');return
53
+ }
54
+ if(entry.kind==='javascript') for(const name of names) if(!entry.symbols.includes(name)) finding('missing-symbol','error',file,loc,`${specifier}: ${name}`,'This symbol is absent from the installed public declaration; consult the module API.','composition.md')
55
+ }
56
+ if(ts) report.checkedRules.push('unpublished-import','missing-symbol','duplicate-owner','nested-surface')
57
+ for(const file of scripts) {
58
+ if(!ts) {skipped(file,'TypeScript parser unavailable');continue}
59
+ const source=program.getSourceFile(file)
60
+ if(!source || source.parseDiagnostics.length) {skipped(file,'Source parse failed');continue}
61
+ report.checkedFiles.push(relative(root,file));const bindings=new Map(), namespaces=new Map()
62
+ const loc=node=>{const p=source.getLineAndCharacterOfPosition(node.getStart(source));return {line:p.line+1,column:p.character+1}}
63
+ const bind=(node,symbol,specifier)=>{const identity=checker.getSymbolAtLocation(node);if(identity) bindings.set(identity,{symbol,specifier})}
64
+ for(const node of source.statements) {
65
+ if(ts.isImportDeclaration(node)&&ts.isStringLiteral(node.moduleSpecifier)) {
66
+ const specifier=node.moduleSpecifier.text, clause=node.importClause, names=[]
67
+ if(clause?.name) {names.push('default');bind(clause.name,'default',specifier)}
68
+ const named=clause?.namedBindings
69
+ if(named&&ts.isNamedImports(named)) for(const element of named.elements) {const name=element.propertyName?.text??element.name.text;names.push(name);bind(element.name,name,specifier)}
70
+ if(named&&ts.isNamespaceImport(named)) namespaces.set(checker.getSymbolAtLocation(named.name),specifier)
71
+ imports(specifier,names,file,loc(node.moduleSpecifier))
72
+ } else if(ts.isExportDeclaration(node)&&node.moduleSpecifier&&ts.isStringLiteral(node.moduleSpecifier)) imports(node.moduleSpecifier.text,node.exportClause&&ts.isNamedExports(node.exportClause)?node.exportClause.elements.map(item=>item.propertyName?.text??item.name.text):[],file,loc(node.moduleSpecifier))
73
+ }
74
+ const owner=tag=>{
75
+ if(ts.isIdentifier(tag)) return bindings.get(checker.getSymbolAtLocation(tag))
76
+ if(ts.isPropertyAccessExpression(tag)) {const specifier=namespaces.get(checker.getSymbolAtLocation(tag.expression));if(specifier) return {specifier,symbol:tag.name.text}}
77
+ }
78
+ const native=tag=>{const value=owner(tag.tagName);return value&&exportEntry(value.specifier)?.symbols.includes(value.symbol)?value.symbol:null}
79
+ const title=tag=>{const attr=tag.attributes.properties.find(item=>ts.isJsxAttribute(item)&&['title','label'].includes(item.name.getText(source)));return attr?.initializer&&ts.isStringLiteral(attr.initializer)?attr.initializer.text:undefined}
80
+ const visit=node=>{
81
+ if(ts.isCallExpression(node)&&node.arguments[0]&&ts.isStringLiteral(node.arguments[0])&&(node.expression.kind===ts.SyntaxKind.ImportKeyword||(ts.isIdentifier(node.expression)&&node.expression.text==='require'&&!checker.getSymbolAtLocation(node.expression)))) imports(node.arguments[0].text,[],file,loc(node.arguments[0]))
82
+ const member=ts.isPropertyAccessExpression(node)?{base:node.expression,name:node.name.text}
83
+ :ts.isQualifiedName(node)?{base:node.left,name:node.right.text}
84
+ :ts.isElementAccessExpression(node)&&node.argumentExpression&&(ts.isStringLiteral(node.argumentExpression)||ts.isNoSubstitutionTemplateLiteral(node.argumentExpression))?{base:node.expression,name:node.argumentExpression.text}:null
85
+ if(member) {
86
+ const specifier=namespaces.get(checker.getSymbolAtLocation(member.base))
87
+ if(specifier) imports(specifier,[member.name],file,loc(node))
88
+ }
89
+ const tag=ts.isJsxElement(node)?node.openingElement:ts.isJsxSelfClosingElement(node)?node:null
90
+ if(tag) {
91
+ const symbol=native(tag)
92
+ if(symbol) for(let ancestor=node.parent;ancestor;ancestor=ancestor.parent) {
93
+ if(!ts.isJsxElement(ancestor)) continue
94
+ const parent=native(ancestor.openingElement);if(!parent) continue
95
+ if(title(tag)&&title(tag)===title(ancestor.openingElement)) finding('duplicate-owner','warning',file,loc(tag),`${parent} / ${symbol}: ${title(tag)}`,'Repeated literal names may duplicate a subject or field owner. Confirm distinct subjects and accessible names.','composition.md')
96
+ if(symbol==='Card'&&parent==='Card') finding('nested-surface','warning',file,loc(tag),'Card inside Card','Confirm the child is an independent subject. ContentBlock or flat FormSection may express an existing-surface region.','composition.md')
97
+ break
98
+ }
99
+ }
100
+ ts.forEachChild(node,visit)
101
+ };visit(source)
102
+ }
103
+ report.checkedRules.push('private-variable','private-selector','migration-token','stylesheet-import')
104
+ for(const file of [...files].filter(path=>!SCRIPT.test(path))) {
105
+ const observations=scanCss(readFileSync(file,'utf8'),file)
106
+ if(observations.unsupported.length) {skipped(file,observations.unsupported.map(item=>item.reason).join('; '));continue}
107
+ report.checkedFiles.push(relative(root,file))
108
+ for(const item of observations.imports) imports(item.specifier,[],file,item)
109
+ for(const item of observations.classes) if(contract.privateStyleNames.classes.includes(item.name)) finding('private-selector','warning',file,item,item.name,'This selector matches a verified CSS Module hash in this package build. Use documented hooks or public tokens.')
110
+ for(const item of observations.variables) {
111
+ if(contract.privateStyleNames.variables.includes(item.name)) finding('private-variable','warning',file,item,item.name,'This package-private variable is not a supported override. Use a documented public token.')
112
+ if(contract.migration?.tokens?.has(item.name)) finding('migration-token','warning',file,item,item.name,`Historical token; ${contract.migration.tokens.get(item.name)?`review ${contract.migration.tokens.get(item.name)}`:'no successor is declared'}.`,'migration.md')
113
+ }
114
+ }
115
+ if(checkSkills) {
116
+ if(!contract.packageRoot) skip('skill-freshness','Installed package root unavailable')
117
+ else {
118
+ const freshness=checkSkill({project:root,source:resolve(contract.packageRoot,'.agents/skills/themelia-ui')})
119
+ if(freshness.exitCode===2) skip('skill-freshness',freshness.error)
120
+ else {report.checkedRules.push('skill-freshness');for(const target of freshness.targets) if(target.status!=='current') finding('skill-freshness','warning',resolve(root,`.${target.target==='agents'?'agents':'claude'}/skills/themelia-ui/SKILL.md`),{line:1,column:1},`${target.target}: ${target.status}; installed ${target.installedVersion??'unknown'}, expected ${freshness.packageVersion}`,`Review modified content and preserve application policy. Run install-skill.mjs --check --json, then reinstall only the generated copy you intend to replace. Unmanaged skills are never overwritten.`,'verification.md')}
121
+ }
122
+ }
123
+ const incomplete=report.skippedChecks.length||report.skippedFiles.length
124
+ return {...report,exitCode:incomplete?2:report.findings.some(item=>item.severity==='error')?1:0}
125
+ }
126
+
127
+ export async function runDiagnoseCli(args=process.argv.slice(2),io=console) {
128
+ let project='.',json=false;const paths=[]
129
+ for(const arg of args) {if(arg==='--json') json=true;else if(arg.startsWith('--project=')) project=arg.slice(10);else if(arg.startsWith('--')) {io.error('usage: diagnose.mjs --project=<directory> [--json] <paths...>');return 2}else paths.push(arg)}
130
+ let result
131
+ try {result=await diagnoseProject({project,paths})} catch(error) {io.error(`Incomplete diagnosis: ${error.message}`);return 2}
132
+ if(json) io.log(JSON.stringify(result,null,2))
133
+ else {io.log(`themelia-ui ${result.packageVersion??'unknown'}: ${result.checkedFiles.length} files, ${result.findings.length} findings`);for(const item of result.findings) io.log(`${item.severity} ${item.file}:${item.line}:${item.column} ${item.ruleId}: ${item.explanation}`);for(const item of [...result.skippedChecks,...result.skippedFiles]) io.log(`incomplete ${item.ruleId??item.file}: ${item.reason}`)}
134
+ return result.exitCode
135
+ }
136
+ if(process.argv[1]&&realpathSync(process.argv[1])===realpathSync(fileURLToPath(import.meta.url))) process.exitCode=await runDiagnoseCli()
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url))
13
13
  export const INDEX_PATH = resolve(HERE, '..', '..', 'docs/generated/component-index.json')
14
+ export const CONTEXTS = ['existing-surface', 'editable', 'read-only', 'server-collection', 'nested-overlay', 'changing-scope']
14
15
 
15
16
  /** One catalogue entry in the shape the finder searches and `--json` prints. */
16
17
  const toRecord = (entry) => ({
@@ -36,7 +37,9 @@ const toRecord = (entry) => ({
36
37
 
37
38
  export function loadIndex(path = INDEX_PATH) {
38
39
  if (!existsSync(path)) throw new Error(`the packaged component index is missing at ${path}`)
39
- return Object.values(JSON.parse(readFileSync(path, 'utf8')).modules).map(toRecord)
40
+ const index = JSON.parse(readFileSync(path, 'utf8'))
41
+ if (!Number.isInteger(index.schemaVersion) || index.schemaVersion < 1 || index.schemaVersion > 4 || !index.modules) throw new Error('unsupported component index schema; use the finder shipped with the installed package')
42
+ return Object.values(index.modules).map(toRecord)
40
43
  }
41
44
 
42
45
  const text = (value) => (Array.isArray(value) ? value.join(' ') : String(value ?? ''))
@@ -87,9 +90,11 @@ const STOPWORDS = new Set([
87
90
  * which beats positive guidance and recipes. `avoidWhen` text never counts as a match.
88
91
  */
89
92
  export function findComponents(modules, query, filters = {}) {
93
+ const contexts = filters.context ?? []
94
+ if (!Array.isArray(contexts) || contexts.some(value => !CONTEXTS.includes(value))) throw new Error(`unknown context; expected ${CONTEXTS.join(', ')}`)
90
95
  const needle = query.trim().toLowerCase()
91
96
  const hasFilter = ['tier', 'profile', 'status', 'module', 'peer', 'route', 'symbol']
92
- .some((name) => Boolean(filters[name]))
97
+ .some((name) => Boolean(filters[name])) || contexts.length > 0
93
98
  if (!needle && !hasFilter) return []
94
99
 
95
100
  const scored = []
@@ -104,7 +109,7 @@ export function findComponents(modules, query, filters = {}) {
104
109
  const symbols = (record.publicSymbols ?? []).map((s) => String(s).toLowerCase())
105
110
  if (filters.symbol && !symbols.includes(filters.symbol.toLowerCase())) continue
106
111
 
107
- if (!needle) {
112
+ if (!needle && !contexts.length) {
108
113
  scored.push({ record, score: 0 })
109
114
  continue
110
115
  }
@@ -123,8 +128,8 @@ export function findComponents(modules, query, filters = {}) {
123
128
  )
124
129
  if (normalizedSymbols.includes(phrase)) score = 100
125
130
  else if (normalizedModule === phrase) score = 90
126
- else if (normalizedSymbols.some((symbol) => symbol.includes(phrase))) score = 75
127
- else if (normalizedModule.includes(phrase)) score = 70
131
+ else if (phrase && normalizedSymbols.some((symbol) => symbol.includes(phrase))) score = 75
132
+ else if (phrase && normalizedModule.includes(phrase)) score = 70
128
133
  else if (queryTerms.length > 1 && identityWholeMatch) score = 65
129
134
  else {
130
135
  const positive = [
@@ -151,16 +156,17 @@ export function findComponents(modules, query, filters = {}) {
151
156
  }
152
157
  for (const entry of record.componentGuidance ?? []) {
153
158
  const curated = entry.guidanceSource === 'component'
154
- const corpus = [entry.symbol, entry.description, curated ? entry.chooseWhen : '', ...(entry.capabilities ?? [])].join(' ')
159
+ const matchedContexts = contexts.filter(value => entry.contexts?.includes(value))
160
+ const corpus = [entry.symbol, entry.description, curated ? entry.chooseWhen : '', ...(entry.capabilities ?? []), ...matchedContexts].join(' ')
155
161
  const hits = termHits(queryTerms, corpus)
156
162
  const exact = normalizedWords(entry.symbol).join(' ') === phrase
157
- if (exact || (queryTerms.length && hits * 2 >= queryTerms.length)) {
158
- const componentScore = exact ? 100 : Math.min(curated ? 64 : 34, (curated ? 39 : 10) + Math.round(25 * hits / queryTerms.length))
159
- componentMatches.push({ ...entry, score: componentScore, matchedTerms: queryTerms.filter((term) => termHits([term], corpus)) })
163
+ if (exact || (queryTerms.length && hits * 2 >= queryTerms.length) || (!needle && matchedContexts.length)) {
164
+ const componentScore = exact ? 100 : Math.min(curated ? 74 : 34, (curated ? 39 : 10) + (queryTerms.length ? Math.round(25 * hits / queryTerms.length) : 0) + matchedContexts.length * 5)
165
+ componentMatches.push({ ...entry, score: componentScore, matchedContexts, contextCoverage: entry.contexts ? 'curated' : 'unknown', matchedTerms: queryTerms.filter((term) => termHits([term], corpus)) })
160
166
  score = Math.max(score, componentScore)
161
167
  }
162
168
  }
163
- if (score > 0) scored.push({ record: filters.explain ? { ...record, match: { score, components: componentMatches.sort((a, b) => b.score - a.score), reason: componentMatches.length ? 'Public component name, description or positive usage guidance' : 'Module identity, positive guidance or attributed recipe' } } : record, score })
169
+ if (score > 0 || !needle) scored.push({ record: filters.explain ? { ...record, match: { score, contexts, contextCoverage: (record.componentGuidance ?? []).some(entry => entry.contexts) ? 'curated' : 'unknown', components: componentMatches.sort((a, b) => b.score - a.score), reason: componentMatches.length ? 'Public component name, description, positive usage guidance or applicable context' : 'Module identity, positive guidance or attributed recipe; uncurated contexts remain unknown' } } : record, score })
164
170
  }
165
171
 
166
172
  return scored
@@ -180,6 +186,7 @@ Options:
180
186
  --symbol=<Name> exact public export name
181
187
  --peer=<package> modules that require this optional peer
182
188
  --route=</path> modules represented on this live preview route
189
+ --context=<context> repeatable: ${CONTEXTS.join(', ')}
183
190
  --limit=<N> return at most N matches (default 8)
184
191
  --explain include matched components, reasons and API anchors
185
192
  --json emit complete machine-readable records
@@ -187,7 +194,7 @@ Options:
187
194
 
188
195
  A text query is optional when at least one catalogue filter is present.`
189
196
 
190
- const VALUE_FLAGS = new Set(['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route', 'limit'])
197
+ const VALUE_FLAGS = new Set(['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route', 'limit', 'context'])
191
198
  const BOOLEAN_FLAGS = new Set(['json', 'help', 'explain'])
192
199
 
193
200
  export function parseFinderArgs(args) {
@@ -207,7 +214,10 @@ export function parseFinderArgs(args) {
207
214
  if (!VALUE_FLAGS.has(rawName)) throw new Error(`unknown option --${rawName}`)
208
215
  const value = rest.join('=')
209
216
  if (!value) throw new Error(`--${rawName} requires a value written as --${rawName}=...`)
210
- options[rawName] = value
217
+ if (rawName === 'context') {
218
+ if (!CONTEXTS.includes(value)) throw new Error(`unknown context ${value}; expected ${CONTEXTS.join(', ')}`)
219
+ options.context = [...(options.context ?? []), value]
220
+ } else options[rawName] = value
211
221
  }
212
222
  const limit = options.limit === undefined ? 8 : Number(options.limit)
213
223
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
@@ -229,7 +239,7 @@ export function runFinderCli(args = process.argv.slice(2), io = console) {
229
239
  return 0
230
240
  }
231
241
  const filters = Object.fromEntries(
232
- ['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route']
242
+ ['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route', 'context']
233
243
  .filter((name) => parsed.options[name])
234
244
  .map((name) => [name, parsed.options[name]]),
235
245
  )
@@ -265,6 +275,11 @@ export function renderMatch(record) {
265
275
  lines.push(` match ${record.match.reason}`)
266
276
  for (const entry of record.match.components.slice(0, 3)) {
267
277
  lines.push(` ${entry.symbol} — ${entry.chooseWhen}`, ` api ${entry.apiDoc}`, ` compose ${entry.composition}`)
278
+ lines.push(` guidance ${entry.guidanceSource === 'component' ? 'component' : 'module fallback'}`)
279
+ lines.push(` context ${entry.contextCoverage ?? 'unknown'}${entry.matchedContexts?.length ? `: ${entry.matchedContexts.join(', ')}` : ''}`)
280
+ const unmatched = (record.match.contexts ?? []).filter(context => !entry.matchedContexts?.includes(context))
281
+ if (unmatched.length) lines.push(` unmatched ${unmatched.join(', ')}`)
282
+ for (const workflow of entry.workflows ?? []) lines.push(` source node_modules/themelia-ui/docs/generated/${workflow.source}`)
268
283
  }
269
284
  }
270
285
  return lines.join('\n')
@@ -2,14 +2,15 @@
2
2
  /*
3
3
  * Installs the themelia-ui agent skill (which component to use, its exact import, what to
4
4
  * avoid) into a project's .agents/ and/or .claude/ skills. Offline: reads the copy shipped
5
- * in this package. Replaces a previous install atomically; never a hand-written skill.
5
+ * in this package. Stages replacements and rolls back caught write failures; never replaces a hand-written skill.
6
6
  * node node_modules/themelia-ui/scripts/consumer/install-skill.mjs --project=. [--target=agents|claude|both]
7
7
  */
8
8
  import {
9
9
  cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync,
10
- statSync,
10
+ lstatSync,
11
11
  } from 'node:fs'
12
- import { dirname, join, relative, resolve } from 'node:path'
12
+ import { createHash } from 'node:crypto'
13
+ import { dirname, join, relative, resolve, sep } from 'node:path'
13
14
  import { fileURLToPath } from 'node:url'
14
15
 
15
16
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -30,17 +31,78 @@ export const TARGET_DIRECTORIES = {
30
31
 
31
32
  /** Whether a destination may be replaced: absent, or carrying our marker. */
32
33
  export function mayOverwrite(destination) {
34
+ if (existsSync(destination) && lstatSync(destination).isSymbolicLink()) return false
33
35
  const skill = join(destination, 'SKILL.md')
34
- if (!existsSync(skill)) return true
36
+ if (!existsSync(skill)) return !existsSync(destination) || readdirSync(destination).length === 0
35
37
  return readFileSync(skill, 'utf8').includes(GENERATED_MARKER)
36
38
  }
37
39
 
38
- export function installSkill({ project = '.', target = 'both', source = SOURCE } = {}) {
40
+ const hashFile = (file) => createHash('sha256').update(readFileSync(file)).digest('hex')
41
+ const inside = (root, file) => {
42
+ const path = relative(realpathSync(root), realpathSync(file))
43
+ return path !== '..' && !path.startsWith('../') && !path.startsWith('..\\')
44
+ }
45
+
46
+ /** A manifest is data, never permission to read paths outside the skill directory. */
47
+ export function readSkillManifest(root) {
48
+ const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'))
49
+ if (manifest.schemaVersion !== 1 || typeof manifest.packageVersion !== 'string' || !manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) throw new Error('unsupported skill manifest')
50
+ if (!manifest.files['SKILL.md']) throw new Error('skill manifest has no SKILL.md')
51
+ for (const [path, hash] of Object.entries(manifest.files)) {
52
+ if (!path || path === 'manifest.json' || path.startsWith('/') || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..') || !/^[a-f0-9]{64}$/.test(hash)) throw new Error(`invalid skill manifest path or hash: ${path}`)
53
+ }
54
+ return manifest
55
+ }
56
+
57
+ /** Inspect installed copies without changing application policy or generated bytes. */
58
+ export function checkSkill({ project = '.', target = 'both', source = SOURCE } = {}) {
59
+ const names = target === 'both' ? ['agents', 'claude'] : [target]
60
+ if (names.some((name) => !(name in TARGET_DIRECTORIES))) throw new Error(`unknown --target=${target}; expected agents, claude or both`)
61
+ let expected
62
+ try {
63
+ expected = readSkillManifest(source)
64
+ const recorded = Object.keys(expected.files).sort()
65
+ const actual = listFiles(source).filter(file => file !== 'manifest.json')
66
+ if (JSON.stringify(recorded) !== JSON.stringify(actual)) throw new Error('packaged skill inventory differs from its manifest')
67
+ for (const [file, hash] of Object.entries(expected.files)) {
68
+ const path = join(source, file)
69
+ if (!inside(source, path) || hashFile(path) !== hash) throw new Error(`packaged skill differs from its manifest: ${file}`)
70
+ }
71
+ } catch (error) { return { packageVersion: null, targets: [], error: error.message, exitCode: 2 } }
72
+ const targets = names.map((name) => {
73
+ const destination = resolve(project, TARGET_DIRECTORIES[name])
74
+ const item = { target: name, status: 'missing', installedVersion: null, changedFiles: [], locallyModifiedFiles: [] }
75
+ if (!existsSync(destination)) return item
76
+ if (!mayOverwrite(destination)) return { ...item, status: 'unmanaged' }
77
+ let installed
78
+ try { installed = readSkillManifest(destination) } catch {
79
+ item.locallyModifiedFiles = listFiles(destination).filter(file => file !== 'manifest.json').filter(file => {
80
+ try { return !expected.files[file] || !inside(destination, join(destination, file)) || hashFile(join(destination, file)) !== expected.files[file] } catch { return true }
81
+ })
82
+ return { ...item, status: 'stale', changedFiles: ['manifest.json', ...item.locallyModifiedFiles] }
83
+ }
84
+ item.installedVersion = installed.packageVersion
85
+ item.locallyModifiedFiles = [...new Set([...Object.keys(installed.files), ...listFiles(destination).filter(file => file !== 'manifest.json')])].filter(file => {
86
+ try { return !installed.files[file] || !inside(destination, join(destination, file)) || hashFile(join(destination, file)) !== installed.files[file] } catch { return true }
87
+ }).sort()
88
+ const files = new Set([...Object.keys(expected.files), ...listFiles(destination).filter(file => file !== 'manifest.json')])
89
+ item.changedFiles = [...files].filter((file) => {
90
+ try { return !expected.files[file] || !inside(destination, join(destination, file)) || hashFile(join(destination, file)) !== expected.files[file] } catch { return true }
91
+ }).sort()
92
+ if (JSON.stringify(installed.files) !== JSON.stringify(expected.files)) item.changedFiles.push('manifest.json')
93
+ item.status = installed.packageVersion !== expected.packageVersion ? 'stale' : item.changedFiles.length ? 'modified' : 'current'
94
+ return item
95
+ })
96
+ return { packageVersion: expected.packageVersion, targets, exitCode: targets.every((item) => item.status === 'current') ? 0 : 1 }
97
+ }
98
+
99
+ export function installSkill({ project = '.', target = 'both', source = SOURCE, overwriteModified = false } = {}) {
39
100
  const targets = target === 'both' ? ['agents', 'claude'] : [target]
40
101
  if (targets.some((name) => !(name in TARGET_DIRECTORIES))) {
41
102
  throw new Error(`unknown --target=${target}; expected agents, claude or both`)
42
103
  }
43
104
  if (!existsSync(source)) throw new Error(`the packaged skill is missing at ${source}`)
105
+ for (const file of listFiles(source)) if (lstatSync(join(source, file)).isSymbolicLink()) throw new Error(`skill source contains a symlink: ${file}`)
44
106
 
45
107
  const nonce = `${process.pid}-${Date.now()}`
46
108
  const destinations = targets.map((name, index) => {
@@ -67,6 +129,10 @@ export function installSkill({ project = '.', target = 'both', source = SOURCE }
67
129
  )
68
130
  }
69
131
  }
132
+ const freshness = checkSkill({ project, target, source })
133
+ if (freshness.exitCode === 2) throw new Error(`invalid packaged skill metadata: ${freshness.error}`)
134
+ const modified = freshness.targets.filter(item => item.locallyModifiedFiles?.length)
135
+ if (modified.length && !overwriteModified) throw new Error(`modified generated files: ${modified.map(item => `${item.target}: ${item.locallyModifiedFiles.join(', ')}`).join('; ')}. Review these changes and preserve application policy separately; --overwrite-modified explicitly replaces them.`)
70
136
 
71
137
  try {
72
138
  for (const item of destinations) {
@@ -78,8 +144,6 @@ export function installSkill({ project = '.', target = 'both', source = SOURCE }
78
144
  renameSync(item.stage, item.destination)
79
145
  item.committed = true
80
146
  }
81
- for (const item of destinations) rmSync(item.backup, { recursive: true, force: true })
82
- return destinations.map(({ destination }) => destination)
83
147
  } catch (error) {
84
148
  for (const item of [...destinations].reverse()) {
85
149
  rmSync(item.stage, { recursive: true, force: true })
@@ -88,6 +152,12 @@ export function installSkill({ project = '.', target = 'both', source = SOURCE }
88
152
  }
89
153
  throw error
90
154
  }
155
+ // Commit is complete; discarded backups cannot support a later rollback.
156
+ for (const item of destinations) {
157
+ try { rmSync(item.backup, { recursive: true, force: true }) }
158
+ catch (error) { throw new Error(`Skill copies installed; backup cleanup failed at ${item.backup}: ${error.message}`) }
159
+ }
160
+ return destinations.map(({ destination }) => destination)
91
161
  }
92
162
 
93
163
  /** Every file under a directory, relative and sorted — for tests and for the summary. */
@@ -96,8 +166,8 @@ export function listFiles(root) {
96
166
  const walk = (dir) => {
97
167
  for (const entry of readdirSync(dir)) {
98
168
  const full = join(dir, entry)
99
- if (statSync(full).isDirectory()) walk(full)
100
- else out.push(relative(root, full))
169
+ if (lstatSync(full).isDirectory()) walk(full)
170
+ else out.push(relative(root, full).split(sep).join('/'))
101
171
  }
102
172
  }
103
173
  if (existsSync(root)) walk(root)
@@ -122,10 +192,13 @@ function runFromCommandLine(moduleUrl) {
122
192
 
123
193
  const invokedDirectly = runFromCommandLine(import.meta.url)
124
194
  if (invokedDirectly) {
125
- const USAGE = `usage: install-skill.mjs [--project=<directory>] [--target=agents|claude|both]
195
+ const USAGE = `usage: install-skill.mjs [--project=<directory>] [--target=agents|claude|both] [--check] [--json] [--overwrite-modified]
126
196
 
127
197
  Copies the packaged themelia-ui consumer skill into a project. Existing generated
128
- copies are replaced atomically; a same-named hand-written skill is never overwritten.`
198
+ copies are staged before replacement; caught write failures restore their backups.
199
+ A same-named hand-written skill is never overwritten.
200
+ --check is read-only: 0 current, 1 missing/drift/unmanaged, 2 invalid package metadata.
201
+ --overwrite-modified replaces reviewed local changes in a generated copy; unmanaged skills remain protected.`
129
202
  try {
130
203
  const options = { project: '.', target: 'both' }
131
204
  for (const argument of process.argv.slice(2)) {
@@ -133,10 +206,19 @@ copies are replaced atomically; a same-named hand-written skill is never overwri
133
206
  console.log(USAGE)
134
207
  process.exit(0)
135
208
  }
209
+ if (argument === '--check' || argument === '--json') { options[argument.slice(2)] = true; continue }
210
+ if (argument === '--overwrite-modified') { options.overwriteModified = true; continue }
136
211
  const match = argument.match(/^--(project|target)=(.+)$/)
137
212
  if (!match) throw new Error(`unknown argument ${argument}\n\n${USAGE}`)
138
213
  options[match[1]] = match[2]
139
214
  }
215
+ if (options.json && !options.check) throw new Error('--json requires --check')
216
+ if (options.check) {
217
+ const report = checkSkill(options)
218
+ console.log(options.json ? JSON.stringify(report, null, 2) : report.error ?? report.targets.map(item => `${item.target}: ${item.status} (installed ${item.installedVersion ?? 'unknown'}, package ${report.packageVersion})${item.changedFiles.length ? ` — ${item.changedFiles.join(', ')}` : ''}`).join('\n'))
219
+ if (report.exitCode === 1 && !options.json) console.log('Review modified/unmanaged copies; reinstall generated copies with install-skill.mjs --project=<directory>. Application policy stays in a separate skill.')
220
+ process.exit(report.exitCode)
221
+ }
140
222
  const installed = installSkill(options)
141
223
  for (const destination of installed) {
142
224
  console.log(`installed ${listFiles(destination).length} file(s) into ${relative(process.cwd(), destination) || '.'}`)
@@ -2,9 +2,10 @@
2
2
 
3
3
  The theme is `src/styles/theme/*.css`: about eighty variables, declared once at `:root`. Every
4
4
  colour is `light-dark(light, dark)`, and the element's `color-scheme` (set by `.light`,
5
- `.dark`, `data-theme` or the operating system) picks the half. Nothing below `:root`
6
- declares a theme variable again, so a value set anywhere reaches every reader below it, and
7
- a dark region inside a light page needs no second set of values.
5
+ `.dark`, `data-theme` or the operating system) picks the half. Component styles inherit
6
+ theme defaults rather than redeclaring them. Explicit scope overrides and density presets
7
+ replace the corresponding inherited values on their regions. A dark region inside a light
8
+ page needs no second set of colour declarations.
8
9
 
9
10
  Components read the theme and compute the rest in the rule that uses it. The package's checks
10
11
  fail on a `var()` nothing defines, a literal colour, radius, spacing or hairline in a