Skip to content
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ description: "Render structured interactive UI inline in your reply via the dsh-
**本地优先(v2.6)**:UI 自己能做的状态变化——判卷、判题、重置、展开、选中——一律本地即时完成,**零模型往返**。action 只用于必须模型参与的事(生成新内容、执行工具、下一步建议)。**交互组件必须带 action:不带 action 的按钮渲染为禁用态,用户点不了;带 action 的按钮点击后有「已触发」本地反馈。**
- button: `{"type":"button","label":"...","tone":"primary|danger|success|ghost","full":true?,"small":true?,"icon":"emoji?","action":"refresh"?}`
- **秘密禁令**:不得索取或生成密码、API Key、访问令牌、恢复码等秘密输入;遇到此类需求直接拒绝并解释
- input: `{"type":"input","label":"...","placeholder":"...","inputType":"text|email","value":"...","action":"name"?,"id":"field-id"?}` — action 在失焦**和回车**时触发(回车带 `submit:true`);**blur 仅值有变化才发送**(聚焦又离开不产生空往返);payload 带 `id` 帮模型定位字段;带 `id` 的值刷新后保留、并被 submit 收集进 `fields`
- input: `{"type":"input","label":"...","placeholder":"...","inputType":"text|email|color","value":"...","action":"name"?,"id":"field-id"?}` — `color` 使用浏览器原生取色器,值使用 `#RRGGBB`;action 在失焦**和回车**时触发(回车带 `submit:true`);**blur 仅值有变化才发送**(聚焦又离开不产生空往返);payload 带 `id` 帮模型定位字段;带 `id` 的值刷新后保留、并被 submit 收集进 `fields`
- select: `{"type":"select","label":"...","options":["...","..."],"selected":下标?,"action":"pick"?,"id":"field-id"?}` — `selected` 预选某选项(缺省显示「请选择…」占位,不静默预选第一项);带 `id` 的选择跨刷新保留并进 submit 的 `fields`
- checkbox: `{"type":"checkbox","label":"...","checked":true?,"action":"toggle"?}`
- slider: `{"type":"slider","label":"...","min":0,"max":100,"step":1,"value":n?,"action":"name"?,"id":"field-id"?}` — 数值表单滑块:实时显示数值;带 `id` 跨刷新保留并进 submit 的 `fields`(拖拽经防抖合并成一次 action)
Expand Down
23 changes: 18 additions & 5 deletions src/client/blocks/forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,13 @@ export function InputNode({ node, onAction, answers }: {
const restored = !secret && id !== undefined ? answers?.fields[id] : undefined
// Initial value: durable state wins over the spec default. Secrets always
// restore as blank so a password can never survive a refresh.
const [value, setValue] = useState<string>(() =>
secret ? '' : (restored ?? node.value ?? ''))
const [value, setValue] = useState<string>(() => {
const initial = secret ? '' : (restored ?? node.value ?? '')
// Match the native color input's opaque hex value, including its black default.
return node.inputType === 'color'
? /^#[0-9a-f]{6}$/i.test(initial) ? initial.toLowerCase() : '#000000'
: initial
})
// Last value actually DELIVERED to the model: blur only sends when the
// value changed since the last delivery (a focus-in/focus-out with no edit
// used to fire a pointless action round trip). Seeded with the mount value
Expand All @@ -413,13 +418,13 @@ export function InputNode({ node, onAction, answers }: {
}
}
const ime = useImeComposing()
// Register a non-blank spec default only when no durable value already exists.
// Register the displayed initial value, including the native color default.
const mounted = useRef(false)
useEffect(() => {
if (mounted.current) return
mounted.current = true
if (!secret && id !== undefined && restored === undefined && node.value !== undefined && node.value.trim() !== '') {
answers?.setField(id, node.value)
if (!secret && id !== undefined && value.trim() !== '') {
answers?.setField(id, value)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
Expand All @@ -434,6 +439,14 @@ export function InputNode({ node, onAction, answers }: {
<input
className={css.input}
type={node.inputType ?? 'text'}
style={node.inputType === 'color' ? {
width: 64,
height: 40,
padding: 4,
boxSizing: 'border-box',
alignSelf: 'flex-start',
cursor: 'pointer',
} : undefined}
placeholder={node.placeholder}
value={value}
onChange={e => {
Expand Down
2 changes: 1 addition & 1 deletion src/client/genui-runtime/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface ComponentSchema {
export const TEXT_SIZES = ['h1', 'h2', 'h3', 'body', 'muted', 'caption'] as const
export const BUTTON_TONES = ['primary', 'danger', 'success', 'ghost'] as const
export const BADGE_TONES = ['success', 'warn', 'danger', 'accent'] as const
export const INPUT_TYPES = ['text', 'email', 'password'] as const
export const INPUT_TYPES = ['text', 'email', 'password', 'color'] as const
export const CALLOUT_TONES = ['info', 'success', 'warning', 'error'] as const
export const CHART_KINDS = ['bars', 'line', 'donut'] as const
export const PLOT_KINDS = ['line', 'area', 'scatter'] as const
Expand Down
2 changes: 1 addition & 1 deletion src/client/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export interface GenuiInput {
label?: string
placeholder?: string
value?: string
inputType?: 'text' | 'email' | 'password'
inputType?: 'text' | 'email' | 'password' | 'color'
/** v2: when set, interaction sends this action back to the model. */
action?: string
/**
Expand Down
101 changes: 101 additions & 0 deletions tests/input-color.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GenuiActionContext } from '../src/client/action-context.ts'
import { GENUI_ACTION_DEBOUNCE_MS } from '../src/client/GenuiBlock.tsx'
import { GenuiBlock } from '../src/client/GenuiBlock.tsx'
import { repairGenuiSpec, validateGenuiSpec } from '../src/client/guard.ts'

afterEach(() => {
cleanup()
vi.useRealTimers()
localStorage.clear()
})

beforeEach(() => {
vi.useFakeTimers()
})

describe('input color runtime schema', () => {
it('accepts and preserves inputType=color', () => {
const spec = { items: [{ type: 'input', inputType: 'color' }] }

expect(validateGenuiSpec(spec)).toEqual({ ok: true, errors: [] })
expect(repairGenuiSpec(spec)?.items[0]).toMatchObject({
type: 'input',
inputType: 'color',
})
})

it('continues to reject unsupported input types', () => {
const spec = { items: [{ type: 'input', inputType: 'date' }] }

expect(validateGenuiSpec(spec).ok).toBe(false)
expect(repairGenuiSpec(spec)?.items[0]).toEqual({ type: 'input' })
})
})

describe('input color rendering and submit collection', () => {
it('renders the native color input and collects its value into fields', () => {
const onAction = vi.fn()
const spec = {
items: [
{ type: 'input', inputType: 'color', label: '主色', id: 'c_primary', value: '#1a3a5c' },
{ type: 'submit', label: '提交', action: 'send' },
],
}

render(
<GenuiActionContext.Provider value={onAction}>
<GenuiBlock spec={spec as never} />
</GenuiActionContext.Provider>,
)

const input = screen.getByLabelText('主色') as HTMLInputElement
expect(input.type).toBe('color')
expect(input.value).toBe('#1a3a5c')

fireEvent.change(input, { target: { value: '#ff0000' } })
expect(input.value).toBe('#ff0000')

fireEvent.click(screen.getByRole('button', { name: '提交' }))
vi.advanceTimersByTime(GENUI_ACTION_DEBOUNCE_MS)

expect(onAction).toHaveBeenCalledWith('send', expect.objectContaining({
fields: { c_primary: '#ff0000' },
}))
})
it.each([undefined, '', 'red', '#ABCDEF'])('submits the displayed initial color %s', initial => {
const onAction = vi.fn()
const expected = initial === '#ABCDEF' ? '#abcdef' : '#000000'
const spec = { items: [
{ type: 'input', inputType: 'color', label: '主色', id: 'c_primary', value: initial },
{ type: 'submit', label: '提交', action: 'send' },
] }
render(<GenuiActionContext.Provider value={onAction}><GenuiBlock spec={spec as never} /></GenuiActionContext.Provider>)
expect((screen.getByLabelText('主色') as HTMLInputElement).value).toBe(expected)
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '提交' }))
vi.advanceTimersByTime(GENUI_ACTION_DEBOUNCE_MS)
expect(onAction).toHaveBeenCalledWith('send', expect.objectContaining({ fields: { c_primary: expected } }))
})

it('restores the user color ahead of the model default', () => {
const onAction = vi.fn()
const spec = { items: [
{ type: 'input', inputType: 'color', label: '主色', id: 'c_primary', value: '#112233' },
{ type: 'submit', label: '提交', action: 'send' },
] }
const panel = <GenuiActionContext.Provider value={onAction}><GenuiBlock spec={spec as never} stateKey="color-restore" /></GenuiActionContext.Provider>
const first = render(panel)
fireEvent.change(screen.getByLabelText('主色'), { target: { value: '#ff0000' } })
vi.advanceTimersByTime(300)
first.unmount()
render(panel)
expect((screen.getByLabelText('主色') as HTMLInputElement).value).toBe('#ff0000')
fireEvent.click(screen.getByRole('button', { name: '提交' }))
vi.advanceTimersByTime(GENUI_ACTION_DEBOUNCE_MS)
expect(onAction).toHaveBeenCalledWith('send', expect.objectContaining({ fields: { c_primary: '#ff0000' } }))
})

})
Loading