@kybernesis/cards-react 0.1.0 → 0.2.0
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/README.md +6 -0
- package/dist/AskView.d.ts +31 -0
- package/dist/AskView.js +119 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/styles.js +16 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -19,6 +19,12 @@ const ctx: CardContext = {
|
|
|
19
19
|
|
|
20
20
|
The host does everything that touches the outside: opening links, fetching pictures and artifacts from the agent, sending a `say` sentence. Leave a callback out and the card draws without it (no Open button, no preview), never broken.
|
|
21
21
|
|
|
22
|
+
A question the agent is waiting on is an `AskView` (from `askFrom` or `readHiddenAsks` in `@kybernesis/cards`): an approval drawn as the email or event with Send it, Edit and Don't send, a purchase with its amount and Approve in Link, a form with its fields, anything else with its options and a box for words. `onAnswer({ optionId?, text?, label })` is the host's to send; pass `answered` once it is, and it shows the outcome instead of the buttons.
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
<AskView ask={ask} ctx={ctx} answered={answered} onAnswer={(reply) => send(reply)} />
|
|
26
|
+
```
|
|
27
|
+
|
|
22
28
|
Theme it with `--kyb-*` variables on any ancestor: `--kyb-ink`, `--kyb-ink-secondary`, `--kyb-ink-tertiary`, `--kyb-ink-inverse`, `--kyb-surface`, `--kyb-surface-raised`, `--kyb-stroke`, `--kyb-stroke-strong`, `--kyb-hover`, `--kyb-selected`, `--kyb-good`, `--kyb-bad`, `--kyb-font-label`, `--kyb-font-mono`, `--kyb-fs-xs`, `--kyb-fs-sm`, `--kyb-fs-base`, `--kyb-lh-base`, `--kyb-shadow-menu`. Each has a light default.
|
|
23
29
|
|
|
24
30
|
Artifacts run in a sandboxed frame with no origin and no network. A host whose own content policy blocks scripts in frames passes `renderArtifactHtml` to draw them its own way.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Ask } from "@kybernesis/cards";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { type CardContext } from "./CardView.js";
|
|
4
|
+
/**
|
|
5
|
+
* A question the agent is waiting on, drawn as what it asks.
|
|
6
|
+
*
|
|
7
|
+
* An approval shows the email, event or message it would send, with Send it,
|
|
8
|
+
* Edit and Don't send. A purchase shows the amount and merchant and opens Link.
|
|
9
|
+
* A form shows its fields. Anything else shows its sentence, its options as
|
|
10
|
+
* buttons and, when it takes words, a box to type them. The host sends the
|
|
11
|
+
* answer the way it answers anything (`onAnswer`); a pressed question then
|
|
12
|
+
* shows what was said, so it is still on record.
|
|
13
|
+
*/
|
|
14
|
+
export interface AskReply {
|
|
15
|
+
optionId?: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
/** What the answer said, in words, for the conversation. */
|
|
18
|
+
label: string;
|
|
19
|
+
}
|
|
20
|
+
export interface AskViewProps {
|
|
21
|
+
ask: Ask;
|
|
22
|
+
ctx: CardContext;
|
|
23
|
+
/** How it was answered, once it was. */
|
|
24
|
+
answered?: AskReply;
|
|
25
|
+
/** Why the last answer did not go, shown under the buttons. */
|
|
26
|
+
error?: string;
|
|
27
|
+
/** True while an answer is on its way. */
|
|
28
|
+
sending?: boolean;
|
|
29
|
+
onAnswer(reply: AskReply): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function AskView(props: AskViewProps): ReactNode;
|
package/dist/AskView.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { cardTitle, formAnswer, isKnownCard, money } from "@kybernesis/cards";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ApprovalBody, CardView } from "./CardView.js";
|
|
5
|
+
function host(url) {
|
|
6
|
+
try {
|
|
7
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return url;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function Done({ title, label, good, detail }) {
|
|
14
|
+
return (_jsxs("div", { className: "kcard kask is-done", children: [_jsxs("div", { className: "kcard__head", children: [_jsx("span", { className: "kcard__title", children: title }), _jsx("span", { className: `kpill${good ? " is-good" : ""}`, children: label })] }), detail ? _jsx("div", { className: "kcard__text", children: detail }) : null] }));
|
|
15
|
+
}
|
|
16
|
+
function Status({ error, sending }) {
|
|
17
|
+
if (error)
|
|
18
|
+
return _jsx("div", { className: "kask__error", children: error });
|
|
19
|
+
if (sending)
|
|
20
|
+
return _jsx("div", { className: "kask__note", children: "Sending\u2026" });
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function ApprovalAsk({ ask, data, title, props }) {
|
|
24
|
+
const editable = data.kind !== "action" && (data.subject !== undefined || data.body !== undefined);
|
|
25
|
+
const [editing, setEditing] = useState(false);
|
|
26
|
+
const [subject, setSubject] = useState(data.subject ?? "");
|
|
27
|
+
const [body, setBody] = useState(data.body ?? "");
|
|
28
|
+
const sends = data.kind === "email" || data.kind === "message";
|
|
29
|
+
const approve = ask.options?.find((o) => o.id === "approve") ? "approve" : ask.options?.[0]?.id ?? "approve";
|
|
30
|
+
const busy = props.sending === true;
|
|
31
|
+
const sendBack = () => {
|
|
32
|
+
const lines = ["Not as written. Make it again with these changes, then ask me again."];
|
|
33
|
+
if (subject.trim() !== (data.subject ?? ""))
|
|
34
|
+
lines.push(`${data.kind === "event" ? "Title" : "Subject"}: ${subject.trim()}`);
|
|
35
|
+
if (body !== (data.body ?? ""))
|
|
36
|
+
lines.push(`${data.kind === "event" ? "Description" : "Body"}:\n${body.trim()}`);
|
|
37
|
+
props.onAnswer({ optionId: "cancel", text: lines.join("\n"), label: "Sent back with changes" });
|
|
38
|
+
};
|
|
39
|
+
return (_jsxs("div", { className: "kcard kcard--approval kask", children: [_jsxs("div", { className: "kcard__head", children: [_jsx("span", { className: "kcard__title", children: title }), data.service ? _jsx("span", { className: "klabel kcard__source", children: data.service }) : null] }), editing ? (_jsxs("div", { className: "kform", children: [data.subject !== undefined ? (_jsxs("label", { className: "kform__field", children: [_jsx("span", { className: "klabel", children: data.kind === "event" ? "Event" : "Subject" }), _jsx("input", { className: "kinput", value: subject, onChange: (e) => setSubject(e.target.value) })] })) : null, _jsxs("label", { className: "kform__field", children: [_jsx("span", { className: "klabel", children: data.kind === "event" ? "Description" : "Message" }), _jsx("textarea", { className: "kinput kinput--long", rows: 8, value: body, onChange: (e) => setBody(e.target.value) })] })] })) : (_jsx(ApprovalBody, { data: data })), _jsx("div", { className: "kcard__actions", children: editing ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "kbtn kbtn--primary", disabled: busy, onClick: sendBack, children: "Send back with changes" }), _jsx("button", { type: "button", className: "kbtn", disabled: busy, onClick: () => setEditing(false), children: "Never mind" })] })) : (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "kbtn kbtn--primary", disabled: busy, onClick: () => props.onAnswer({ optionId: approve, label: "Approved" }), children: sends ? (data.action === "Save draft" ? "Save it" : "Send it") : "Approve" }), editable ? (_jsx("button", { type: "button", className: "kbtn", disabled: busy, onClick: () => setEditing(true), children: "Edit" })) : null, _jsx("button", { type: "button", className: "kbtn", disabled: busy, onClick: () => props.onAnswer({ optionId: "cancel", label: "Declined" }), children: sends ? "Don't send" : "Decline" })] })) }), _jsx(Status, { error: props.error, sending: props.sending })] }));
|
|
40
|
+
}
|
|
41
|
+
function FieldInput({ field, value, onChange }) {
|
|
42
|
+
if (field.kind === "long") {
|
|
43
|
+
return _jsx("textarea", { className: "kinput kinput--long", rows: 4, value: value, placeholder: field.placeholder, onChange: (e) => onChange(e.target.value) });
|
|
44
|
+
}
|
|
45
|
+
if (field.kind === "choice" && field.options?.length) {
|
|
46
|
+
return (_jsx("div", { className: "kform__choices", children: field.options.map((o) => (_jsx("button", { type: "button", className: `kbtn${value === o ? " kbtn--primary" : ""}`, onClick: () => onChange(o), children: o }, o))) }));
|
|
47
|
+
}
|
|
48
|
+
const type = field.kind === "number" ? "number" : field.kind === "date" ? "date" : field.kind === "email" ? "email" : "text";
|
|
49
|
+
return _jsx("input", { className: "kinput", type: type, value: value, placeholder: field.placeholder, onChange: (e) => onChange(e.target.value) });
|
|
50
|
+
}
|
|
51
|
+
function FormAsk({ ask, data, title, props }) {
|
|
52
|
+
const [values, setValues] = useState(() => Object.fromEntries(data.fields.map((f) => [f.id, f.value ?? ""])));
|
|
53
|
+
const missing = data.fields.some((f) => f.required && !values[f.id]?.trim());
|
|
54
|
+
const cancel = ask.options?.find((o) => o.id === "cancel");
|
|
55
|
+
const busy = props.sending === true;
|
|
56
|
+
const submit = () => {
|
|
57
|
+
const filled = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, v.trim()]).filter(([, v]) => v));
|
|
58
|
+
const summary = data.fields.filter((f) => filled[f.id]).map((f) => `${f.label}: ${filled[f.id]}`).join("\n");
|
|
59
|
+
props.onAnswer({ text: formAnswer(filled), label: summary || "Sent" });
|
|
60
|
+
};
|
|
61
|
+
return (_jsxs("div", { className: "kcard kcard--form kask", children: [_jsxs("div", { className: "kcard__head", children: [_jsx("span", { className: "kcard__title", children: title ?? "A few details" }), _jsx("span", { className: "klabel kcard__source", children: "Question" })] }), ask.text ? _jsx("div", { className: "kask__text", children: ask.text }) : null, _jsx("div", { className: "kform", children: data.fields.map((f) => (_jsxs("label", { className: "kform__field", children: [_jsxs("span", { className: "klabel", children: [f.label, f.required ? " *" : ""] }), _jsx(FieldInput, { field: f, value: values[f.id] ?? "", onChange: (v) => setValues((cur) => ({ ...cur, [f.id]: v })) })] }, f.id))) }), _jsxs("div", { className: "kcard__actions", children: [_jsx("button", { type: "button", className: "kbtn kbtn--primary", disabled: missing || busy, onClick: submit, children: data.submit ?? "Send" }), cancel ? (_jsx("button", { type: "button", className: "kbtn", disabled: busy, onClick: () => props.onAnswer({ optionId: cancel.id, label: cancel.label }), children: cancel.label })) : null] }), _jsx(Status, { error: props.error, sending: props.sending })] }));
|
|
62
|
+
}
|
|
63
|
+
/** The options as buttons and, when the question takes words, a box for them. */
|
|
64
|
+
function Choices({ ask, props, skip = [] }) {
|
|
65
|
+
const [words, setWords] = useState("");
|
|
66
|
+
const options = ask.options?.filter((o) => !skip.includes(o.id)) ?? [];
|
|
67
|
+
const typed = ask.allowFreeform === true || !ask.options?.length;
|
|
68
|
+
const busy = props.sending === true;
|
|
69
|
+
return (_jsxs(_Fragment, { children: [options.length ? (_jsx("div", { className: "kcard__actions", children: options.map((o, i) => (_jsx("button", { type: "button", title: o.description, className: `kbtn${o.style === "primary" || (i === 0 && ask.kind === "tool-approval") ? " kbtn--primary" : ""}`, disabled: busy, onClick: () => props.onAnswer({ optionId: o.id, label: o.label }), children: o.label }, o.id))) })) : null, typed ? (_jsxs("form", { className: "kask__typed", onSubmit: (e) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
const t = words.trim();
|
|
72
|
+
if (t)
|
|
73
|
+
props.onAnswer({ text: t, label: t });
|
|
74
|
+
}, children: [_jsx("input", { className: "kinput", value: words, placeholder: "Your answer", onChange: (e) => setWords(e.target.value) }), _jsx("button", { type: "submit", className: "kbtn", disabled: busy || !words.trim(), children: "Send" })] })) : null, _jsx(Status, { error: props.error, sending: props.sending })] }));
|
|
75
|
+
}
|
|
76
|
+
function outcome(reply) {
|
|
77
|
+
if (reply.optionId === "cancel")
|
|
78
|
+
return { label: /changes/i.test(reply.label) ? "Sent back" : "Declined", good: false };
|
|
79
|
+
return { label: reply.optionId === "approve" || reply.optionId === "approved" ? "Approved" : "Answered", good: true };
|
|
80
|
+
}
|
|
81
|
+
export function AskView(props) {
|
|
82
|
+
const { ask, ctx, answered } = props;
|
|
83
|
+
const card = ask.card && isKnownCard(ask.card) ? ask.card : undefined;
|
|
84
|
+
if (card?.card === "approval") {
|
|
85
|
+
const title = cardTitle(card) ?? card.data.action;
|
|
86
|
+
if (answered) {
|
|
87
|
+
const o = outcome(answered);
|
|
88
|
+
return _jsx(Done, { title: title, label: o.label, good: o.good, detail: card.data.subject });
|
|
89
|
+
}
|
|
90
|
+
return _jsx(ApprovalAsk, { ask: ask, data: card.data, title: title, props: props });
|
|
91
|
+
}
|
|
92
|
+
if (card?.card === "form") {
|
|
93
|
+
if (answered) {
|
|
94
|
+
const skipped = answered.optionId === "cancel";
|
|
95
|
+
return _jsx(Done, { title: card.title ?? "Your answers", label: skipped ? "Skipped" : "Sent", good: !skipped, detail: skipped ? undefined : answered.label });
|
|
96
|
+
}
|
|
97
|
+
return _jsx(FormAsk, { ask: ask, data: card.data, title: card.title, props: props });
|
|
98
|
+
}
|
|
99
|
+
if (ask.spend) {
|
|
100
|
+
const s = ask.spend;
|
|
101
|
+
const merchant = s.merchant ?? (s.merchant_url ? host(s.merchant_url) : "a merchant");
|
|
102
|
+
if (answered) {
|
|
103
|
+
const cancelled = answered.optionId === "cancel";
|
|
104
|
+
return _jsx(Done, { title: "Purchase approval", label: cancelled ? "Cancelled" : "Approved", good: !cancelled, detail: `${money(s.amount, s.currency)} at ${merchant}` });
|
|
105
|
+
}
|
|
106
|
+
return (_jsxs("div", { className: "kcard kcard--spend kask", children: [_jsxs("div", { className: "kcard__head", children: [_jsx("span", { className: "kcard__title", children: "Purchase approval" }), _jsx("span", { className: "klabel kcard__source", children: "Link" })] }), _jsxs("div", { className: "kask__amount", children: [_jsx("span", { className: "kask__total", children: money(s.amount, s.currency) }), _jsxs("span", { className: "kask__note", children: ["at ", merchant, s.merchant_url && s.merchant ? _jsxs(_Fragment, { children: [" \u00B7 ", host(s.merchant_url)] }) : null] })] }), s.approval_url ? (_jsx("div", { className: "kcard__actions", children: _jsx("button", { type: "button", className: "kbtn kbtn--primary", onClick: () => ctx.openUrl(s.approval_url), children: "Approve in Link" }) })) : null, _jsx(Choices, { ask: ask, props: props }), _jsx("div", { className: "kask__note", children: "Approving in Link issues a one-time card for exactly this amount. The agent checks with Link before paying." })] }));
|
|
107
|
+
}
|
|
108
|
+
if (ask.vault) {
|
|
109
|
+
const v = ask.vault;
|
|
110
|
+
const what = v.kind === "login" ? "a login" : v.kind === "card" ? "a card" : v.kind === "address" ? "an address" : "contact details";
|
|
111
|
+
if (answered)
|
|
112
|
+
return _jsx(Done, { title: `Needs ${what}`, label: answered.optionId === "cancel" ? "Cancelled" : "Answered", good: answered.optionId !== "cancel" });
|
|
113
|
+
return (_jsxs("div", { className: "kcard kcard--vault kask", children: [_jsxs("div", { className: "kcard__head", children: [_jsxs("span", { className: "kcard__title", children: ["Needs ", what, v.site ? _jsxs(_Fragment, { children: [" for ", host(v.site)] }) : null] }), _jsx("span", { className: "klabel kcard__source", children: "Vault" })] }), v.reason ? _jsx("div", { className: "kask__text", children: v.reason }) : ask.text ? _jsx("div", { className: "kask__text", children: ask.text }) : null, _jsx("div", { className: "kask__note", children: "Add it to your vault in KYBER Studio. Never type a password or card number in a conversation." }), _jsx(Choices, { ask: { ...ask, allowFreeform: false }, props: props })] }));
|
|
114
|
+
}
|
|
115
|
+
// Any other question: its card, if it carries one, then its sentence and options.
|
|
116
|
+
if (answered)
|
|
117
|
+
return _jsx(Done, { title: ask.text.split("\n")[0].slice(0, 120) || "Question", label: "Answered", good: true, detail: answered.label });
|
|
118
|
+
return (_jsxs("div", { className: "kask-stack", children: [ask.card ? _jsx(CardView, { card: ask.card, ctx: ctx }) : null, _jsxs("div", { className: "kcard kask", children: [_jsx("div", { className: "kask__text", children: ask.text }), _jsx(Choices, { ask: ask, props: props })] })] }));
|
|
119
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { ApprovalBody, ArtifactFrame, CardStack, CardView, dateLabel, formatSize, whenLabel, withPolicy, type CardContext, type Loaded, } from "./CardView.js";
|
|
2
|
+
export { AskView, type AskReply, type AskViewProps } from "./AskView.js";
|
|
2
3
|
export { cardStyles } from "./styles.js";
|
package/dist/index.js
CHANGED
package/dist/styles.js
CHANGED
|
@@ -85,6 +85,22 @@ export const cardStyles = `
|
|
|
85
85
|
.kbrief__facts dd { margin: 2px 0 0; }
|
|
86
86
|
.kbrief .kstats { margin-top: 12px; }
|
|
87
87
|
|
|
88
|
+
.kform__field { display: flex; flex-direction: column; gap: 4px; }
|
|
89
|
+
.kinput {
|
|
90
|
+
width: 100%; box-sizing: border-box; min-height: 30px; padding: 5px 9px; border-radius: 8px;
|
|
91
|
+
border: 1px solid var(--kyb-stroke-strong, rgba(22, 24, 29, 0.16)); background: var(--kyb-surface, #ffffff);
|
|
92
|
+
color: var(--kyb-ink, #16181d); font: inherit; font-size: var(--kyb-fs-base, 14px);
|
|
93
|
+
}
|
|
94
|
+
.kinput--long { resize: vertical; min-height: 90px; }
|
|
95
|
+
.kbtn:disabled { opacity: 0.5; cursor: default; }
|
|
96
|
+
.kask-stack { display: flex; flex-direction: column; gap: 8px; }
|
|
97
|
+
.kask__text { white-space: pre-wrap; margin-bottom: 4px; }
|
|
98
|
+
.kask__note { font-size: var(--kyb-fs-sm, 13px); color: var(--kyb-ink-tertiary, #8b91a0); margin-top: 8px; }
|
|
99
|
+
.kask__error { font-size: var(--kyb-fs-sm, 13px); color: var(--kyb-bad, #dc2626); margin-top: 8px; }
|
|
100
|
+
.kask__amount { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
|
101
|
+
.kask__amount .kask__note { margin-top: 0; }
|
|
102
|
+
.kask__total { font-size: 22px; line-height: 28px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
103
|
+
.kask__typed { display: flex; gap: 7px; margin-top: 10px; }
|
|
88
104
|
.klink { display: block; width: 100%; text-align: left; padding: 0; }
|
|
89
105
|
.klink__desc { color: var(--kyb-ink-secondary, #5c6270); margin-bottom: 4px; }
|
|
90
106
|
.klink__url { font-size: var(--kyb-fs-sm, 13px); color: var(--kyb-ink-tertiary, #8b91a0); text-decoration: underline; text-decoration-color: var(--kyb-stroke-strong, rgba(22, 24, 29, 0.16)); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/cards-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "React components that draw Kybernesis cards: one frame and one body per card type, themed by CSS variables, with the host's own way to open links, artifacts and files.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"test": "node --test \"test/**/*.test.mjs\""
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@kybernesis/cards": "^0.
|
|
34
|
+
"@kybernesis/cards": "^0.5.0",
|
|
35
35
|
"react": ">=18"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@kybernesis/cards": "^0.
|
|
38
|
+
"@kybernesis/cards": "^0.5.0",
|
|
39
39
|
"@types/node": "^24.0.0",
|
|
40
40
|
"@types/react": "^19.0.0",
|
|
41
41
|
"react": "^19.0.0",
|