@hanzo/build 0.2.68 → 0.2.70

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.
@@ -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
@@ -1,14 +1,16 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type Target } from './api/call.ts';
3
- import { type GearState } from './gear.ts';
3
+ import { type GearState, type Mode } from './gear.ts';
4
4
  import type { Composer, Prep, Turn } from './plugin.ts';
5
5
  import type { Shelf } from './plus.tsx';
6
- import type { Chip } from './prompt.tsx';
6
+ import type { Chip, PromptOptions } from './prompt.tsx';
7
7
  /** What the surface lends the plugins: its draft, its files, its way about. */
8
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,14 +18,25 @@ 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[];
24
+ /** The mode pill: this surface's modes, the conversation's choice among them. */
25
+ mode: NonNullable<PromptOptions['mode']>;
26
+ /** Whether `what` may run: in Manual the person is asked (once, always in this conversation, or not); else yes. */
27
+ allow: (what: Asked) => Promise<boolean>;
22
28
  /** The words the gear adds to every message, '' when none. */
23
29
  text: string;
24
30
  }
31
+ /** What Manual asks about: a plugin, or a Dev run. `id` is what "always" remembers. */
32
+ export interface Asked {
33
+ id: string;
34
+ name: string;
35
+ note: string;
36
+ }
25
37
  /**
26
38
  * The conversation `key` names (`''` for one not begun), for target `t`.
27
- * `customize(tab)` opens Dev's Customize on that tab.
39
+ * `customize(tab)` opens Dev's Customize on that tab; `modes` are the ones this
40
+ * surface offers (a choice it does not offer reads as Auto).
28
41
  */
29
- export declare function useCarry(t: Target, key: string, customize: (tab: 'skills') => void, lent: Lent): Carry;
42
+ export declare function useCarry(t: Target, key: string, customize: (tab: 'skills') => void, lent: Lent, modes: readonly Mode[]): Carry;
package/lib/carry.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * What a message carries beside its words, assembled once for Chat and Dev:
4
4
  * the conversation's gear (gear.ts) drawn as the `+` menu's shelves and the
@@ -10,16 +10,21 @@ import { jsx as _jsx } from "react/jsx-runtime";
10
10
  * field, so the text rides the message
11
11
  * memory what Hanzo remembers about the person (GET /v1/ai/memory/list):
12
12
  * the platform injects none itself
13
- * mode Plan: lay the plan out and wait; Auto says nothing
13
+ * mode Plan: lay the plan out and wait; Auto says nothing; Manual
14
+ * asks the person before each plugin runs for a message (`Allow`)
14
15
  *
15
16
  * A shelf is offered only where its service answers; a surface adds its own
16
17
  * (Chat its agents, Dev its devices) beside these.
17
18
  */
18
- import { BookOpen, ListChecks, ScrollText } from '@hanzogui/lucide-icons-2';
19
+ import { SizableText, XStack } from '@hanzo/gui';
20
+ import { BookOpen, ListChecks, Palette, ScrollText } from '@hanzogui/lucide-icons-2';
21
+ import { Button, Dialog, DialogContent, DialogDescription, DialogTitle } from '@hanzo/ui';
19
22
  import { useEffect, useMemo, useRef, useState } from 'react';
20
23
  import { call, query, unwrap } from './api/call.js';
21
24
  import { active, authored, document, nameOf as skillOf } from './api/skills.js';
25
+ import { brand, brands } from './api/studio.js';
22
26
  import { useRead } from './data.js';
27
+ import { said as designSaid } from './design.js';
23
28
  import { useGear } from './gear.js';
24
29
  import { active as lighted, prep, shelf as pluginShelf, usePlugins } from './plugins/index.js';
25
30
  /** The most of each skill a message carries. */
@@ -34,8 +39,13 @@ async function remembered(t) {
34
39
  .filter(Boolean);
35
40
  }
36
41
  /** The words `skills`, `memory` and `mode` add to a message. */
37
- async function said(t, skills, memory, mode) {
42
+ async function said(t, skills, memory, mode, design) {
38
43
  const parts = [];
44
+ if (design) {
45
+ const b = await brand(t, design).catch(() => null);
46
+ if (b?.name)
47
+ parts.push(designSaid(b));
48
+ }
39
49
  if (skills.length) {
40
50
  const own = await authored(t).catch(() => []);
41
51
  for (const n of skills) {
@@ -53,11 +63,16 @@ async function said(t, skills, memory, mode) {
53
63
  parts.push('Plan mode: lay out the plan — the steps, what each changes and why — and stop. Change nothing and produce no final work until the person says go.');
54
64
  return parts.join('\n\n');
55
65
  }
66
+ /** Manual's pause: what is about to run, what it does, and the three answers. Closing it is a no. */
67
+ function Allow({ what, onAnswer }) {
68
+ return (_jsx(Dialog, { open: true, onOpenChange: (o) => !o && onAnswer('deny'), children: _jsxs(DialogContent, { maxW: 440, width: "100%", "data-slot": "allow", children: [_jsx(DialogTitle, { children: `Allow ${what.name}?` }), _jsx(DialogDescription, { children: what.note ? `${what.note}. Manual asks before each one runs.` : 'Manual asks before each one runs.' }), _jsxs(XStack, { justify: "flex-end", gap: "$2", pt: "$3", flexWrap: "wrap", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => onAnswer('deny'), children: "Deny" }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => onAnswer('always'), children: _jsx(SizableText, { fontSize: 14, children: "Always in this chat" }) }), _jsx(Button, { size: "sm", onPress: () => onAnswer('once'), children: "Allow once" })] })] }) }));
69
+ }
56
70
  /**
57
71
  * The conversation `key` names (`''` for one not begun), for target `t`.
58
- * `customize(tab)` opens Dev's Customize on that tab.
72
+ * `customize(tab)` opens Dev's Customize on that tab; `modes` are the ones this
73
+ * surface offers (a choice it does not offer reads as Auto).
59
74
  */
