@mindfiredigital/ignix-lite-engine 1.1.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 (91) hide show
  1. package/.turbo/turbo-build.log +22 -0
  2. package/CHANGELOG.md +7 -0
  3. package/LICENSE +21 -0
  4. package/README.md +283 -0
  5. package/dist/index.d.ts +171 -0
  6. package/dist/index.js +2540 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/manifests/accordion.json +61 -0
  9. package/dist/manifests/alert.json +69 -0
  10. package/dist/manifests/avatar.json +75 -0
  11. package/dist/manifests/badge.json +74 -0
  12. package/dist/manifests/breadcrumb.json +87 -0
  13. package/dist/manifests/button.json +85 -0
  14. package/dist/manifests/card.json +91 -0
  15. package/dist/manifests/checkbox.json +122 -0
  16. package/dist/manifests/codeblock.json +63 -0
  17. package/dist/manifests/combobox.json +33 -0
  18. package/dist/manifests/dialog.json +64 -0
  19. package/dist/manifests/divider.json +47 -0
  20. package/dist/manifests/dropdown.json +105 -0
  21. package/dist/manifests/form.json +81 -0
  22. package/dist/manifests/grid.json +143 -0
  23. package/dist/manifests/input.json +99 -0
  24. package/dist/manifests/meter.json +103 -0
  25. package/dist/manifests/navigation.json +70 -0
  26. package/dist/manifests/progress.json +88 -0
  27. package/dist/manifests/radio.json +121 -0
  28. package/dist/manifests/select.json +109 -0
  29. package/dist/manifests/skeleton.json +101 -0
  30. package/dist/manifests/tab.json +88 -0
  31. package/dist/manifests/table.json +92 -0
  32. package/dist/manifests/textarea.json +117 -0
  33. package/dist/manifests/toast.json +157 -0
  34. package/dist/manifests/tooltip.json +115 -0
  35. package/dist/vector-index.json +14015 -0
  36. package/package.json +33 -0
  37. package/src/global.d.ts +3 -0
  38. package/src/index.ts +14 -0
  39. package/src/manifests/accordion.json +61 -0
  40. package/src/manifests/alert.json +69 -0
  41. package/src/manifests/avatar.json +75 -0
  42. package/src/manifests/badge.json +74 -0
  43. package/src/manifests/breadcrumb.json +87 -0
  44. package/src/manifests/button.json +85 -0
  45. package/src/manifests/card.json +91 -0
  46. package/src/manifests/checkbox.json +122 -0
  47. package/src/manifests/codeblock.json +63 -0
  48. package/src/manifests/combobox.json +33 -0
  49. package/src/manifests/dialog.json +64 -0
  50. package/src/manifests/divider.json +47 -0
  51. package/src/manifests/dropdown.json +105 -0
  52. package/src/manifests/form.json +81 -0
  53. package/src/manifests/grid.json +143 -0
  54. package/src/manifests/index.ts +49 -0
  55. package/src/manifests/input.json +99 -0
  56. package/src/manifests/meter.json +103 -0
  57. package/src/manifests/navigation.json +70 -0
  58. package/src/manifests/progress.json +88 -0
  59. package/src/manifests/radio.json +121 -0
  60. package/src/manifests/select.json +109 -0
  61. package/src/manifests/skeleton.json +101 -0
  62. package/src/manifests/tab.json +88 -0
  63. package/src/manifests/table.json +92 -0
  64. package/src/manifests/textarea.json +117 -0
  65. package/src/manifests/toast.json +157 -0
  66. package/src/manifests/tooltip.json +115 -0
  67. package/src/tools/build-index.ts +43 -0
  68. package/src/tools/check-a11y.ts +96 -0
  69. package/src/tools/embedder.ts +18 -0
  70. package/src/tools/generate-theme.ts +42 -0
  71. package/src/tools/get-emmet.ts +64 -0
  72. package/src/tools/get-manifests.ts +55 -0
  73. package/src/tools/handoff.ts +302 -0
  74. package/src/tools/intent-engine.ts +215 -0
  75. package/src/tools/list-components.ts +20 -0
  76. package/src/tools/preview.ts +186 -0
  77. package/src/tools/search-index.ts +82 -0
  78. package/src/tools/theme-palette.ts +65 -0
  79. package/src/tools/theme-tokens.ts +176 -0
  80. package/src/tools/token-counter.ts +59 -0
  81. package/src/tools/validator.ts +353 -0
  82. package/src/types.ts +63 -0
  83. package/src/utils/a11y-rules.ts +873 -0
  84. package/src/utils/a11y-types.ts +15 -0
  85. package/src/utils/cosine.ts +15 -0
  86. package/src/utils/emmet-helpers.ts +283 -0
  87. package/src/utils/intent-helpers.ts +66 -0
  88. package/src/utils/intent-parser.ts +175 -0
  89. package/src/utils/tokenizer.ts +7 -0
  90. package/tsconfig.json +17 -0
  91. package/tsup.config.ts +10 -0
