design systems: made by chat, kept as Studio brands, attached from + › Design system; an attached one is said to the model and drives decks; @hanzo/build 0.2.69

This commit is contained in:
2026-10-20 16:00:18 -07:00
parent b68a9f5156
commit 626ff36639
11 changed files with 359 additions and 13 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/build",
"version": "0.2.68",
"version": "0.2.69",
"type": "module",
"license": "Apache-2.0 OR MIT",
"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.",
+35
View File
@@ -32,6 +32,41 @@ export interface Start {
input: Record<string, unknown>
title?: string
parent?: string
/** A brand (design system) the run reads as brand.* — its palette, type and voice. */
brand?: string
}
/**
* A design system: one of Studio's brands, the one store for them. Its tokens
* are W3C design tokens (color, font, type, space, radius) and two groups of our
* own, `components` and `voice`, each a line a model follows.
*
* GET /v1/studio/brands {data: [brand]} the ones you may read
* GET /v1/studio/brands/{id} one
* POST /v1/studio/brands {name, tokens, shared?} → brand
*/
export interface Brand {
id: string
name: string
tokens: Record<string, unknown>
}
export function brandOf(raw: unknown): Brand {
const o = obj(raw)
return { id: str(o.id), name: str(o.name), tokens: obj(o.tokens) }
}
export async function brands(t: Target, init?: { signal?: AbortSignal }): Promise<Brand[]> {
const raw = obj(await call<unknown>(t, 'GET', '/v1/studio/brands', undefined, init))
return (Array.isArray(raw.data) ? raw.data : []).map(brandOf).filter((b) => b.id)
}
export async function brand(t: Target, id: string, init?: { signal?: AbortSignal }): Promise<Brand> {
return brandOf(await call<unknown>(t, 'GET', `/v1/studio/brands/${seg(id)}`, undefined, init))
}
export async function keepBrand(t: Target, b: { name: string; tokens: Record<string, unknown> }, signal?: AbortSignal): Promise<Brand> {
return brandOf(await call<unknown>(t, 'POST', '/v1/studio/brands', { name: b.name, tokens: b.tokens }, { signal }))
}
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
+43 -7
View File
@@ -14,12 +14,14 @@
* A shelf is offered only where its service answers; a surface adds its own
* (Chat its agents, Dev its devices) beside these.
*/
import { BookOpen, ListChecks, ScrollText } from '@hanzogui/lucide-icons-2'
import { BookOpen, ListChecks, Palette, ScrollText } from '@hanzogui/lucide-icons-2'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { call, query, unwrap, type Target } from './api/call.ts'
import { active, authored, document, nameOf as skillOf } from './api/skills.ts'
import { brand, brands } from './api/studio.ts'
import { useRead } from './data.ts'
import { said as designSaid } from './design.ts'
import { useGear, type GearState } from './gear.ts'
import type { Composer, Prep, Turn } from './plugin.ts'
import { active as lighted, prep, shelf as pluginShelf, usePlugins } from './plugins/index.ts'
@@ -38,6 +40,8 @@ export type Lent = Omit<Composer, 'target' | 'show'>
export interface Carry {
gear: GearState
skills: Shelf
/** The design systems (Studio brands): attach one, or make one by chat. */
design: Shelf
/** The plugins that run for this person (`@` lists the same). */
plugins: Shelf
/** A dialog a plugin opened, for the surface to draw; null when none is. */
@@ -45,7 +49,7 @@ export interface Carry {
/** Whether a plugin that prepares a message is on. */
preparing: boolean
/** What the plugins that are on add to the message about to go. */
prep: (turn: Turn, signal: AbortSignal) => Promise<Prep>
prep: (turn: Omit<Turn, 'design'>, signal: AbortSignal) => Promise<Prep>
/** The chips after `+`: the skills on, Research, and Plan. */
on: Chip[]
/** The words the gear adds to every message, '' when none. */
@@ -61,8 +65,12 @@ async function remembered(t: Target): Promise<string[]> {
}
/** The words `skills`, `memory` and `mode` add to a message. */
async function said(t: Target, skills: readonly string[], memory: boolean, mode: string): Promise<string> {
async function said(t: Target, skills: readonly string[], memory: boolean, mode: string, design: string): Promise<string> {
const parts: string[] = []
if (design) {
const b = await brand(t, design).catch(() => null)
if (b?.name) parts.push(designSaid(b))
}
if (skills.length) {
const own = await authored(t).catch(() => [])
for (const n of skills) {
@@ -105,7 +113,7 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
const signed = Boolean(t.token())
const list = useRead(signed ? () => active(t) : null, [], [t, signed])
const names = useMemo(() => list.value.map((s) => skillOf(s.name)).filter(Boolean), [list.value])
const { skills, research, memory, mode } = gear.gear
const { skills, research, memory, mode, design } = gear.gear
const shelf: Shelf = {
rows: names.map((n) => ({ id: n, label: n, icon: <ScrollText size={16} />, on: skills.includes(n) })),
@@ -119,12 +127,34 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
}
const plugins = pluginShelf(found, gear, composer)
// The design systems: Studio's brands, the one store for them.
const kits = useRead(signed ? () => brands(t) : null, [], [t, signed, design])
const named = kits.value.find((b) => b.id === design)
const designs: Shelf = {
rows: kits.value.map((b) => {
const c = (b.tokens.color ?? {}) as Record<string, { $value?: unknown }>
return { id: b.id, label: b.name, note: [c.primary?.$value, c.accent?.$value].filter(Boolean).join(' '), icon: <Palette size={16} />, on: b.id === design }
}),
pick: (id) => gear.set({ design: design === id ? '' : id }),
loading: kits.loading,
error: kits.error ? 'The design systems could not be read.' : null,
empty: 'No design systems yet',
create: {
label: 'Create design system',
onPress: () => {
if (!gear.gear.plugins.includes('design')) gear.toggle('plugins', 'design')
lentNow.current.setDraft('Make me a design system for ')
},
},
}
const lit = lighted(found.plugins, gear.gear)
const on: Chip[] = [
...skills.map((n) => ({ id: `skill:${n}`, label: n, icon: <ScrollText size={14} />, onRemove: () => gear.toggle('skills', n) })),
...(research ? [{ id: 'research', label: 'Research', icon: <BookOpen size={14} />, onRemove: () => gear.set({ research: false }) }] : []),
...(mode === 'plan' ? [{ id: 'plan', label: 'Plan', icon: <ListChecks size={14} />, onRemove: () => gear.set({ mode: 'auto' }) }] : []),
...(design ? [{ id: 'design', label: named?.name ?? 'Design system', icon: <Palette size={14} />, onRemove: () => gear.set({ design: '' }) }] : []),
...lit
.filter((p) => p.id !== 'research')
.map((p) => ({ id: `plugin:${p.id}`, label: p.name, icon: p.icon, onRemove: () => gear.toggle('plugins', p.id) })),
@@ -136,13 +166,13 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
useEffect(() => {
if (!signedIn) return setText(mode === 'plan' ? 'Plan mode: lay out the plan and stop until the person says go.' : '')
let live = true
void said(t, skills, memory, mode).then((x) => live && setText(x))
void said(t, skills, memory, mode, design).then((x) => live && setText(x))
return () => {
live = false
}
// `skills` is read through `asked`, its contents.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, signedIn, asked, memory, mode])
}, [t, signedIn, asked, memory, mode, design])
return {
gear,
@@ -152,6 +182,12 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
on,
text,
preparing: lit.length > 0,
prep: (turn, signal) => prep(found.plugins, gear.gear, turn, composer, signal),
design: designs,
// A design system a plugin made is attached at once, and the maker turns off: it made one.
prep: (turn, signal) =>
prep(found.plugins, gear.gear, { ...turn, design }, composer, signal).then((p) => {
if (p.design) gear.set({ design: p.design, plugins: gear.gear.plugins.filter((x) => x !== 'design') })
return p
}),
}
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'vitest'
import { drafted, preview, said, tokensOf, type Draft } from './design.ts'
const raw = {
name: 'Tidepool',
colors: { primary: '#0E7C86', secondary: '#1B3A4B', accent: '#F2A541', background: '#FFFFFF', surface: '#F4F7F8', text: '#0B1F2A', muted: '#5B7280', border: '#D5E0E4' },
fonts: { heading: 'Fraunces', body: 'Inter', mono: 'JetBrains Mono' },
scale: [12, 14, 16, 20, 28, 40],
spacing: [4, 8, 12, 16, 24, 32],
radius: { sm: 4, md: 8, lg: 16 },
components: { button: 'Solid teal, 8px corners', card: 'White on surface, hairline border', input: 'Surface fill', nav: 'Left aligned', hero: 'Big serif headline' },
voice: { tone: 'Calm and exact', do: ['Say what it does'], dont: ['Hype'] },
}
describe('design system', () => {
test('a model answer becomes a draft, and a broken one says why', () => {
expect(typeof drafted(raw)).toBe('object')
expect(drafted({ ...raw, colors: { primary: 'teal' } })).toMatch(/primary, a background and a text/)
expect(drafted({ ...raw, name: '' })).toMatch(/no name/)
})
test('tokens are W3C groups with our components and voice beside them', () => {
const t = tokensOf(drafted(raw) as Draft)
expect((t.color as Record<string, { $value: string }>).primary.$value).toBe('#0E7C86')
expect((t.radius as Record<string, { $value: string }>).md.$value).toBe('8px')
expect(Object.keys(t)).toEqual(expect.arrayContaining(['color', 'font', 'type', 'space', 'radius', 'components', 'voice']))
})
test('what the model is told names every colour and the voice; the page draws them', () => {
const b = { id: 'brd_1', name: 'Tidepool', tokens: tokensOf(drafted(raw) as Draft) }
const told = said(b)
expect(told).toContain('#F2A541')
expect(told).toContain('Calm and exact')
expect(preview(b)).toContain('--primary:#0E7C86')
expect(preview(b)).not.toContain('<script')
})
})
+152
View File
@@ -0,0 +1,152 @@
/**
* A design system as the composer handles it: drafted by a model, kept as a
* Studio brand (W3C design tokens plus `components` and `voice`), said to the
* model on every message of a conversation it is attached to, and drawn as a
* page to look at. Pure, so it is tested without a browser.
*/
import type { Brand } from './api/studio.ts'
/** What a model is asked to answer: the system in plain fields. */
export interface Draft {
name: string
colors: Record<string, string>
fonts: { heading: string; body: string; mono: string }
/** Type sizes, px, smallest first. */
scale: number[]
/** Spacing steps, px. */
spacing: number[]
radius: Record<string, number>
/** One line each: how a button, card, input, nav… looks and behaves. */
components: Record<string, string>
voice: { tone: string; do: string[]; dont: string[] }
}
/** The schema the drafting call decodes under. */
export const SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['name', 'colors', 'fonts', 'scale', 'spacing', 'radius', 'components', 'voice'],
properties: {
name: { type: 'string' },
colors: {
type: 'object',
additionalProperties: false,
required: ['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'],
properties: Object.fromEntries(['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'].map((k) => [k, { type: 'string' }])),
},
fonts: { type: 'object', additionalProperties: false, required: ['heading', 'body', 'mono'], properties: { heading: { type: 'string' }, body: { type: 'string' }, mono: { type: 'string' } } },
scale: { type: 'array', items: { type: 'number' } },
spacing: { type: 'array', items: { type: 'number' } },
radius: { type: 'object', additionalProperties: false, required: ['sm', 'md', 'lg'], properties: { sm: { type: 'number' }, md: { type: 'number' }, lg: { type: 'number' } } },
components: {
type: 'object',
additionalProperties: false,
required: ['button', 'card', 'input', 'nav', 'hero'],
properties: Object.fromEntries(['button', 'card', 'input', 'nav', 'hero'].map((k) => [k, { type: 'string' }])),
},
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' } } } },
},
} as const
const HEX = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
const obj = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {})
const nums = (v: unknown, lo: number, hi: number): number[] => (Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number' && n >= lo && n <= hi) : [])
const lines = (v: unknown): string[] => (Array.isArray(v) ? v.map(str).filter(Boolean).slice(0, 8) : [])
/** A model's answer as a Draft, or why it is not one. */
export function drafted(raw: unknown): Draft | string {
const o = obj(raw)
const name = str(o.name).slice(0, 80)
if (!name) return 'The design system has no name.'
const colors = Object.fromEntries(Object.entries(obj(o.colors)).filter(([, v]) => HEX.test(str(v))).map(([k, v]) => [k, str(v)]))
if (!colors.primary || !colors.background || !colors.text) return 'The design system needs at least a primary, a background and a text colour.'
const f = obj(o.fonts)
const fonts = { heading: str(f.heading) || 'Inter', body: str(f.body) || 'Inter', mono: str(f.mono) || 'ui-monospace' }
const r = obj(o.radius)
const radius = Object.fromEntries(Object.entries(r).filter(([, v]) => typeof v === 'number' && v >= 0 && v <= 64)) as Record<string, number>
const v = obj(o.voice)
return {
name,
colors,
fonts,
scale: nums(o.scale, 8, 160).slice(0, 10),
spacing: nums(o.spacing, 0, 256).slice(0, 12),
radius,
components: Object.fromEntries(Object.entries(obj(o.components)).map(([k, d]) => [k, str(d)]).filter(([, d]) => d)),
voice: { tone: str(v.tone), do: lines(v.do), dont: lines(v.dont) },
}
}
/** A Draft as W3C design tokens, with our two groups beside them. */
export function tokensOf(d: Draft): Record<string, unknown> {
const group = <T>(rec: Record<string, T>, type: string, unit = '') =>
Object.fromEntries(Object.entries(rec).map(([k, v]) => [k, { $value: unit ? `${v}${unit}` : v, $type: type }]))
return {
color: group(d.colors, 'color'),
font: group({ heading: d.fonts.heading, body: d.fonts.body, mono: d.fonts.mono }, 'fontFamily'),
type: group(Object.fromEntries(d.scale.map((n, i) => [`step${i}`, n])), 'dimension', 'px'),
space: group(Object.fromEntries(d.spacing.map((n, i) => [`s${i}`, n])), 'dimension', 'px'),
radius: group(d.radius, 'dimension', 'px'),
components: Object.fromEntries(Object.entries(d.components).map(([k, v]) => [k, { $value: v, $type: 'string' }])),
voice: { tone: { $value: d.voice.tone, $type: 'string' }, do: { $value: d.voice.do, $type: 'string' }, dont: { $value: d.voice.dont, $type: 'string' } },
}
}
/** One token group as `name value` pairs. */
function pairs(tokens: Record<string, unknown>, group: string): [string, string][] {
return Object.entries(obj(tokens[group])).map(([k, v]) => {
const val = obj(v).$value
return [k, Array.isArray(val) ? val.join('; ') : String(val ?? '')]
})
}
/** What a model is told on every message of a conversation the system is attached to. */
export function said(b: Brand): string {
const t = b.tokens
const row = (g: string) => pairs(t, g).map(([k, v]) => `${k} ${v}`).join(', ')
const parts = [
`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.`,
row('color') && `Colours: ${row('color')}.`,
row('font') && `Fonts: ${row('font')}.`,
row('type') && `Type scale: ${row('type')}.`,
row('space') && `Spacing: ${row('space')}.`,
row('radius') && `Radius: ${row('radius')}.`,
row('components') && `Components: ${pairs(t, 'components').map(([k, v]) => `${k} — ${v}`).join(' ')}`,
row('voice') && `Voice: ${pairs(t, 'voice').map(([k, v]) => `${k}: ${v}`).join(' | ')}`,
'In HTML, set these as CSS custom properties on :root and use them; load the fonts from Google Fonts when they are web fonts.',
]
return parts.filter(Boolean).join('\n')
}
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
/** The system drawn as a page: its palette, type, a button, a card, an input, its voice. */
export function preview(b: Brand): string {
const t = b.tokens
const c = Object.fromEntries(pairs(t, 'color'))
const f = Object.fromEntries(pairs(t, 'font'))
const r = Object.fromEntries(pairs(t, 'radius'))
const sizes = pairs(t, 'type').map(([, v]) => v)
const voice = Object.fromEntries(pairs(t, 'voice'))
const comps = pairs(t, 'components')
const fonts = [f.heading, f.body].filter((x) => x && !/^ui-|system|sans-serif|serif$/i.test(x))
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">` : ''
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('')
const scale = sizes.map((s) => `<div style="font-size:${esc(s)}">${esc(b.name)} — ${esc(s)}</div>`).join('')
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}
<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'}}
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}
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)}
.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}
.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)}
.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}
li{color:var(--muted)}</style></head><body>
<h1 style="font-size:40px">${esc(b.name)}</h1><p style="color:var(--muted)">${esc(voice.tone ?? '')}</p>
<h2>Colour</h2><div class="row">${swatches}</div>
<h2>Type</h2>${scale}
<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>
<ul>${comps.map(([k, v]) => `<li><b style="color:var(--text)">${esc(k)}</b> — ${esc(v)}</li>`).join('')}</ul>
<h2>Voice</h2><p><b>Do</b>: ${esc(voice.do ?? '')}</p><p><b>Don't</b>: ${esc(voice.dont ?? '')}</p>
</body></html>`
}
+3 -1
View File
@@ -246,7 +246,8 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
const prepped = carry.preparing ? await carry.prep({ text: draft.trim(), files: [], thread: '' }, new AbortController().signal) : {}
// An answer of a plugin's own (a deck) is the answer: no run is started for it.
if (prepped.reply) {
setNote(prepped.reply)
// Its words, without the page it drew (Chat shows that in the side panel).
setNote(prepped.reply.split('```')[0]!.trim())
return
}
const run = await start(t, {
@@ -457,6 +458,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
plus={{
upload: () => pick(files, setFiles, setNote),
skills: carry.skills,
design: carry.design,
plugins: carry.plugins,
devices,
memory: { on: carry.gear.gear.memory, set: (on) => carry.gear.set({ memory: on }) },
+4
View File
@@ -38,6 +38,8 @@ export interface Turn {
files: File[]
/** The conversation's id, or '' for one not begun. */
thread: string
/** The design system attached to the conversation (a Studio brand id), or ''. */
design: string
}
/** What a plugin adds to the message. */
@@ -48,6 +50,8 @@ export interface Prep {
files?: File[]
/** An answer of the plugin's own, in markdown; the model is not asked. */
reply?: string
/** A design system the plugin made: attached to the conversation from then on. */
design?: string
}
export interface Plugin {
+80
View File
@@ -0,0 +1,80 @@
/**
* Design system: while it is on, a message is a brief for one — "make me a
* design system for X", or a site to take it from — and the answer is the
* system: drafted by a Hanzo model under a strict schema (palette, type,
* spacing, radii, components, voice), kept as a Studio brand (the one store for
* them), drawn as a page in the side panel, and attached to the conversation, so
* everything made after it follows it. From `+` › Design system it attaches to
* any later conversation, in Chat or in Dev.
*/
import { Palette } from '@hanzogui/lucide-icons-2'
import { call, type Target } from '../api/call.ts'
import { keepBrand } from '../api/studio.ts'
import { drafted, preview, SCHEMA, tokensOf } from '../design.ts'
import type { Plugin, Prep, Turn } from '../plugin.ts'
/** Hanzo's model that decodes under a strict schema. */
export const DRAFTER = 'zen5-flash'
/** The site a brief points at, if it names one. */
export const siteOf = (text: string): string => text.match(/https?:\/\/[^\s)]+/)?.[0] ?? ''
const BRIEF =
'You design design systems. From the brief (and the site text, when given), answer one complete, coherent system: ' +
'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); ' +
'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; ' +
'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.'
async function site(t: Target, url: string, signal: AbortSignal): Promise<string> {
if (!url) return ''
const d = await call<{ success?: boolean; data?: { markdown?: string } }>(t, 'POST', '/v1/crawl', { url }, { signal }).catch(() => null)
return d?.success ? (d.data?.markdown ?? '').slice(0, 6000) : ''
}
export async function make(turn: Turn, t: Target, signal: AbortSignal): Promise<Prep> {
const brief = turn.text.trim()
if (!brief) return {}
const page = await site(t, siteOf(brief), signal)
const raw = await call<{ choices?: { message?: { content?: unknown } }[] }>(
t,
'POST',
'/v1/chat/completions',
{
model: DRAFTER,
max_tokens: 2000,
messages: [
{ role: 'system', content: BRIEF },
{ role: 'user', content: page ? `${brief}\n\nThe site, as text:\n${page}` : brief },
],
response_format: { type: 'json_schema', json_schema: { name: 'design_system', strict: true, schema: SCHEMA } },
},
{ signal },
)
const text = raw?.choices?.[0]?.message?.content
let parsed: unknown = null
try {
parsed = JSON.parse(typeof text === 'string' ? text : '')
} catch {
throw new Error('The model did not answer a design system. Try the brief again.')
}
const d = drafted(parsed)
if (typeof d === 'string') throw new Error(d)
const b = await keepBrand(t, { name: d.name, tokens: tokensOf(d) }, signal)
const file = `${d.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'design-system'}.html`
const reply = [
`**${d.name}** is saved and attached to this conversation: what you build here follows it, and \`+\` › Design system attaches it anywhere else.`,
`Colours ${Object.values(d.colors).join(' ')} · ${d.fonts.heading} / ${d.fonts.body} · radius ${Object.values(d.radius).join('/')}px`,
d.voice.tone ? `Voice: ${d.voice.tone}` : '',
'```html ' + file + '\n' + preview(b) + '\n```',
]
return { reply: reply.filter(Boolean).join('\n\n'), design: b.id }
}
export const designer: Plugin = {
id: 'design',
name: 'Design system',
note: 'Make one from a brief or a site',
icon: <Palette size={16} />,
prepare: (turn, c, signal) => make(turn, c.target, signal),
}
+2
View File
@@ -28,6 +28,7 @@ import type { Shelf } from '../plus.tsx'
import { path } from '../route.ts'
import { pdf } from './pdf.tsx'
import { project } from './project.tsx'
import { designer } from './design.tsx'
import { deep } from './research.tsx'
import { sketch } from './sketch.tsx'
import { slides } from './slides.tsx'
@@ -59,6 +60,7 @@ export async function probe(t: Target): Promise<Plugin[]> {
sketch,
administers(t.token(), t.org) ? template : null,
runs.includes('slides') ? slides : null,
studio.status === 'fulfilled' ? designer : null,
pdf(runs.includes('document')),
].filter((p): p is Plugin => p !== null)
}
+3 -3
View File
@@ -44,14 +44,14 @@ function wire(route: (path: string, body: any) => [number, unknown]) {
}
// The kit's parts and marks are react-native drawings a node test cannot load; what is tested here is data and wire.
vi.mock('@hanzogui/lucide-icons-2', () => Object.fromEntries(['FileText', 'Folder', 'LayoutTemplate', 'Presentation', 'Signature', 'Telescope'].map((n) => [n, () => null])))
vi.mock('@hanzogui/lucide-icons-2', () => Object.fromEntries(['FileText', 'Folder', 'LayoutTemplate', 'Palette', 'Presentation', 'Signature', 'Telescope'].map((n) => [n, () => null])))
vi.mock('@hanzo/gui', () => ({ SizableText: () => null, XStack: () => null, YStack: () => null }))
vi.mock('@hanzo/ui', () => Object.fromEntries(['Button', 'Dialog', 'DialogContent', 'DialogDescription', 'DialogTitle', 'Input', 'Textarea'].map((n) => [n, () => null])))
afterEach(() => vi.unstubAllGlobals())
const composer = (t: Target, go = vi.fn()): Composer => ({ target: t, draft: '', setDraft: vi.fn(), attach: vi.fn(), show: vi.fn(), go })
const turn = (text: string, files: File[] = [], thread = ''): Turn => ({ text, files, thread })
const turn = (text: string, files: File[] = [], thread = ''): Turn => ({ text, files, thread, design: '' })
function gears(g: Partial<Gear> = {}): GearState & { gear: Gear } {
const state = { gear: { ...BARE, ...g } } as GearState
@@ -68,7 +68,7 @@ describe('what is offered', () => {
it('an admin with projects and Studio gets every plugin, in the menu’s order', async () => {
wire((p) => (p === '/v1/projects' ? [200, [{ slug: 'site', name: 'Site' }]] : studio(['slides', 'document', 'image'])))
const list = await probe(target(admin))
expect(list.map((p) => p.id)).toEqual(['project', 'research', 'sketch', 'template', 'slides', 'pdf'])
expect(list.map((p) => p.id)).toEqual(['project', 'research', 'sketch', 'template', 'slides', 'design', 'pdf'])
expect(list.find((p) => p.id === 'pdf')!.note).toBe('Read, create and verify PDFs')
})
+1 -1
View File
@@ -46,7 +46,7 @@ export async function deck(turn: Turn, t: Target, signal: AbortSignal): Promise<
const ask = turn.text.trim()
if (!ask) return {}
const last = turn.thread ? decks.get(turn.thread) : undefined
const r = await make(t, { template: 'slides', title: ask.slice(0, 80), input: { brief: last ? revision(last, ask) : ask, format: 'pptx' } }, signal)
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)
if (turn.thread) decks.set(turn.thread, r)
return { reply: told(r) }
}