60
- export function useCarry(t, key, customize, lent) {
75
+ export function useCarry(t, key, customize, lent, modes) {
61
76
  const gear = useGear(t, key);
62
77
  const [dialog, show] = useState(null);
63
78
  // The composer a plugin acts on, read when it acts: the draft moves under it.
@@ -77,7 +92,8 @@ export function useCarry(t, key, customize, lent) {
77
92
  const signed = Boolean(t.token());
78
93
  const list = useRead(signed ? () => active(t) : null, [], [t, signed]);
79
94
  const names = useMemo(() => list.value.map((s) => skillOf(s.name)).filter(Boolean), [list.value]);
80
- const { skills, research, memory, mode } = gear.gear;
95
+ const { skills, research, memory, design } = gear.gear;
96
+ const mode = modes.includes(gear.gear.mode) ? gear.gear.mode : 'auto';
81
97
  const shelf = {
82
98
  rows: names.map((n) => ({ id: n, label: n, icon: _jsx(ScrollText, { size: 16 }), on: skills.includes(n) })),
83
99
  pick: (id) => gear.toggle('skills', id),
@@ -89,11 +105,33 @@ export function useCarry(t, key, customize, lent) {
89
105
  browse: { label: 'Browse skills', onPress: () => customize('skills') },
90
106
  };
91
107
  const plugins = pluginShelf(found, gear, composer);
108
+ // The design systems: Studio's brands, the one store for them.
109
+ const kits = useRead(signed ? () => brands(t) : null, [], [t, signed, design]);
110
+ const named = kits.value.find((b) => b.id === design);
111
+ const designs = {
112
+ rows: kits.value.map((b) => {
113
+ const c = (b.tokens.color ?? {});
114
+ 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 };
115
+ }),
116
+ pick: (id) => gear.set({ design: design === id ? '' : id }),
117
+ loading: kits.loading,
118
+ error: kits.error ? 'The design systems could not be read.' : null,
119
+ empty: 'No design systems yet',
120
+ create: {
121
+ label: 'Create design system',
122
+ onPress: () => {
123
+ if (!gear.gear.plugins.includes('design'))
124
+ gear.toggle('plugins', 'design');
125
+ lentNow.current.setDraft('Make me a design system for ');
126
+ },
127
+ },
128
+ };
92
129
  const lit = lighted(found.plugins, gear.gear);
93
130
  const on = [
94
131
  ...skills.map((n) => ({ id: `skill:${n}`, label: n, icon: _jsx(ScrollText, { size: 14 }), onRemove: () => gear.toggle('skills', n) })),
95
132
  ...(research ? [{ id: 'research', label: 'Research', icon: _jsx(BookOpen, { size: 14 }), onRemove: () => gear.set({ research: false }) }] : []),
96
133
  ...(mode === 'plan' ? [{ id: 'plan', label: 'Plan', icon: _jsx(ListChecks, { size: 14 }), onRemove: () => gear.set({ mode: 'auto' }) }] : []),
134
+ ...(design ? [{ id: 'design', label: named?.name ?? 'Design system', icon: _jsx(Palette, { size: 14 }), onRemove: () => gear.set({ design: '' }) }] : []),
97
135
  ...lit
98
136
  .filter((p) => p.id !== 'research')
99
137
  .map((p) => ({ id: `plugin:${p.id}`, label: p.name, icon: p.icon, onRemove: () => gear.toggle('plugins', p.id) })),
@@ -105,21 +143,51 @@ export function useCarry(t, key, customize, lent) {
105
143
  if (!signedIn)
106
144
  return setText(mode === 'plan' ? 'Plan mode: lay out the plan and stop until the person says go.' : '');
107
145
  let live = true;
108
- void said(t, skills, memory, mode).then((x) => live && setText(x));
146
+ void said(t, skills, memory, mode, design).then((x) => live && setText(x));
109
147
  return () => {
110
148
  live = false;
111
149
  };
112
150
  // `skills` is read through `asked`, its contents.
113
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
114
- }, [t, signedIn, asked, memory, mode]);
152
+ }, [t, signedIn, asked, memory, mode, design]);
153
+ // Manual: what is about to run is asked of the person, unless allowed for the rest of the conversation.
154
+ const kept = useRef([]);
155
+ kept.current = gear.gear.allowed;
156
+ const allow = async (what) => {
157
+ if (mode !== 'manual' || kept.current.includes(what.id))
158
+ return true;
159
+ const v = await new Promise((done) => show(_jsx(Allow, { what: what, onAnswer: (x) => {
160
+ show(null);
161
+ done(x);
162
+ } })));
163
+ if (v === 'always') {
164
+ kept.current = [...kept.current, what.id];
165
+ gear.set({ allowed: kept.current });
166
+ }
167
+ return v !== 'deny';
168
+ };
115
169
  return {
116
170
  gear,
171
+ mode: { value: mode, set: (m) => gear.set({ mode: m }), items: modes },
172
+ allow,
117
173
  skills: shelf,
118
174
  plugins,
119
175
  dialog,
120
176
  on,
121
177
  text,
122
178
  preparing: lit.length > 0,
123
- prep: (turn, signal) => prep(found.plugins, gear.gear, turn, composer, signal),
179
+ design: designs,
180
+ // A design system a plugin made is attached at once, and the maker turns off: it made one.
181
+ prep: async (turn, signal) => {
182
+ // One plugin at a time, so each pause is its own question.
183
+ const denied = [];
184
+ for (const x of lit)
185
+ if (!signal.aborted && !(await allow({ id: x.id, name: x.name, note: x.note })))
186
+ denied.push(x.id);
187
+ const p = await prep(found.plugins, gear.gear, { ...turn, design }, composer, signal, denied);
188
+ if (p.design)
189
+ gear.set({ design: p.design, plugins: gear.gear.plugins.filter((x) => x !== 'design') });
190
+ return p;
191
+ },
124
192
  };
125
193
  }
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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.d.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  import { type Target } from './api/call.ts';
2
2
  /** What a run or a turn may do on its own: the permission mode. */
