mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
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:
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
@@ -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 }) },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user