@hanzo/build 0.2.67 → 0.2.69
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.
- package/lib/api/studio.d.ts +27 -0
- package/lib/api/studio.js +14 -0
- package/lib/carry.d.ts +3 -1
- package/lib/carry.js +41 -6
- package/lib/design.d.ts +134 -0
- package/lib/design.js +123 -0
- package/lib/gear.js +41 -11
- package/lib/landing.js +3 -1
- package/lib/plugin.d.ts +4 -0
- package/lib/plugins/design.d.ts +8 -0
- package/lib/plugins/design.js +70 -0
- package/lib/plugins/index.js +2 -0
- package/lib/plugins/slides.js +1 -1
- package/package.json +1 -1
package/lib/api/studio.d.ts
CHANGED
|
@@ -30,7 +30,34 @@ export interface Start {
|
|
|
30
30
|
input: Record<string, unknown>;
|
|
31
31
|
title?: string;
|
|
32
32
|
parent?: string;
|
|
33
|
+
/** A brand (design system) the run reads as brand.* — its palette, type and voice. */
|
|
34
|
+
brand?: string;
|
|
33
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* A design system: one of Studio's brands, the one store for them. Its tokens
|
|
38
|
+
* are W3C design tokens (color, font, type, space, radius) and two groups of our
|
|
39
|
+
* own, `components` and `voice`, each a line a model follows.
|
|
40
|
+
*
|
|
41
|
+
* GET /v1/studio/brands {data: [brand]} the ones you may read
|
|
42
|
+
* GET /v1/studio/brands/{id} one
|
|
43
|
+
* POST /v1/studio/brands {name, tokens, shared?} → brand
|
|
44
|
+
*/
|
|
45
|
+
export interface Brand {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
tokens: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
export declare function brandOf(raw: unknown): Brand;
|
|
51
|
+
export declare function brands(t: Target, init?: {
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}): Promise<Brand[]>;
|
|
54
|
+
export declare function brand(t: Target, id: string, init?: {
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}): Promise<Brand>;
|
|
57
|
+
export declare function keepBrand(t: Target, b: {
|
|
58
|
+
name: string;
|
|
59
|
+
tokens: Record<string, unknown>;
|
|
60
|
+
}, signal?: AbortSignal): Promise<Brand>;
|
|
34
61
|
export declare function run(raw: unknown): Run;
|
|
35
62
|
/** A run that has ended, one way or the other. */
|
|
36
63
|
export declare const ended: (r: Run) => boolean;
|
package/lib/api/studio.js
CHANGED
|
@@ -14,6 +14,20 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { call, Refusal, seg } from './call.js';
|
|
16
16
|
import { item } from './library.js';
|
|
17
|
+
export function brandOf(raw) {
|
|
18
|
+
const o = obj(raw);
|
|
19
|
+
return { id: str(o.id), name: str(o.name), tokens: obj(o.tokens) };
|
|
20
|
+
}
|
|
21
|
+
export async function brands(t, init) {
|
|
22
|
+
const raw = obj(await call(t, 'GET', '/v1/studio/brands', undefined, init));
|
|
23
|
+
return (Array.isArray(raw.data) ? raw.data : []).map(brandOf).filter((b) => b.id);
|
|
24
|
+
}
|
|
25
|
+
export async function brand(t, id, init) {
|
|
26
|
+
return brandOf(await call(t, 'GET', `/v1/studio/brands/${seg(id)}`, undefined, init));
|
|
27
|
+
}
|
|
28
|
+
export async function keepBrand(t, b, signal) {
|
|
29
|
+
return brandOf(await call(t, 'POST', '/v1/studio/brands', { name: b.name, tokens: b.tokens }, { signal }));
|
|
30
|
+
}
|
|
17
31
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
18
32
|
const obj = (v) => (v && typeof v === 'object' ? v : {});
|
|
19
33
|
export function run(raw) {
|
package/lib/carry.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type Lent = Omit<Composer, 'target' | 'show'>;
|
|
|
9
9
|
export interface Carry {
|
|
10
10
|
gear: GearState;
|
|
11
11
|
skills: Shelf;
|
|
12
|
+
/** The design systems (Studio brands): attach one, or make one by chat. */
|
|
13
|
+
design: Shelf;
|
|
12
14
|
/** The plugins that run for this person (`@` lists the same). */
|
|
13
15
|
plugins: Shelf;
|
|
14
16
|
/** A dialog a plugin opened, for the surface to draw; null when none is. */
|
|
@@ -16,7 +18,7 @@ export interface Carry {
|
|
|
16
18
|
/** Whether a plugin that prepares a message is on. */
|
|
17
19
|
preparing: boolean;
|
|
18
20
|
/** What the plugins that are on add to the message about to go. */
|
|
19
|
-
prep: (turn: Turn, signal: AbortSignal) => Promise<Prep>;
|
|
21
|
+
prep: (turn: Omit<Turn, 'design'>, signal: AbortSignal) => Promise<Prep>;
|
|
20
22
|
/** The chips after `+`: the skills on, Research, and Plan. */
|
|
21
23
|
on: Chip[];
|
|
22
24
|
/** The words the gear adds to every message, '' when none. */
|
package/lib/carry.js
CHANGED
|
@@ -15,11 +15,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
15
15
|
* A shelf is offered only where its service answers; a surface adds its own
|
|
16
16
|
* (Chat its agents, Dev its devices) beside these.
|
|
17
17
|
*/
|
|
18
|
-
import { BookOpen, ListChecks, ScrollText } from '@hanzogui/lucide-icons-2';
|
|
18
|
+
import { BookOpen, ListChecks, Palette, ScrollText } from '@hanzogui/lucide-icons-2';
|
|
19
19
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
20
20
|
import { call, query, unwrap } from './api/call.js';
|
|
21
21
|
import { active, authored, document, nameOf as skillOf } from './api/skills.js';
|
|
22
|
+
import { brand, brands } from './api/studio.js';
|
|
22
23
|
import { useRead } from './data.js';
|
|
24
|
+
import { said as designSaid } from './design.js';
|
|
23
25
|
import { useGear } from './gear.js';
|
|
24
26
|
import { active as lighted, prep, shelf as pluginShelf, usePlugins } from './plugins/index.js';
|
|
25
27
|
/** The most of each skill a message carries. */
|
|
@@ -34,8 +36,13 @@ async function remembered(t) {
|
|
|
34
36
|
.filter(Boolean);
|
|
35
37
|
}
|
|
36
38
|
/** The words `skills`, `memory` and `mode` add to a message. */
|
|
37
|
-
async function said(t, skills, memory, mode) {
|
|
39
|
+
async function said(t, skills, memory, mode, design) {
|
|
38
40
|
const parts = [];
|
|
41
|
+
if (design) {
|
|
42
|
+
const b = await brand(t, design).catch(() => null);
|
|
43
|
+
if (b?.name)
|
|
44
|
+
parts.push(designSaid(b));
|
|
45
|
+
}
|
|
39
46
|
if (skills.length) {
|
|
40
47
|
const own = await authored(t).catch(() => []);
|
|
41
48
|
for (const n of skills) {
|
|
@@ -77,7 +84,7 @@ export function useCarry(t, key, customize, lent) {
|
|
|
77
84
|
const signed = Boolean(t.token());
|
|
78
85
|
const list = useRead(signed ? () => active(t) : null, [], [t, signed]);
|
|
79
86
|
const names = useMemo(() => list.value.map((s) => skillOf(s.name)).filter(Boolean), [list.value]);
|
|
80
|
-
const { skills, research, memory, mode } = gear.gear;
|
|
87
|
+
const { skills, research, memory, mode, design } = gear.gear;
|
|
81
88
|
const shelf = {
|
|
82
89
|
rows: names.map((n) => ({ id: n, label: n, icon: _jsx(ScrollText, { size: 16 }), on: skills.includes(n) })),
|
|
83
90
|
pick: (id) => gear.toggle('skills', id),
|
|
@@ -89,11 +96,33 @@ export function useCarry(t, key, customize, lent) {
|
|
|
89
96
|
browse: { label: 'Browse skills', onPress: () => customize('skills') },
|
|
90
97
|
};
|
|
91
98
|
const plugins = pluginShelf(found, gear, composer);
|
|
99
|
+
// The design systems: Studio's brands, the one store for them.
|
|
100
|
+
const kits = useRead(signed ? () => brands(t) : null, [], [t, signed, design]);
|
|
101
|
+
const named = kits.value.find((b) => b.id === design);
|
|
102
|
+
const designs = {
|
|
103
|
+
rows: kits.value.map((b) => {
|
|
104
|
+
const c = (b.tokens.color ?? {});
|
|
105
|
+
return { id: b.id, label: b.name, note: [c.primary?.$value, c.accent?.$value].filter(Boolean).join(' '), icon: _jsx(Palette, { size: 16 }), on: b.id === design };
|
|
106
|
+
}),
|
|
107
|
+
pick: (id) => gear.set({ design: design === id ? '' : id }),
|
|
108
|
+
loading: kits.loading,
|
|
109
|
+
error: kits.error ? 'The design systems could not be read.' : null,
|
|
110
|
+
empty: 'No design systems yet',
|
|
111
|
+
create: {
|
|
112
|
+
label: 'Create design system',
|
|
113
|
+
onPress: () => {
|
|
114
|
+
if (!gear.gear.plugins.includes('design'))
|
|
115
|
+
gear.toggle('plugins', 'design');
|
|
116
|
+
lentNow.current.setDraft('Make me a design system for ');
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
};
|
|
92
120
|
const lit = lighted(found.plugins, gear.gear);
|
|
93
121
|
const on = [
|
|
94
122
|
...skills.map((n) => ({ id: `skill:${n}`, label: n, icon: _jsx(ScrollText, { size: 14 }), onRemove: () => gear.toggle('skills', n) })),
|
|
95
123
|
...(research ? [{ id: 'research', label: 'Research', icon: _jsx(BookOpen, { size: 14 }), onRemove: () => gear.set({ research: false }) }] : []),
|
|
96
124
|
...(mode === 'plan' ? [{ id: 'plan', label: 'Plan', icon: _jsx(ListChecks, { size: 14 }), onRemove: () => gear.set({ mode: 'auto' }) }] : []),
|
|
125
|
+
...(design ? [{ id: 'design', label: named?.name ?? 'Design system', icon: _jsx(Palette, { size: 14 }), onRemove: () => gear.set({ design: '' }) }] : []),
|
|
97
126
|
...lit
|
|
98
127
|
.filter((p) => p.id !== 'research')
|
|
99
128
|
.map((p) => ({ id: `plugin:${p.id}`, label: p.name, icon: p.icon, onRemove: () => gear.toggle('plugins', p.id) })),
|
|
@@ -105,13 +134,13 @@ export function useCarry(t, key, customize, lent) {
|
|
|
105
134
|
if (!signedIn)
|
|
106
135
|
return setText(mode === 'plan' ? 'Plan mode: lay out the plan and stop until the person says go.' : '');
|
|
107
136
|
let live = true;
|
|
108
|
-
void said(t, skills, memory, mode).then((x) => live && setText(x));
|
|
137
|
+
void said(t, skills, memory, mode, design).then((x) => live && setText(x));
|
|
109
138
|
return () => {
|
|
110
139
|
live = false;
|
|
111
140
|
};
|
|
112
141
|
// `skills` is read through `asked`, its contents.
|
|
113
142
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
|
-
}, [t, signedIn, asked, memory, mode]);
|
|
143
|
+
}, [t, signedIn, asked, memory, mode, design]);
|
|
115
144
|
return {
|
|
116
145
|
gear,
|
|
117
146
|
skills: shelf,
|
|
@@ -120,6 +149,12 @@ export function useCarry(t, key, customize, lent) {
|
|
|
120
149
|
on,
|
|
121
150
|
text,
|
|
122
151
|
preparing: lit.length > 0,
|
|
123
|
-
|
|
152
|
+
design: designs,
|
|
153
|
+
// A design system a plugin made is attached at once, and the maker turns off: it made one.
|
|
154
|
+
prep: (turn, signal) => prep(found.plugins, gear.gear, { ...turn, design }, composer, signal).then((p) => {
|
|
155
|
+
if (p.design)
|
|
156
|
+
gear.set({ design: p.design, plugins: gear.gear.plugins.filter((x) => x !== 'design') });
|
|
157
|
+
return p;
|
|
158
|
+
}),
|
|
124
159
|
};
|
|
125
160
|
}
|
package/lib/design.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A design system as the composer handles it: drafted by a model, kept as a
|
|
3
|
+
* Studio brand (W3C design tokens plus `components` and `voice`), said to the
|
|
4
|
+
* model on every message of a conversation it is attached to, and drawn as a
|
|
5
|
+
* page to look at. Pure, so it is tested without a browser.
|
|
6
|
+
*/
|
|
7
|
+
import type { Brand } from './api/studio.ts';
|
|
8
|
+
/** What a model is asked to answer: the system in plain fields. */
|
|
9
|
+
export interface Draft {
|
|
10
|
+
name: string;
|
|
11
|
+
colors: Record<string, string>;
|
|
12
|
+
fonts: {
|
|
13
|
+
heading: string;
|
|
14
|
+
body: string;
|
|
15
|
+
mono: string;
|
|
16
|
+
};
|
|
17
|
+
/** Type sizes, px, smallest first. */
|
|
18
|
+
scale: number[];
|
|
19
|
+
/** Spacing steps, px. */
|
|
20
|
+
spacing: number[];
|
|
21
|
+
radius: Record<string, number>;
|
|
22
|
+
/** One line each: how a button, card, input, nav… looks and behaves. */
|
|
23
|
+
components: Record<string, string>;
|
|
24
|
+
voice: {
|
|
25
|
+
tone: string;
|
|
26
|
+
do: string[];
|
|
27
|
+
dont: string[];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** The schema the drafting call decodes under. */
|
|
31
|
+
export declare const SCHEMA: {
|
|
32
|
+
readonly type: 'object';
|
|
33
|
+
readonly additionalProperties: false;
|
|
34
|
+
readonly required: readonly ['name', 'colors', 'fonts', 'scale', 'spacing', 'radius', 'components', 'voice'];
|
|
35
|
+
readonly properties: {
|
|
36
|
+
readonly name: {
|
|
37
|
+
readonly type: 'string';
|
|
38
|
+
};
|
|
39
|
+
readonly colors: {
|
|
40
|
+
readonly type: 'object';
|
|
41
|
+
readonly additionalProperties: false;
|
|
42
|
+
readonly required: readonly ['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'];
|
|
43
|
+
readonly properties: {
|
|
44
|
+
[k: string]: {
|
|
45
|
+
type: string;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
readonly fonts: {
|
|
50
|
+
readonly type: 'object';
|
|
51
|
+
readonly additionalProperties: false;
|
|
52
|
+
readonly required: readonly ['heading', 'body', 'mono'];
|
|
53
|
+
readonly properties: {
|
|
54
|
+
readonly heading: {
|
|
55
|
+
readonly type: 'string';
|
|
56
|
+
};
|
|
57
|
+
readonly body: {
|
|
58
|
+
readonly type: 'string';
|
|
59
|
+
};
|
|
60
|
+
readonly mono: {
|
|
61
|
+
readonly type: 'string';
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
readonly scale: {
|
|
66
|
+
readonly type: 'array';
|
|
67
|
+
readonly items: {
|
|
68
|
+
readonly type: 'number';
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
readonly spacing: {
|
|
72
|
+
readonly type: 'array';
|
|
73
|
+
readonly items: {
|
|
74
|
+
readonly type: 'number';
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
readonly radius: {
|
|
78
|
+
readonly type: 'object';
|
|
79
|
+
readonly additionalProperties: false;
|
|
80
|
+
readonly required: readonly ['sm', 'md', 'lg'];
|
|
81
|
+
readonly properties: {
|
|
82
|
+
readonly sm: {
|
|
83
|
+
readonly type: 'number';
|
|
84
|
+
};
|
|
85
|
+
readonly md: {
|
|
86
|
+
readonly type: 'number';
|
|
87
|
+
};
|
|
88
|
+
readonly lg: {
|
|
89
|
+
readonly type: 'number';
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
readonly components: {
|
|
94
|
+
readonly type: 'object';
|
|
95
|
+
readonly additionalProperties: false;
|
|
96
|
+
readonly required: readonly ['button', 'card', 'input', 'nav', 'hero'];
|
|
97
|
+
readonly properties: {
|
|
98
|
+
[k: string]: {
|
|
99
|
+
type: string;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
readonly voice: {
|
|
104
|
+
readonly type: 'object';
|
|
105
|
+
readonly additionalProperties: false;
|
|
106
|
+
readonly required: readonly ['tone', 'do', 'dont'];
|
|
107
|
+
readonly properties: {
|
|
108
|
+
readonly tone: {
|
|
109
|
+
readonly type: 'string';
|
|
110
|
+
};
|
|
111
|
+
readonly do: {
|
|
112
|
+
readonly type: 'array';
|
|
113
|
+
readonly items: {
|
|
114
|
+
readonly type: 'string';
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
readonly dont: {
|
|
118
|
+
readonly type: 'array';
|
|
119
|
+
readonly items: {
|
|
120
|
+
readonly type: 'string';
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
/** A model's answer as a Draft, or why it is not one. */
|
|
128
|
+
export declare function drafted(raw: unknown): Draft | string;
|
|
129
|
+
/** A Draft as W3C design tokens, with our two groups beside them. */
|
|
130
|
+
export declare function tokensOf(d: Draft): Record<string, unknown>;
|
|
131
|
+
/** What a model is told on every message of a conversation the system is attached to. */
|
|
132
|
+
export declare function said(b: Brand): string;
|
|
133
|
+
/** The system drawn as a page: its palette, type, a button, a card, an input, its voice. */
|
|
134
|
+
export declare function preview(b: Brand): string;
|
package/lib/design.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** The schema the drafting call decodes under. */
|
|
2
|
+
export const SCHEMA = {
|
|
3
|
+
type: 'object',
|
|
4
|
+
additionalProperties: false,
|
|
5
|
+
required: ['name', 'colors', 'fonts', 'scale', 'spacing', 'radius', 'components', 'voice'],
|
|
6
|
+
properties: {
|
|
7
|
+
name: { type: 'string' },
|
|
8
|
+
colors: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
required: ['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'],
|
|
12
|
+
properties: Object.fromEntries(['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'].map((k) => [k, { type: 'string' }])),
|
|
13
|
+
},
|
|
14
|
+
fonts: { type: 'object', additionalProperties: false, required: ['heading', 'body', 'mono'], properties: { heading: { type: 'string' }, body: { type: 'string' }, mono: { type: 'string' } } },
|
|
15
|
+
scale: { type: 'array', items: { type: 'number' } },
|
|
16
|
+
spacing: { type: 'array', items: { type: 'number' } },
|
|
17
|
+
radius: { type: 'object', additionalProperties: false, required: ['sm', 'md', 'lg'], properties: { sm: { type: 'number' }, md: { type: 'number' }, lg: { type: 'number' } } },
|
|
18
|
+
components: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
required: ['button', 'card', 'input', 'nav', 'hero'],
|
|
22
|
+
properties: Object.fromEntries(['button', 'card', 'input', 'nav', 'hero'].map((k) => [k, { type: 'string' }])),
|
|
23
|
+
},
|
|
24
|
+
voice: { type: 'object', additionalProperties: false, required: ['tone', 'do', 'dont'], properties: { tone: { type: 'string' }, do: { type: 'array', items: { type: 'string' } }, dont: { type: 'array', items: { type: 'string' } } } },
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const HEX = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
|
28
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
29
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
30
|
+
const nums = (v, lo, hi) => (Array.isArray(v) ? v.filter((n) => typeof n === 'number' && n >= lo && n <= hi) : []);
|
|
31
|
+
const lines = (v) => (Array.isArray(v) ? v.map(str).filter(Boolean).slice(0, 8) : []);
|
|
32
|
+
/** A model's answer as a Draft, or why it is not one. */
|
|
33
|
+
export function drafted(raw) {
|
|
34
|
+
const o = obj(raw);
|
|
35
|
+
const name = str(o.name).slice(0, 80);
|
|
36
|
+
if (!name)
|
|
37
|
+
return 'The design system has no name.';
|
|
38
|
+
const colors = Object.fromEntries(Object.entries(obj(o.colors)).filter(([, v]) => HEX.test(str(v))).map(([k, v]) => [k, str(v)]));
|
|
39
|
+
if (!colors.primary || !colors.background || !colors.text)
|
|
40
|
+
return 'The design system needs at least a primary, a background and a text colour.';
|
|
41
|
+
const f = obj(o.fonts);
|
|
42
|
+
const fonts = { heading: str(f.heading) || 'Inter', body: str(f.body) || 'Inter', mono: str(f.mono) || 'ui-monospace' };
|
|
43
|
+
const r = obj(o.radius);
|
|
44
|
+
const radius = Object.fromEntries(Object.entries(r).filter(([, v]) => typeof v === 'number' && v >= 0 && v <= 64));
|
|
45
|
+
const v = obj(o.voice);
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
colors,
|
|
49
|
+
fonts,
|
|
50
|
+
scale: nums(o.scale, 8, 160).slice(0, 10),
|
|
51
|
+
spacing: nums(o.spacing, 0, 256).slice(0, 12),
|
|
52
|
+
radius,
|
|
53
|
+
components: Object.fromEntries(Object.entries(obj(o.components)).map(([k, d]) => [k, str(d)]).filter(([, d]) => d)),
|
|
54
|
+
voice: { tone: str(v.tone), do: lines(v.do), dont: lines(v.dont) },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** A Draft as W3C design tokens, with our two groups beside them. */
|
|
58
|
+
export function tokensOf(d) {
|
|
59
|
+
const group = (rec, type, unit = '') => Object.fromEntries(Object.entries(rec).map(([k, v]) => [k, { $value: unit ? `${v}${unit}` : v, $type: type }]));
|
|
60
|
+
return {
|
|
61
|
+
color: group(d.colors, 'color'),
|
|
62
|
+
font: group({ heading: d.fonts.heading, body: d.fonts.body, mono: d.fonts.mono }, 'fontFamily'),
|
|
63
|
+
type: group(Object.fromEntries(d.scale.map((n, i) => [`step${i}`, n])), 'dimension', 'px'),
|
|
64
|
+
space: group(Object.fromEntries(d.spacing.map((n, i) => [`s${i}`, n])), 'dimension', 'px'),
|
|
65
|
+
radius: group(d.radius, 'dimension', 'px'),
|
|
66
|
+
components: Object.fromEntries(Object.entries(d.components).map(([k, v]) => [k, { $value: v, $type: 'string' }])),
|
|
67
|
+
voice: { tone: { $value: d.voice.tone, $type: 'string' }, do: { $value: d.voice.do, $type: 'string' }, dont: { $value: d.voice.dont, $type: 'string' } },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** One token group as `name value` pairs. */
|
|
71
|
+
function pairs(tokens, group) {
|
|
72
|
+
return Object.entries(obj(tokens[group])).map(([k, v]) => {
|
|
73
|
+
const val = obj(v).$value;
|
|
74
|
+
return [k, Array.isArray(val) ? val.join('; ') : String(val ?? '')];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** What a model is told on every message of a conversation the system is attached to. */
|
|
78
|
+
export function said(b) {
|
|
79
|
+
const t = b.tokens;
|
|
80
|
+
const row = (g) => pairs(t, g).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
81
|
+
const parts = [
|
|
82
|
+
`Design system "${b.name}" is attached to this conversation. Everything you make — pages, apps, components, slides, images and copy — follows it exactly; never invent other colours, fonts or radii.`,
|
|
83
|
+
row('color') && `Colours: ${row('color')}.`,
|
|
84
|
+
row('font') && `Fonts: ${row('font')}.`,
|
|
85
|
+
row('type') && `Type scale: ${row('type')}.`,
|
|
86
|
+
row('space') && `Spacing: ${row('space')}.`,
|
|
87
|
+
row('radius') && `Radius: ${row('radius')}.`,
|
|
88
|
+
row('components') && `Components: ${pairs(t, 'components').map(([k, v]) => `${k} — ${v}`).join(' ')}`,
|
|
89
|
+
row('voice') && `Voice: ${pairs(t, 'voice').map(([k, v]) => `${k}: ${v}`).join(' | ')}`,
|
|
90
|
+
'In HTML, set these as CSS custom properties on :root and use them; load the fonts from Google Fonts when they are web fonts.',
|
|
91
|
+
];
|
|
92
|
+
return parts.filter(Boolean).join('\n');
|
|
93
|
+
}
|
|
94
|
+
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
95
|
+
/** The system drawn as a page: its palette, type, a button, a card, an input, its voice. */
|
|
96
|
+
export function preview(b) {
|
|
97
|
+
const t = b.tokens;
|
|
98
|
+
const c = Object.fromEntries(pairs(t, 'color'));
|
|
99
|
+
const f = Object.fromEntries(pairs(t, 'font'));
|
|
100
|
+
const r = Object.fromEntries(pairs(t, 'radius'));
|
|
101
|
+
const sizes = pairs(t, 'type').map(([, v]) => v);
|
|
102
|
+
const voice = Object.fromEntries(pairs(t, 'voice'));
|
|
103
|
+
const comps = pairs(t, 'components');
|
|
104
|
+
const fonts = [f.heading, f.body].filter((x) => x && !/^ui-|system|sans-serif|serif$/i.test(x));
|
|
105
|
+
const link = fonts.length ? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${fonts.map((x) => `family=${encodeURIComponent(x)}:wght@400;600;700`).join('&')}&display=swap">` : '';
|
|
106
|
+
const swatches = Object.entries(c).map(([k, v]) => `<div class="sw"><span style="background:${esc(v)}"></span><b>${esc(k)}</b><code>${esc(v)}</code></div>`).join('');
|
|
107
|
+
const scale = sizes.map((s) => `<div style="font-size:${esc(s)}">${esc(b.name)} — ${esc(s)}</div>`).join('');
|
|
108
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(b.name)}</title>${link}
|
|
109
|
+
<style>:root{--primary:${c.primary};--secondary:${c.secondary ?? c.primary};--accent:${c.accent ?? c.primary};--bg:${c.background};--surface:${c.surface ?? c.background};--text:${c.text};--muted:${c.muted ?? c.text};--border:${c.border ?? c.muted ?? c.text};--r-sm:${r.sm ?? '4px'};--r-md:${r.md ?? '8px'};--r-lg:${r.lg ?? '16px'}}
|
|
110
|
+
body{margin:0;background:var(--bg);color:var(--text);font-family:'${esc(f.body ?? 'Inter')}',system-ui,sans-serif;padding:40px;line-height:1.5}
|
|
111
|
+
h1,h2{font-family:'${esc(f.heading ?? 'Inter')}',system-ui,sans-serif;margin:0 0 12px}h2{margin-top:40px;font-size:14px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
|
|
112
|
+
.row{display:flex;flex-wrap:wrap;gap:16px}.sw{display:flex;flex-direction:column;gap:4px;width:120px}.sw span{height:64px;border-radius:var(--r-md);border:1px solid var(--border)}.sw code{color:var(--muted);font-size:12px}
|
|
113
|
+
.btn{background:var(--primary);color:var(--bg);border:0;border-radius:var(--r-md);padding:12px 20px;font:inherit;font-weight:600}.btn.ghost{background:transparent;color:var(--text);border:1px solid var(--border)}
|
|
114
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);padding:20px;max-width:320px}input{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:var(--r-sm);padding:10px 12px;font:inherit;width:260px}
|
|
115
|
+
li{color:var(--muted)}</style></head><body>
|
|
116
|
+
<h1 style="font-size:40px">${esc(b.name)}</h1><p style="color:var(--muted)">${esc(voice.tone ?? '')}</p>
|
|
117
|
+
<h2>Colour</h2><div class="row">${swatches}</div>
|
|
118
|
+
<h2>Type</h2>${scale}
|
|
119
|
+
<h2>Components</h2><div class="row" style="align-items:flex-start"><button class="btn">Primary action</button><button class="btn ghost">Secondary</button><input placeholder="An input"><div class="card"><b>A card</b><p style="color:var(--muted);margin:8px 0 0">${esc(comps.find(([k]) => k === 'card')?.[1] ?? '')}</p></div></div>
|
|
120
|
+
<ul>${comps.map(([k, v]) => `<li><b style="color:var(--text)">${esc(k)}</b> — ${esc(v)}</li>`).join('')}</ul>
|
|
121
|
+
<h2>Voice</h2><p><b>Do</b>: ${esc(voice.do ?? '')}</p><p><b>Don't</b>: ${esc(voice.dont ?? '')}</p>
|
|
122
|
+
</body></html>`;
|
|
123
|
+
}
|
package/lib/gear.js
CHANGED
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Both live in the account, so a choice follows the person to every device. A
|
|
11
11
|
* conversation that has none of its own reads the default; changing one writes
|
|
12
|
-
* that conversation's
|
|
12
|
+
* that conversation's. A conversation not begun holds its choice on the page
|
|
13
|
+
* (`draft`) and hands it to the conversation once it has an id. What carries
|
|
14
|
+
* over to the next new conversation is only the switches (`STICKY`: Research,
|
|
15
|
+
* Memory, the mode); a skill or a plugin is picked for the conversation it was
|
|
16
|
+
* picked in.
|
|
13
17
|
*/
|
|
14
|
-
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
15
19
|
import { call } from './api/call.js';
|
|
16
20
|
/**
|
|
17
21
|
* The modes the platform honours. Manual and Accept edits are not here: a cloud
|
|
@@ -32,6 +36,9 @@ export const BARE = {
|
|
|
32
36
|
memory: true,
|
|
33
37
|
mode: 'auto',
|
|
34
38
|
};
|
|
39
|
+
/** What a new conversation carries over from the last: the switches, never a pick. */
|
|
40
|
+
const STICKY = ['research', 'memory', 'mode'];
|
|
41
|
+
const pick = (g) => ({ research: g.research, memory: g.memory, mode: g.mode });
|
|
35
42
|
/** How many conversations keep a choice of their own: the document holds 16 KiB in all. */
|
|
36
43
|
export const KEEP = 30;
|
|
37
44
|
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x !== '') : []);
|
|
@@ -62,7 +69,7 @@ export function shelve(gears, key, g) {
|
|
|
62
69
|
}
|
|
63
70
|
return out;
|
|
64
71
|
}
|
|
65
|
-
let doc = { gear: BARE, gears: {}, language: null };
|
|
72
|
+
let doc = { gear: BARE, gears: {}, draft: null, language: null };
|
|
66
73
|
let loaded = null;
|
|
67
74
|
const listeners = new Set();
|
|
68
75
|
const emit = () => listeners.forEach((f) => f());
|
|
@@ -81,7 +88,7 @@ function load(t) {
|
|
|
81
88
|
const gear = gearOf(raw.gear);
|
|
82
89
|
const all = raw.gears && typeof raw.gears === 'object' ? raw.gears : {};
|
|
83
90
|
const language = typeof raw.language === 'string' && raw.language ? raw.language : null;
|
|
84
|
-
doc = { gear, gears: Object.fromEntries(Object.entries(all).map(([k, v]) => [k, gearOf(v, gear)])), language };
|
|
91
|
+
doc = { gear, gears: Object.fromEntries(Object.entries(all).map(([k, v]) => [k, gearOf(v, gear)])), draft: doc.draft, language };
|
|
85
92
|
emit();
|
|
86
93
|
})
|
|
87
94
|
.catch(() => {
|
|
@@ -96,16 +103,39 @@ export function useGear(t, key = '') {
|
|
|
96
103
|
useEffect(() => load(t), [t]);
|
|
97
104
|
const now = useSyncExternalStore(subscribe, () => doc, () => doc);
|
|
98
105
|
const [error, setError] = useState(null);
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
emit();
|
|
106
|
+
// A conversation with no choice of its own starts from the switches alone.
|
|
107
|
+
const fresh = useMemo(() => ({ ...BARE, ...pick(now.gear) }), [now.gear]);
|
|
108
|
+
const gear = key ? (now.gears[key] ?? fresh) : (now.draft ?? fresh);
|
|
109
|
+
const save = useCallback((patch) => {
|
|
104
110
|
if (!t.token())
|
|
105
111
|
return;
|
|
106
|
-
const patch = key ? { gears: fresh.gears } : { gear: g };
|
|
107
112
|
call(t, 'PATCH', '/v1/pref', patch).then(() => setError(null), (e) => setError(e instanceof Error ? e.message : 'Your choice was not saved.'));
|
|
108
|
-
}, [
|
|
113
|
+
}, [t]);
|
|
114
|
+
// The conversation just begun takes what was picked before it had an id.
|
|
115
|
+
const was = useRef(key);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const from = was.current;
|
|
118
|
+
was.current = key;
|
|
119
|
+
if (from || !key || !doc.draft || doc.gears[key])
|
|
120
|
+
return;
|
|
121
|
+
doc = { ...doc, gears: shelve(doc.gears, key, doc.draft), draft: null };
|
|
122
|
+
emit();
|
|
123
|
+
save({ gears: doc.gears });
|
|
124
|
+
}, [key, save]);
|
|
125
|
+
const write = useCallback((g) => {
|
|
126
|
+
if (key) {
|
|
127
|
+
doc = { ...doc, gears: shelve(doc.gears, key, g) };
|
|
128
|
+
emit();
|
|
129
|
+
return save({ gears: doc.gears });
|
|
130
|
+
}
|
|
131
|
+
// Not begun: the page holds it; the switches become the default too.
|
|
132
|
+
const sticky = { ...doc.gear, ...pick(g) };
|
|
133
|
+
const moved = STICKY.some((k) => sticky[k] !== doc.gear[k]);
|
|
134
|
+
doc = { ...doc, draft: g, ...(moved ? { gear: sticky } : null) };
|
|
135
|
+
emit();
|
|
136
|
+
if (moved)
|
|
137
|
+
save({ gear: sticky });
|
|
138
|
+
}, [key, save]);
|
|
109
139
|
return useMemo(() => ({
|
|
110
140
|
gear,
|
|
111
141
|
error,
|
package/lib/landing.js
CHANGED
|
@@ -205,7 +205,8 @@ export function Landing({ onStarted }) {
|
|
|
205
205
|
const prepped = carry.preparing ? await carry.prep({ text: draft.trim(), files: [], thread: '' }, new AbortController().signal) : {};
|
|
206
206
|
// An answer of a plugin's own (a deck) is the answer: no run is started for it.
|
|
207
207
|
if (prepped.reply) {
|
|
208
|
-
|
|
208
|
+
// Its words, without the page it drew (Chat shows that in the side panel).
|
|
209
|
+
setNote(prepped.reply.split('```')[0].trim());
|
|
209
210
|
return;
|
|
210
211
|
}
|
|
211
212
|
const run = await start(t, {
|
|
@@ -308,6 +309,7 @@ export function Landing({ onStarted }) {
|
|
|
308
309
|
: `${codebase} has no environment yet, so every run starts it bare.` }), env.value.state === 'proposed' && env.value.session ? (_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(env.value.session), children: "Review" })) : (_jsx(Button, { size: "sm", onPress: () => setOffering(true), children: "Set up environment" }))] })) : null, note || unhonoured(mode, place.id) || mind.note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note || unhonoured(mode, place.id) || mind.note })) : null, _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: () => void send(), busy: busy, label: "Describe a task or ask a question", head: head, plus: {
|
|
309
310
|
upload: () => pick(files, setFiles, setNote),
|
|
310
311
|
skills: carry.skills,
|
|
312
|
+
design: carry.design,
|
|
311
313
|
plugins: carry.plugins,
|
|
312
314
|
devices,
|
|
313
315
|
memory: { on: carry.gear.gear.memory, set: (on) => carry.gear.set({ memory: on }) },
|
package/lib/plugin.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface Turn {
|
|
|
35
35
|
files: File[];
|
|
36
36
|
/** The conversation's id, or '' for one not begun. */
|
|
37
37
|
thread: string;
|
|
38
|
+
/** The design system attached to the conversation (a Studio brand id), or ''. */
|
|
39
|
+
design: string;
|
|
38
40
|
}
|
|
39
41
|
/** What a plugin adds to the message. */
|
|
40
42
|
export interface Prep {
|
|
@@ -44,6 +46,8 @@ export interface Prep {
|
|
|
44
46
|
files?: File[];
|
|
45
47
|
/** An answer of the plugin's own, in markdown; the model is not asked. */
|
|
46
48
|
reply?: string;
|
|
49
|
+
/** A design system the plugin made: attached to the conversation from then on. */
|
|
50
|
+
design?: string;
|
|
47
51
|
}
|
|
48
52
|
export interface Plugin {
|
|
49
53
|
id: string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Target } from '../api/call.ts';
|
|
2
|
+
import type { Plugin, Prep, Turn } from '../plugin.ts';
|
|
3
|
+
/** Hanzo's model that decodes under a strict schema. */
|
|
4
|
+
export declare const DRAFTER = "zen5-flash";
|
|
5
|
+
/** The site a brief points at, if it names one. */
|
|
6
|
+
export declare const siteOf: (text: string) => string;
|
|
7
|
+
export declare function make(turn: Turn, t: Target, signal: AbortSignal): Promise<Prep>;
|
|
8
|
+
export declare const designer: Plugin;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Design system: while it is on, a message is a brief for one — "make me a
|
|
4
|
+
* design system for X", or a site to take it from — and the answer is the
|
|
5
|
+
* system: drafted by a Hanzo model under a strict schema (palette, type,
|
|
6
|
+
* spacing, radii, components, voice), kept as a Studio brand (the one store for
|
|
7
|
+
* them), drawn as a page in the side panel, and attached to the conversation, so
|
|
8
|
+
* everything made after it follows it. From `+` › Design system it attaches to
|
|
9
|
+
* any later conversation, in Chat or in Dev.
|
|
10
|
+
*/
|
|
11
|
+
import { Palette } from '@hanzogui/lucide-icons-2';
|
|
12
|
+
import { call } from '../api/call.js';
|
|
13
|
+
import { keepBrand } from '../api/studio.js';
|
|
14
|
+
import { drafted, preview, SCHEMA, tokensOf } from '../design.js';
|
|
15
|
+
/** Hanzo's model that decodes under a strict schema. */
|
|
16
|
+
export const DRAFTER = 'zen5-flash';
|
|
17
|
+
/** The site a brief points at, if it names one. */
|
|
18
|
+
export const siteOf = (text) => text.match(/https?:\/\/[^\s)]+/)?.[0] ?? '';
|
|
19
|
+
const BRIEF = 'You design design systems. From the brief (and the site text, when given), answer one complete, coherent system: ' +
|
|
20
|
+
'a name; eight hex colours with enough contrast for text on background and on primary; heading, body and mono fonts (Google Fonts names where you can); ' +
|
|
21
|
+
'a type scale of 6-8 px sizes; a spacing scale of 6-10 px steps; sm, md and lg radii; one line each for button, card, input, nav and hero; ' +
|
|
22
|
+
'and a voice: its tone in a sentence, 3-5 do and 3-5 dont lines. Take colours, fonts and tone from the site when one is given.';
|
|
23
|
+
async function site(t, url, signal) {
|
|
24
|
+
if (!url)
|
|
25
|
+
return '';
|
|
26
|
+
const d = await call(t, 'POST', '/v1/crawl', { url }, { signal }).catch(() => null);
|
|
27
|
+
return d?.success ? (d.data?.markdown ?? '').slice(0, 6000) : '';
|
|
28
|
+
}
|
|
29
|
+
export async function make(turn, t, signal) {
|
|
30
|
+
const brief = turn.text.trim();
|
|
31
|
+
if (!brief)
|
|
32
|
+
return {};
|
|
33
|
+
const page = await site(t, siteOf(brief), signal);
|
|
34
|
+
const raw = await call(t, 'POST', '/v1/chat/completions', {
|
|
35
|
+
model: DRAFTER,
|
|
36
|
+
max_tokens: 2000,
|
|
37
|
+
messages: [
|
|
38
|
+
{ role: 'system', content: BRIEF },
|
|
39
|
+
{ role: 'user', content: page ? `${brief}\n\nThe site, as text:\n${page}` : brief },
|
|
40
|
+
],
|
|
41
|
+
response_format: { type: 'json_schema', json_schema: { name: 'design_system', strict: true, schema: SCHEMA } },
|
|
42
|
+
}, { signal });
|
|
43
|
+
const text = raw?.choices?.[0]?.message?.content;
|
|
44
|
+
let parsed = null;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(typeof text === 'string' ? text : '');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error('The model did not answer a design system. Try the brief again.');
|
|
50
|
+
}
|
|
51
|
+
const d = drafted(parsed);
|
|
52
|
+
if (typeof d === 'string')
|
|
53
|
+
throw new Error(d);
|
|
54
|
+
const b = await keepBrand(t, { name: d.name, tokens: tokensOf(d) }, signal);
|
|
55
|
+
const file = `${d.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'design-system'}.html`;
|
|
56
|
+
const reply = [
|
|
57
|
+
`**${d.name}** is saved and attached to this conversation: what you build here follows it, and \`+\` › Design system attaches it anywhere else.`,
|
|
58
|
+
`Colours ${Object.values(d.colors).join(' ')} · ${d.fonts.heading} / ${d.fonts.body} · radius ${Object.values(d.radius).join('/')}px`,
|
|
59
|
+
d.voice.tone ? `Voice: ${d.voice.tone}` : '',
|
|
60
|
+
'```html ' + file + '\n' + preview(b) + '\n```',
|
|
61
|
+
];
|
|
62
|
+
return { reply: reply.filter(Boolean).join('\n\n'), design: b.id };
|
|
63
|
+
}
|
|
64
|
+
export const designer = {
|
|
65
|
+
id: 'design',
|
|
66
|
+
name: 'Design system',
|
|
67
|
+
note: 'Make one from a brief or a site',
|
|
68
|
+
icon: _jsx(Palette, { size: 16 }),
|
|
69
|
+
prepare: (turn, c, signal) => make(turn, c.target, signal),
|
|
70
|
+
};
|
package/lib/plugins/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { say } from '../customize/say.js';
|
|
|
23
23
|
import { path } from '../route.js';
|
|
24
24
|
import { pdf } from './pdf.js';
|
|
25
25
|
import { project } from './project.js';
|
|
26
|
+
import { designer } from './design.js';
|
|
26
27
|
import { deep } from './research.js';
|
|
27
28
|
import { sketch } from './sketch.js';
|
|
28
29
|
import { slides } from './slides.js';
|
|
@@ -46,6 +47,7 @@ export async function probe(t) {
|
|
|
46
47
|
sketch,
|
|
47
48
|
administers(t.token(), t.org) ? template : null,
|
|
48
49
|
runs.includes('slides') ? slides : null,
|
|
50
|
+
studio.status === 'fulfilled' ? designer : null,
|
|
49
51
|
pdf(runs.includes('document')),
|
|
50
52
|
].filter((p) => p !== null);
|
|
51
53
|
}
|
package/lib/plugins/slides.js
CHANGED
|
@@ -40,7 +40,7 @@ export async function deck(turn, t, signal) {
|
|
|
40
40
|
if (!ask)
|
|
41
41
|
return {};
|
|
42
42
|
const last = turn.thread ? decks.get(turn.thread) : undefined;
|
|
43
|
-
const r = await make(t, { template: 'slides', title: ask.slice(0, 80), input: { brief: last ? revision(last, ask) : ask, format: 'pptx' } }, signal);
|
|
43
|
+
const r = await make(t, { template: 'slides', title: ask.slice(0, 80), input: { brief: last ? revision(last, ask) : ask, format: 'pptx' }, ...(turn.design ? { brand: turn.design } : {}) }, signal);
|
|
44
44
|
if (turn.thread)
|
|
45
45
|
decks.set(turn.thread, r);
|
|
46
46
|
return { reply: told(r) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/build",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.69",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
|