@astryxdesign/cli 0.6.5-canary.01972bc → 0.6.5-canary.01fb22f

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 (67) hide show
  1. package/README.md +2 -2
  2. package/api/build/_adapter.d.mts +14 -0
  3. package/api/build/_adapter.mjs +58 -1
  4. package/api/build/build.test.mjs +74 -1
  5. package/api/build/kit/kit.mjs +39 -7
  6. package/api/build/kit/rank.d.mts +8 -0
  7. package/api/build/kit/rank.mjs +14 -0
  8. package/api/build/kit/weights.d.mts +82 -0
  9. package/api/build/kit/weights.json +1 -0
  10. package/api/build/kit/weights.mjs +305 -0
  11. package/api/build/kit/weights.test.mjs +190 -0
  12. package/api/docs/docs.d.mts +6 -0
  13. package/api/docs/docs.depth.test.mjs +132 -0
  14. package/api/docs/docs.doc.mjs +31 -2
  15. package/api/docs/docs.mjs +44 -1
  16. package/api/docs/docs.type.d.mts +38 -1
  17. package/api/docs/docs.type.mjs +18 -1
  18. package/api/docs/node/node.d.mts +28 -3
  19. package/api/docs/node/node.mjs +146 -23
  20. package/api/gap-report/gap-report.d.mts +3 -1
  21. package/api/gap-report/gap-report.mjs +12 -2
  22. package/api/gap-report/gap-report.test.mjs +106 -0
  23. package/api/hook/list/list.d.mts +1 -1
  24. package/api/integration/add-theme.mjs +93 -3
  25. package/api/integration/add-theme.test.mjs +100 -0
  26. package/api/integration/integrationAddTheme.doc.mjs +1 -1
  27. package/api/search/search.d.mts +12 -5
  28. package/api/search/search.doc.mjs +4 -4
  29. package/api/search/search.mjs +123 -53
  30. package/api/search/search.test.mjs +123 -8
  31. package/api/search/search.type.d.mts +5 -5
  32. package/api/search/search.type.mjs +5 -5
  33. package/api/theme/build/build.icon-lineage.test.mjs +117 -0
  34. package/api/theme/build/build.icon-preservation.test.mjs +639 -0
  35. package/api/theme/build/build.mjs +146 -69
  36. package/api/theme/build/build.test.mjs +190 -1
  37. package/api/theme/build/icon-imports.d.mts +47 -0
  38. package/api/theme/build/icon-imports.mjs +691 -0
  39. package/api/theme/themeBuild.doc.d.mts +4 -1
  40. package/api/theme/themeBuild.doc.mjs +14 -3
  41. package/assets/docs/theme.doc.mjs +2 -1
  42. package/assets/docs/tokens.doc.mjs +3 -3
  43. package/assets/docs/tree/add-a-theme.doc.mjs +4 -0
  44. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +1 -1
  45. package/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupShowcase.doc.mjs +15 -0
  46. package/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupShowcase.tsx +38 -0
  47. package/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.doc.mjs +20 -0
  48. package/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.tsx +48 -0
  49. package/clients/cli/commands/build-theme.mjs +8 -2
  50. package/clients/cli/commands/discover.mjs +29 -1
  51. package/clients/cli/commands/discover.no-source.test.mjs +75 -0
  52. package/clients/cli/commands/docs.depth.test.mjs +219 -0
  53. package/clients/cli/commands/docs.doc.mjs +15 -2
  54. package/clients/cli/commands/docs.mjs +222 -5
  55. package/clients/cli/commands/gap-report.mjs +12 -0
  56. package/clients/cli/commands/gap-report.test.mjs +63 -0
  57. package/clients/cli/commands/integration-add.doc.mjs +1 -1
  58. package/clients/cli/commands/search.doc.mjs +5 -4
  59. package/clients/cli/commands/search.mjs +30 -6
  60. package/clients/cli/commands/search.test.mjs +60 -16
  61. package/clients/cli/commands/theme-list.behavior.test.mjs +41 -0
  62. package/foundation/agent-docs/agent-docs.mjs +2 -1
  63. package/foundation/agent-docs/agent-docs.test.mjs +8 -0
  64. package/foundation/discovery/theme-discovery.d.mts +6 -0
  65. package/foundation/discovery/theme-discovery.mjs +9 -0
  66. package/foundation/doc-compiler/doc-loads.test.mjs +1 -1
  67. package/package.json +9 -9