3
- export type Mode = 'auto' | 'plan';
3
+ export type Mode = 'auto' | 'manual' | 'plan';
4
4
  /**
5
- * The modes the platform honours. Manual and Accept edits are not here: a cloud
6
- * run takes `build` or `plan` (apps/coding) and a chat turn asks before nothing,
7
- * so offering them would promise a pause that never comes.
5
+ * The modes, one line each. Chat offers Auto and Manual; Dev adds Plan. Manual
6
+ * is a real pause: before a plugin runs for a message (a search, a site read, a
7
+ * deck), the person allows it once, always in this conversation, or not, and a
8
+ * Dev run is asked for before it starts.
8
9
  */
9
10
  export declare const MODES: readonly {
10
11
  id: Mode;
11
12
  label: string;
12
13
  note: string;
13
14
  }[];
15
+ /** The modes each surface offers. */
16
+ export declare const CHAT_MODES: readonly Mode[];
17
+ export declare const DEV_MODES: readonly Mode[];
14
18
  export interface Gear {
15
19
  /** Skill names (`/v1/tool/skills`). */
16
20
  skills: string[];
@@ -25,6 +29,8 @@ export interface Gear {
25
29
  research: boolean;
26
30
  memory: boolean;
27
31
  mode: Mode;
32
+ /** In Manual, the plugins allowed for the rest of this conversation. */
33
+ allowed: string[];
28
34
  }
29
35
  export declare const BARE: Gear;
30
36
  /** How many conversations keep a choice of their own: the document holds 16 KiB in all. */
package/lib/gear.js CHANGED
@@ -18,14 +18,19 @@
18
18
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
19
19
  import { call } from './api/call.js';
20
20
  /**
21
- * The modes the platform honours. Manual and Accept edits are not here: a cloud
22
- * run takes `build` or `plan` (apps/coding) and a chat turn asks before nothing,
23
- * so offering them would promise a pause that never comes.
21
+ * The modes, one line each. Chat offers Auto and Manual; Dev adds Plan. Manual
22
+ * is a real pause: before a plugin runs for a message (a search, a site read, a
23
+ * deck), the person allows it once, always in this conversation, or not, and a
24
+ * Dev run is asked for before it starts.
24
25
  */
25
26
  export const MODES = [
26
- { id: 'auto', label: 'Auto', note: 'Hanzo works on its own and stops to ask only if something looks unsafe.' },
27
- { id: 'plan', label: 'Plan', note: 'Hanzo lays out the plan first and changes nothing until you say go.' },
27
+ { id: 'auto', label: 'Auto', note: 'Runs tools on its own; pauses only for anything risky.' },
28
+ { id: 'manual', label: 'Manual', note: 'Asks before each tool, connector or site it opens.' },
29
+ { id: 'plan', label: 'Plan', note: 'Writes the plan and waits for your go before changing anything.' },
28
30
  ];
31
+ /** The modes each surface offers. */
32
+ export const CHAT_MODES = ['auto', 'manual'];
33
+ export const DEV_MODES = ['auto', 'manual', 'plan'];
29
34
  export const BARE = {
30
35
  skills: [],
31
36
  connectors: [],
@@ -35,6 +40,7 @@ export const BARE = {
35
40
  research: false,
36
41
  memory: true,
37
42
  mode: 'auto',
43
+ allowed: [],
38
44
  };
39
45
  /** What a new conversation carries over from the last: the switches, never a pick. */
40
46
  const STICKY = ['research', 'memory', 'mode'];
@@ -55,6 +61,7 @@ export function gearOf(raw, base = BARE) {
55
61
  research: typeof o.research === 'boolean' ? o.research : base.research,
56
62
  memory: typeof o.memory === 'boolean' ? o.memory : base.memory,
57
63
  mode: MODES.some((m) => m.id === o.mode) ? o.mode : base.mode,
64
+ allowed: 'allowed' in o ? list(o.allowed) : base.allowed,
58
65
  };
59
66
  }
60
67
  /** `gears` with `key`'s entry first, cut to `KEEP`. */
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The grip between two panes, one for every edge: the sidebar's, the side
3
+ * panel's, a run's chat against its work. The kit's Grip with its `inline` bar
4
+ * (drawn only while it is reached for), its band centred across the gutter and
5
+ * the bar halfway down it, so each edge looks and moves the same.
6
+ *
7
+ * side the edge the box it sits in is pinned to (Grip's): `left` for a
8
+ * sidebar, whose free edge is its right, `right` for an aside
9
+ * gap the gutter's width; the frame's `--pane-gap` by default
10
+ * inset the box's own border, px: an absolute place is measured inside it
11
+ */
12
+ import { type GripProps } from '@hanzo/ui';
13
+ export type GutterProps = Omit<GripProps, 'bar'> & {
14
+ gap?: string | number;
15
+ inset?: number;
16
+ };
17
+ export declare function Gutter({ side, gap, inset, ...rest }: GutterProps): import("react").JSX.Element;
package/lib/gutter.js ADDED
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The grip between two panes, one for every edge: the sidebar's, the side
4
+ * panel's, a run's chat against its work. The kit's Grip with its `inline` bar
5
+ * (drawn only while it is reached for), its band centred across the gutter and
6
+ * the bar halfway down it, so each edge looks and moves the same.
7
+ *
8
+ * side the edge the box it sits in is pinned to (Grip's): `left` for a
9
+ * sidebar, whose free edge is its right, `right` for an aside
10
+ * gap the gutter's width; the frame's `--pane-gap` by default
11
+ * inset the box's own border, px: an absolute place is measured inside it
12
+ */
13
+ import { Grip } from '@hanzo/ui';
14
+ import { gap as GAP } from './pane.js';
15
+ export function Gutter({ side, gap = GAP, inset = 0, ...rest }) {
16
+ const g = typeof gap === 'number' ? `${gap}px` : gap;
17
+ // A CSS length gui takes as written; its types name only tokens and numbers.
18
+ const band = `max(8px, ${g})`;
19
+ // The band's start, so its middle is the gutter's: half the gutter and half the band out from the edge.
20
+ const at = (w) => `calc(-1 * (${g} + ${w}) / 2 - ${inset}px)`;
21
+ const edge = side === 'left' ? 'r' : 'l';
22
+ return (_jsx(Grip, { side: side, bar: "inline", justify: "center", width: band, [edge]: at(band),
23
+ // A finger's band, centred on the same line.
24
+ $touchable: { width: 44, [edge]: at('44px') }, ...rest }));
25
+ }
package/lib/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export { DevSection, DOTS, useSessions } from './section.tsx';
30
30
  export { HostProvider } from './host.tsx';
31
31
  export { Slack, useWho, Who } from './foot.tsx';
32
32
  export { Grip, type GripProps } from '@hanzo/ui';
33
+ export { Gutter, type GutterProps } from './gutter.tsx';
33
34
  export { administers } from './claims.ts';
34
35
  export { Meter, Title } from './meter.tsx';
35
36
  export { Plan } from './settings/plan.tsx';
@@ -51,7 +52,7 @@ export { href, parse, path, PLACES, route, SESSION, SLUG, type Route, type Scree
51
52
  export { catalog, EFFORTS, FIRST, FREE, grants, isFree, keep, kept, nameOf, reasons, useCatalog, useChoice, useMind, type Choice, type Effort, type Mind } from './mind.ts';
52
53
  export { ASK, cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, trigger, Tune, useEar, useMention, useUpload, type Chip, type PromptOptions } from './prompt.tsx';
53
54
  export { Plus, type Link, type PlusProps, type Row as ShelfRow, type Shelf, type Switch } from './plus.tsx';
54
- export { BARE, gearOf, MODES, useGear, useLanguage, type Gear, type GearState, type Mode as GearMode } from './gear.ts';
55
+ export { BARE, CHAT_MODES, DEV_MODES, gearOf, MODES, useGear, useLanguage, type Gear, type GearState, type Mode as GearMode } from './gear.ts';
55
56
  export { useCarry, type Carry } from './carry.tsx';
56
57
  export { Dictation } from './mic.tsx';
57
58
  export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker, type Artifact, type Listen } from './reply.tsx';
package/lib/index.js CHANGED
@@ -30,6 +30,7 @@ export { DevSection, DOTS, useSessions } from './section.js';
30
30
  export { HostProvider } from './host.js';
31
31
  export { Slack, useWho, Who } from './foot.js';
32
32
  export { Grip } from '@hanzo/ui';
33
+ export { Gutter } from './gutter.js';
33
34
  export { administers } from './claims.js';
34
35
  export { Meter, Title } from './meter.js';
35
36
  export { Plan } from './settings/plan.js';
@@ -46,7 +47,7 @@ export { href, parse, path, PLACES, route, SESSION, SLUG } from './route.js';
46
47
  export { catalog, EFFORTS, FIRST, FREE, grants, isFree, keep, kept, nameOf, reasons, useCatalog, useChoice, useMind } from './mind.js';
47
48
  export { ASK, cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, trigger, Tune, useEar, useMention, useUpload } from './prompt.js';
48
49
  export { Plus } from './plus.js';
49
- export { BARE, gearOf, MODES, useGear, useLanguage } from './gear.js';
50
+ export { BARE, CHAT_MODES, DEV_MODES, gearOf, MODES, useGear, useLanguage } from './gear.js';
50
51
  export { useCarry } from './carry.js';
51
52
  export { Dictation } from './mic.js';
52
53
  export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker } from './reply.js';
package/lib/landing.js CHANGED
@@ -36,6 +36,7 @@ import { usePrefs } from './prefs.js';
36
36
  import { Publish } from './publish.js';
37
37
  import { path } from './route.js';
38
38
  import { useCarry } from './carry.js';
39
+ import { DEV_MODES } from './gear.js';
39
40
  import { Suggestions, useSuggestions } from './suggestions.js';
40
41
  import { MEASURE, Prompt, useEar } from './prompt.js';
41
42
  import { Monitor as Device } from '@hanzogui/lucide-icons-2';
@@ -134,8 +135,8 @@ export function Landing({ onStarted }) {
134
135
  }
135
136
  },
136
137
  go: (href) => (href.startsWith('http') ? host.open(href) : host.go(href)),
137
- });
138
- const mode = carry.gear.gear.mode === 'plan' ? 'plan' : 'build';
138
+ }, DEV_MODES);
139
+ const mode = carry.mode.value === 'plan' ? 'plan' : 'build';
139
140
  const hints = useSuggestions(t);