@@ -0,0 +1,215 @@
1
+ import { searchIndex } from './search-index.js'
2
+ import { getIntentEntries, scoreEntry } from '../utils/intent-parser.js'
3
+ import { getTokenCount } from '../utils/tokenizer.js'
4
+ import { tokenise } from '../utils/intent-helpers.js'
5
+ import {
6
+ expandEmmet,
7
+ extractComponents,
8
+ interpolateEmmet
9
+ } from '../utils/emmet-helpers.js'
10
+
11
+ import type { IntentEntry } from '../utils/intent-parser.js'
12
+ import type { MCPResponse } from '../types.js'
13
+
14
+
15
+ type IntentMatch = {
16
+ name: string
17
+ emmet: string
18
+ score: number
19
+ source: 'intent-table' | 'vector-index'
20
+ }
21
+
22
+ const LAYER1_THRESHOLD = 15 // minimum score to trust an intent-table hit
23
+
24
+ function searchIntentTable(description: string): IntentMatch | null {
25
+ const entries = getIntentEntries()
26
+ if (entries.length === 0) return null
27
+
28
+ const isStitchRequested =
29
+ /\b(and|plus|\+)\b/i.test(description) || description.includes(',')
30
+
31
+ const queryWords = tokenise(description)
32
+
33
+ const candidates: {
34
+ entry: IntentEntry
35
+ score: number
36
+ density: number
37
+ components: string[]
38
+ }[] = []
39
+ let bestSingle: (IntentMatch & { density: number }) | null = null
40
+
41
+ for (const entry of entries) {
42
+ const { score, density } = scoreEntry(entry, queryWords)
43
+ if (score >= LAYER1_THRESHOLD) {
44
+ const components = extractComponents(entry.emmet)
45
+ candidates.push({ entry, score, density, components })
46
+
47
+ const isBetter =
48
+ !bestSingle ||
49
+ score > bestSingle.score ||
50
+ (score === bestSingle.score && density > bestSingle.density)
51
+ if (isBetter) {
52
+ bestSingle = {
53
+ name: entry.name,
54
+ emmet: entry.emmet,
55
+ score,
56
+ density,
57
+ source: 'intent-table'
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ if (bestSingle && bestSingle.score >= 18) {
64
+ if (!isStitchRequested || bestSingle.score >= 25) {
65
+ return bestSingle
66
+ }
67
+ }
68
+
69
+ if (isStitchRequested && candidates.length >= 2) {
70
+ candidates.sort((a, b) => b.score - a.score || b.density - a.density)
71
+
72
+ const selected: typeof candidates = []
73
+ const usedComponents = new Set<string>()
74
+
75
+ for (const c of candidates) {
76
+ const hasOverlap = c.components.some((comp) => usedComponents.has(comp))
77
+ if (!hasOverlap) {
78
+ selected.push(c)
79
+ c.components.forEach((comp) => usedComponents.add(comp))
80
+ }
81
+ }
82
+
83
+ if (selected.length >= 2) {
84
+ selected.sort((a, b) => {
85
+ const indexA = description
86
+ .toLowerCase()
87
+ .indexOf(tokenise(a.entry.name)[0])
88
+ const indexB = description
89
+ .toLowerCase()
90
+ .indexOf(tokenise(b.entry.name)[0])
91
+ return indexA - indexB
92
+ })
93
+
94
+ const combinedEmmet = selected.map((s) => s.entry.emmet).join('+')
95
+ const totalScore = selected.reduce((sum, s) => sum + s.score, 0)
96
+
97
+ return {
98
+ name: selected.map((s) => s.entry.name).join(' and '),
99
+ emmet: combinedEmmet,
100
+ score: totalScore,
101
+ source: 'intent-table'
102
+ }
103
+ }
104
+ }
105
+
106
+ if (bestSingle && bestSingle.score >= 18) {
107
+ if (!isStitchRequested || bestSingle.score >= 25) {
108
+ return bestSingle
109
+ }
110
+ }
111
+ return null
112
+ }
113
+
114
+
115
+ function searchVectorLayer(description: string): IntentMatch | null {
116
+ const results = searchIndex(description)
117
+ if (results.length === 0) return null
118
+
119
+ const isStitchRequested =
120
+ /\b(and|plus|\+)\b/i.test(description) || description.includes(',')
121
+
122
+ if (isStitchRequested && results.length >= 2) {
123
+ const top = results[0]
124
+ const second = results[1]
125
+ if (top.name !== second.name && top.score >= 2.0 && second.score >= 2.0) {
126
+ const indexA = description.toLowerCase().indexOf(top.name.toLowerCase())
127
+ const indexB = description.toLowerCase().indexOf(second.name.toLowerCase())
128
+ const firstItem = indexA < indexB ? top : second
129
+ const secondItem = indexA < indexB ? second : top
130
+ return {
131
+ name: `${firstItem.name} and ${secondItem.name}`,
132
+ emmet: `${firstItem.emmet}+${secondItem.emmet}`,
133
+ score: (top.score + second.score) / 2,
134
+ source: 'vector-index'
135
+ }
136
+ }
137
+ }
138
+
139
+ const top = results[0]
140
+ return {
141
+ name: top.name,
142
+ emmet: top.emmet,
143
+ score: top.score,
144
+ source: 'vector-index'
145
+ }
146
+ }
147
+
148
+
149
+ export async function howToBuild(description: string): Promise<MCPResponse> {
150
+ let cleanDesc = description.trim()
151
+ if (cleanDesc.startsWith('{') && cleanDesc.endsWith('}')) {
152
+ try {
153
+ const parsed = JSON.parse(cleanDesc)
154
+ if (parsed && typeof parsed.description === 'string') {
155
+ cleanDesc = parsed.description
156
+ } else if (parsed && typeof parsed.text === 'string') {
157
+ cleanDesc = parsed.text
158
+ }
159
+ } catch {
160
+ // Ignore parsing errors, keep original string
161
+ }
162
+ }
163
+
164
+ // Layer 1 - fast, deterministic, hand-crafted intent table
165
+ let match = searchIntentTable(cleanDesc)
166
+
167
+ // Layer 2 - vector index fallback for novel / unknown queries
168
+ if (!match) {
169
+ match = searchVectorLayer(cleanDesc)
170
+ }
171
+
172
+ if (!match) {
173
+ return {
174
+ content: [
175
+ {
176
+ type: 'text',
177
+ text: JSON.stringify({
178
+ emmet: '',
179
+ html: '',
180
+ components_used: [],
181
+ confidence: 0,
182
+ tokens: 0,
183
+ tokens_used: 15,
184
+ source: 'no-match',
185
+ suggestion:
186
+ 'Call list_components() to see all available components, or try a different description.'
187
+ })
188
+ }
189
+ ]
190
+ }
191
+ }
192
+
193
+ // Apply dynamic interpolation to customize labels, types, and colors on the fly
194
+ const emmet = interpolateEmmet(match.emmet, cleanDesc)
195
+ const components_used = extractComponents(emmet)
196
+
197
+ const confidence = Math.min(1, match.score / 40)
198
+
199
+ return {
200
+ content: [
201
+ {
202
+ type: 'text',
203
+ text: JSON.stringify({
204
+ emmet,
205
+ html: expandEmmet(emmet),
206
+ components_used,
207
+ confidence: Number(confidence.toFixed(2)),
208
+ tokens: getTokenCount(emmet),
209
+ tokens_used: 15,
210
+ source: match.source // tells caller which layer matched
211
+ })
212
+ }
213
+ ]
214
+ }
215
+ }
@@ -0,0 +1,20 @@
1
+ import { manifests } from '../manifests/index.js'
2
+
3
+ import type { MCPResponse } from '../types.js'
4
+
5
+ export function listComponents(): MCPResponse {
6
+ const components = Object.keys(manifests).sort()
7
+
8
+ return {
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: JSON.stringify({
13
+ components,
14
+ count: components.length,
15
+ tokens_used: components.length
16
+ })
17
+ }
18
+ ]
19
+ }
20
+ }
@@ -0,0 +1,186 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import { chromium, webkit } from 'playwright'
3
+ import { readFileSync, existsSync } from 'fs'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import { expandEmmet } from '../utils/emmet-helpers.js'
7
+ import type { MCPResponse } from '../types.js'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const __dirname = path.dirname(__filename)
11
+
12
+ export type PreviewOptions = {
13
+ width?: number
14
+ theme?: string
15
+ scale?: number
16
+ }
17
+
18
+ export type PreviewArgs = {
19
+ input: string
20
+ options?: PreviewOptions
21
+ }
22
+
23
+ export async function preview(args: PreviewArgs): Promise<MCPResponse> {
24
+ const start = Date.now()
25
+ const { input, options = {} } = args
26
+ const { width = 400, scale = 2, theme } = options
27
+
28
+ // Sanitize width and scale to prevent emulation protocol crashes on non-positive values
29
+ const sanitizedWidth = typeof width === 'number' && width > 0 ? width : 400
30
+ const sanitizedScale = typeof scale === 'number' && scale > 0 ? scale : 2
31
+
32
+ // Expand Emmet to HTML if it doesn't look like standard HTML
33
+ const html = input.trim().startsWith('<') ? input : expandEmmet(input)
34
+
35
+ // Resolve CSS path by searching upwards for workspace root directory
36
+ let currentDir = __dirname
37
+ let rootDir = ''
38
+ for (let i = 0; i < 6; i++) {
39
+ if (
40
+ existsSync(path.join(currentDir, 'pnpm-workspace.yaml')) ||
41
+ (existsSync(path.join(currentDir, 'package.json')) &&
42
+ existsSync(path.join(currentDir, 'packages')))
43
+ ) {
44
+ rootDir = currentDir
45
+ break
46
+ }
47
+ const parent = path.dirname(currentDir)
48
+ if (parent === currentDir) break
49
+ currentDir = parent
50
+ }
51
+
52
+ let cssPath = ''
53
+ if (rootDir) {
54
+ cssPath = path.join(rootDir, 'packages/core/dist/ignix-lite.min.css')
55
+ }
56
+
57
+ // Fallback options
58
+ if (!cssPath || !existsSync(cssPath)) {
59
+ cssPath = path.resolve(
60
+ process.cwd(),
61
+ 'packages/core/dist/ignix-lite.min.css'
62
+ )
63
+ }
64
+ if (!existsSync(cssPath)) {
65
+ cssPath = path.resolve(process.cwd(), '../core/dist/ignix-lite.min.css')
66
+ }
67
+ if (!existsSync(cssPath)) {
68
+ cssPath = path.resolve(__dirname, '../../../core/dist/ignix-lite.min.css')
69
+ }
70
+
71
+ let cssContent = ''
72
+ if (existsSync(cssPath)) {
73
+ cssContent = readFileSync(cssPath, 'utf8')
74
+ } else {
75
+ console.warn(`[preview] CSS file not found at: ${cssPath}`)
76
+ }
77
+
78
+ let browser
79
+ try {
80
+ browser = await webkit.launch({
81
+ headless: true
82
+ })
83
+
84
+ const context = await browser.newContext({
85
+ javaScriptEnabled: false,
86
+ viewport: {
87
+ width: sanitizedWidth,
88
+ height: 600
89
+ },
90
+ deviceScaleFactor: sanitizedScale,
91
+ colorScheme: (theme === 'dark' || theme === 'light') ? theme : undefined
92
+ })
93
+
94
+ const page = await context.newPage()
95
+
96
+ // Route interception (SSRF Mitigation & Sandbox Hardening)
97
+ // Only allow data URIs and about:blank. Block all outgoing HTTP/HTTPS traffic.
98
+ await page.route('**/*', (route) => {
99
+ const url = route.request().url()
100
+ if (url.startsWith('data:') || url === 'about:blank') {
101
+ route.continue()
102
+ } else {
103
+ route.abort()
104
+ }
105
+ })
106
+
107
+ // Set page content with Ignix-Lite styles injected
108
+ const pageContent = `
109
+ <!DOCTYPE html>
110
+ <html>
111
+ <head>
112
+ <meta charset="utf-8">
113
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">
114
+ <style>
115
+ body {
116
+ margin: 0;
117
+ padding: 16px;
118
+ background-color: var(--ix-surface, #f9fafb);
119
+ font-family: var(--ix-font, sans-serif);
120
+ }
121
+ #preview-container {
122
+ display: flex;
123
+ justify-content: center;
124
+ align-items: center;
125
+ flex-wrap: wrap;
126
+ gap: 16px;
127
+ width: 100%;
128
+ box-sizing: border-box;
129
+ }
130
+ ${cssContent}
131
+ </style>
132
+ </head>
133
+ <body>
134
+ <div id="preview-container">${html}</div>
135
+ </body>
136
+ </html>
137
+ `
138
+ await page.setContent(pageContent, { waitUntil: 'load' })
139
+
140
+ // Take screenshot of container
141
+ const container = page.locator('#preview-container')
142
+ let base64Png = ''
143
+ try {
144
+ const buffer = await container.screenshot()
145
+ base64Png = buffer.toString('base64')
146
+ } catch (error) {
147
+ // Fallback to taking a full page screenshot if container has 0 size or fails
148
+ const buffer = await page.screenshot()
149
+ base64Png = buffer.toString('base64')
150
+ }
151
+
152
+ const duration = Date.now() - start
153
+
154
+ return {
155
+ content: [
156
+ {
157
+ type: 'text',
158
+ text: JSON.stringify({
159
+ png: `data:image/png;base64,${base64Png}`,
160
+ width: sanitizedWidth,
161
+ render_ms: duration,
162
+ tokens_used: 5
163
+ })
164
+ }
165
+ ]
166
+ }
167
+ } catch (error) {
168
+ const errorMessage = error instanceof Error ? error.message : String(error)
169
+ console.error('[preview] Render failed:', error)
170
+ return {
171
+ content: [
172
+ {
173
+ type: 'text',
174
+ text: JSON.stringify({
175
+ error: `Preview render failed: ${errorMessage}`,
176
+ tokens_used: 5
177
+ })
178
+ }
179
+ ]
180
+ }
181
+ } finally {
182
+ if (browser) {
183
+ await browser.close()
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,82 @@
1
+ import path from 'path'
2
+ import { readFileSync, existsSync } from 'fs'
3
+ import { fileURLToPath } from 'url'
4
+ import { embedText } from './embedder.js'
5
+ import { cosineSimilarity } from '../utils/cosine.js'
6
+ import { STOP_WORDS } from '../utils/intent-helpers.js'
7
+
8
+ type IndexItem = {
9
+ name: string
10
+ emmet: string
11
+ searchable: string
12
+ embedding: number[]
13
+ }
14
+
15
+ const __filename = fileURLToPath(import.meta.url)
16
+ const __dirname = path.dirname(__filename)
17
+
18
+ let _index: IndexItem[] | null = null
19
+
20
+ function loadIndex(): IndexItem[] {
21
+ if (_index) return _index
22
+ const paths = [
23
+ path.resolve(__dirname, '../../dist/vector-index.json'),
24
+ path.resolve(__dirname, './vector-index.json'),
25
+ path.resolve(__dirname, '../vector-index.json')
26
+ ]
27
+ let indexPath = paths[0]
28
+ for (const p of paths) {
29
+ if (existsSync(p)) {
30
+ indexPath = p
31
+ break
32
+ }
33
+ }
34
+ if (!existsSync(indexPath)) {
35
+ console.warn(
36
+ `[search-index] dist/vector-index.json not found at: ${indexPath} - run pnpm build:index`
37
+ )
38
+ _index = []
39
+ return _index
40
+ }
41
+ try {
42
+ _index = JSON.parse(readFileSync(indexPath, 'utf8'))
43
+ } catch (err) {
44
+ console.error(`[search-index] Failed to load index from ${indexPath}:`, err)
45
+ _index = []
46
+ }
47
+ return _index!
48
+ }
49
+
50
+ export function searchIndex(description: string) {
51
+ const index = loadIndex()
52
+ const queryEmbedding = embedText(description)
53
+
54
+ const words = description.toLowerCase().split(/\s+/)
55
+ const ranked = index
56
+ .map((item) => {
57
+ const similarity = cosineSimilarity(queryEmbedding, item.embedding)
58
+ let boost = 0
59
+ const searchable = item.searchable.toLowerCase()
60
+
61
+ words.forEach((word) => {
62
+ if (STOP_WORDS.has(word) || word.length < 3) {
63
+ return
64
+ }
65
+ if (item.name.toLowerCase() === word) {
66
+ boost += 2
67
+ } else if (item.name.toLowerCase().includes(word)) {
68
+ boost += 1
69
+ }
70
+ if (searchable.includes(word)) {
71
+ boost += 0.3
72
+ }
73
+ })
74
+ return {
75
+ ...item,
76
+ score: similarity + boost
77
+ }
78
+ })
79
+ .sort((a, b) => b.score - a.score)
80
+ return ranked.slice(0, 3)
81
+ }
82
+
@@ -0,0 +1,65 @@
1
+ export interface ColorEntry {
2
+ primary: string
3
+ contrast: string
4
+ }
5
+
6
+ export const PALETTE: Record<string, ColorEntry> = {
7
+ red: { primary: '#ef4444', contrast: '#ffffff' },
8
+ orange: { primary: '#f97316', contrast: '#ffffff' },
9
+ amber: { primary: '#f59e0b', contrast: '#111827' },
10
+ yellow: { primary: '#eab308', contrast: '#111827' },
11
+ lime: { primary: '#84cc16', contrast: '#111827' },
12
+ green: { primary: '#22c55e', contrast: '#ffffff' },
13
+ emerald: { primary: '#10b981', contrast: '#ffffff' },
14
+ teal: { primary: '#14b8a6', contrast: '#ffffff' },
15
+ cyan: { primary: '#06b6d4', contrast: '#111827' },
16
+ sky: { primary: '#0ea5e9', contrast: '#111827' },
17
+ blue: { primary: '#3b82f6', contrast: '#ffffff' },
18
+ indigo: { primary: '#6366f1', contrast: '#ffffff' },
19
+ violet: { primary: '#8b5cf6', contrast: '#ffffff' },
20
+ purple: { primary: '#a855f7', contrast: '#ffffff' },
21
+ fuchsia: { primary: '#d946ef', contrast: '#ffffff' },
22
+ pink: { primary: '#ec4899', contrast: '#ffffff' },
23
+ rose: { primary: '#f43f5e', contrast: '#ffffff' },
24
+ cyberpunk: { primary: '#ec4899', contrast: '#ffffff' },
25
+ neon: { primary: '#d946ef', contrast: '#ffffff' },
26
+ eco: { primary: '#10b981', contrast: '#ffffff' },
27
+ forest: { primary: '#0f766e', contrast: '#ffffff' },
28
+ coffee: { primary: '#78350f', contrast: '#ffffff' },
29
+ ocean: { primary: '#0284c7', contrast: '#ffffff' },
30
+ sunset: { primary: '#f97316', contrast: '#ffffff' },
31
+ midnight: { primary: '#3730a3', contrast: '#ffffff' },
32
+ lavender: { primary: '#8b5cf6', contrast: '#ffffff' },
33
+ coral: { primary: '#f43f5e', contrast: '#ffffff' },
34
+ slate: { primary: '#64748b', contrast: '#ffffff' },
35
+ gold: { primary: '#d97706', contrast: '#111827' },
36
+ silver: { primary: '#94a3b8', contrast: '#111827' }
37
+ }
38
+
39
+ export function expandHex(hex: string): string {
40
+ const h = hex.replace('#', '')
41
+ return h.length === 3 ? '#' + h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : hex
42
+ }
43
+
44
+ export function contrastFor(hex6: string): string {
45
+ const h = hex6.replace('#', '')
46
+ const r = parseInt(h.substring(0, 2), 16)
47
+ const g = parseInt(h.substring(2, 4), 16)
48
+ const b = parseInt(h.substring(4, 6), 16)
49
+ return (r * 299 + g * 587 + b * 114) / 1000 > 165 ? '#111827' : '#ffffff'
50
+ }
51
+
52
+ export function resolveColor(query: string): ColorEntry {
53
+ const hexMatch = query.match(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/)
54
+ if (hexMatch) {
55
+ const primary = expandHex(hexMatch[0])
56
+ return { primary, contrast: contrastFor(primary) }
57
+ }
58
+
59
+ const sorted = Object.keys(PALETTE).sort((a, b) => b.length - a.length)
60
+ for (const name of sorted) {
61
+ if (query.includes(name)) return PALETTE[name]
62
+ }
63
+
64
+ return { primary: '#6366f1', contrast: '#ffffff' }
65
+ }