package/README.md CHANGED
@@ -69,7 +69,7 @@ Results for "button" (20 of 239):
69
69
 
70
70
  Options:
71
71
 
72
- - `--type <component|hook|doc|template>`: restrict to a single domain
72
+ - `--type <component|hook|doc|template|theme>`: restrict to a single domain (`doc` and `theme` work outside an app too)
73
73
  - `--limit <n>`: cap the number of results (default 20)
74
74
  - `--verbose`: also print each result's match score and reason
75
75
  - `--json`: typed `{ apiVersion, type: 'search', data: { query, matchCount, results } }` envelope — `matchCount` is how many candidates matched in total, `results` the slice `--limit` allowed
@@ -91,7 +91,7 @@ Options:
91
91
  | `init` | Initialize the design system in your project |
92
92
  | `integration` | Author and verify an Astryx integration package |
93
93
  | `layout` | Generate XDS layouts from compressed expressions (XLE/XLO) |
94
- | `search` | Search components, hooks, docs, and templates in one ranked list |
94
+ | `search` | Search components, hooks, docs, templates, and themes in one ranked list |
95
95
  | `swizzle` | Copy component source for customization |
96
96
  | `template` | List, show, or scaffold page and block templates |
97
97
  | `theme` | Create and build themes: add a shipped one, compile to CSS, or list what a theme can override |
@@ -40,6 +40,20 @@ export function loadPageTemplates(cwd: string): Promise<PageTemplate[]>;
40
40
  * @returns {Promise<ComponentWords[]>}
41
41
  */
42
42
  export function loadComponents(cwd: string): Promise<ComponentWords[]>;
43
+ /**
44
+ * The matcher weights checked in beside the kit (`kit/weights.json`), read
45
+ * once; null when the file is absent or unreadable.
46
+ * @returns {import('./kit/weights.mjs').WeightsFile | null}
47
+ */
48
+ export function loadWeights(): import("./kit/weights.mjs").WeightsFile | null;
49
+ /**
50
+ * Whether a parsed weights file has the shape the kit reads: every row has a
51
+ * weight per candidate, every bias a number per candidate, and three blend
52
+ * numbers per member (the tables plus the ranker) and one for the shell.
53
+ * @param {any} file
54
+ * @returns {file is import('./kit/weights.mjs').WeightsFile}
55
+ */
56
+ export function isWeightsFile(file: any): file is import("./kit/weights.mjs").WeightsFile;
43
57
  /**
44
58
  * A page template the kit can recommend starting from.
45
59
  */
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @file The build subject's environment access: the page templates a project
5
- * can scaffold, and the components it can use.
5
+ * can scaffold, the components it can use, and the checked-in matcher weights.
6
6
  *
7
7
  * @input Template and component discovery for `cwd` — the CLI's own templates
8
8
  * and Core's components, plus any that the project's configured integrations
@@ -16,6 +16,8 @@
16
16
  * from the template subject's, components from search's.
17
17
  */
18
18
 
19
+ import fs from 'node:fs';
20
+
19
21
  import {discoverTemplates} from '../template/template.mjs';
20
22
  import {componentKeywords} from '../search/search.mjs';
21
23
  import {findCoreDir} from '../../foundation/fs/paths.mjs';
@@ -89,3 +91,58 @@ export async function loadComponents(cwd) {
89
91
  return [];
90
92
  }
91
93
  }