140
141
  // The chosen codebase's environment, so New can say when it has none yet.
141
142
  const codebase = isForge(kept.repo) ? kept.repo.name : '';
@@ -205,7 +206,13 @@ export function Landing({ onStarted }) {
205
206
  const prepped = carry.preparing ? await carry.prep({ text: draft.trim(), files: [], thread: '' }, new AbortController().signal) : {};
206
207
  // An answer of a plugin's own (a deck) is the answer: no run is started for it.
207
208
  if (prepped.reply) {
208
- setNote(prepped.reply);
209
+ // Its words, without the page it drew (Chat shows that in the side panel).
210
+ setNote(prepped.reply.split('```')[0].trim());
211
+ return;
212
+ }
213
+ // Manual: the run is asked for before it starts; it edits, commits and pushes on its own once it has.
214
+ if (!(await carry.allow({ id: 'run', name: 'Dev to start this run', note: 'It edits, tests, commits and pushes its own branch' }))) {
215
+ setNote('Not started.');
209
216
  return;
210
217
  }
211
218
  const run = await start(t, {
@@ -303,15 +310,16 @@ export function Landing({ onStarted }) {
303
310
  loading: places.loading,
304
311
  error: places.error?.message ?? null,
305
312
  };
306
- return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, items: "center", justify: "center", pb: "$10", overflowY: "auto", overflowX: "hidden", children: [_jsxs(YStack, { width: "100%", maxW: MEASURE, gap: "$5", children: [_jsx(SizableText, { render: "h1", fontSize: "$7", lineHeight: "$7", fontWeight: "700", color: "$ink", style: { textAlign: 'center' }, children: prefs.callName ? `What’s up next, ${prefs.callName}?` : 'What’s up next?' }), _jsxs(YStack, { gap: "$2", children: [stranger ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", role: "alert", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: `${codebase} is not one of this organization’s repositories.` }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setPicking(true), children: "Choose a repository" })] })) : env.value && (env.value.state === 'proposed' || unset(env.value)) && !place.id ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: env.value.state === 'proposed'
313
+ return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, items: "center", justify: "center", pb: "$10", overflowY: "auto", overflowX: "hidden", children: [_jsxs(YStack, { width: "100%", maxW: MEASURE, gap: "$3", children: [_jsx(SizableText, { render: "h1", fontSize: "$7", lineHeight: "$7", fontWeight: "700", color: "$ink", style: { textAlign: 'center' }, children: "Build what\u2019s next." }), _jsxs(YStack, { gap: "$2", children: [stranger ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", role: "alert", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: `${codebase} is not one of this organization’s repositories.` }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setPicking(true), children: "Choose a repository" })] })) : env.value && (env.value.state === 'proposed' || unset(env.value)) && !place.id ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: env.value.state === 'proposed'
307
314
  ? `A proposed environment for ${codebase} is waiting for review.`
308
315
  : `${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
316
  upload: () => pick(files, setFiles, setNote),
310
317
  skills: carry.skills,
318
+ design: carry.design,
311
319
  plugins: carry.plugins,
312
320
  devices,
313
321
  memory: { on: carry.gear.gear.memory, set: (on) => carry.gear.set({ memory: on }) },
314
- }, on: carry.on, slash: carry.skills, at: carry.plugins, mode: { value: carry.gear.gear.mode, set: (m) => carry.gear.set({ mode: m }) }, where: where, below: _jsx(Suggestions, { rows: hints.rows, onPick: setDraft, onDismiss: hints.dismiss }), mind: mind, limits: limits, onUpgrade: () => host.go(path({ kind: 'screen', screen: 'plans' })), voice: voice, language: prefs.language ?? null, onLanguage: (id) => void savePrefs({ language: id }), talk: talk })] })] }), _jsx(Publish, { source: adding, onClose: () => setAdding(null) }), carry.dialog, codebase ? (_jsx(SetupDialog, { repo: codebase, homes: homes, open: offering, onOpenChange: setOffering, onStart: setup, onRepo: choose, onSaved: () => {
322
+ }, on: carry.on, slash: carry.skills, at: carry.plugins, mode: carry.mode, where: where, below: _jsx(Suggestions, { rows: hints.rows, onPick: setDraft, onDismiss: hints.dismiss }), mind: mind, limits: limits, onUpgrade: () => host.go(path({ kind: 'screen', screen: 'plans' })), voice: voice, language: prefs.language ?? null, onLanguage: (id) => void savePrefs({ language: id }), talk: talk })] })] }), _jsx(Publish, { source: adding, onClose: () => setAdding(null) }), carry.dialog, codebase ? (_jsx(SetupDialog, { repo: codebase, homes: homes, open: offering, onOpenChange: setOffering, onStart: setup, onRepo: choose, onSaved: () => {
315
323
  setOffering(false);
316
324
  env.reload();
317
325
  } })) : null] }));
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
+ };
@@ -24,4 +24,4 @@ export declare function merge(preps: readonly Prep[]): Prep;
24
24
  * fails the message, with its name and its reason: a report asked for and not
25
25
  * researched must not arrive looking researched.
26
26
  */
27
- export declare function prep(plugins: readonly Plugin[], g: Gear, turn: Turn, c: Composer, signal: AbortSignal): Promise<Prep>;
27
+ export declare function prep(plugins: readonly Plugin[], g: Gear, turn: Turn, c: Composer, signal: AbortSignal, denied?: readonly string[]): Promise<Prep>;
@@ -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
  }
@@ -114,8 +116,10 @@ export function merge(preps) {
114
116
  * fails the message, with its name and its reason: a report asked for and not
115
117
  * researched must not arrive looking researched.
116
118
  */
117
- export async function prep(plugins, g, turn, c, signal) {
118
- const preps = await Promise.all(active(plugins, g).map((p) => p.prepare(turn, c, signal).catch((e) => {
119
+ export async function prep(plugins, g, turn, c, signal, denied = []) {
120
+ const preps = await Promise.all(active(plugins, g)
121
+ .filter((p) => !denied.includes(p.id))
122
+ .map((p) => p.prepare(turn, c, signal).catch((e) => {
119
123
  if (signal.aborted)
120
124
  throw e;
121
125
  throw new Error(`${p.name}: ${say(e)}`);
@@ -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/lib/project.js CHANGED
@@ -42,7 +42,7 @@ import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
42
42
  import { Clock, ExternalLink, MousePointerClick, PanelLeft, RefreshCcw, Share2, Upload, } from '@hanzogui/lucide-icons-2';
43
43
  import { Attachments, CHAT, Console, DEVICES, Feedback, FileTabs, FileTree, PageSelect, PreviewFrame, ProjectChip, VIEWS, Views, Workspace, fold, } from '@hanzo/ui/agents';
44
44
  import { Message } from '@hanzo/ui/chat';
45
- import { Button, Dialog, DialogContent, DialogTitle, Grip } from '@hanzo/ui';
45
+ import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
46
46
  import { HanzoMark } from '@hanzo/ui/product';
47
47
  import { useDictation, useVoice } from '@hanzo/voice';
48
48
  import { useEffect, useMemo, useRef, useState } from 'react';
@@ -57,6 +57,7 @@ import { decode, outcome, pull, said, scrub, who } from './api/turn.js';
57
57
  import { verdict as record } from './api/verdict.js';
58
58
  import { useKept, useProjects, useRead, useRun } from './data.js';
59
59
  import { Merge } from './git.js';
60
+ import { Gutter } from './gutter.js';
60
61
  import { useHost, useTarget } from './host.js';
61
62
  import { useMind } from './mind.js';
62
63
  import { Out } from './out.js';
@@ -416,7 +417,7 @@ export function Project({ at }) {
416
417
  ? `${named.org}/${named.name} is open on ${home}. Say what to change and it gets built there.`
417
418
  : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { "data-slot": "project-ask", px: "$3", pb: "$3", gap: "$2", minW: 0, $md: { pr: 0 }, children: [note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
418
419
  ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
419
- : undefined, busy: busy || (running && !draft.trim()), placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", plus: { upload: () => pick(attached, setAttached, setNote) }, mode: running ? null : { value: mode === 'plan' ? 'plan' : 'auto', set: (m) => setMode(m === 'plan' ? 'plan' : 'build') }, mind: running ? null : mind, voice: voice })] }), wide && !shut ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
420
+ : undefined, busy: busy || (running && !draft.trim()), placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", plus: { upload: () => pick(attached, setAttached, setNote) }, mode: running ? null : { value: mode === 'plan' ? 'plan' : 'auto', set: (m) => setMode(m === 'plan' ? 'plan' : 'build'), items: ['auto', 'plan'] }, mind: running ? null : mind, voice: voice })] }), wide && !shut ? (_jsx(Gutter, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", gap: GUTTER })) : null] }));
420
421
  // The preview stays loaded while another view is open, so coming back to it
421
422
  // is the page as it was left.
422
423
  const preview = (_jsxs(YStack, { "data-slot": "preview", flex: 1, minH: 0, minW: 0, gap: "$2", display: view === 'preview' ? 'flex' : 'none', children: [_jsxs(XStack, { "data-slot": "preview-bar", items: "center", gap: "$1.5", minW: 0, shrink: 0, "$max-md": { px: '$2' }, children: [_jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage, shrink: 1 }), _jsx(XStack, { flex: 1 }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: src, title: `${title} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: stalled
package/lib/prompt.d.ts CHANGED
@@ -73,6 +73,7 @@ export interface PromptOptions {
73
73
  mode?: {
74
74
  value: Mode;
75
75
  set: (m: Mode) => void;
76
+ items?: readonly Mode[];
76
77
  } | null;
77
78
  /** At the start of the row under the frame: Dev's where and which repository. */
78
79
  where?: ReactNode;
package/lib/prompt.js CHANGED
@@ -113,7 +113,7 @@ export function Tune({ mind, mode, where, onUpgrade }) {
113
113
  return (_jsxs(XStack, { "data-slot": "tune", width: "100%", items: "center", gap: 2, flexWrap: "wrap", rowGap: 4, children: [where ? (_jsx(XStack, { items: "center", gap: 2, flexWrap: "wrap", minW: 0, "$max-sm": { width: '100%' }, children: where })) : null, _jsx(XStack, { flex: 1, "$max-sm": { display: 'none' } }), _jsxs(XStack, { items: "center", gap: 2, ml: "auto", shrink: 0, children: [mind ? _jsx(Model, { mind: mind, onUpgrade: onUpgrade }) : null, mind && thinks && pace ? (_jsx(Opens, { label: `Effort: ${pace.label}`, word: pace.label, dim: true, children: (close) => (_jsx(Choices, { title: "Effort", items: EFFORTS, value: mind.effort, onPick: (e) => {
114
114
  mind.pace(e);
115
115
  close();
116
- } })) })) : null, mode ? (_jsxs(Popover, { allowFlip: true, placement: "top-end", children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(XStack, { render: "button", "data-slot": "mode", "aria-label": `Mode: ${MODES.find((m) => m.id === mode.value)?.label}`, items: "center", height: 24, px: 8, ml: 4, rounded: 6, bg: "$hover", cursor: "pointer", hoverStyle: { bg: '$raised' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, "$max-sm": { height: 44, px: 12 }, children: _jsx(SizableText, { fontSize: 14, color: "$ink", children: MODES.find((m) => m.id === mode.value)?.label }) }) }), _jsx(PopoverContent, { solid: true, p: 6, width: 288, maxW: "calc(100vw - 24px)", children: _jsx(Choices, { title: "Mode", items: MODES, value: mode.value, onPick: mode.set }) })] })) : null] })] }));
116
+ } })) })) : null, mode ? (_jsxs(Popover, { allowFlip: true, placement: "top-end", children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(XStack, { render: "button", "data-slot": "mode", "aria-label": `Mode: ${MODES.find((m) => m.id === mode.value)?.label}`, items: "center", height: 24, px: 8, ml: 4, rounded: 6, bg: "$hover", cursor: "pointer", hoverStyle: { bg: '$raised' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, "$max-sm": { height: 44, px: 12 }, children: _jsx(SizableText, { fontSize: 14, color: "$ink", children: MODES.find((m) => m.id === mode.value)?.label }) }) }), _jsx(PopoverContent, { solid: true, p: 6, width: 288, maxW: "calc(100vw - 24px)", children: _jsx(Choices, { title: "Mode", items: mode.items ? MODES.filter((m) => mode.items.includes(m.id)) : MODES, value: mode.value, onPick: mode.set }) })] })) : null] })] }));
117
117
  }
