@joplo-app/plugin-ui 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.d.ts +2 -2
- package/dist/catalog.js +17 -1
- package/dist/components.d.ts +4 -0
- package/dist/components.js +4 -0
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +8 -0
- package/dist/plugin.js +27 -4
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +109 -2
- package/dist/version.json +1 -1
- package/package.json +7 -2
package/dist/catalog.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 只换词汇,不换渲染:运行时不引入 Mantine,仍由 Joplo 渲染成低保真风格。
|
|
8
8
|
*/
|
|
9
9
|
export declare const HTML_TAGS: readonly string[];
|
|
10
|
-
export declare const COMPONENT_TYPES: readonly ["AppShell", "AppShell.Header", "AppShell.Navbar", "AppShell.Main", "AppShell.Aside", "AppShell.Footer", "Container", "Stack", "Group", "Flex", "Grid", "Grid.Col", "SimpleGrid", "Center", "Box", "Space", "Paper", "Card", "Card.Section", "Divider", "ScrollArea", "Title", "Text", "Code", "Kbd", "Blockquote", "List", "List.Item", "Badge", "Avatar", "Image", "ThemeIcon", "Indicator", "Timeline", "Timeline.Item", "Table", "Table.Thead", "Table.Tbody", "Table.Tr", "Table.Th", "Table.Td", "DataTable", "Accordion", "Accordion.Item", "Accordion.Panel", "Radio", "Tabs.List", "Tabs.Panel", "Breadcrumbs", "Stepper", "Stepper.Step", "Alert", "Loader", "Progress", "RingProgress", "Skeleton", "Notification", "Modal", "Drawer", "ChatMessage", "ToolCall", "Thinking", "Artifact", "Email", "DataCard", "StateFlow", "Terminal", "Terminal.Command", "Terminal.Output", "Phone", "Avatar.Group", "Radio.Card", "EmptyState", "ListRow", "Stat", "KeyValue", "BarChart", "LineChart", "Menu", "Menu.Target", "Menu.Dropdown", "Menu.Label", "Menu.Divider", "div", "span", "p", "h1", "h2", "h3", "h4", "main", "section", "header", "footer", "nav", "aside", "ul", "ol", "li", "strong", "em", "small", "code", "Placeholder", "Button", "ActionIcon", "CloseButton", "Anchor", "a", "NavLink", "Burger", "TextInput", "PasswordInput", "NumberInput", "Textarea", "Select", "MultiSelect", "Checkbox", "Radio.Group", "Switch", "SegmentedControl", "FileInput", "Tabs", "Tabs.Tab", "Accordion.Control", "Pagination", "PromptInput", "Terminal.Input", "Artifact.Ref", "Menu.Item", "TabBar"];
|
|
10
|
+
export declare const COMPONENT_TYPES: readonly ["AppShell", "AppShell.Header", "AppShell.Navbar", "AppShell.Main", "AppShell.Aside", "AppShell.Footer", "Container", "Stack", "Group", "Flex", "Grid", "Grid.Col", "SimpleGrid", "Center", "Box", "Space", "Paper", "Card", "Card.Section", "Divider", "ScrollArea", "Title", "Text", "Code", "Kbd", "Blockquote", "List", "List.Item", "Badge", "Avatar", "Image", "ThemeIcon", "Indicator", "Timeline", "Timeline.Item", "Table", "Table.Thead", "Table.Tbody", "Table.Tr", "Table.Th", "Table.Td", "DataTable", "Accordion", "Accordion.Item", "Accordion.Panel", "Radio", "Tabs.List", "Tabs.Panel", "Breadcrumbs", "Stepper", "Stepper.Step", "Alert", "Loader", "Progress", "RingProgress", "Skeleton", "Notification", "Modal", "Drawer", "ChatMessage", "ToolCall", "Thinking", "Artifact", "Email", "DataCard", "StateFlow", "Terminal", "Terminal.Command", "Terminal.Output", "Phone", "Avatar.Group", "Radio.Card", "EmptyState", "ListRow", "Stat", "KeyValue", "BarChart", "LineChart", "Menu", "Menu.Target", "Menu.Dropdown", "Menu.Label", "Menu.Divider", "div", "span", "p", "h1", "h2", "h3", "h4", "main", "section", "header", "footer", "nav", "aside", "ul", "ol", "li", "strong", "em", "small", "code", "Placeholder", "Board", "BrowserWindow", "Note", "Arrow", "Button", "ActionIcon", "CloseButton", "Anchor", "a", "NavLink", "Burger", "TextInput", "PasswordInput", "NumberInput", "Textarea", "Select", "MultiSelect", "Checkbox", "Radio.Group", "Switch", "SegmentedControl", "FileInput", "Tabs", "Tabs.Tab", "Accordion.Control", "Pagination", "PromptInput", "Terminal.Input", "Artifact.Ref", "Menu.Item", "TabBar"];
|
|
11
11
|
export type ComponentType = (typeof COMPONENT_TYPES)[number];
|
|
12
12
|
export declare const INTERACTIVE_TYPES: ReadonlySet<string>;
|
|
13
13
|
/** 可接收输入值;type/select/toggle 动作写入 state.inputs[nodeId]。 */
|
|
@@ -19,7 +19,7 @@ export declare const VALUE_TYPES: ReadonlySet<string>;
|
|
|
19
19
|
export declare const GATED_CONTAINERS: ReadonlySet<string>;
|
|
20
20
|
/**
|
|
21
21
|
* 每个组件一行常用属性提示(`joplo skill components` 由此生成)。所有组件另外都接受:
|
|
22
|
-
* 样式属性 m/mt/mb/ml/mr/mx/my、p/pt/pb/pl/pr/px/py、w/h/miw/maw/mih/mah、c/bg/fz/fw/ta/ff/lh/opacity,
|
|
22
|
+
* 样式属性 m/mt/mb/ml/mr/mx/my、p/pt/pb/pl/pr/px/py、w/h/miw/maw/mih/mah、pos/top/left/right/bottom、c/bg/fz/fw/ta/ff/lh/opacity,
|
|
23
23
|
* 响应式写成 { base, xs, sm, md, lg, xl };visibleFrom / hiddenFrom;以及 Joplo 属性 id/when/disabled/disabledReason/unmodeled/note。
|
|
24
24
|
*/
|
|
25
25
|
export declare const COMPONENT_DOCS: Record<ComponentType, string>;
|
package/dist/catalog.js
CHANGED
|
@@ -36,6 +36,8 @@ const STRUCTURE = [
|
|
|
36
36
|
...HTML,
|
|
37
37
|
// Joplo
|
|
38
38
|
'Placeholder', // props: label, h —— 图片/图表等占位
|
|
39
|
+
// 白板(D96):一块白板上摆多屏画面,箭头连成流程,便签写说明
|
|
40
|
+
'Board', 'BrowserWindow', 'Note', 'Arrow',
|
|
39
41
|
];
|
|
40
42
|
/** 看起来可操作:必须有演示行为、明确禁用并说明原因,或标记 unmodeled。 */
|
|
41
43
|
const INTERACTIVE = [
|
|
@@ -60,7 +62,7 @@ export const VALUE_TYPES = new Set([
|
|
|
60
62
|
export const GATED_CONTAINERS = new Set(['Modal', 'Drawer', 'Menu.Dropdown']);
|
|
61
63
|
/**
|
|
62
64
|
* 每个组件一行常用属性提示(`joplo skill components` 由此生成)。所有组件另外都接受:
|
|
63
|
-
* 样式属性 m/mt/mb/ml/mr/mx/my、p/pt/pb/pl/pr/px/py、w/h/miw/maw/mih/mah、c/bg/fz/fw/ta/ff/lh/opacity,
|
|
65
|
+
* 样式属性 m/mt/mb/ml/mr/mx/my、p/pt/pb/pl/pr/px/py、w/h/miw/maw/mih/mah、pos/top/left/right/bottom、c/bg/fz/fw/ta/ff/lh/opacity,
|
|
64
66
|
* 响应式写成 { base, xs, sm, md, lg, xl };visibleFrom / hiddenFrom;以及 Joplo 属性 id/when/disabled/disabledReason/unmodeled/note。
|
|
65
67
|
*/
|
|
66
68
|
export const COMPONENT_DOCS = {
|
|
@@ -168,6 +170,10 @@ export const COMPONENT_DOCS = {
|
|
|
168
170
|
'Menu.Label': 'Section label inside Menu.Dropdown',
|
|
169
171
|
'Menu.Divider': 'Divider inside Menu.Dropdown',
|
|
170
172
|
Placeholder: 'label, h; placeholder for images, charts, etc.',
|
|
173
|
+
Board: 'dots (dotted paper, default true); a whiteboard: place screens, notes and arrows on it with pos="absolute" top / left (design px). Put it inside content:Canvas to move the camera between screens',
|
|
174
|
+
BrowserWindow: 'url, label (caption above the frame, e.g. "1. Sign in"), w, h; a browser window frame for one screen on a Board; children are its page',
|
|
175
|
+
Note: 'color (yellow default; pink, blue, green, gray), rotate (degrees, default -2), w; a sticky note that stays on the Board; children = text',
|
|
176
|
+
Arrow: 'from, to (ids of elements on the same Board), label, bend (-1…1, default 0.25), dashed, color; a persistent arrow between two elements; direct child of Board, reveal it with when',
|
|
171
177
|
Button: 'variant: filled|light|outline|subtle|default|white, color, size, fullWidth, loading, leftSection / rightSection (character)',
|
|
172
178
|
ActionIcon: "Icon button: aria-label (required as name), variant, color, size; one character child such as '+'",
|
|
173
179
|
CloseButton: 'aria-label, size',
|
|
@@ -201,6 +207,16 @@ export const COMPONENT_DOCS = {
|
|
|
201
207
|
* 只给最容易写错、或需要和步骤配合的组件;其余组件看 COMPONENT_DOCS 的一行提示就够。
|
|
202
208
|
*/
|
|
203
209
|
export const COMPONENT_EXAMPLES = {
|
|
210
|
+
Board: `<Canvas w={2400} h={1400}>
|
|
211
|
+
<Board>
|
|
212
|
+
<BrowserWindow id="login" label="1. Sign in" url="app.com/login" pos="absolute" top={200} left={120} w={520} h={400}>
|
|
213
|
+
<Stack p="lg"><TextInput id="email" label="Email" /><Button id="send">Send link</Button></Stack>
|
|
214
|
+
</BrowserWindow>
|
|
215
|
+
<BrowserWindow id="sent" label="2. Check email" pos="absolute" top={200} left={800} w={520} h={400} when={$.ui.sent}>…</BrowserWindow>
|
|
216
|
+
<Arrow id="a_send" from="send" to="sent" label="send" when={$.ui.sent} />
|
|
217
|
+
<Note id="n_privacy" pos="absolute" top={660} left={140} color="pink">Unknown email? Still say "sent"</Note>
|
|
218
|
+
</Board>
|
|
219
|
+
</Canvas>`,
|
|
204
220
|
EmptyState: `<EmptyState icon="✉" title="No members yet" description="Invite teammates to start working together">
|
|
205
221
|
<Button id="invite_first">Invite members</Button>
|
|
206
222
|
</EmptyState>`,
|
package/dist/components.d.ts
CHANGED
|
@@ -148,3 +148,7 @@ export declare const Menu: ((props: JoploProps) => any) & {
|
|
|
148
148
|
/** 手机底部标签栏:`<TabBar id="tabs" aria-label="底部导航" value={$.ui.tab} data={[{ value: 'home', label: '首页', icon: '⌂' }]} />`,select 切换。 */
|
|
149
149
|
export declare const TabBar: (props: JoploProps) => any;
|
|
150
150
|
export declare const Placeholder: (props: JoploProps) => any;
|
|
151
|
+
export declare const Board: (props: JoploProps) => any;
|
|
152
|
+
export declare const BrowserWindow: (props: JoploProps) => any;
|
|
153
|
+
export declare const Note: (props: JoploProps) => any;
|
|
154
|
+
export declare const Arrow: (props: JoploProps) => any;
|
package/dist/components.js
CHANGED
|
@@ -127,3 +127,7 @@ export const Menu = Object.assign(c('Menu'), { Target: c('Menu.Target'), Dropdow
|
|
|
127
127
|
export const TabBar = c('TabBar');
|
|
128
128
|
// ---------- Joplo ----------
|
|
129
129
|
export const Placeholder = c('Placeholder');
|
|
130
|
+
export const Board = c('Board');
|
|
131
|
+
export const BrowserWindow = c('BrowserWindow');
|
|
132
|
+
export const Note = c('Note');
|
|
133
|
+
export const Arrow = c('Arrow');
|
package/dist/guide.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export declare const skill = "# Standard UI: ui\n\nUse this plugin for application interfaces, forms, overlays, navigation, data displays, device frames and HTML fallback content. Install @joplo-app/plugin-ui, import ui from it and list ui in config.plugins. The engine enables nothing implicitly.\n\n## Start and verify\n- Import author markers directly from @joplo-app/plugin-ui and write normal TSX. The compiler emits ui:Button and other namespaced types; write HTML tags directly, which compile to ui:div, ui:span, etc.\n- Read joplo skill ui/components for the enabled catalog; joplo components Menu DataTable gives props, behavior groups and runnable author snippets. If two plugins share a short name, request the full namespaced name.\n- Give interactive elements stable ids. Describe their behavior with flow steps, disabled plus disabledReason, or unmodeled. Run check, then render the affected steps; passing schema validation alone does not prove the intended screen is visible.\n\n## State and visibility\n- type / select / toggle steps write inputs[id]. Use value, checked or defaultValue for initial visuals. Paused audiences can type (including multiline PromptInput), change Switch/Checkbox/Radio/Select/SegmentedControl, switch Tabs and expand Accordion locally; value/checked bindings synchronize local views. Playing, jumping or Reset discards these edits. A matching next scripted action wins; an action in another reachable scenario asks to switch.\n- Select accepts the displayed label or stored value; MultiSelect toggles membership. For a bound option list, ensure the list exists before the select step.\n- Modal and Drawer use opened, only; the old open alias is not supported. Menu forwards opened to its own dropdown; an explicitly set child value wins, and a nested menu owns its own state.\n- AppShell regions and Phone child width use the same declared layout data as playback. A narrow screen can hide a target even when when is true; check the actual viewport before changing the step.\n- Image accepts embedded project assets via asset('./photo.jpg'). URLs and ordinary paths intentionally draw a placeholder; fix IMAGE_PLACEHOLDER by embedding the file. fit accepts cover or contain.\n- Display properties are declared per component for translation. Identifiers, structural values and unrelated props are not automatically display text; for custom content, declare text or exact textPaths traits in its own plugin. For nested data, target titles/labels precisely; do not translate an entire configuration object containing colors or identifiers.\n\n## Migration and recovery\n- Plugin unavailable placeholders indicate a missing or mismatched runtime: reinstall matching package versions and rerun build. The ui catalog, runtime and author markers are checked together by the release smoke test. Runtime imports must be production dependencies or explicit shared host services: a working workspace or prebuilt web bundle does not prove the package runtime can load after a clean install.\n\nRelated topics: components, authoring, focus, check, plugins\n";
|
|
2
|
-
export declare const patterns = "# UI recipes and pitfalls\n\n## A prompt the audience can actually edit\nPromptInput renders a native textarea. Arbitrary text is disposable exploration and follows the same first-input pause, bound value synchronization and reset/resume rules as other text fields. Captures and disabled prompts remain read-only. Keep submission in a declared click/press step or free reaction; editing alone does not call a model or send anything. Test a non-script phrase with a real keyboard, because replayed .type() does not prove that audience input works.\n\n## Readable period interfaces\nMake the working interface the subject: enlarge input text, action targets and visible results together, rather than using a large year beside a tiny window. A compact chapter label and one source line can carry context; keep the full attribution/license inventory with the assets.\n\nFor a collected CSS device frame, retain only licensed generic shell parts and record the adaptations; a later frame library is not evidence of a historical model. When CSS or fonts are embedded into an offline export, retain the applicable full license notices in that exported asset as well as in the repository. Scope and match the original selector specificity when resizing a speaker or button, otherwise an apparently correct override can lose to a library descendant rule. Use the rendered control dimensions to verify the override.\n\nHTML text nodes may be wrapped in spans by this renderer. Avoid blanket descendant-span hiding to remove one note: it can also hide the main text. Remove the unwanted note from the authored tree or give that specific element a class. Device children with z-index can also overpaint a later sibling: give a transition scrim and its focal object explicit stacking levels and inspect the real pixels.\n\n## Styling a collected interface library\nVendor only the required, redistributable CSS primitives; scope them under an authored root class. Exclude unverified bundled fonts, icon files and logos rather than treating the stylesheet's license as permission for all assets. Record the exact extraction and selector changes alongside retained licenses.\n\nChoose --jp-stroke: clean in a project theme when CSS supplies historical borders and button bevels. UI components supply inline defaults for size, padding and color; a class alone may not win. Prefer documented props or explicit style overrides for ordinary cases. When a reusable vendor theme must own these values, narrowly scoped !important declarations can override just those defaults. Check computed styles, the actual rendered button and text contrast; do not globally change --jp-ink merely to tint a button.\n\nNative inputs are the editable surface; their displayed text is not a real backend. A local send reaction can set ui.sent while the result bubble reads inputs.<fieldId>, so the audience sees their own text without copying it into script data. Use a named option.free object, give each reaction a stable key, and retain the deterministic send effects in a scenario. Verify the same reaction in the actual built site as well as the board; a valid script does not prove that publication retained local reaction data. Test a non-script phrase: an exact next action can commit the script instead, and an exact action belonging to another scenario can prompt to switch.\n\n\nThese examples use this explicitly enabled plugin. Engine flow/state/review rules remain in the engine skill.\n\n## Controls over a visual story\n- Use Image with asset() for the actual downloaded artwork; keep source/creator/license/modification details beside the files. CSS crop/opacity changes still belong in that record. Do not use a remote URL as an offline-image substitute.\n- On a dark art background, Button's default filled variant may draw a Rough fill over your custom CSS. Use variant=\"subtle\" when authoring the whole surface, set text size/color explicitly, and choose --jp-stroke: clean in the project theme when appropriate. A class does not automatically override component inline font/color values. Verify contrast, focus and hitbox after the final composition.\n- Hover and dragging need a visible click alternative. A discrete timeline can use one draggable handle plus labeled endpoint buttons; keep the effects identical in both paths. Use a large enough drop zone and test it after the Canvas scales, not only at design resolution.\n\n### authoring: worked example\n```tsx\nimport { $, done, flow, option, page, set } from '@joplo-app/kit';\nimport { Alert, Button, Loader, Stack, TextInput } from '@joplo-app/plugin-ui';\nconst send = (name: string, cases: string[]) => flow(name, { start: '/reset', cases })\n .type('email', 'ana@exmple.com').click('send', 'Send the reset link', set($.ui.sending, true));\nexport default option({\n name: 'A \u00B7 Email link', idea: 'Send a reset link by email',\n state: { ui: { sending: false, sent: false, emailError: '' } },\n routes: { '/reset': page({ title: 'Reset password' }, <Stack>\n <TextInput id=\"email\" label=\"Email\" error={$.ui.emailError} />\n <Button id=\"send\" disabled={$.ui.sending} disabledReason=\"Sending\u2026\">Send link</Button>\n <Loader when={$.ui.sending} />\n <Alert id=\"sent\" color=\"green\" when={$.ui.sent}>Check your inbox</Alert>\n </Stack>) },\n scenarios: {\n unknown_email: send('Typo, then retry', ['unknown_email'])\n .system('No account for this email', set($.ui.sending, false), set($.ui.emailError, 'No account with this email'))\n .type('email', 'ana@example.com', set($.ui.emailError, ''))\n .click('send', 'Retry', set($.ui.sending, true))\n .system('Server accepts', set($.ui.sending, false), set($.ui.sent, true), done('Link sent after retry')),\n },\n});\n```\n\nFrom authoring:\nNative UI controls need no free declaration: Tabs, Accordion, text inputs, Switch, Checkbox, Radio, Select and SegmentedControl change local values. A bound checked/value prop updates locally too, keeping two views of the same state synchronized. Keep the scripted result in step effects; do not store audience input or credentials in the source.\n\nFrom authoring:\n- layouts/app.tsx: export default layout(<AppShell header={{ height: 56 }} navbar={{ width: 240, breakpoint: 'sm' }}><AppShell.Navbar>\u2026</AppShell.Navbar><AppShell.Main><Outlet/></AppShell.Main><Slot name=\"overlay\"/></AppShell>).\n\nFrom authoring:\n- asset('./photo.jpg'): reads an image next to the calling file and returns a data URI for <Image src> or a CSS backgroundImage (see components).\n\nFrom authoring:\n- on: { '/members': { actions: <Button id=\"invite\">Invite</Button> } }: add elements to a slot on an existing route.\n\nFrom authoring:\n- routes: { '/members/new': page({ title: 'New member' }, <Stack>\u2026</Stack>) }: add a route for this option.\n\nFrom authoring:\n- Props can bind: <TextInput id=\"email\" error={$.ui.emailError}/>, <Text>{$.data.name}</Text>.\n\nFrom authoring:\n- Overlays: <Modal id=\"dlg\" opened={$.ui.open} title=\"\u2026\">; Drawer works the same way. Only opened controls visibility.\n\nFrom authoring:\n- Use Stack / Group / SimpleGrid / Grid props: <Group justify=\"space-between\">, <SimpleGrid cols={{ base: 1, md: 3 }}>.\n\nFrom authoring:\n- Use responsive objects { base, xs, sm, md, lg, xl }, or visibleFrom=\"sm\" / hiddenFrom=\"sm\". AppShell collapses navigation below navbar.breakpoint.\n\nFrom authoring:\n- Set input labels directly: <TextInput id=\"email\" label=\"Email\"/>. Auto captions use them.\n\nFrom demo:\n- Drag begins on a declared drag target before stage pan/plugin orbit; Enter or Space on that target provides the scripted drag alternative. Pointerdown pauses immediately and lets the same drag finish. Hover never pauses playback, but matches normally while paused; touch activates hover by tapping and follows the pause-and-apply rule. Editable input keeps its first key, Space and arrow keys. The first wheel event pauses and scrolls; keep its inertia in the scroll area before stage zoom.\n\nFrom demo:\n- Each page has one device, desktop by default. page({ viewport: 'mobile' }, tree) uses a Phone frame at a fixed width of 390.\n\nFrom demo:\n- To show a phone turned sideways (a landscape app, a video player), write <Phone landscape> in a layer: the screen is 772 \u00D7 358 with the notch on the left.\n\nFrom demo:\n- Each layer tab shows an icon so viewers can tell layers apart when many tabs are squeezed: layer('Terminal', <Cli/>, { icon: 'terminal' }). Presets: window, browser, mobile, desktop, terminal, chat, agent, server, data, email, doc, flow. Default: mobile for mobile layers, otherwise window.\n\nFrom demo:\n <Grid h=\"100%\"><Grid.Col span={6}><Stack h=\"100%\"><ScrollArea style={{ flex: 1, minHeight: 0 }}>Messages</ScrollArea>\n\nFrom demo:\n <Group><PromptInput id=\"prompt\"/><Button id=\"send\">Send</Button></Group></Stack></Grid.Col></Grid>\n\nFrom demo:\n- The same applies to placeholders and artifacts: use h=\"100%\" on Placeholder; Artifact fills its container by default.\n\nFrom demo:\n- What gets squeezed: in an h=\"100%\" column, a Table, DataTable, or ScrollArea shrinks to the space left and scrolls inside. Rows stay in the DOM and check passes, but viewers see fewer rows.\n\nFrom demo:\n- .card('Title', { sub?, points?, role? }, ...effects): one large sentence filling the stage, as a step. Use for openings, endings, time jumps, and role switches.\n\nFrom demo:\n- Agent UI: <Thinking title={$.ui.thought} active={$.ui.thinking} duration=\"6 seconds\">Process</Thinking> shows thinking;\n\nFrom demo:\n <Artifact title=\"itinerary.md\" kind=\"doc\" status={$.ui.planStatus}>Content</Artifact> is a side artifact; reference it in the conversation with <Artifact.Ref id=\"\u2026\"/>.\n\nFrom demo:\n- Email: a simple external email card. The card is not interactive; buttons in its body use ordinary steps.\n\nFrom demo:\n Example: <Email from=\"Camping assistant\" to=\"Ajie\" subject=\"Itinerary updated\" time=\"Friday 20:05\">Body</Email>.\n\nFrom demo:\n- DataCard / StateFlow: place in a separate layer and switch to it with shot. Only these two components show this step's changes and before/after values. StateFlow marks earlier states completed according to states order.\n\nFrom demo:\n Example: layer('Itinerary data', <Stack><StateFlow value={$.data.trip.status} states={['draft','shared']} labels={{ draft: 'Draft', shared: 'Shared' }}/><DataCard title=\"Itinerary\" value={$.data.trip} fields={[{ key: 'status', label: 'Status' }]}/></Stack>).\n\nFrom demo:\n- Filled buttons default to --jp-ink, so changing --jp-ink also recolors body text. There is no primary-color variable yet: set the brand color on the button itself, <Button color=\"#e8590c\">, instead of overriding inline styles.\n\nFrom focus:\n Skipped on purpose: modals and drawers (their backdrop already says it), DataCard / StateFlow (they show their own change), more than 3 changes at once, a new page or layer, the same element changing again or another line appended to the same log, and any step where you wrote a focus yourself.\n\nFrom focus:\n- Secrets and private data: <Code redact>sk-live-\u2026</Code>, or redact={not($.ui.revealed)} to unblur after a step. Blurred, layout unchanged, on every step.\n\nFrom check:\n- Group props: label, icon (client, service, model, database, queue, storage, external, tool, user, cloud, gateway, lock, network, device, box, brain), tone (gray, blue, green, orange, red, purple, teal), group (parent id). Groups nest.\n\nFrom check:\n- IMAGE_PLACEHOLDER: <Image src> is a URL or plain path, which is never loaded. Put the file in the project and write src={asset('./photo.jpg')}.\n\nFrom check:\n- INVALID_IMAGE_FIT: <Image fit> accepts cover or contain.\n\nFrom check:\nAn element that looks usable (Button, TextInput, Select, NavLink, a, \u2026; see components) has an id but no step in any scenario of this option. Viewers will try it; decide what happens:\n\nFrom focus:\n Skipped on purpose: modals and drawers (their backdrop already says it), DataCard / StateFlow (they show their own change), more than 3 changes at once, a new page or layer, the same element changing again or another line appended to the same log, and any step where you wrote a focus yourself.\n\nFrom focus:\n- Secrets and private data: <Code redact>sk-live-\u2026</Code>, or redact={not($.ui.revealed)} to unblur after a step. Blurred, layout unchanged, on every step.\n\nCommon pitfall: Only catalog components and the listed HTML tags exist; <br/> is UNKNOWN_COMPONENT, so write 'Line 1\\nLine 2' inside <Text>\n\nCommon pitfall: UI and HTML belong to this plugin: import author markers directly from @joplo-app/plugin-ui, explicitly enable ui in config.plugins, and read ui/components. The engine has no bare-name or old-IR compatibility layer.";
|
|
2
|
+
export declare const patterns = "# UI recipes and pitfalls\n\n## A prompt the audience can actually edit\nPromptInput renders a native textarea. Arbitrary text is disposable exploration and follows the same first-input pause, bound value synchronization and reset/resume rules as other text fields. Captures and disabled prompts remain read-only. Keep submission in a declared click/press step or free reaction; editing alone does not call a model or send anything. Test a non-script phrase with a real keyboard, because replayed .type() does not prove that audience input works.\n\n## Readable period interfaces\nMake the working interface the subject: enlarge input text, action targets and visible results together, rather than using a large year beside a tiny window. A compact chapter label and one source line can carry context; keep the full attribution/license inventory with the assets.\n\nFor a collected CSS device frame, retain only licensed generic shell parts and record the adaptations; a later frame library is not evidence of a historical model. When CSS or fonts are embedded into an offline export, retain the applicable full license notices in that exported asset as well as in the repository. Scope and match the original selector specificity when resizing a speaker or button, otherwise an apparently correct override can lose to a library descendant rule. Use the rendered control dimensions to verify the override.\n\nHTML text nodes may be wrapped in spans by this renderer. Avoid blanket descendant-span hiding to remove one note: it can also hide the main text. Remove the unwanted note from the authored tree or give that specific element a class. Device children with z-index can also overpaint a later sibling: give a transition scrim and its focal object explicit stacking levels and inspect the real pixels.\n\n## Styling a collected interface library\nVendor only the required, redistributable CSS primitives; scope them under an authored root class. Exclude unverified bundled fonts, icon files and logos rather than treating the stylesheet's license as permission for all assets. Record the exact extraction and selector changes alongside retained licenses.\n\nChoose --jp-stroke: clean in a project theme when CSS supplies historical borders and button bevels. UI components supply inline defaults for size, padding and color; a class alone may not win. Prefer documented props or explicit style overrides for ordinary cases. When a reusable vendor theme must own these values, narrowly scoped !important declarations can override just those defaults. Check computed styles, the actual rendered button and text contrast; do not globally change --jp-ink merely to tint a button.\n\nNative inputs are the editable surface; their displayed text is not a real backend. A local send reaction can set ui.sent while the result bubble reads inputs.<fieldId>, so the audience sees their own text without copying it into script data. Use a named option.free object, give each reaction a stable key, and retain the deterministic send effects in a scenario. Verify the same reaction in the actual built site as well as the board; a valid script does not prove that publication retained local reaction data. Test a non-script phrase: an exact next action can commit the script instead, and an exact action belonging to another scenario can prompt to switch.\n\n\nThese examples use this explicitly enabled plugin. Engine flow/state/review rules remain in the engine skill.\n\n## A whole flow on one whiteboard\nUse when reviewers should see the whole feature, not one screen at a time: several screens side by side, joined by arrows, with sticky notes for edge cases, and the camera walking from screen to screen.\n- One layer: <Canvas w h> (from @joplo-app/plugin-content, enable content too) containing one <Board>. Place each screen as <BrowserWindow id label url pos=\"absolute\" top left w h> (or a Phone) and write its page inside with normal ui components; they stay clickable and typeable.\n- <Arrow from to label> joins two element ids on the same Board (a button to the next screen reads best). <Note pos=\"absolute\" top left color> holds a comment that stays on the board; callout() is for a reason that belongs to one step only. Arrow must be a direct child of Board; ARROW_TARGET_UNKNOWN means no element on that Board has the id.\n- Reveal the next screen and its arrow in the same step with when; an arrow to a hidden element is not drawn. Arrows draw themselves in when they appear during playback.\n- Per step: camera('screenA', 'screenB') frames the pair being connected. End with a camera on every visible screen and note, not camera() of the whole world, which leaves empty paper when later screens are hidden.\n- Leave 160\u2013280 px between screens for arrow labels; keep notes slightly rotated (default -2\u00B0) and screens straight. Text inside screens is read at the zoom of a two-screen shot: check it in a rendered storyboard.\n\n## Controls over a visual story\n- Use Image with asset() for the actual downloaded artwork; keep source/creator/license/modification details beside the files. CSS crop/opacity changes still belong in that record. Do not use a remote URL as an offline-image substitute.\n- On a dark art background, Button's default filled variant may draw a Rough fill over your custom CSS. Use variant=\"subtle\" when authoring the whole surface, set text size/color explicitly, and choose --jp-stroke: clean in the project theme when appropriate. A class does not automatically override component inline font/color values. Verify contrast, focus and hitbox after the final composition.\n- Hover and dragging need a visible click alternative. A discrete timeline can use one draggable handle plus labeled endpoint buttons; keep the effects identical in both paths. Use a large enough drop zone and test it after the Canvas scales, not only at design resolution.\n\n### authoring: worked example\n```tsx\nimport { $, done, flow, option, page, set } from '@joplo-app/kit';\nimport { Alert, Button, Loader, Stack, TextInput } from '@joplo-app/plugin-ui';\nconst send = (name: string, cases: string[]) => flow(name, { start: '/reset', cases })\n .type('email', 'ana@exmple.com').click('send', 'Send the reset link', set($.ui.sending, true));\nexport default option({\n name: 'A \u00B7 Email link', idea: 'Send a reset link by email',\n state: { ui: { sending: false, sent: false, emailError: '' } },\n routes: { '/reset': page({ title: 'Reset password' }, <Stack>\n <TextInput id=\"email\" label=\"Email\" error={$.ui.emailError} />\n <Button id=\"send\" disabled={$.ui.sending} disabledReason=\"Sending\u2026\">Send link</Button>\n <Loader when={$.ui.sending} />\n <Alert id=\"sent\" color=\"green\" when={$.ui.sent}>Check your inbox</Alert>\n </Stack>) },\n scenarios: {\n unknown_email: send('Typo, then retry', ['unknown_email'])\n .system('No account for this email', set($.ui.sending, false), set($.ui.emailError, 'No account with this email'))\n .type('email', 'ana@example.com', set($.ui.emailError, ''))\n .click('send', 'Retry', set($.ui.sending, true))\n .system('Server accepts', set($.ui.sending, false), set($.ui.sent, true), done('Link sent after retry')),\n },\n});\n```\n\nFrom authoring:\nNative UI controls need no free declaration: Tabs, Accordion, text inputs, Switch, Checkbox, Radio, Select and SegmentedControl change local values. A bound checked/value prop updates locally too, keeping two views of the same state synchronized. Keep the scripted result in step effects; do not store audience input or credentials in the source.\n\nFrom authoring:\n- layouts/app.tsx: export default layout(<AppShell header={{ height: 56 }} navbar={{ width: 240, breakpoint: 'sm' }}><AppShell.Navbar>\u2026</AppShell.Navbar><AppShell.Main><Outlet/></AppShell.Main><Slot name=\"overlay\"/></AppShell>).\n\nFrom authoring:\n- asset('./photo.jpg'): reads an image next to the calling file and returns a data URI for <Image src> or a CSS backgroundImage (see components).\n\nFrom authoring:\n- on: { '/members': { actions: <Button id=\"invite\">Invite</Button> } }: add elements to a slot on an existing route.\n\nFrom authoring:\n- routes: { '/members/new': page({ title: 'New member' }, <Stack>\u2026</Stack>) }: add a route for this option.\n\nFrom authoring:\n- Props can bind: <TextInput id=\"email\" error={$.ui.emailError}/>, <Text>{$.data.name}</Text>.\n\nFrom authoring:\n- Overlays: <Modal id=\"dlg\" opened={$.ui.open} title=\"\u2026\">; Drawer works the same way. Only opened controls visibility.\n\nFrom authoring:\n- Use Stack / Group / SimpleGrid / Grid props: <Group justify=\"space-between\">, <SimpleGrid cols={{ base: 1, md: 3 }}>.\n\nFrom authoring:\n- Use responsive objects { base, xs, sm, md, lg, xl }, or visibleFrom=\"sm\" / hiddenFrom=\"sm\". AppShell collapses navigation below navbar.breakpoint.\n\nFrom authoring:\n- Set input labels directly: <TextInput id=\"email\" label=\"Email\"/>. Auto captions use them.\n\nFrom demo:\n- Drag begins on a declared drag target before stage pan/plugin orbit; Enter or Space on that target provides the scripted drag alternative. Pointerdown pauses immediately and lets the same drag finish. Hover never pauses playback, but matches normally while paused; touch activates hover by tapping and follows the pause-and-apply rule. Editable input keeps its first key, Space and arrow keys. The first wheel event pauses and scrolls; keep its inertia in the scroll area before stage zoom.\n\nFrom demo:\n- Each page has one device, desktop by default. page({ viewport: 'mobile' }, tree) uses a Phone frame at a fixed width of 390.\n\nFrom demo:\n- To show a phone turned sideways (a landscape app, a video player), write <Phone landscape> in a layer: the screen is 772 \u00D7 358 with the notch on the left.\n\nFrom demo:\n- Each layer tab shows an icon so viewers can tell layers apart when many tabs are squeezed: layer('Terminal', <Cli/>, { icon: 'terminal' }). Presets: window, browser, mobile, desktop, terminal, chat, agent, server, data, email, doc, flow. Default: mobile for mobile layers, otherwise window.\n\nFrom demo:\n <Grid h=\"100%\"><Grid.Col span={6}><Stack h=\"100%\"><ScrollArea style={{ flex: 1, minHeight: 0 }}>Messages</ScrollArea>\n\nFrom demo:\n <Group><PromptInput id=\"prompt\"/><Button id=\"send\">Send</Button></Group></Stack></Grid.Col></Grid>\n\nFrom demo:\n- The same applies to placeholders and artifacts: use h=\"100%\" on Placeholder; Artifact fills its container by default.\n\nFrom demo:\n- What gets squeezed: in an h=\"100%\" column, a Table, DataTable, or ScrollArea shrinks to the space left and scrolls inside. Rows stay in the DOM and check passes, but viewers see fewer rows.\n\nFrom demo:\n- .card('Title', { sub?, points?, role? }, ...effects): one large sentence filling the stage, as a step. Use for openings, endings, time jumps, and role switches.\n\nFrom demo:\n- Agent UI: <Thinking title={$.ui.thought} active={$.ui.thinking} duration=\"6 seconds\">Process</Thinking> shows thinking;\n\nFrom demo:\n <Artifact title=\"itinerary.md\" kind=\"doc\" status={$.ui.planStatus}>Content</Artifact> is a side artifact; reference it in the conversation with <Artifact.Ref id=\"\u2026\"/>.\n\nFrom demo:\n- Email: a simple external email card. The card is not interactive; buttons in its body use ordinary steps.\n\nFrom demo:\n Example: <Email from=\"Camping assistant\" to=\"Ajie\" subject=\"Itinerary updated\" time=\"Friday 20:05\">Body</Email>.\n\nFrom demo:\n- DataCard / StateFlow: place in a separate layer and switch to it with shot. Only these two components show this step's changes and before/after values. StateFlow marks earlier states completed according to states order.\n\nFrom demo:\n Example: layer('Itinerary data', <Stack><StateFlow value={$.data.trip.status} states={['draft','shared']} labels={{ draft: 'Draft', shared: 'Shared' }}/><DataCard title=\"Itinerary\" value={$.data.trip} fields={[{ key: 'status', label: 'Status' }]}/></Stack>).\n\nFrom demo:\n- Filled buttons default to --jp-ink, so changing --jp-ink also recolors body text. There is no primary-color variable yet: set the brand color on the button itself, <Button color=\"#e8590c\">, instead of overriding inline styles.\n\nFrom focus:\n Skipped on purpose: modals and drawers (their backdrop already says it), DataCard / StateFlow (they show their own change), more than 3 changes at once, a new page or layer, the same element changing again or another line appended to the same log, and any step where you wrote a focus yourself.\n\nFrom focus:\n- Secrets and private data: <Code redact>sk-live-\u2026</Code>, or redact={not($.ui.revealed)} to unblur after a step. Blurred, layout unchanged, on every step.\n\nFrom check:\n- Group props: label, icon (client, service, model, database, queue, storage, external, tool, user, cloud, gateway, lock, network, device, box, brain), tone (gray, blue, green, orange, red, purple, teal), group (parent id). Groups nest.\n\nFrom check:\n- IMAGE_PLACEHOLDER: <Image src> is a URL or plain path, which is never loaded. Put the file in the project and write src={asset('./photo.jpg')}.\n\nFrom check:\n- INVALID_IMAGE_FIT: <Image fit> accepts cover or contain.\n\nFrom check:\nAn element that looks usable (Button, TextInput, Select, NavLink, a, \u2026; see components) has an id but no step in any scenario of this option. Viewers will try it; decide what happens:\n\nFrom focus:\n Skipped on purpose: modals and drawers (their backdrop already says it), DataCard / StateFlow (they show their own change), more than 3 changes at once, a new page or layer, the same element changing again or another line appended to the same log, and any step where you wrote a focus yourself.\n\nFrom focus:\n- Secrets and private data: <Code redact>sk-live-\u2026</Code>, or redact={not($.ui.revealed)} to unblur after a step. Blurred, layout unchanged, on every step.\n\nCommon pitfall: Only catalog components and the listed HTML tags exist; <br/> is UNKNOWN_COMPONENT, so write 'Line 1\\nLine 2' inside <Text>\n\nCommon pitfall: UI and HTML belong to this plugin: import author markers directly from @joplo-app/plugin-ui, explicitly enable ui in config.plugins, and read ui/components. The engine has no bare-name or old-IR compatibility layer.";
|
|
3
3
|
export declare function componentGuide(docs: Record<string, string>, examples: Record<string, string>): string;
|
package/dist/guide.js
CHANGED
|
@@ -42,6 +42,14 @@ Native inputs are the editable surface; their displayed text is not a real backe
|
|
|
42
42
|
|
|
43
43
|
These examples use this explicitly enabled plugin. Engine flow/state/review rules remain in the engine skill.
|
|
44
44
|
|
|
45
|
+
## A whole flow on one whiteboard
|
|
46
|
+
Use when reviewers should see the whole feature, not one screen at a time: several screens side by side, joined by arrows, with sticky notes for edge cases, and the camera walking from screen to screen.
|
|
47
|
+
- One layer: <Canvas w h> (from @joplo-app/plugin-content, enable content too) containing one <Board>. Place each screen as <BrowserWindow id label url pos="absolute" top left w h> (or a Phone) and write its page inside with normal ui components; they stay clickable and typeable.
|
|
48
|
+
- <Arrow from to label> joins two element ids on the same Board (a button to the next screen reads best). <Note pos="absolute" top left color> holds a comment that stays on the board; callout() is for a reason that belongs to one step only. Arrow must be a direct child of Board; ARROW_TARGET_UNKNOWN means no element on that Board has the id.
|
|
49
|
+
- Reveal the next screen and its arrow in the same step with when; an arrow to a hidden element is not drawn. Arrows draw themselves in when they appear during playback.
|
|
50
|
+
- Per step: camera('screenA', 'screenB') frames the pair being connected. End with a camera on every visible screen and note, not camera() of the whole world, which leaves empty paper when later screens are hidden.
|
|
51
|
+
- Leave 160–280 px between screens for arrow labels; keep notes slightly rotated (default -2°) and screens straight. Text inside screens is read at the zoom of a two-screen shot: check it in a rendered storyboard.
|
|
52
|
+
|
|
45
53
|
## Controls over a visual story
|
|
46
54
|
- Use Image with asset() for the actual downloaded artwork; keep source/creator/license/modification details beside the files. CSS crop/opacity changes still belong in that record. Do not use a remote URL as an offline-image substitute.
|
|
47
55
|
- On a dark art background, Button's default filled variant may draw a Rough fill over your custom CSS. Use variant="subtle" when authoring the whole surface, set text size/color explicitly, and choose --jp-stroke: clean in the project theme when appropriate. A class does not automatically override component inline font/color values. Verify contrast, focus and hitbox after the final composition.
|
package/dist/plugin.js
CHANGED
|
@@ -15,6 +15,7 @@ const display = {
|
|
|
15
15
|
KeyValue: ['items'], BarChart: ['data', 'unit'], LineChart: ['data', 'unit'],
|
|
16
16
|
NavLink: ['label', 'description', 'leftSection', 'rightSection'], Button: ['leftSection', 'rightSection'],
|
|
17
17
|
'Menu.Item': ['leftSection', 'rightSection'], 'Tabs.Tab': ['leftSection', 'rightSection'],
|
|
18
|
+
BrowserWindow: ['label', 'url'], Arrow: ['label'],
|
|
18
19
|
};
|
|
19
20
|
const textPaths = { DataTable: [['columns', '*', 'title']], DataCard: [['fields', '*', 'label']], BarChart: [['series', '*', 'label']], LineChart: [['series', '*', 'label']] };
|
|
20
21
|
const fieldText = ['label', 'description', 'placeholder', 'error', 'value'];
|
|
@@ -28,10 +29,10 @@ const traits = (name) => ({
|
|
|
28
29
|
...(textPaths[name] ? { textPaths: textPaths[name] } : {}),
|
|
29
30
|
text: ['aria-label', ...(display[name] ?? []), ...(VALUE_TYPES.has(name) ? fieldText : []), ...(['Select', 'MultiSelect', 'SegmentedControl', 'TabBar'].includes(name) ? ['data'] : [])],
|
|
30
31
|
...(name === 'Indicator' ? { disabledPresentation: true } : {}),
|
|
31
|
-
...(['Card', 'Paper', 'Modal', 'Drawer', 'Alert', 'Notification', 'Email', 'DataCard', 'StateFlow', 'Artifact', 'Terminal', 'ChatMessage', 'Table', 'DataTable', 'Accordion.Item', 'Tabs.Panel', 'AppShell.Navbar', 'AppShell.Aside', 'AppShell.Header', 'AppShell.Footer'].includes(name) ? { frame: true } : {}),
|
|
32
|
+
...(['Card', 'Paper', 'Modal', 'Drawer', 'Alert', 'Notification', 'Email', 'DataCard', 'StateFlow', 'Artifact', 'Terminal', 'ChatMessage', 'Table', 'DataTable', 'Accordion.Item', 'Tabs.Panel', 'AppShell.Navbar', 'AppShell.Aside', 'AppShell.Header', 'AppShell.Footer', 'BrowserWindow'].includes(name) ? { frame: true } : {}),
|
|
32
33
|
...(VALUE_TYPES.has(name) ? { input: name === 'MultiSelect' ? 'multiple' : 'single' } : {}),
|
|
33
34
|
...(GATED_CONTAINERS.has(name) ? { gate: ['opened'], selfEvident: true } : {}),
|
|
34
|
-
...(['DataCard', 'StateFlow'].includes(name) ? { selfEvident: true } : {}),
|
|
35
|
+
...(['DataCard', 'StateFlow', 'Board', 'Arrow'].includes(name) ? { selfEvident: true } : {}),
|
|
35
36
|
...(name === 'Checkbox' ? { toggleCaption: 'check' } : {}),
|
|
36
37
|
...(['Select', 'MultiSelect', 'SegmentedControl', 'TabBar'].includes(name) ? { choices: { prop: 'data' } } : {}),
|
|
37
38
|
...(name === 'Radio.Group' ? { choices: { descendants: ['ui:Radio', 'ui:Radio.Card'] } } : {}),
|
|
@@ -50,10 +51,31 @@ export const components = Object.fromEntries(COMPONENT_TYPES.map(name => [name,
|
|
|
50
51
|
...(INTERACTIVE_TYPES.has(name) ? { interactive: true } : {}),
|
|
51
52
|
traits: traits(name),
|
|
52
53
|
...(name === 'Menu' ? { inherit: [{ target: 'ui:Menu.Dropdown', props: ['opened'] }] } : {}),
|
|
54
|
+
...(name === 'Arrow' ? { parent: 'Board' } : {}),
|
|
53
55
|
}]));
|
|
54
56
|
export default definePlugin({ packageName: '@joplo-app/plugin-ui',
|
|
55
57
|
id: 'ui', title: 'Standard UI', version: packageInfo.version, runtime: new URL(import.meta.url.endsWith('.ts') ? './runtime.tsx' : './runtime.js', import.meta.url), skill, skillTopics: { components: { description: 'Component props and author examples', body: componentGuide(COMPONENT_DOCS, COMPONENT_EXAMPLES) }, patterns: { description: 'UI recipes and individual pitfalls', body: patterns } }, components,
|
|
56
|
-
compile: {
|
|
58
|
+
compile: {
|
|
59
|
+
// derive 对每个节点各调一次、只给它自己的子树;箭头都在 Board 里,所以从 Board 统一校验 from / to。
|
|
60
|
+
derive(root, { report }) {
|
|
61
|
+
if (root.type !== 'ui:Board')
|
|
62
|
+
return;
|
|
63
|
+
const all = (n) => [n, ...(n.children ?? []).flatMap(all)];
|
|
64
|
+
const nodes = all(root);
|
|
65
|
+
const ids = new Set(nodes.map(n => n.id));
|
|
66
|
+
for (const n of nodes) {
|
|
67
|
+
if (n.type !== 'ui:Arrow')
|
|
68
|
+
continue;
|
|
69
|
+
for (const key of ['from', 'to']) {
|
|
70
|
+
const v = n.props?.[key];
|
|
71
|
+
if (typeof v !== 'string' || !v)
|
|
72
|
+
report('ARROW_INVALID', `<Arrow> needs ${key}="<element id>"`, n.source);
|
|
73
|
+
else if (!ids.has(v))
|
|
74
|
+
report('ARROW_TARGET_UNKNOWN', `<Arrow ${key}="${v}">: no element with id "${v}" on this Board; give the target element id="${v}"`, n.source);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
node(n, { report }) {
|
|
57
79
|
if (n.type !== 'ui:Image')
|
|
58
80
|
return;
|
|
59
81
|
const { src, fit } = n.props;
|
|
@@ -61,6 +83,7 @@ export default definePlugin({ packageName: '@joplo-app/plugin-ui',
|
|
|
61
83
|
report('IMAGE_PLACEHOLDER', `<ui:Image src="${src.length > 60 ? `${src.slice(0, 57)}...` : src}"> is drawn as a placeholder: only embedded images are shown. Put the file in the project and write src={asset('./photo.jpg')} (path relative to this file; import asset from @joplo-app/kit)`, n.source, 'warning');
|
|
62
84
|
if (fit !== undefined && !(typeof fit === 'string' && ['cover', 'contain'].includes(fit)))
|
|
63
85
|
report('INVALID_IMAGE_FIT', `<ui:Image> fit="${String(fit)}" is not supported (available: cover, contain)`, n.source);
|
|
64
|
-
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
65
88
|
});
|
|
66
89
|
export * from "./components.js";
|
package/dist/runtime.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const PHONE_LANDSCAPE: {
|
|
|
4
4
|
height: number;
|
|
5
5
|
};
|
|
6
6
|
/** 内嵌的图片(asset() 生成的 data:image/… URI)才真正画出来;网址、路径不去加载(编译期会提示),仍画占位。 */
|
|
7
|
+
/** 嵌入的图片:asset() 的 data URL,或发布产物里的素材文件(D98);远程地址仍画占位。 */
|
|
7
8
|
export declare const embeddedImage: (src: unknown) => string | null;
|
|
8
9
|
/** 选中的行:selectedRecords 里的 id(或带 idAccessor 的记录)。 */
|
|
9
10
|
export declare function selectedIds(selected: unknown, idAccessor: string): Set<string>;
|
package/dist/runtime.js
CHANGED
|
@@ -5,7 +5,7 @@ import { AnimatePresence } from 'motion/react';
|
|
|
5
5
|
import { motion } from '@joplo-app/kit/renderer';
|
|
6
6
|
import { clsx } from 'clsx';
|
|
7
7
|
import { twMerge } from 'tailwind-merge';
|
|
8
|
-
import { defineRuntime } from '@joplo-app/kit/plugin';
|
|
8
|
+
import { defineRuntime, embeddedAsset } from '@joplo-app/kit/plugin';
|
|
9
9
|
import { useCtx, useI18n as useHostI18n, useR, useRoot, useRootAttrs, useStroke, useLook, RenderNode, RendererContextProvider, Rough, RoughPath, roundRectPath, rectOf, nodeLabel, resolveProps, resolveValue, isSelfVisible, walkNodes, atWidth, childWidth, choicesOf, layoutRegions, styleOf } from '@joplo-app/kit/renderer';
|
|
10
10
|
import { ACCENT, BG, DANGER, onFill, DIMMED, FONT, INFO, INK, INK2, LINE, LINE_STRONG, MONO, OK, SOFT, SPACING, SUBTLE, SURFACE, TITLE_SIZE, WARN, color, dim, fontSize, light, radius, space, spaceNum, tint } from '@joplo-app/kit/renderer-style';
|
|
11
11
|
import { changeFor, dataRows, displayData, stateFlowItems } from "./story-data.js";
|
|
@@ -360,7 +360,8 @@ function Avatar({ node, p, children }) {
|
|
|
360
360
|
return (_jsxs("span", { ...root, "data-jround": "", children: [_jsx(Rough, { seedKey: node.id, shape: "ellipse", stroke: c, strokeWidth: 1.1 }), _jsx("span", { children: p.name || p.alt || !children ? initials(name) : children })] }));
|
|
361
361
|
}
|
|
362
362
|
/** 内嵌的图片(asset() 生成的 data:image/… URI)才真正画出来;网址、路径不去加载(编译期会提示),仍画占位。 */
|
|
363
|
-
|
|
363
|
+
/** 嵌入的图片:asset() 的 data URL,或发布产物里的素材文件(D98);远程地址仍画占位。 */
|
|
364
|
+
export const embeddedImage = (src) => embeddedAsset(src, 'image');
|
|
364
365
|
function ImageBox({ node, p }) {
|
|
365
366
|
const { t: i18n } = useI18n();
|
|
366
367
|
const r = useR(p);
|
|
@@ -1324,6 +1325,108 @@ function TabBar({ node, p, disabled }) {
|
|
|
1324
1325
|
}) }));
|
|
1325
1326
|
}
|
|
1326
1327
|
// ============ 注册表:目录中每个类型都必须有实现(Record<ComponentType, …> 由类型检查保证) ============
|
|
1328
|
+
// ============ 白板(D96):多屏画面摆在一块白板上,箭头连成流程,便签写说明 ============
|
|
1329
|
+
/** 白板:点阵纸,默认铺满父容器(放进 content:Canvas 就能用镜头在各屏之间移动);子元素用 pos="absolute" top / left 摆放。 */
|
|
1330
|
+
function Board({ node, p, children }) {
|
|
1331
|
+
const dots = p.dots !== false;
|
|
1332
|
+
const root = useRoot(node, p, { style: { position: 'relative', width: '100%', height: '100%', background: dots ? `radial-gradient(circle, ${LINE_STRONG} 1.3px, transparent 1.5px) 0 0 / 28px 28px, ${SURFACE}` : SURFACE } });
|
|
1333
|
+
return _jsx("div", { ...root, "data-jboard": "", children: children });
|
|
1334
|
+
}
|
|
1335
|
+
/** 浏览器窗口外框:一屏画面;label 是框上方的手写标题(“1. 登录”)。 */
|
|
1336
|
+
function BrowserWindow({ node, p, children }) {
|
|
1337
|
+
const mode = useStroke();
|
|
1338
|
+
const root = useRoot(node, p, { className: cn('jbox', mode === 'clean' && 'rounded-lg'), style: { display: 'flex', flexDirection: 'column', background: SURFACE, width: 520, height: 400, flexShrink: 0 } });
|
|
1339
|
+
return (_jsxs("div", { ...root, children: [_jsx(Rough, { seedKey: node.id, strokeWidth: 1.6 }), p.label !== undefined ? _jsx("div", { "aria-hidden": true, style: { position: 'absolute', left: 2, bottom: '100%', marginBottom: 10, fontSize: 22, color: DIMMED, whiteSpace: 'nowrap' }, children: str(p.label) }) : null, _jsxs("div", { className: "flex shrink-0 items-center gap-2 px-3", style: { height: 38, borderBottom: mode === 'clean' ? `1px solid ${LINE}` : undefined, position: 'relative' }, children: [[0, 1, 2].map(i => _jsx("span", { className: "jbox", "data-jround": "", style: { width: 11, height: 11, borderRadius: 999 }, children: _jsx(Rough, { seedKey: `${node.id}:dot${i}`, shape: "ellipse", strokeWidth: 1.1, inset: 0, stroke: SOFT }) }, i)), _jsxs("span", { className: "jbox jtrunc", style: { flex: 1, height: 22, marginLeft: 6, fontSize: 13, color: DIMMED, display: 'flex', alignItems: 'center', padding: '0 10px', borderRadius: 6, background: SUBTLE }, children: [_jsx(Rough, { seedKey: `${node.id}:url`, strokeWidth: 1, roughness: 0.8, inset: 1, stroke: SOFT }), _jsx("span", { children: str(p.url) })] }), mode === 'rough' ? _jsx("span", { className: "jbox", style: { position: 'absolute', left: 4, right: 4, bottom: -2, height: 4 }, children: _jsx(Rough, { seedKey: `${node.id}:bar`, shape: "line", strokeWidth: 1.1, inset: 0, stroke: SOFT }) }) : null] }), _jsx("div", { style: { flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }, children: children })] }));
|
|
1340
|
+
}
|
|
1341
|
+
/** 便签:一直留在白板上的说明(callout 只在一步里出现)。 */
|
|
1342
|
+
function Note({ node, p, children }) {
|
|
1343
|
+
const c = color(p.color ?? 'yellow') ?? WARN;
|
|
1344
|
+
const deg = typeof p.rotate === 'number' && Number.isFinite(p.rotate) ? p.rotate : -2;
|
|
1345
|
+
const root = useRoot(node, p, { className: 'jbox', style: { width: 240, padding: '14px 16px', fontSize: 17, lineHeight: 1.4, color: INK, whiteSpace: 'pre-line', background: tint(c, 26), boxShadow: '0 6px 14px rgba(0,0,0,.08)', rotate: `${deg}deg` } });
|
|
1346
|
+
return (_jsxs("div", { ...root, children: [_jsx(Rough, { seedKey: node.id, stroke: tint(c, 70), strokeWidth: 1, roughness: 0.7, inset: 0 }), _jsx("div", { "data-jtext": "", children: children })] }));
|
|
1347
|
+
}
|
|
1348
|
+
/** 元素在白板里的布局位置:offset 链,不受镜头缩放和 FLIP transform 影响。 */
|
|
1349
|
+
function offsetInBoard(el, root) {
|
|
1350
|
+
let x = 0, y = 0;
|
|
1351
|
+
let n = el;
|
|
1352
|
+
while (n && n !== root) {
|
|
1353
|
+
x += n.offsetLeft;
|
|
1354
|
+
y += n.offsetTop;
|
|
1355
|
+
n = n.offsetParent;
|
|
1356
|
+
}
|
|
1357
|
+
return n === root ? { x, y, w: el.offsetWidth, h: el.offsetHeight } : null;
|
|
1358
|
+
}
|
|
1359
|
+
const cssEscape = (id) => (typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'));
|
|
1360
|
+
/** 量出白板上若干元素的位置;目标出现、消失、改尺寸、字体加载完成都会重新量;没渲染的目标给 null。 */
|
|
1361
|
+
function useBoardRects(ids) {
|
|
1362
|
+
const ref = useRef(null);
|
|
1363
|
+
const [rects, setRects] = useState(null);
|
|
1364
|
+
const key = ids.join('\n');
|
|
1365
|
+
useLayoutEffect(() => {
|
|
1366
|
+
const svg = ref.current;
|
|
1367
|
+
const board = svg?.closest('[data-jboard]');
|
|
1368
|
+
if (!svg || !board)
|
|
1369
|
+
return;
|
|
1370
|
+
let last = '';
|
|
1371
|
+
const find = (id) => board.querySelector(`[data-jid="${cssEscape(id)}"]`);
|
|
1372
|
+
const measure = () => {
|
|
1373
|
+
const next = ids.map(id => { const el = find(id); return el && el.offsetParent ? offsetInBoard(el, board) : null; });
|
|
1374
|
+
const sig = JSON.stringify(next);
|
|
1375
|
+
if (sig !== last) {
|
|
1376
|
+
last = sig;
|
|
1377
|
+
setRects(next);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
measure();
|
|
1381
|
+
const ro = new ResizeObserver(measure);
|
|
1382
|
+
ro.observe(board);
|
|
1383
|
+
for (const id of ids) {
|
|
1384
|
+
const el = find(id);
|
|
1385
|
+
if (el)
|
|
1386
|
+
ro.observe(el);
|
|
1387
|
+
}
|
|
1388
|
+
const mo = new MutationObserver(measure);
|
|
1389
|
+
mo.observe(board, { childList: true, subtree: true });
|
|
1390
|
+
let alive = true;
|
|
1391
|
+
document.fonts?.ready.then(() => { if (alive)
|
|
1392
|
+
measure(); });
|
|
1393
|
+
return () => { alive = false; ro.disconnect(); mo.disconnect(); };
|
|
1394
|
+
}, [key]);
|
|
1395
|
+
return { ref, rects };
|
|
1396
|
+
}
|
|
1397
|
+
/** 从 r 的中心朝 (tx, ty) 出发,落在外扩 pad 的边框上的点。 */
|
|
1398
|
+
function edgePoint(r, tx, ty, pad) {
|
|
1399
|
+
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
|
1400
|
+
const dx = tx - cx, dy = ty - cy;
|
|
1401
|
+
if (!dx && !dy)
|
|
1402
|
+
return { x: cx, y: cy };
|
|
1403
|
+
const t = Math.min(dx ? (r.w / 2 + pad) / Math.abs(dx) : Infinity, dy ? (r.h / 2 + pad) / Math.abs(dy) : Infinity);
|
|
1404
|
+
return { x: cx + dx * t, y: cy + dy * t };
|
|
1405
|
+
}
|
|
1406
|
+
/** 两个元素之间一直保留的箭头(引擎的 mark 只在一步里出现)。手绘描边用 Rough.js,干净线框退回普通 SVG。 */
|
|
1407
|
+
function Arrow({ node, p }) {
|
|
1408
|
+
const mode = useStroke();
|
|
1409
|
+
const { animate } = useCtx();
|
|
1410
|
+
const attrs = useRootAttrs(node);
|
|
1411
|
+
const { ref, rects } = useBoardRects([str(p.from), str(p.to)]);
|
|
1412
|
+
const stroke = color(p.color) ?? INK;
|
|
1413
|
+
const a = rects?.[0], b = rects?.[1];
|
|
1414
|
+
let d = '', at = null;
|
|
1415
|
+
if (a && b) {
|
|
1416
|
+
const s = edgePoint(a, b.x + b.w / 2, b.y + b.h / 2, 10), e = edgePoint(b, a.x + a.w / 2, a.y + a.h / 2, 14);
|
|
1417
|
+
const len = Math.hypot(e.x - s.x, e.y - s.y) || 1;
|
|
1418
|
+
const bend = Math.max(-1, Math.min(1, typeof p.bend === 'number' && Number.isFinite(p.bend) ? p.bend : 0.25));
|
|
1419
|
+
const c = { x: (s.x + e.x) / 2 - ((e.y - s.y) / len) * bend * len * 0.3, y: (s.y + e.y) / 2 + ((e.x - s.x) / len) * bend * len * 0.3 };
|
|
1420
|
+
const ang = Math.atan2(e.y - c.y, e.x - c.x), head = 16, spread = 0.45;
|
|
1421
|
+
const f = (n) => n.toFixed(1);
|
|
1422
|
+
d = `M${f(s.x)},${f(s.y)} Q${f(c.x)},${f(c.y)} ${f(e.x)},${f(e.y)} M${f(e.x - head * Math.cos(ang - spread))},${f(e.y - head * Math.sin(ang - spread))} L${f(e.x)},${f(e.y)} L${f(e.x - head * Math.cos(ang + spread))},${f(e.y - head * Math.sin(ang + spread))}`;
|
|
1423
|
+
at = { x: 0.25 * s.x + 0.5 * c.x + 0.25 * e.x, y: 0.25 * s.y + 0.5 * c.y + 0.25 * e.y };
|
|
1424
|
+
}
|
|
1425
|
+
const dashed = p.dashed === true;
|
|
1426
|
+
return (_jsxs(_Fragment, { children: [_jsx("svg", { ref: ref, ...attrs, "aria-label": nodeLabel(node), style: { position: 'absolute', left: 0, top: 0, width: 1, height: 1, overflow: 'visible', pointerEvents: 'none', zIndex: 5 }, children: d ? (mode === 'clean'
|
|
1427
|
+
? _jsx("path", { d: d, fill: "none", stroke: stroke, strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", strokeDasharray: dashed ? '7 6' : undefined })
|
|
1428
|
+
: _jsx(RoughPath, { seedKey: `${node.id}:${d.length}`, d: d, stroke: stroke, strokeWidth: 1.8, dashed: dashed, roughness: 1, draw: animate })) : null }), at && p.label !== undefined ? _jsx("div", { "aria-hidden": true, "data-jtext": "", style: { position: 'absolute', left: at.x, top: at.y, transform: 'translate(-50%, -50%)', background: SURFACE, padding: '1px 8px', fontSize: 17, color: p.color !== undefined ? stroke : DIMMED, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 6 }, children: str(p.label) }) : null] }));
|
|
1429
|
+
}
|
|
1327
1430
|
const IMPL = {
|
|
1328
1431
|
AppShell,
|
|
1329
1432
|
'AppShell.Header': ShellHeader,
|
|
@@ -1438,6 +1541,10 @@ const IMPL = {
|
|
|
1438
1541
|
PromptInput,
|
|
1439
1542
|
'Artifact.Ref': ArtifactRef,
|
|
1440
1543
|
'Terminal.Input': TerminalInput,
|
|
1544
|
+
Board,
|
|
1545
|
+
BrowserWindow,
|
|
1546
|
+
Note,
|
|
1547
|
+
Arrow,
|
|
1441
1548
|
};
|
|
1442
1549
|
export const IMPLEMENTED_TYPES = Object.keys(IMPL).map(name => `ui:${name}`);
|
|
1443
1550
|
export default defineRuntime({
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joplo-app/plugin-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"joplo": {
|
|
6
6
|
"api": 2
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@joplo-app/kit": "0.
|
|
28
|
+
"@joplo-app/kit": "0.4.1",
|
|
29
29
|
"react": "19.3.1",
|
|
30
30
|
"motion": "14.9.1",
|
|
31
31
|
"clsx": "2.1.1",
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
},
|
|
39
39
|
"description": "Joplo first-party UI and HTML vocabulary",
|
|
40
40
|
"license": "PolyForm-Shield-1.0.0",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/Joplo-app/Joplo.git",
|
|
44
|
+
"directory": "plugins/ui"
|
|
45
|
+
},
|
|
41
46
|
"engines": {
|
|
42
47
|
"node": ">=22"
|
|
43
48
|
},
|