94
+
95
+ /** @type {import('./kit/weights.mjs').WeightsFile | null | undefined} */
96
+ let weights;
97
+
98
+ /**
99
+ * The matcher weights checked in beside the kit (`kit/weights.json`), read
100
+ * once; null when the file is absent or unreadable.
101
+ * @returns {import('./kit/weights.mjs').WeightsFile | null}
102
+ */
103
+ export function loadWeights() {
104
+ if (weights === undefined) {
105
+ try {
106
+ const file = JSON.parse(
107
+ fs.readFileSync(new URL('./kit/weights.json', import.meta.url), 'utf8'),
108
+ );
109
+ weights = isWeightsFile(file) ? file : null;
110
+ } catch {
111
+ weights = null;
112
+ }
113
+ }
114
+ return weights ?? null;
115
+ }
116
+
117
+ /**
118
+ * Whether a parsed weights file has the shape the kit reads: every row has a
119
+ * weight per candidate, every bias a number per candidate, and three blend
120
+ * numbers per member (the tables plus the ranker) and one for the shell.
121
+ * @param {any} file
122
+ * @returns {file is import('./kit/weights.mjs').WeightsFile}
123
+ */
124
+ export function isWeightsFile(file) {
125
+ const n = Array.isArray(file?.candidates) ? file.candidates.length : 0;
126
+ return (
127
+ n > 0 &&
128
+ Array.isArray(file.tables) &&
129
+ file.tables.length > 0 &&
130
+ file.tables.every(
131
+ (/** @type {any} */ t) =>
132
+ Array.isArray(t?.words) &&
133
+ Array.isArray(t.rows) &&
134
+ t.rows.length === t.words.length &&
135
+ t.rows.every(
136
+ (/** @type {any} */ r) => typeof r === 'string' && r.length === n,
137
+ ) &&
138
+ Array.isArray(t.bias) &&
139
+ t.bias.length === n &&
140
+ t.bias.every((/** @type {any} */ b) => Number.isFinite(b)) &&
141
+ Number.isFinite(t.clip) &&
142
+ Number.isFinite(t.step),
143
+ ) &&
144
+ Array.isArray(file.blend) &&
145
+ file.blend.length === 3 * (file.tables.length + 1) + 1 &&
146
+ file.blend.every((/** @type {any} */ x) => Number.isFinite(x))
147
+ );
148
+ }
@@ -269,7 +269,7 @@ describe('build kit — a thin kit says what to try next', () => {
269
269
  // A skeleton is a 35-line excerpt: a reader who studies it and composes
270
270
  // the rest loses the spacing the template exists to carry. A loose match
271
271
  // is still the best start there is, so `start` scaffolds it.
272
- const r = await build('quarterly business review', {cwd: REPO});
272
+ const r = await build('weekly business review with targets', {cwd: REPO});
273
273
  expect(r.type).toBe('build.kit');
274
274
  if (r.type !== 'build.kit') return;
275
275
  expect(r.data.directMatch).toBe(false);
@@ -477,6 +477,79 @@ describe('build kit — every page starts from a template', () => {
477
477
  expect(r.data.pages.map(p => p.name)).not.toContain('side-gallery');
478
478
  });
479
479
 
480
+ it('names a direct match the start does not use, and calls the start direct only when it is', async () => {
481
+ for (const idea of ['a login form', 'a docs site for our API', 'contact form']) {
482
+ const r = await build(idea, {cwd: REPO});
483
+ if (r.type !== 'build.kit') throw new Error(r.type);
484
+ expect(r.data.directMatch).toBe(true);
485
+ const match = r.data.pages[0].name;
486
+ if (r.data.start?.name === match) {
487
+ expect(r.data.start?.basis).toBe('direct');
488
+ } else {
489
+ expect(['closest', 'fallback']).toContain(r.data.start?.basis);
490
+ expect(r.data.start?.reason).toContain(`\`${match}\``);
491
+ }
492
+ }
493
+ });
494
+
495
+ it('keeps a template search matched directly rather than the app shell', async () => {
496
+ for (const [idea, name] of [
497
+ ['a login screen', 'login'],
498
+ ['a checkout wizard', 'checkout-wizard'],
499
+ ]) {
500
+ const r = await build(idea, {cwd: REPO});
501
+ if (r.type !== 'build.kit') throw new Error(r.type);
502
+ expect(r.data.directMatch).toBe(true);
503
+ expect(r.data.start?.name).toBe(name);
504
+ }
505
+ });
506
+
507
+ it('lets the weights choose another template over a direct match', async () => {
508
+ const r = await build('a login form', {cwd: REPO});
509
+ if (r.type !== 'build.kit') throw new Error(r.type);
510
+ expect(r.data.directMatch).toBe(true);
511
+ const match = r.data.pages[0].name;
512
+ expect(r.data.start?.name).not.toBe('shell-top-nav');
513
+ expect(r.data.start?.name).not.toBe(match);
514
+ expect(r.data.start?.reason).toContain(`\`${match}\``);
515
+ });
516
+
517
+ it('starts a part that names no page from the app shell', async () => {
518
+ for (const idea of ['a kanban card', 'a date range picker']) {
519
+ const r = await build(idea, {cwd: REPO});
520
+ if (r.type !== 'build.kit') throw new Error(r.type);
521
+ expect(r.data.start).toMatchObject({name: 'shell-top-nav', basis: 'fallback'});
522
+ expect(r.data.start?.reason).toMatch(/part of a page/);
523
+ }
524
+ });
525
+
526
+ it('does not keep a loose match over the app shell', async () => {
527
+ // Search matches no template directly, so the ranker's closest page does
528
+ // not override the shell the weights choose.
529
+ const r = await build('quarterly business review', {cwd: REPO});
530
+ if (r.type !== 'build.kit') throw new Error(r.type);
531
+ expect(r.data.directMatch).toBe(false);
532
+ expect(r.data.start).toMatchObject({name: 'shell-top-nav', basis: 'fallback'});
533
+ expect(r.data.start?.reason).toMatch(/closest/);
534
+ });
535
+
536
+ it('starts a new page with no matching template from the app shell', async () => {
537
+ const r = await build('a new page', {cwd: REPO});
538
+ if (r.type !== 'build.kit') throw new Error(r.type);
539
+ expect(r.data.start?.name).toBe('shell-top-nav');
540
+ });
541
+
542
+ it('starts a page the words describe from its template', async () => {
543
+ for (const [idea, name] of [
544
+ ['a weekly report of sales by region', 'dashboard-scorecard'],
545
+ ['a pricing page with three plans and a comparison table', 'table-page'],
546
+ ]) {
547
+ const r = await build(idea, {cwd: REPO});
548
+ if (r.type !== 'build.kit') throw new Error(r.type);
549
+ expect(r.data.start?.name).toBe(name);
550
+ }
551
+ });
552
+
480
553
  it('starts a component in a container from a template with that frame', async () => {
481
554
  // "in a modal": the modal is the frame, so the dialog template leads
482
555
  // instead of the app shell.
@@ -32,8 +32,15 @@ import {findCoreDir} from '../../../foundation/fs/paths.mjs';
32
32
  import {AstryxError} from '../../error.mjs';
33
33
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
34
34
  import {getResultCoverage} from '../../search/coverage.mjs';
35
- import {loadComponents, loadPageTemplates} from '../_adapter.mjs';
36
- import {ideaKind, pickAlternatives, pickStart, rankPages} from './rank.mjs';
35
+ import {loadComponents, loadPageTemplates, loadWeights} from '../_adapter.mjs';
36
+ import {
37
+ asksForNewPage,
38
+ ideaKind,
39
+ pickAlternatives,
40
+ pickStart,
41
+ rankPages,
42
+ } from './rank.mjs';
43
+ import {weighStart} from './weights.mjs';
37
44
 
38
45
  /** A page at/above this score is a confident direct match. */
39
46
  const PAGE_DIRECT = 95;
@@ -151,9 +158,10 @@ function placement(kind, inPage) {
151
158
  * @param {SearchResultEntry[]} pages
152
159
  * @param {boolean} directMatch
153
160
  * @param {PageTemplate[]} catalog
161
+ * @param {string} idea
154
162
  * @returns {Omit<BuildStart, 'alternatives'> | null}
155
163
  */
156
- function chooseStart(ranked, kind, pages, directMatch, catalog) {
164
+ function chooseStart(ranked, kind, pages, directMatch, catalog, idea) {
157
165
  const direct = directMatch ? pages[0].name : null;
158
166
  const unready =
159
167
  direct && !catalog.some(t => t.name === direct) ? direct : null;
@@ -170,7 +178,27 @@ function chooseStart(ranked, kind, pages, directMatch, catalog) {
170
178
  direct && direct !== startName
171
179
  ? `Search matched \`${direct}\` by name, but ${place}`
172
180
  : place[0].toUpperCase() + place.slice(1);
173
- const pick = pickStart(ranked, kind);
181
+ const proposed = pickStart(ranked, kind);
182
+ // The checked-in word weights (weights.mjs), blended with the ranker's
183
+ // scores, decide the start of a whole page. A part or an edit starts where
184
+ // the ranker's placement rules put it (spec:AST-048/FR3).
185
+ const weighed =
186
+ kind === 'page'
187
+ ? weighStart(idea, ranked, proposed, catalog, {
188
+ weights: loadWeights(),
189
+ newPage: asksForNewPage(idea, catalog),
190
+ })
191
+ : undefined;
192
+ // A shell start keeps the shell the ranker named, if any, and never replaces
193
+ // the template the ranker chose for a page search matched directly.
194
+ const pick =
195
+ weighed === undefined
196
+ ? proposed
197
+ : weighed === null
198
+ ? proposed?.family === 'Shell' || (proposed && direct && !unready)
199
+ ? proposed
200
+ : null
201
+ : (ranked.find(r => r.name === weighed) ?? proposed);
174
202
  const closest = pick && catalog.find(t => t.name === pick.name);
175
203
  if (pick && closest) {
176
204
  const agrees = closest.name === direct;
@@ -194,7 +222,9 @@ function chooseStart(ranked, kind, pages, directMatch, catalog) {
194
222
  if (shell) {
195
223
  // The shell can also be the ranker's best guess without the evidence to
196
224
  // lead ("horizontal site navigation"); say so rather than "no match".
197
- const nearest = ranked[0]?.name === shell.name && ranked[0].hits > 0;
225
+ const nearest =
226
+ (ranked[0]?.name === shell.name && ranked[0].hits > 0) ||
227
+ (weighed === null && !!proposed && proposed.family !== 'Shell');
198
228
  const place = placement(kind, false);
199
229
  return {
200
230
  ...asTemplate(shell),
@@ -204,7 +234,9 @@ function chooseStart(ranked, kind, pages, directMatch, catalog) {
204
234
  : place
205
235
  ? placed(place, shell.name)
206
236
  : direct
207
- ? `Search matched \`${direct}\` by name, but too little of the idea fits it, so start from the app shell.`
237
+ ? weighed === null
238
+ ? `Search matched \`${direct}\` by name, but the app shell is the closer start.`
239
+ : `Search matched \`${direct}\` by name, but too little of the idea fits it, so start from the app shell.`
208
240
  : nearest
209
241
  ? 'No template is a clear match; the app shell is the closest.'
210
242
  : loose
@@ -346,7 +378,7 @@ export async function buildKit(query, options = {}) {
346
378
  )
347
379
  : 'page';
348
380
  const chosen = wantsPages
349
- ? chooseStart(ranked, kind, matchedPages, directMatch, catalog)
381
+ ? chooseStart(ranked, kind, matchedPages, directMatch, catalog, query)
350
382
  : null;
351
383
  // Name the ranker's next two templates beside the start: the reader judges
352
384
  // meaning better than keywords do, and an acceptable template is in these
@@ -21,6 +21,14 @@ export function rankPages(query: string, pages: PageTemplate[]): RankedPage[];
21
21
  * @returns {IdeaKind}
22
22
  */
23
23
  export function ideaKind(query: string, pages: PageTemplate[], components: ComponentWords[]): IdeaKind;
24
+ /**
25
+ * Whether an idea asks for a new page (see `asksNewPage`), from the idea and
26
+ * the project's page templates.
27
+ * @param {string} query
28
+ * @param {PageTemplate[]} pages
29
+ * @returns {boolean}
30
+ */
31
+ export function asksForNewPage(query: string, pages: PageTemplate[]): boolean;
24
32
  /**
25
33
  * The next closest templates after the start, best first: the ones a reader
26
34
  * should check the idea against when the start's shape is wrong. Each matched
@@ -543,6 +543,20 @@ function asksNewPage(phrases, familyWords) {
543
543
  });
544
544
  }
545
545
 
546
+ /**
547
+ * Whether an idea asks for a new page (see `asksNewPage`), from the idea and
548
+ * the project's page templates.
549
+ * @param {string} query
550
+ * @param {PageTemplate[]} pages
551
+ * @returns {boolean}
552
+ */
553
+ export function asksForNewPage(query, pages) {
554
+ return asksNewPage(
555
+ String(query).toLowerCase().split(PHRASE_END),
556
+ familyWordsOf(pages),
557
+ );
558
+ }
559
+
546
560
  /**
547
561
  * The words of a component's name: "DateRangeInput" is date, range, input.
548
562
  * @param {ComponentWords} component
@@ -0,0 +1,82 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * The Porter stem of a lowercase word.
6
+ * @param {string} word
7
+ * @returns {string}
8
+ */
9
+ export function stem(word: string): string;
10
+ /**
11
+ * Lowercase words of an idea that can carry weight: letters only, two or more.
12
+ * @param {string} idea
13
+ * @returns {string[]}
14
+ */
15
+ export function weightWords(idea: string): string[];
16
+ /**
17
+ * Where an idea starts, by the blended weights.
18
+ * @param {string} idea
19
+ * @param {import('./rank.mjs').RankedPage[]} ranked the ranker's output for the idea
20
+ * @param {import('./rank.mjs').RankedPage | null} pick the ranker's own start
21
+ * @param {{name: string}[]} catalog the project's ready page templates
22
+ * @param {{weights?: WeightsFile | null, newPage?: boolean}} [options]
23
+ * `newPage`: the idea asks for a new page (rank.mjs `asksForNewPage`)
24
+ * @returns {string | null | undefined} a template id, null for the app shell,
25
+ * undefined without weights
26
+ */
27
+ export function weighStart(idea: string, ranked: import("./rank.mjs").RankedPage[], pick: import("./rank.mjs").RankedPage | null, catalog: {
28
+ name: string;
29
+ }[], { weights, newPage }?: {
30
+ weights?: WeightsFile | null;
31
+ newPage?: boolean;
32
+ }): string | null | undefined;
33
+ /**
34
+ * @file Word weights for `build`'s START (spec:AST-048): a checked-in table of
35
+ * per-template word weights, blended with the page ranker's own scores, picks
36
+ * the template an idea starts from.
37
+ *
38
+ * @input An idea, the ranker's output for it (`rankPages`, `pickStart`), the
39
+ * project's ready page templates, and the weights (`weights.json` beside this
40
+ * file, read by the adapter): the candidates (the app shell as `SHELL`, then page template ids), one or more
41
+ * word tables (stemmed words, a bias per candidate, and one row of weights
42
+ * per word, a character per candidate) and the blend numbers.
43
+ * @output `weighStart(idea, ranked, pick, catalog)`: the template id to start
44
+ * from, null for the app shell, or undefined when no table is available, in
45
+ * which case the ranker's own pick stands.
46
+ * @position Beside rank.mjs (api/build/kit/); kit.mjs calls it for every
47
+ * whole page (a part or an edit follows the ranker's placement rules). The
48
+ * ranker's pick of a template the tables do not list stands, so a new
49
+ * template can start a build.
50
+ */
51
+ /** The app shell, as one candidate. */
52
+ export const SHELL: "SHELL";
53
+ export type WeightTable = {
54
+ /**
55
+ * stemmed words
56
+ */
57
+ words: string[];
58
+ /**
59
+ * one per candidate
60
+ */
61
+ bias: number[];
62
+ clip: number;
63
+ step: number;
64
+ /**
65
+ * one per word: a character per candidate,
66
+ * weight = (charCode - 97) * step - clip
67
+ */
68
+ rows: string[];
69
+ };
70
+ export type WeightsFile = {
71
+ version: number;
72
+ /**
73
+ * `SHELL`, then page template ids
74
+ */
75
+ candidates: string[];
76
+ tables: WeightTable[];
77
+ /**
78
+ * three numbers per member (the tables, in order,
79
+ * with the ranker second), then one for `SHELL`
80
+ */
81
+ blend: number[];
82
+ };