118
118
  /** The `/` or `@` the caret ends on, and the words typed after it. */
119
119
  export function trigger(value) {
package/lib/run.js CHANGED
@@ -25,7 +25,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
25
25
  */
26
26
  import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
27
27
  import { ChevronDown, ChevronRight, Clock, Copy, ExternalLink, GitPullRequest, Globe, Pencil, Share } from '@hanzogui/lucide-icons-2';
28
- import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Grip, Input } from '@hanzo/ui';
28
+ import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input } from '@hanzo/ui';
29
29
  import { Steer } from '@hanzo/ui/agents';
30
30
  import { Failure } from '@hanzo/ui/chat';
31
31
  import { useLimits } from '@hanzo/ui/product/useLimits';
@@ -41,6 +41,7 @@ import { Desk, SEED } from './desk.js';
41
41
  import { useHost, useTarget } from './host.js';
42
42
  import { useMind } from './mind.js';
43
43
  import { Out } from './out.js';
44
+ import { Gutter } from './gutter.js';
44
45
  import { gap, pane } from './pane.js';
45
46
  import { measure, Prompt, useEar } from './prompt.js';
46
47
  import { useSpoken } from './voice.js';
@@ -372,15 +373,9 @@ export function Run({ id }) {
372
373
  ...(busy || waiting ? ['pause', 'resume', 'stop'] : []),
373
374
  ] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null] }), _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: send, ready: Boolean(draft.trim()), disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', head: files.length ? _jsx(XStack, { gap: "$1.5", flexWrap: "wrap", children: _jsx(Files, { files: files, onFiles: setFiles }) }) : undefined, plus: { upload: () => pick(files, setFiles, setNote) },
