@kybernesis/cards-react 0.1.0 → 0.3.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 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,37 @@
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
+ * In a shared conversation a question can be someone else's. `waitingOn`
15
+ * draws it without buttons, saying whose answer it waits for, and a question
16
+ * that carries `settled` shows how it ended for everyone who sees it.
17
+ */
18
+ export interface AskReply {
19
+ optionId?: string;
20
+ text?: string;
21
+ /** What the answer said, in words, for the conversation. */
22
+ label: string;
23
+ }
24
+ export interface AskViewProps {
25
+ ask: Ask;
26
+ ctx: CardContext;
27
+ /** How it was answered, once it was. */
28
+ answered?: AskReply;
29
+ /** Why the last answer did not go, shown under the buttons. */
30
+ error?: string;
31
+ /** True while an answer is on its way. */
32
+ sending?: boolean;
33
+ /** Someone else's question: drawn without buttons, waiting on this name. */
34
+ waitingOn?: string;
35
+ onAnswer(reply: AskReply): void;
36
+ }
37
+ export declare function AskView(props: AskViewProps): ReactNode;
@@ -0,0 +1,155 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { cardTitle, formAnswer, isKnownCard, money, settledLabel, settledLine } 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
+ function askTitle(ask) {
82
+ const card = ask.card && isKnownCard(ask.card) ? ask.card : undefined;
83
+ if (card?.card === "approval")
84
+ return cardTitle(card) ?? card.data.action;
85
+ if (card?.card === "form")
86
+ return card.title ?? "A few details";
87
+ if (ask.spend)
88
+ return "Purchase approval";
89
+ return ask.text.split("\n")[0].slice(0, 120) || "Question";
90
+ }
91
+ /** What the question asks, without anything to press: the email, the amount, or the sentence. */
92
+ function AskBody({ ask }) {
93
+ const card = ask.card && isKnownCard(ask.card) ? ask.card : undefined;
94
+ if (card?.card === "approval")
95
+ return _jsx(ApprovalBody, { data: card.data });
96
+ if (ask.spend) {
97
+ const s = ask.spend;
98
+ return (_jsxs("div", { className: "kask__amount", children: [_jsx("span", { className: "kask__total", children: money(s.amount, s.currency) }), _jsxs("span", { className: "kask__note", children: ["at ", s.merchant ?? (s.merchant_url ? host(s.merchant_url) : "a merchant")] })] }));
99
+ }
100
+ return ask.text ? _jsx("div", { className: "kask__text", children: ask.text }) : null;
101
+ }
102
+ /** A question answered or let go, as everyone in the conversation sees it. */
103
+ function Settled({ ask }) {
104
+ const outcome = ask.settled.outcome;
105
+ const good = outcome === "approved" || outcome === "answered";
106
+ const card = ask.card && isKnownCard(ask.card) ? ask.card : undefined;
107
+ return (_jsxs("div", { className: "kcard kask is-done", children: [_jsxs("div", { className: "kcard__head", children: [_jsx("span", { className: "kcard__title", children: askTitle(ask) }), _jsx("span", { className: `kpill${good ? " is-good" : ""}`, children: settledLabel(ask) })] }), card?.card === "approval" && card.data.subject ? _jsx("div", { className: "kcard__text", children: card.data.subject }) : null, _jsx("div", { className: "kask__note", children: settledLine(ask) })] }));
108
+ }
109
+ /** Someone else's question, open: what it asks, and whose answer it waits for. */
110
+ function Waiting({ ask, name }) {
111
+ return (_jsxs("div", { className: "kcard kask", children: [_jsxs("div", { className: "kcard__head", children: [_jsx("span", { className: "kcard__title", children: askTitle(ask) }), _jsxs("span", { className: "kpill", children: ["Waiting on ", name] })] }), _jsx(AskBody, { ask: ask })] }));
112
+ }
113
+ export function AskView(props) {
114
+ const { ask, ctx, answered } = props;
115
+ const card = ask.card && isKnownCard(ask.card) ? ask.card : undefined;
116
+ if (ask.settled)
117
+ return _jsx(Settled, { ask: ask });
118
+ if (props.waitingOn && !answered)
119
+ return _jsx(Waiting, { ask: ask, name: props.waitingOn });
120
+ if (card?.card === "approval") {
121
+ const title = cardTitle(card) ?? card.data.action;
122
+ if (answered) {
123
+ const o = outcome(answered);
124
+ return _jsx(Done, { title: title, label: o.label, good: o.good, detail: card.data.subject });
125
+ }
126
+ return _jsx(ApprovalAsk, { ask: ask, data: card.data, title: title, props: props });
127
+ }
128
+ if (card?.card === "form") {
129
+ if (answered) {
130
+ const skipped = answered.optionId === "cancel";
131
+ return _jsx(Done, { title: card.title ?? "Your answers", label: skipped ? "Skipped" : "Sent", good: !skipped, detail: skipped ? undefined : answered.label });
132
+ }
133
+ return _jsx(FormAsk, { ask: ask, data: card.data, title: card.title, props: props });
134
+ }
135
+ if (ask.spend) {
136
+ const s = ask.spend;
137
+ const merchant = s.merchant ?? (s.merchant_url ? host(s.merchant_url) : "a merchant");
138
+ if (answered) {
139
+ const cancelled = answered.optionId === "cancel";
140
+ return _jsx(Done, { title: "Purchase approval", label: cancelled ? "Cancelled" : "Approved", good: !cancelled, detail: `${money(s.amount, s.currency)} at ${merchant}` });
141
+ }
142
+ 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." })] }));
143
+ }
144
+ if (ask.vault) {
145
+ const v = ask.vault;
146
+ const what = v.kind === "login" ? "a login" : v.kind === "card" ? "a card" : v.kind === "address" ? "an address" : "contact details";
147
+ if (answered)
148
+ return _jsx(Done, { title: `Needs ${what}`, label: answered.optionId === "cancel" ? "Cancelled" : "Answered", good: answered.optionId !== "cancel" });
149
+ 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 })] }));
150
+ }
151
+ // Any other question: its card, if it carries one, then its sentence and options.
152
+ if (answered)
153
+ return _jsx(Done, { title: ask.text.split("\n")[0].slice(0, 120) || "Question", label: "Answered", good: true, detail: answered.label });
154
+ 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 })] })] }));
155
+ }
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
@@ -1,2 +1,3 @@
1
1
  export { ApprovalBody, ArtifactFrame, CardStack, CardView, dateLabel, formatSize, whenLabel, withPolicy, } from "./CardView.js";
2
+ export { AskView } from "./AskView.js";
2
3
  export { cardStyles } from "./styles.js";
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.1.0",
3
+ "version": "0.3.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.4.0",
34
+ "@kybernesis/cards": "^0.6.0",
35
35
  "react": ">=18"
36
36
  },
37
37
  "devDependencies": {
38
- "@kybernesis/cards": "^0.4.0",
38
+ "@kybernesis/cards": "^0.6.0",
39
39
  "@types/node": "^24.0.0",
40
40
  "@types/react": "^19.0.0",
41
41
  "react": "^19.0.0",