374
375
  // What a new run from here is made with; words that steer this run use neither.
375
- mind: signed && !steering ? mind : null, limits: limits, voice: voice, note: "Hanzo is AI and can make mistakes." })] })] }), desk && !wide ? desk_(true) : null, desk && wide ? (_jsxs(YStack, { "data-slot": "desk", ref: split.aside, ...pane,
376
+ mind: signed && !steering ? mind : null, limits: limits, voice: voice })] })] }), desk && !wide ? desk_(true) : null, desk && wide ? (_jsxs(YStack, { "data-slot": "desk", ref: split.aside, ...pane,
376
377
  // A pane of its own beside the run's, the gutter between them.
377
- ml: gap, position: "relative", shrink: 0, minH: 0, style: split.asideStyle, children: [split.grip ? (_jsx(Grip, { ...split.grip, label: "Resize the side panel", bar: "inline",
378
- // The handle halfway down the edge, where a hand looks for it.
379
- justify: "center",
380
- // In the gutter between the two panes; `left` is measured inside the pane's 1px edge.
381
- l: `calc(-1 * ${gap} - ${pane.borderWidth}px)`, width: gap,
382
- // A finger's band, centred on the gutter.
383
- $touchable: { l: `calc(-22px - ${gap} / 2 - ${pane.borderWidth}px)`, width: 44 } })) : null, _jsx(SideProvider, { value: split.side, children: desk_(false) })] })) : null, _jsx(Dialog, { open: naming, onOpenChange: setNaming, children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Rename this run" }), _jsx(Input, { autoFocus: true, value: name, onChangeText: setName, "aria-label": "The run\u2019s name", maxLength: 512, onKeyDown: (e) => {
378
+ ml: gap, position: "relative", shrink: 0, minH: 0, style: split.asideStyle, children: [split.grip ? (_jsx(Gutter, { ...split.grip, label: "Resize the side panel", inset: pane.borderWidth })) : null, _jsx(SideProvider, { value: split.side, children: desk_(false) })] })) : null, _jsx(Dialog, { open: naming, onOpenChange: setNaming, children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Rename this run" }), _jsx(Input, { autoFocus: true, value: name, onChangeText: setName, "aria-label": "The run\u2019s name", maxLength: 512, onKeyDown: (e) => {
384
379
  if (e.key === 'Enter')
385
380
  save();
386
381
  } }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => setNaming(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: save, children: "Save" })] })] }) })] }));
package/lib/suggest.d.ts CHANGED
@@ -196,6 +196,10 @@ export declare function answer(raw: unknown, inputs: Input[]): Suggestion[];
196
196
  export declare function kept(raw: unknown): Kept | undefined;
197
197
  /** The rows to draw: those not dismissed. */
198
198
  export declare const shown: (k: Kept | null | undefined) => Suggestion[];
199
+ /** What Hanzo does, three ways in: what fills the rows a person's activity and signup do not. */
200
+ export declare const STARTERS: readonly Suggestion[];
201
+ /** The kept rows, filled to `MOST` with the starters not dismissed: never one lonely row. */
202
+ export declare const fill: (k: Kept | null | undefined) => Suggestion[];
199
203
  /** `k` with `id` dismissed: remembered first, the oldest forgotten past `REMEMBER`. */
200
204
  export declare function dismissed(k: Kept, id: string): Kept;
201
205
  /** The addresses and titles of dismissed rows the model is told to skip. */
package/lib/suggest.js CHANGED
@@ -190,9 +190,9 @@ const SYSTEM = [
190
190
  '- prompt: the message the person would send Hanzo to do the step, first person, complete on its own, naming the source and its address when it has one.',
191
191
  '- href: copied exactly from the input the row is about, or "" when that input has none. Never write an address that is not in the input.',
192
192
  '- icon: github for a github.com address, agent for a run or an automation, chat for a conversation, file for a file, task for any other issue or task.',
193
- 'With no activity, suggest from what the person told us at signup (their role, their work): first things to ask Hanzo, with href "".',
193
+ 'Answer three rows. Take them from the activity first; fill the rest from what the person told us at signup (their role, their work): first things to ask Hanzo, with href "".',
194
194
  'Never repeat an item listed under skip. Never suggest the same item twice.',
195
- 'If nothing is worth saying, answer {"rows": []}. Fewer good rows beat three weak ones.',
195
+ 'With neither activity nor anything from signup worth a row, answer fewer; never pad with a weak one.',
196
196
  ].join('\n');
197
197
  /** The chat completion that asks for rows, or null when there is nothing to make them from. */
198
198
  export function request(inputs, seed, skip, now) {
@@ -309,6 +309,19 @@ export function kept(raw) {
309
309
  }
310
310
  /** The rows to draw: those not dismissed. */
311
311
  export const shown = (k) => (k ? k.rows.filter((r) => !k.dismissed.includes(r.id)).slice(0, MOST) : []);
312
+ /** What Hanzo does, three ways in: what fills the rows a person's activity and signup do not. */
313
+ export const STARTERS = [
314
+ { id: 'start:chat', icon: 'chat', title: 'Think something through', href: '', summary: '', action: 'Ask Hanzo', prompt: 'Help me think through ' },
315
+ { id: 'start:dev', icon: 'agent', title: 'Build an app or a site', href: '', summary: '', action: 'Describe it', prompt: 'Build me ' },
316
+ { id: 'start:research', icon: 'file', title: 'Research a topic', href: '', summary: '', action: 'Get a cited brief', prompt: 'Research this and give me a short brief with sources: ' },
317
+ ];
318
+ /** The kept rows, filled to `MOST` with the starters not dismissed: never one lonely row. */
319
+ export const fill = (k) => {
320
+ if (!k)
321
+ return [];
322
+ const rows = shown(k);
323
+ return [...rows, ...STARTERS.filter((r) => !k.dismissed.includes(r.id))].slice(0, Math.max(rows.length, MOST));
324
+ };
312
325
  /** `k` with `id` dismissed: remembered first, the oldest forgotten past `REMEMBER`. */
313
326
  export function dismissed(k, id) {
314
327
  return { ...k, dismissed: [id, ...k.dismissed.filter((x) => x !== id)].slice(0, REMEMBER) };
@@ -1,7 +1,7 @@
1
1
  import { type Target } from './api/call.ts';
2
2
  import { type Suggestion } from './suggest.ts';
3
3
  export interface Suggested {
4
- /** The rows to draw: at most three, none dismissed. */
4
+ /** The rows to draw: three when any are, none dismissed. */
5
5
  rows: Suggestion[];
6
6
  /** Dismiss one, remembered by the account. */
7
7
  dismiss: (id: string) => void;
@@ -33,7 +33,7 @@ import { list } from './api/sessions.js';
33
33
  import { everything } from './api/work.js';
34
34
  import { claims } from './claims.js';
35
35
  import { Out } from './out.js';
36
- import { answer, dismissed, due, inputs, request, seed, shown, sig, skip, stale, TAKE } from './suggest.js';
36
+ import { answer, dismissed, due, fill, inputs, request, seed, sig, skip, stale, TAKE } from './suggest.js';
37
37
  /** A source that refuses this person (a 4xx other than an expired sign-in or a rate) has nothing for them; any other failure leaves it unknown. */
38
38
  function soft(p, none) {
39
39
  return p.catch((e) => {
@@ -120,7 +120,8 @@ export function useSuggestions(t) {
120
120
  load(t).then(() => setError(null), (e) => setError(said(e)));
121
121
  }, [t]);
122
122
  const now = useSyncExternalStore(subscribe, () => doc, () => doc);
123
- const rows = useMemo(() => shown(now), [now]);
123
+ // Filled to three with what Hanzo does, so a new account and a quiet one never see one lonely row.
124
+ const rows = useMemo(() => fill(now), [now]);
124
125
  const dismiss = useCallback((id) => {
125
126
  if (!doc)
126
127
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.68",
3
+ "version": "0.2.70",
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.",