@clawnify/app 0.2.2 → 0.3.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.
@@ -79,10 +79,28 @@ interface AppNavGroup {
79
79
  label?: string;
80
80
  items: AppNavItem[];
81
81
  }
82
+ /** Host messages this app handles itself. The host falls back for the rest. */
83
+ type HostFeature = "changes" | "path";
84
+ /**
85
+ * What the user is looking at, for the dashboard's chat. It lets them say
86
+ * "this contact" and be understood. Labels and ids only: the chat reads the
87
+ * record itself through your API if it needs more.
88
+ */
89
+ interface ChatContext {
90
+ /** The screen, e.g. "Contact" or "Pipeline". */
91
+ label?: string;
92
+ /** The record on screen, when there is one. */
93
+ record?: {
94
+ type: string;
95
+ id: string;
96
+ label?: string;
97
+ };
98
+ }
82
99
  type AppToHostMessage = {
83
100
  source: "clawnify-app";
84
101
  v: 1;
85
102
  type: "ready";
103
+ features?: HostFeature[];
86
104
  } | {
87
105
  source: "clawnify-app";
88
106
  v: 1;
@@ -97,6 +115,16 @@ type AppToHostMessage = {
97
115
  v: 1;
98
116
  type: "location";
99
117
  path: string;
118
+ } | {
119
+ source: "clawnify-app";
120
+ v: 1;
121
+ type: "context";
122
+ context: ChatContext | null;
123
+ } | {
124
+ source: "clawnify-app";
125
+ v: 1;
126
+ type: "chat";
127
+ prompt?: string;
100
128
  };
101
129
  type HostToAppMessage = {
102
130
  source: "clawnify-host";
@@ -107,6 +135,16 @@ type HostToAppMessage = {
107
135
  v: 1;
108
136
  type: "navigate";
109
137
  id: string;
138
+ } | {
139
+ source: "clawnify-host";
140
+ v: 1;
141
+ type: "navigate";
142
+ path: string;
143
+ } | {
144
+ source: "clawnify-host";
145
+ v: 1;
146
+ type: "changed";
147
+ paths: string[];
110
148
  };
111
149
  /**
112
150
  * Whether this page is running inside the Clawnify dashboard. Computed once at
@@ -114,6 +152,46 @@ type HostToAppMessage = {
114
152
  * for the tab so in-iframe full navigations keep the mode.
115
153
  */
116
154
  declare const embedded: boolean;
155
+ /**
156
+ * Open the dashboard's chat, optionally with a prompt in the composer. The
157
+ * prompt is a DRAFT: the user reads it and presses send. The host never sends
158
+ * text an app wrote, because an app renders data other people typed (a form
159
+ * submission, a contact note) and that text must not drive the user's agent.
160
+ *
161
+ * Returns false when standalone (no dashboard, no chat): hide the button then,
162
+ * or check `embedded` before rendering it.
163
+ *
164
+ * {embedded && <button onClick={() => openChat(`Draft a follow-up for ${c.name}`)}>Ask AI</button>}
165
+ * {embedded && <button onClick={() => openChat()}>Chat</button>} // no prompt: just open it
166
+ */
167
+ declare function openChat(prompt?: string): boolean;
168
+ /**
169
+ * Tell the dashboard's chat what the user is looking at, so "this contact"
170
+ * means the one on screen. Call it from the screen that shows the record; the
171
+ * context clears when that screen unmounts. Labels and ids only, never the
172
+ * record's content: the chat reads that through your API when it needs to.
173
+ *
174
+ * useChatContext({ label: "Contact", record: { type: "contact", id: c.id, label: c.name } });
175
+ */
176
+ declare function useChatContext(context: ChatContext | null): void;
177
+ /**
178
+ * Run `onChange` when the dashboard's chat has written this app's data (a
179
+ * POST, PUT, PATCH or DELETE through your API), with the API paths it called.
180
+ * Refetch what is on screen. While no mounted component uses this hook, the
181
+ * dashboard reloads the whole iframe after such a write instead.
182
+ *
183
+ * useHostChanges(() => { void reload(); });
184
+ */
185
+ declare function useHostChanges(onChange: (paths: string[]) => void): void;
186
+ /**
187
+ * Let the dashboard (its chat, a deep link) open any page of the app through
188
+ * your router, without reloading the iframe. `path` is an in-app path such as
189
+ * "/contacts/42?tab=deals". While no mounted component uses this hook, the
190
+ * dashboard reloads the iframe at that path instead.
191
+ *
192
+ * useHostNavigate((path) => navigate(path));
193
+ */
194
+ declare function useHostNavigate(onNavigate: (path: string) => void): void;
117
195
  /**
118
196
  * Tell the host where the app is, so the dashboard URL can restore this view
119
197
  * on reload. Call it whenever your router's location changes; a no-op when
@@ -149,4 +227,4 @@ interface AppNavProps {
149
227
  */
150
228
  declare function AppNav({ title, icon, groups, active, onNavigate, children }: AppNavProps): react.JSX.Element | null;
151
229
 
152
- export { AppNav, type AppNavGroup, type AppNavItem, type AppNavProps, type AppToHostMessage, HOST_BRIDGE_VERSION, type HostToAppMessage, NAV_COLORS, type NavColor, TILE_ICONS, TileIcon, type TileIconName, type TileIconProps, embedded, navColorFor, reportLocation };
230
+ export { AppNav, type AppNavGroup, type AppNavItem, type AppNavProps, type AppToHostMessage, type ChatContext, HOST_BRIDGE_VERSION, type HostFeature, type HostToAppMessage, NAV_COLORS, type NavColor, TILE_ICONS, TileIcon, type TileIconName, type TileIconProps, embedded, navColorFor, openChat, reportLocation, useChatContext, useHostChanges, useHostNavigate };
@@ -188,6 +188,107 @@ function post(message) {
188
188
  if (!embedded) return;
189
189
  window.parent.postMessage(message, "*");
190
190
  }
191
+ var hostListeners = /* @__PURE__ */ new Set();
192
+ var featureCounts = /* @__PURE__ */ new Map();
193
+ var listening = false;
194
+ var chatContext = null;
195
+ function announce() {
196
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "ready", features: [...featureCounts.keys()] });
197
+ }
198
+ function listen() {
199
+ if (listening || !embedded) return;
200
+ listening = true;
201
+ window.addEventListener("message", (e) => {
202
+ if (e.source !== window.parent) return;
203
+ const d = e.data;
204
+ if (!d || d.source !== "clawnify-host" || d.v !== HOST_BRIDGE_VERSION) return;
205
+ if (d.type === "hello" && chatContext) {
206
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "context", context: chatContext });
207
+ }
208
+ for (const listener of [...hostListeners]) listener(d);
209
+ });
210
+ announce();
211
+ }
212
+ function subscribe(listener, feature) {
213
+ if (!embedded) return () => {
214
+ };
215
+ listen();
216
+ hostListeners.add(listener);
217
+ if (feature) {
218
+ const n = featureCounts.get(feature) ?? 0;
219
+ featureCounts.set(feature, n + 1);
220
+ if (n === 0) announce();
221
+ }
222
+ return () => {
223
+ hostListeners.delete(listener);
224
+ if (!feature) return;
225
+ const n = (featureCounts.get(feature) ?? 1) - 1;
226
+ if (n > 0) featureCounts.set(feature, n);
227
+ else {
228
+ featureCounts.delete(feature);
229
+ announce();
230
+ }
231
+ };
232
+ }
233
+ function isAppPath(path) {
234
+ return typeof path === "string" && path.length <= 2048 && /^\/(?![/\\])/.test(path);
235
+ }
236
+ function openChat(prompt) {
237
+ if (!embedded) return false;
238
+ post({
239
+ source: "clawnify-app",
240
+ v: HOST_BRIDGE_VERSION,
241
+ type: "chat",
242
+ ...prompt ? { prompt: String(prompt).slice(0, 2e3) } : {}
243
+ });
244
+ return true;
245
+ }
246
+ function useChatContext(context) {
247
+ const key = embedded && context ? JSON.stringify(sanitizeContext(context)) : "";
248
+ useEffect(() => {
249
+ if (!embedded || !key) return;
250
+ listen();
251
+ chatContext = JSON.parse(key);
252
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "context", context: chatContext });
253
+ return () => {
254
+ chatContext = null;
255
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "context", context: null });
256
+ };
257
+ }, [key]);
258
+ }
259
+ function sanitizeContext(c) {
260
+ return {
261
+ ...c.label ? { label: String(c.label).slice(0, 60) } : {},
262
+ ...c.record && c.record.type && c.record.id ? {
263
+ record: {
264
+ type: String(c.record.type).slice(0, 40),
265
+ id: String(c.record.id).slice(0, 80),
266
+ ...c.record.label ? { label: String(c.record.label).slice(0, 80) } : {}
267
+ }
268
+ } : {}
269
+ };
270
+ }
271
+ function useHostChanges(onChange) {
272
+ const ref = useRef(onChange);
273
+ ref.current = onChange;
274
+ useEffect(
275
+ () => subscribe((d) => {
276
+ if (d.type !== "changed" || !Array.isArray(d.paths)) return;
277
+ ref.current(d.paths.filter((p) => typeof p === "string").slice(0, 20));
278
+ }, "changes"),
279
+ []
280
+ );
281
+ }
282
+ function useHostNavigate(onNavigate) {
283
+ const ref = useRef(onNavigate);
284
+ ref.current = onNavigate;
285
+ useEffect(
286
+ () => subscribe((d) => {
287
+ if (d.type === "navigate" && "path" in d && isAppPath(d.path)) ref.current(d.path);
288
+ }, "path"),
289
+ []
290
+ );
291
+ }
191
292
  function reportLocation(path) {
192
293
  let clean = path;
193
294
  try {
@@ -212,25 +313,22 @@ function AppNav({ title, icon, groups, active, onNavigate, children }) {
212
313
  const { title: title2, icon: icon2, groups: groups2, active: active2 } = JSON.parse(payload);
213
314
  post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "nav", title: title2, icon: icon2, groups: groups2, active: active2 });
214
315
  }, [payload]);
215
- useEffect(() => {
216
- if (!embedded) return;
217
- function onMessage(e) {
218
- if (e.source !== window.parent) return;
219
- const d = e.data;
220
- if (!d || d.source !== "clawnify-host" || d.v !== HOST_BRIDGE_VERSION) return;
316
+ useEffect(
317
+ () => subscribe((d) => {
221
318
  if (d.type === "hello") {
222
319
  lastSent.current = "";
223
320
  const g = sanitize(groupsRef.current);
224
321
  post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "nav", title, icon, groups: g, active });
225
- } else if (d.type === "navigate" && typeof d.id === "string") {
322
+ } else if (d.type === "navigate" && "id" in d && typeof d.id === "string") {
226
323
  const item = groupsRef.current.flatMap((g) => g.items).find((i) => i.id === d.id);
227
324
  if (item) onNavigateRef.current(item);
228
325
  }
229
- }
230
- window.addEventListener("message", onMessage);
231
- post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "ready" });
232
- return () => window.removeEventListener("message", onMessage);
233
- }, []);
326
+ }),
327
+ // title/active are read from the closure only on `hello`; the publish
328
+ // effect above covers their normal updates.
329
+ // eslint-disable-next-line react-hooks/exhaustive-deps
330
+ []
331
+ );
234
332
  if (embedded) return null;
235
333
  const home = groups.flatMap((g) => g.items).find((i) => i.home);
236
334
  const brand = /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -414,6 +512,10 @@ export {
414
512
  TileIcon,
415
513
  embedded,
416
514
  navColorFor,
417
- reportLocation
515
+ openChat,
516
+ reportLocation,
517
+ useChatContext,
518
+ useHostChanges,
519
+ useHostNavigate
418
520
  };
419
521
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/client/index.tsx"],"sourcesContent":["/**\n * @clawnify/app/client — the host bridge for a Clawnify app's browser side.\n *\n * A Clawnify app is a full page on its own origin. Opened directly (local\n * `pnpm dev`, or `<slug>.apps.clawnify.com`) it renders its own sidebar.\n * Opened inside the Clawnify dashboard it sits in an iframe, and the dashboard\n * wants to render that same navigation in ITS sidebar so the user sees one\n * nav, not two.\n *\n * `<AppNav>` is the one definition of the app's navigation that serves both:\n *\n * • standalone → it draws the app's sidebar (desktop rail, mobile strip)\n * • embedded → it draws nothing and posts the nav tree to the host over\n * `postMessage`; the host renders it and posts `navigate`\n * back when the user clicks.\n *\n * Nothing about the app changes between the two: same router, same screens,\n * same data. Only who paints the list of links.\n *\n * import { AppNav, reportLocation } from \"@clawnify/app/client\";\n *\n * <AppNav\n * title=\"OpenDialer\"\n * icon=\"phone\"\n * groups={[{ label: \"Work\", items: [\n * { id: \"dashboard\", label: \"Dashboard\", href: \"/\", home: true },\n * { id: \"leads\", label: \"Leads\", href: \"/leads\", icon: \"users\", count: 12 },\n * ]}]}\n * active=\"leads\"\n * onNavigate={(item) => navigate(item.href!)}\n * />\n *\n * Re-render with a new `groups` / `active` and the host sidebar updates in\n * the same tick — counts are just a field, so \"Leads 12\" becomes \"Leads 11\"\n * the moment your own state does. No polling, no endpoint.\n *\n * Embedding is detected from the `clawnify_host=1` query parameter the\n * dashboard appends to the iframe URL, remembered in sessionStorage so a\n * full-page navigation inside the iframe stays in embedded mode. A page that\n * is iframed by anything else, or opened directly, is \"standalone\".\n *\n * Protocol (versioned, additive — `nav` and `location` are the first\n * message types; more host surfaces can ride the same channel later):\n *\n * app → host { source: \"clawnify-app\", v: 1, type: \"ready\" }\n * { source: \"clawnify-app\", v: 1, type: \"nav\", title, icon?, groups, active? }\n * { source: \"clawnify-app\", v: 1, type: \"location\", path }\n * host → app { source: \"clawnify-host\", v: 1, type: \"hello\" }\n * { source: \"clawnify-host\", v: 1, type: \"navigate\", id }\n */\nimport {\n useEffect,\n useRef,\n type MouseEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AddressBook,\n Archive,\n Bell,\n BookOpen,\n Briefcase,\n Building,\n Calendar,\n CalendarDots,\n Camera,\n ChartBar,\n ChartPie,\n ChatCircle,\n CheckSquare,\n ClipboardText,\n Clock,\n ClockCounterClockwise,\n Code,\n CreditCard,\n CurrencyDollar,\n Database,\n Envelope,\n File,\n FileText,\n Folder,\n Gear,\n Globe,\n GridFour,\n Hash,\n House,\n Image,\n Kanban,\n Lightning,\n Link,\n List,\n ListChecks,\n MagnifyingGlass,\n MapTrifold,\n Microphone,\n Package,\n PaperPlaneTilt,\n Phone,\n Play,\n Pulse,\n Robot,\n ShoppingCart,\n Sparkle,\n SquaresFour,\n Stack,\n Star,\n Table,\n Tag,\n Target,\n Tray,\n Truck,\n Upload,\n User,\n VideoCamera,\n Wallet,\n type Icon as PhosphorIcon,\n} from \"@phosphor-icons/react\";\n\nexport const HOST_BRIDGE_VERSION = 1 as const;\n\n/** One row in the navigation. `icon` is a lucide icon name (kebab-case). */\nexport interface AppNavItem {\n /** Stable identifier the host hands back on `navigate`. */\n id: string;\n label: string;\n /**\n * In-app path this item opens. Optional: a state-driven view can omit it.\n * When present the host uses it to restore the view on reload and to make\n * the dashboard URL shareable.\n */\n href?: string;\n /** Icon name from TILE_ICONS, e.g. \"layout-dashboard\". Unknown names render a dot. */\n icon?: string;\n /** Live badge, e.g. items waiting. Omit for none. */\n count?: number;\n /**\n * The app's home screen. Not listed as a row: standalone the brand row\n * opens it, and in the dashboard the app's own header does. One per app.\n */\n home?: boolean;\n /**\n * Tile colour for this section. Sections are the records inside an app,\n * so they draw as a small coloured tile with a white glyph (the app itself\n * is navigation and stays an outline glyph). One of `NAV_COLORS`; omitted\n * = picked from the palette by `id`, stable across renders and hosts.\n */\n color?: NavColor;\n}\n\n/** The section tile palette, shared by the standalone sidebar and the dashboard. */\nexport const NAV_COLORS = {\n red: \"#ef4444\",\n orange: \"#f97514\",\n amber: \"#f59e0b\",\n green: \"#22c55e\",\n sky: \"#17bde9\",\n blue: \"#266df0\",\n violet: \"#8b5cf6\",\n pink: \"#ec4899\",\n} as const;\nexport type NavColor = keyof typeof NAV_COLORS;\n\n/** Deterministic palette pick for an item without an explicit colour. */\nexport function navColorFor(id: string): NavColor {\n const names = Object.keys(NAV_COLORS) as NavColor[];\n let h = 0;\n for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;\n return names[h % names.length];\n}\n\n/**\n * The icon library: the names the Clawnify dashboard can draw for an app\n * and its sections (resolved to the Phosphor set in both sidebars). Pick\n * from these; the apps AGENTS.md lists them by meaning.\n */\nexport const TILE_ICONS = [\n \"activity\",\n \"archive\",\n \"bar-chart-3\",\n \"bell\",\n \"book-open\",\n \"bot\",\n \"briefcase\",\n \"building-2\",\n \"calendar\",\n \"calendar-days\",\n \"camera\",\n \"check-square\",\n \"clipboard-list\",\n \"clock\",\n \"code-2\",\n \"contact\",\n \"credit-card\",\n \"database\",\n \"dollar-sign\",\n \"file\",\n \"file-text\",\n \"folder\",\n \"globe\",\n \"hash\",\n \"history\",\n \"home\",\n \"image\",\n \"inbox\",\n \"kanban\",\n \"layers\",\n \"layout-dashboard\",\n \"layout-grid\",\n \"link\",\n \"list\",\n \"list-checks\",\n \"mail\",\n \"map\",\n \"message-square\",\n \"mic\",\n \"package\",\n \"phone\",\n \"pie-chart\",\n \"play\",\n \"search\",\n \"send\",\n \"settings\",\n \"shopping-cart\",\n \"sparkles\",\n \"star\",\n \"table\",\n \"tag\",\n \"target\",\n \"truck\",\n \"upload\",\n \"user\",\n \"users\",\n \"video\",\n \"wallet\",\n \"zap\",\n] as const;\nexport type TileIconName = (typeof TILE_ICONS)[number];\n\nexport interface TileIconProps {\n /** Icon name from TILE_ICONS. Unknown or missing = a small dot. */\n icon?: string;\n /** Palette colour. Omitted = picked from `seed` (or `icon`) by hash. */\n color?: NavColor;\n /** Stable string the colour is derived from when `color` is not given. */\n seed?: string;\n /** Tile edge in px. The glyph scales with it. Default 16. */\n size?: number;\n className?: string;\n title?: string;\n}\n\n/**\n * The record tile: a small rounded square in a palette colour with a white\n * lucide glyph. The sidebar draws every app section with it; apps can use\n * the same tile anywhere a record type needs an identity mark (table rows,\n * cards, pickers) so the two surfaces stay one system. Self-contained inline\n * styles, so it works outside <AppNav> and in any stylesheet setup.\n *\n * <TileIcon icon=\"users\" color=\"blue\" />\n * <TileIcon icon=\"phone\" seed={lead.id} size={20} />\n */\nexport function TileIcon({ icon, color, seed, size = 16, className, title }: TileIconProps) {\n const fill = NAV_COLORS[color ?? navColorFor(seed ?? icon ?? \"\")];\n // The glyph box is 0.7 of the tile: Phosphor draws on ~224 of its 256\n // units, so the visible glyph lands near 60% of the tile with clear\n // padding around it, solid white — Attio's record-tile proportion.\n const glyph = Math.round(size * 0.7);\n return (\n <span\n className={className}\n title={title}\n aria-hidden={title ? undefined : true}\n style={{\n display: \"inline-flex\",\n width: size,\n height: size,\n flexShrink: 0,\n alignItems: \"center\",\n justifyContent: \"center\",\n borderRadius: \"30%\",\n border: \"1px solid rgb(0 0 0 / 0.06)\",\n background: fill,\n color: \"#fff\",\n }}\n >\n {iconFor(icon) ? (\n (() => { const I = iconFor(icon)!; return <I size={glyph} weight=\"fill\" aria-hidden />; })()\n ) : (\n <span style={{ width: glyph * 0.6, height: glyph * 0.6, borderRadius: 9999, background: \"currentColor\" }} />\n )}\n </span>\n );\n}\n\nexport interface AppNavGroup {\n /** Eyebrow shown above the items. Omit for an unlabelled group. */\n label?: string;\n items: AppNavItem[];\n}\n\nexport type AppToHostMessage =\n | { source: \"clawnify-app\"; v: 1; type: \"ready\" }\n | {\n source: \"clawnify-app\";\n v: 1;\n type: \"nav\";\n title: string;\n /** lucide icon name for the app itself. */\n icon?: string;\n groups: AppNavGroup[];\n active?: string;\n }\n | { source: \"clawnify-app\"; v: 1; type: \"location\"; path: string };\n\nexport type HostToAppMessage =\n | { source: \"clawnify-host\"; v: 1; type: \"hello\" }\n | { source: \"clawnify-host\"; v: 1; type: \"navigate\"; id: string };\n\nconst HOST_PARAM = \"clawnify_host\";\nconst STORAGE_KEY = \"clawnify_host\";\n\n/**\n * Whether this page is running inside the Clawnify dashboard. Computed once at\n * module load — the query parameter is only on the initial URL — and remembered\n * for the tab so in-iframe full navigations keep the mode.\n */\nexport const embedded: boolean = (() => {\n if (typeof window === \"undefined\") return false;\n if (window.parent === window) return false;\n let flagged = false;\n try {\n flagged = new URLSearchParams(window.location.search).get(HOST_PARAM) === \"1\";\n if (flagged) window.sessionStorage.setItem(STORAGE_KEY, \"1\");\n else flagged = window.sessionStorage.getItem(STORAGE_KEY) === \"1\";\n } catch {\n // Storage can throw in a partitioned or locked-down iframe; the query\n // parameter alone still decides for this document.\n }\n return flagged;\n})();\n\nfunction post(message: AppToHostMessage) {\n if (!embedded) return;\n // The dashboard may live on app.clawnify.com or a white-label domain, so the\n // app cannot pin the target origin. The payload is navigation labels and a\n // path — nothing secret — and the host verifies the sender is its own iframe.\n window.parent.postMessage(message, \"*\");\n}\n\n/**\n * Tell the host where the app is, so the dashboard URL can restore this view\n * on reload. Call it whenever your router's location changes; a no-op when\n * standalone.\n */\nexport function reportLocation(path: string) {\n // The initial URL carries the dashboard's own parameters (the app token\n // and the host flag). They are not part of the app's location and must\n // never land in a URL the dashboard writes, so strip them here as well.\n let clean = path;\n try {\n const u = new URL(path, \"http://app.invalid\");\n u.searchParams.delete(\"token\");\n u.searchParams.delete(HOST_PARAM);\n clean = u.pathname + u.search + u.hash;\n } catch {\n // Not a path we can parse: send as-is, the host validates its shape.\n }\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"location\", path: clean });\n}\n\nexport interface AppNavProps {\n /** App name, shown in the brand row standalone and in the host's switcher. */\n title: string;\n /**\n * The app's own glyph, a lucide icon name (kebab-case). Drawn as an\n * outline icon with no background: in the standalone brand row, and by the\n * dashboard on the app's sidebar item. Tiles are for records, not for\n * navigation, so the app's coloured tile icon stays out of the sidebar.\n */\n icon?: string;\n groups: AppNavGroup[];\n /** `id` of the active item. */\n active?: string;\n /** Called with the item the user chose, from either renderer. */\n onNavigate: (item: AppNavItem) => void;\n /** Rendered at the bottom of the standalone sidebar (a footer action, say). */\n children?: ReactNode;\n}\n\n/**\n * The app's navigation: sidebar when standalone, host-fed when embedded.\n *\n * Standalone layout: the sidebar is a 260px column at ≥768px and a horizontal\n * strip below that, so put it first inside a `flex flex-col md:flex-row`\n * container. It paints with the app's own theme tokens (`--surface`,\n * `--border`, `--primary`…) and falls back to the platform defaults when a\n * token is missing.\n */\nexport function AppNav({ title, icon, groups, active, onNavigate, children }: AppNavProps) {\n // Always the latest callback, so the host listener never calls a stale one.\n const onNavigateRef = useRef(onNavigate);\n onNavigateRef.current = onNavigate;\n const groupsRef = useRef(groups);\n groupsRef.current = groups;\n\n // Embedded: publish the tree whenever it changes. Serialising is the cheap\n // way to dedupe — apps build `groups` inline every render.\n const payload = embedded\n ? JSON.stringify({ title, icon, groups: sanitize(groups), active })\n : \"\";\n const lastSent = useRef(\"\");\n useEffect(() => {\n if (!embedded || payload === lastSent.current) return;\n lastSent.current = payload;\n const { title, icon, groups, active } = JSON.parse(payload) as {\n title: string;\n icon?: string;\n groups: AppNavGroup[];\n active?: string;\n };\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"nav\", title, icon, groups, active });\n }, [payload]);\n\n // Embedded: handshake + listen for the host's clicks.\n useEffect(() => {\n if (!embedded) return;\n function onMessage(e: MessageEvent) {\n if (e.source !== window.parent) return;\n const d = e.data as HostToAppMessage | undefined;\n if (!d || d.source !== \"clawnify-host\" || d.v !== HOST_BRIDGE_VERSION) return;\n if (d.type === \"hello\") {\n // Host (re)mounted its listener after we first published: resend.\n lastSent.current = \"\";\n const g = sanitize(groupsRef.current);\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"nav\", title, icon, groups: g, active });\n } else if (d.type === \"navigate\" && typeof d.id === \"string\") {\n const item = groupsRef.current.flatMap((g) => g.items).find((i) => i.id === d.id);\n if (item) onNavigateRef.current(item);\n }\n }\n window.addEventListener(\"message\", onMessage);\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"ready\" });\n return () => window.removeEventListener(\"message\", onMessage);\n // title/active are read from the closure only on `hello`; the publish\n // effect above covers their normal updates.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n if (embedded) return null;\n\n const home = groups.flatMap((g) => g.items).find((i) => i.home);\n const brand = (\n <>\n {(() => { const I = iconFor(icon); return I ? <I size={16} aria-hidden /> : null; })()}\n <span>{title}</span>\n </>\n );\n\n return (\n <aside className=\"cn-nav\">\n <style>{CSS}</style>\n {home ? (\n <a\n href={home.href ?? \"/\"}\n className=\"cn-nav-brand\"\n data-active={home.id === active || undefined}\n aria-current={home.id === active ? \"page\" : undefined}\n onClick={(e: MouseEvent) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n e.preventDefault();\n onNavigate(home);\n }}\n >\n {brand}\n </a>\n ) : (\n <div className=\"cn-nav-brand\">{brand}</div>\n )}\n <nav className=\"cn-nav-groups\" aria-label=\"Primary\">\n {groups.map((g, gi) => (\n <div className=\"cn-nav-group\" key={g.label ?? gi}>\n {g.label && <p className=\"cn-nav-eyebrow\">{g.label}</p>}\n {g.items\n .filter((item) => !item.home)\n .map((item) => (\n <NavRow key={item.id} item={item} active={item.id === active} onNavigate={onNavigate} />\n ))}\n </div>\n ))}\n </nav>\n {children && <div className=\"cn-nav-footer\">{children}</div>}\n </aside>\n );\n}\n\nfunction NavRow({\n item,\n active,\n onNavigate,\n}: {\n item: AppNavItem;\n active: boolean;\n onNavigate: (item: AppNavItem) => void;\n}) {\n const body = (\n <>\n <TileIcon icon={item.icon} color={item.color} seed={item.id} />\n <span className=\"cn-nav-label\">{item.label}</span>\n {typeof item.count === \"number\" && <span className=\"cn-nav-count\">{item.count}</span>}\n </>\n );\n // A real link when there is an href — middle-click and copy-link keep\n // working — but the click itself goes through the app's router.\n if (item.href) {\n return (\n <a\n href={item.href}\n className=\"cn-nav-item\"\n data-active={active || undefined}\n aria-current={active ? \"page\" : undefined}\n onClick={(e: MouseEvent) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n e.preventDefault();\n onNavigate(item);\n }}\n >\n {body}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n className=\"cn-nav-item\"\n data-active={active || undefined}\n aria-current={active ? \"page\" : undefined}\n onClick={() => onNavigate(item)}\n >\n {body}\n </button>\n );\n}\n\n/** Our names → Phosphor components; the same map the dashboard holds. */\nconst ICONS: Record<string, PhosphorIcon> = {\n \"activity\": Pulse,\n \"archive\": Archive,\n \"bar-chart-3\": ChartBar,\n \"bell\": Bell,\n \"book-open\": BookOpen,\n \"bot\": Robot,\n \"briefcase\": Briefcase,\n \"building-2\": Building,\n \"calendar\": Calendar,\n \"calendar-days\": CalendarDots,\n \"camera\": Camera,\n \"check-square\": CheckSquare,\n \"clipboard-list\": ClipboardText,\n \"clock\": Clock,\n \"code-2\": Code,\n \"contact\": AddressBook,\n \"credit-card\": CreditCard,\n \"database\": Database,\n \"dollar-sign\": CurrencyDollar,\n \"file\": File,\n \"file-text\": FileText,\n \"folder\": Folder,\n \"globe\": Globe,\n \"hash\": Hash,\n \"history\": ClockCounterClockwise,\n \"home\": House,\n \"image\": Image,\n \"inbox\": Tray,\n \"kanban\": Kanban,\n \"layers\": Stack,\n \"layout-dashboard\": SquaresFour,\n \"layout-grid\": GridFour,\n \"link\": Link,\n \"list\": List,\n \"list-checks\": ListChecks,\n \"mail\": Envelope,\n \"map\": MapTrifold,\n \"message-square\": ChatCircle,\n \"mic\": Microphone,\n \"package\": Package,\n \"phone\": Phone,\n \"pie-chart\": ChartPie,\n \"play\": Play,\n \"search\": MagnifyingGlass,\n \"send\": PaperPlaneTilt,\n \"settings\": Gear,\n \"shopping-cart\": ShoppingCart,\n \"sparkles\": Sparkle,\n \"star\": Star,\n \"table\": Table,\n \"tag\": Tag,\n \"target\": Target,\n \"truck\": Truck,\n \"upload\": Upload,\n \"user\": User,\n \"users\": User,\n \"video\": VideoCamera,\n \"wallet\": Wallet,\n \"zap\": Lightning,\n};\nfunction iconFor(name?: string): PhosphorIcon | undefined {\n return name ? ICONS[name] : undefined;\n}\n\n/** Only the fields the protocol defines, with sane bounds, cross the bridge. */\nfunction sanitize(groups: AppNavGroup[]): AppNavGroup[] {\n return groups.slice(0, 20).map((g) => ({\n ...(g.label ? { label: String(g.label).slice(0, 40) } : {}),\n items: g.items.slice(0, 100).map((i) => ({\n id: String(i.id),\n label: String(i.label).slice(0, 60),\n ...(i.href ? { href: String(i.href) } : {}),\n ...(i.icon ? { icon: String(i.icon) } : {}),\n ...(typeof i.count === \"number\" && Number.isFinite(i.count) ? { count: i.count } : {}),\n ...(i.home ? { home: true } : {}),\n ...(i.color && i.color in NAV_COLORS ? { color: i.color } : {}),\n })),\n }));\n}\n\n// Scoped by class prefix; tokens follow apps/DESIGN.md with its light defaults\n// as fallbacks, so an app with a partial theme still gets a readable sidebar.\nconst CSS = `\n.cn-nav{width:16.25rem;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);color:var(--foreground,#1a202c)}\n.cn-nav-brand{height:3.5rem;flex-shrink:0;display:flex;align-items:center;gap:.5rem;padding:0 1.25rem;border-bottom:1px solid var(--border,#e2e8f0);font-size:.875rem;font-weight:600;color:inherit;text-decoration:none}\na.cn-nav-brand:hover{background:var(--sunken,#f1f5f9)}\n.cn-nav-brand>svg{color:var(--primary,#dd5164)}\n.cn-nav-groups{flex:1;overflow-y:auto;padding:.75rem}\n.cn-nav-group+.cn-nav-group{margin-top:1rem}\n.cn-nav-eyebrow{margin:0 0 .25rem;padding:0 .625rem;font-size:.75rem;font-weight:500;color:var(--muted,#475569)}\n.cn-nav-item{display:flex;align-items:center;gap:.5rem;width:100%;margin:0 0 .125rem;padding:.4375rem .625rem;border:0;border-radius:.375rem;background:none;color:inherit;font:inherit;font-size:.875rem;text-align:left;text-decoration:none;cursor:pointer}\n.cn-nav-item:hover{background:var(--sunken,#f1f5f9)}\n.cn-nav-item[data-active]{background:color-mix(in srgb,var(--primary,#dd5164) 12%,transparent);color:var(--primary,#dd5164);font-weight:600}\n.cn-nav-item:focus-visible{outline:2px solid var(--ring,#2563eb);outline-offset:-2px}\n.cn-nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.cn-nav-count{font-size:.75rem;font-variant-numeric:tabular-nums;color:var(--muted,#475569)}\n.cn-nav-item[data-active] .cn-nav-count{color:inherit}\n.cn-nav-footer{padding:.75rem;border-top:1px solid var(--border,#e2e8f0)}\n@media (max-width:767px){\n.cn-nav{width:100%;flex-direction:row;border-right:0;border-bottom:1px solid var(--border,#e2e8f0)}\n.cn-nav-brand,.cn-nav-eyebrow,.cn-nav-footer{display:none}\n.cn-nav-groups{display:flex;gap:.25rem;padding:.5rem .75rem;overflow-x:auto;overflow-y:hidden}\n.cn-nav-group{display:flex;gap:.25rem}\n.cn-nav-group+.cn-nav-group{margin:0}\n.cn-nav-item{width:auto;margin:0;white-space:nowrap}\n}\n`;\n"],"mappings":"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","names":["title","icon","groups","active"]}
1
+ {"version":3,"sources":["../../src/client/index.tsx"],"sourcesContent":["/**\n * @clawnify/app/client — the host bridge for a Clawnify app's browser side.\n *\n * A Clawnify app is a full page on its own origin. Opened directly (local\n * `pnpm dev`, or `<slug>.apps.clawnify.com`) it renders its own sidebar.\n * Opened inside the Clawnify dashboard it sits in an iframe, and the dashboard\n * wants to render that same navigation in ITS sidebar so the user sees one\n * nav, not two.\n *\n * `<AppNav>` is the one definition of the app's navigation that serves both:\n *\n * • standalone → it draws the app's sidebar (desktop rail, mobile strip)\n * • embedded → it draws nothing and posts the nav tree to the host over\n * `postMessage`; the host renders it and posts `navigate`\n * back when the user clicks.\n *\n * Nothing about the app changes between the two: same router, same screens,\n * same data. Only who paints the list of links.\n *\n * import { AppNav, reportLocation } from \"@clawnify/app/client\";\n *\n * <AppNav\n * title=\"OpenDialer\"\n * icon=\"phone\"\n * groups={[{ label: \"Work\", items: [\n * { id: \"dashboard\", label: \"Dashboard\", href: \"/\", home: true },\n * { id: \"leads\", label: \"Leads\", href: \"/leads\", icon: \"users\", count: 12 },\n * ]}]}\n * active=\"leads\"\n * onNavigate={(item) => navigate(item.href!)}\n * />\n *\n * Re-render with a new `groups` / `active` and the host sidebar updates in\n * the same tick — counts are just a field, so \"Leads 12\" becomes \"Leads 11\"\n * the moment your own state does. No polling, no endpoint.\n *\n * Embedding is detected from the `clawnify_host=1` query parameter the\n * dashboard appends to the iframe URL, remembered in sessionStorage so a\n * full-page navigation inside the iframe stays in embedded mode. A page that\n * is iframed by anything else, or opened directly, is \"standalone\".\n *\n * The same channel connects the app to the dashboard's chat (the AG-UI idea:\n * the agent and the screen share one loop):\n *\n * openChat(\"Draft a follow-up for Mario\") opens the chat, text as a draft\n * setChatContext({ label, record }) what the user is looking at\n * useHostChanges((paths) => refetch()) the chat just wrote your data\n * useHostNavigate((path) => navigate(path)) the chat opens one of your pages\n *\n * Protocol (versioned, additive):\n *\n * app → host { source: \"clawnify-app\", v: 1, type: \"ready\", features? }\n * { source: \"clawnify-app\", v: 1, type: \"nav\", title, icon?, groups, active? }\n * { source: \"clawnify-app\", v: 1, type: \"location\", path }\n * { source: \"clawnify-app\", v: 1, type: \"context\", context }\n * { source: \"clawnify-app\", v: 1, type: \"chat\", prompt? }\n * host → app { source: \"clawnify-host\", v: 1, type: \"hello\" }\n * { source: \"clawnify-host\", v: 1, type: \"navigate\", id }\n * { source: \"clawnify-host\", v: 1, type: \"navigate\", path }\n * { source: \"clawnify-host\", v: 1, type: \"changed\", paths }\n *\n * `features` tells the host which of its messages the app handles, so it can\n * fall back for the ones it does not: without \"changes\" the host reloads the\n * iframe after the chat writes data, without \"path\" it reloads it at the new\n * path instead of asking the app's router.\n */\nimport {\n useEffect,\n useRef,\n type MouseEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AddressBook,\n Archive,\n Bell,\n BookOpen,\n Briefcase,\n Building,\n Calendar,\n CalendarDots,\n Camera,\n ChartBar,\n ChartPie,\n ChatCircle,\n CheckSquare,\n ClipboardText,\n Clock,\n ClockCounterClockwise,\n Code,\n CreditCard,\n CurrencyDollar,\n Database,\n Envelope,\n File,\n FileText,\n Folder,\n Gear,\n Globe,\n GridFour,\n Hash,\n House,\n Image,\n Kanban,\n Lightning,\n Link,\n List,\n ListChecks,\n MagnifyingGlass,\n MapTrifold,\n Microphone,\n Package,\n PaperPlaneTilt,\n Phone,\n Play,\n Pulse,\n Robot,\n ShoppingCart,\n Sparkle,\n SquaresFour,\n Stack,\n Star,\n Table,\n Tag,\n Target,\n Tray,\n Truck,\n Upload,\n User,\n VideoCamera,\n Wallet,\n type Icon as PhosphorIcon,\n} from \"@phosphor-icons/react\";\n\nexport const HOST_BRIDGE_VERSION = 1 as const;\n\n/** One row in the navigation. `icon` is a lucide icon name (kebab-case). */\nexport interface AppNavItem {\n /** Stable identifier the host hands back on `navigate`. */\n id: string;\n label: string;\n /**\n * In-app path this item opens. Optional: a state-driven view can omit it.\n * When present the host uses it to restore the view on reload and to make\n * the dashboard URL shareable.\n */\n href?: string;\n /** Icon name from TILE_ICONS, e.g. \"layout-dashboard\". Unknown names render a dot. */\n icon?: string;\n /** Live badge, e.g. items waiting. Omit for none. */\n count?: number;\n /**\n * The app's home screen. Not listed as a row: standalone the brand row\n * opens it, and in the dashboard the app's own header does. One per app.\n */\n home?: boolean;\n /**\n * Tile colour for this section. Sections are the records inside an app,\n * so they draw as a small coloured tile with a white glyph (the app itself\n * is navigation and stays an outline glyph). One of `NAV_COLORS`; omitted\n * = picked from the palette by `id`, stable across renders and hosts.\n */\n color?: NavColor;\n}\n\n/** The section tile palette, shared by the standalone sidebar and the dashboard. */\nexport const NAV_COLORS = {\n red: \"#ef4444\",\n orange: \"#f97514\",\n amber: \"#f59e0b\",\n green: \"#22c55e\",\n sky: \"#17bde9\",\n blue: \"#266df0\",\n violet: \"#8b5cf6\",\n pink: \"#ec4899\",\n} as const;\nexport type NavColor = keyof typeof NAV_COLORS;\n\n/** Deterministic palette pick for an item without an explicit colour. */\nexport function navColorFor(id: string): NavColor {\n const names = Object.keys(NAV_COLORS) as NavColor[];\n let h = 0;\n for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;\n return names[h % names.length];\n}\n\n/**\n * The icon library: the names the Clawnify dashboard can draw for an app\n * and its sections (resolved to the Phosphor set in both sidebars). Pick\n * from these; the apps AGENTS.md lists them by meaning.\n */\nexport const TILE_ICONS = [\n \"activity\",\n \"archive\",\n \"bar-chart-3\",\n \"bell\",\n \"book-open\",\n \"bot\",\n \"briefcase\",\n \"building-2\",\n \"calendar\",\n \"calendar-days\",\n \"camera\",\n \"check-square\",\n \"clipboard-list\",\n \"clock\",\n \"code-2\",\n \"contact\",\n \"credit-card\",\n \"database\",\n \"dollar-sign\",\n \"file\",\n \"file-text\",\n \"folder\",\n \"globe\",\n \"hash\",\n \"history\",\n \"home\",\n \"image\",\n \"inbox\",\n \"kanban\",\n \"layers\",\n \"layout-dashboard\",\n \"layout-grid\",\n \"link\",\n \"list\",\n \"list-checks\",\n \"mail\",\n \"map\",\n \"message-square\",\n \"mic\",\n \"package\",\n \"phone\",\n \"pie-chart\",\n \"play\",\n \"search\",\n \"send\",\n \"settings\",\n \"shopping-cart\",\n \"sparkles\",\n \"star\",\n \"table\",\n \"tag\",\n \"target\",\n \"truck\",\n \"upload\",\n \"user\",\n \"users\",\n \"video\",\n \"wallet\",\n \"zap\",\n] as const;\nexport type TileIconName = (typeof TILE_ICONS)[number];\n\nexport interface TileIconProps {\n /** Icon name from TILE_ICONS. Unknown or missing = a small dot. */\n icon?: string;\n /** Palette colour. Omitted = picked from `seed` (or `icon`) by hash. */\n color?: NavColor;\n /** Stable string the colour is derived from when `color` is not given. */\n seed?: string;\n /** Tile edge in px. The glyph scales with it. Default 16. */\n size?: number;\n className?: string;\n title?: string;\n}\n\n/**\n * The record tile: a small rounded square in a palette colour with a white\n * lucide glyph. The sidebar draws every app section with it; apps can use\n * the same tile anywhere a record type needs an identity mark (table rows,\n * cards, pickers) so the two surfaces stay one system. Self-contained inline\n * styles, so it works outside <AppNav> and in any stylesheet setup.\n *\n * <TileIcon icon=\"users\" color=\"blue\" />\n * <TileIcon icon=\"phone\" seed={lead.id} size={20} />\n */\nexport function TileIcon({ icon, color, seed, size = 16, className, title }: TileIconProps) {\n const fill = NAV_COLORS[color ?? navColorFor(seed ?? icon ?? \"\")];\n // The glyph box is 0.7 of the tile: Phosphor draws on ~224 of its 256\n // units, so the visible glyph lands near 60% of the tile with clear\n // padding around it, solid white — Attio's record-tile proportion.\n const glyph = Math.round(size * 0.7);\n return (\n <span\n className={className}\n title={title}\n aria-hidden={title ? undefined : true}\n style={{\n display: \"inline-flex\",\n width: size,\n height: size,\n flexShrink: 0,\n alignItems: \"center\",\n justifyContent: \"center\",\n borderRadius: \"30%\",\n border: \"1px solid rgb(0 0 0 / 0.06)\",\n background: fill,\n color: \"#fff\",\n }}\n >\n {iconFor(icon) ? (\n (() => { const I = iconFor(icon)!; return <I size={glyph} weight=\"fill\" aria-hidden />; })()\n ) : (\n <span style={{ width: glyph * 0.6, height: glyph * 0.6, borderRadius: 9999, background: \"currentColor\" }} />\n )}\n </span>\n );\n}\n\nexport interface AppNavGroup {\n /** Eyebrow shown above the items. Omit for an unlabelled group. */\n label?: string;\n items: AppNavItem[];\n}\n\n/** Host messages this app handles itself. The host falls back for the rest. */\nexport type HostFeature = \"changes\" | \"path\";\n\n/**\n * What the user is looking at, for the dashboard's chat. It lets them say\n * \"this contact\" and be understood. Labels and ids only: the chat reads the\n * record itself through your API if it needs more.\n */\nexport interface ChatContext {\n /** The screen, e.g. \"Contact\" or \"Pipeline\". */\n label?: string;\n /** The record on screen, when there is one. */\n record?: { type: string; id: string; label?: string };\n}\n\nexport type AppToHostMessage =\n | { source: \"clawnify-app\"; v: 1; type: \"ready\"; features?: HostFeature[] }\n | {\n source: \"clawnify-app\";\n v: 1;\n type: \"nav\";\n title: string;\n /** lucide icon name for the app itself. */\n icon?: string;\n groups: AppNavGroup[];\n active?: string;\n }\n | { source: \"clawnify-app\"; v: 1; type: \"location\"; path: string }\n | { source: \"clawnify-app\"; v: 1; type: \"context\"; context: ChatContext | null }\n | { source: \"clawnify-app\"; v: 1; type: \"chat\"; prompt?: string };\n\nexport type HostToAppMessage =\n | { source: \"clawnify-host\"; v: 1; type: \"hello\" }\n | { source: \"clawnify-host\"; v: 1; type: \"navigate\"; id: string }\n | { source: \"clawnify-host\"; v: 1; type: \"navigate\"; path: string }\n | { source: \"clawnify-host\"; v: 1; type: \"changed\"; paths: string[] };\n\nconst HOST_PARAM = \"clawnify_host\";\nconst STORAGE_KEY = \"clawnify_host\";\n\n/**\n * Whether this page is running inside the Clawnify dashboard. Computed once at\n * module load — the query parameter is only on the initial URL — and remembered\n * for the tab so in-iframe full navigations keep the mode.\n */\nexport const embedded: boolean = (() => {\n if (typeof window === \"undefined\") return false;\n if (window.parent === window) return false;\n let flagged = false;\n try {\n flagged = new URLSearchParams(window.location.search).get(HOST_PARAM) === \"1\";\n if (flagged) window.sessionStorage.setItem(STORAGE_KEY, \"1\");\n else flagged = window.sessionStorage.getItem(STORAGE_KEY) === \"1\";\n } catch {\n // Storage can throw in a partitioned or locked-down iframe; the query\n // parameter alone still decides for this document.\n }\n return flagged;\n})();\n\nfunction post(message: AppToHostMessage) {\n if (!embedded) return;\n // The dashboard may live on app.clawnify.com or a white-label domain, so the\n // app cannot pin the target origin. The payload is navigation labels, a\n // path, record labels and a draft prompt — nothing secret — and the host\n // verifies the sender is its own iframe.\n window.parent.postMessage(message, \"*\");\n}\n\n// ── One channel for every SDK surface ──────────────────────────────────────\n// <AppNav>, the chat context and the host hooks share one window listener, so\n// an app can use any of them without the others (a single-screen app has no\n// <AppNav> but can still refresh when the chat writes its data).\n\ntype HostListener = (message: HostToAppMessage) => void;\nconst hostListeners = new Set<HostListener>();\n/** How many mounted hooks handle each feature; announced while above zero. */\nconst featureCounts = new Map<HostFeature, number>();\nlet listening = false;\nlet chatContext: ChatContext | null = null;\n\nfunction announce() {\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"ready\", features: [...featureCounts.keys()] });\n}\n\nfunction listen() {\n if (listening || !embedded) return;\n listening = true;\n window.addEventListener(\"message\", (e: MessageEvent) => {\n if (e.source !== window.parent) return;\n const d = e.data as HostToAppMessage | undefined;\n if (!d || d.source !== \"clawnify-host\" || d.v !== HOST_BRIDGE_VERSION) return;\n // Host (re)mounted its listener: it has lost what we told it.\n if (d.type === \"hello\" && chatContext) {\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"context\", context: chatContext });\n }\n for (const listener of [...hostListeners]) listener(d);\n });\n announce();\n}\n\nfunction subscribe(listener: HostListener, feature?: HostFeature): () => void {\n if (!embedded) return () => {};\n listen();\n hostListeners.add(listener);\n if (feature) {\n const n = featureCounts.get(feature) ?? 0;\n featureCounts.set(feature, n + 1);\n if (n === 0) announce();\n }\n return () => {\n hostListeners.delete(listener);\n if (!feature) return;\n const n = (featureCounts.get(feature) ?? 1) - 1;\n if (n > 0) featureCounts.set(feature, n);\n else {\n featureCounts.delete(feature);\n announce();\n }\n };\n}\n\n/** An in-app path the host may hand us: absolute, same origin. */\nfunction isAppPath(path: unknown): path is string {\n return typeof path === \"string\" && path.length <= 2048 && /^\\/(?![/\\\\])/.test(path);\n}\n\n/**\n * Open the dashboard's chat, optionally with a prompt in the composer. The\n * prompt is a DRAFT: the user reads it and presses send. The host never sends\n * text an app wrote, because an app renders data other people typed (a form\n * submission, a contact note) and that text must not drive the user's agent.\n *\n * Returns false when standalone (no dashboard, no chat): hide the button then,\n * or check `embedded` before rendering it.\n *\n * {embedded && <button onClick={() => openChat(`Draft a follow-up for ${c.name}`)}>Ask AI</button>}\n * {embedded && <button onClick={() => openChat()}>Chat</button>} // no prompt: just open it\n */\nexport function openChat(prompt?: string): boolean {\n if (!embedded) return false;\n post({\n source: \"clawnify-app\",\n v: HOST_BRIDGE_VERSION,\n type: \"chat\",\n ...(prompt ? { prompt: String(prompt).slice(0, 2000) } : {}),\n });\n return true;\n}\n\n/**\n * Tell the dashboard's chat what the user is looking at, so \"this contact\"\n * means the one on screen. Call it from the screen that shows the record; the\n * context clears when that screen unmounts. Labels and ids only, never the\n * record's content: the chat reads that through your API when it needs to.\n *\n * useChatContext({ label: \"Contact\", record: { type: \"contact\", id: c.id, label: c.name } });\n */\nexport function useChatContext(context: ChatContext | null) {\n const key = embedded && context ? JSON.stringify(sanitizeContext(context)) : \"\";\n useEffect(() => {\n if (!embedded || !key) return;\n listen();\n chatContext = JSON.parse(key) as ChatContext;\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"context\", context: chatContext });\n return () => {\n chatContext = null;\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"context\", context: null });\n };\n }, [key]);\n}\n\nfunction sanitizeContext(c: ChatContext): ChatContext {\n return {\n ...(c.label ? { label: String(c.label).slice(0, 60) } : {}),\n ...(c.record && c.record.type && c.record.id\n ? {\n record: {\n type: String(c.record.type).slice(0, 40),\n id: String(c.record.id).slice(0, 80),\n ...(c.record.label ? { label: String(c.record.label).slice(0, 80) } : {}),\n },\n }\n : {}),\n };\n}\n\n/**\n * Run `onChange` when the dashboard's chat has written this app's data (a\n * POST, PUT, PATCH or DELETE through your API), with the API paths it called.\n * Refetch what is on screen. While no mounted component uses this hook, the\n * dashboard reloads the whole iframe after such a write instead.\n *\n * useHostChanges(() => { void reload(); });\n */\nexport function useHostChanges(onChange: (paths: string[]) => void) {\n const ref = useRef(onChange);\n ref.current = onChange;\n useEffect(\n () =>\n subscribe((d) => {\n if (d.type !== \"changed\" || !Array.isArray(d.paths)) return;\n ref.current(d.paths.filter((p): p is string => typeof p === \"string\").slice(0, 20));\n }, \"changes\"),\n [],\n );\n}\n\n/**\n * Let the dashboard (its chat, a deep link) open any page of the app through\n * your router, without reloading the iframe. `path` is an in-app path such as\n * \"/contacts/42?tab=deals\". While no mounted component uses this hook, the\n * dashboard reloads the iframe at that path instead.\n *\n * useHostNavigate((path) => navigate(path));\n */\nexport function useHostNavigate(onNavigate: (path: string) => void) {\n const ref = useRef(onNavigate);\n ref.current = onNavigate;\n useEffect(\n () =>\n subscribe((d) => {\n if (d.type === \"navigate\" && \"path\" in d && isAppPath(d.path)) ref.current(d.path);\n }, \"path\"),\n [],\n );\n}\n\n/**\n * Tell the host where the app is, so the dashboard URL can restore this view\n * on reload. Call it whenever your router's location changes; a no-op when\n * standalone.\n */\nexport function reportLocation(path: string) {\n // The initial URL carries the dashboard's own parameters (the app token\n // and the host flag). They are not part of the app's location and must\n // never land in a URL the dashboard writes, so strip them here as well.\n let clean = path;\n try {\n const u = new URL(path, \"http://app.invalid\");\n u.searchParams.delete(\"token\");\n u.searchParams.delete(HOST_PARAM);\n clean = u.pathname + u.search + u.hash;\n } catch {\n // Not a path we can parse: send as-is, the host validates its shape.\n }\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"location\", path: clean });\n}\n\nexport interface AppNavProps {\n /** App name, shown in the brand row standalone and in the host's switcher. */\n title: string;\n /**\n * The app's own glyph, a lucide icon name (kebab-case). Drawn as an\n * outline icon with no background: in the standalone brand row, and by the\n * dashboard on the app's sidebar item. Tiles are for records, not for\n * navigation, so the app's coloured tile icon stays out of the sidebar.\n */\n icon?: string;\n groups: AppNavGroup[];\n /** `id` of the active item. */\n active?: string;\n /** Called with the item the user chose, from either renderer. */\n onNavigate: (item: AppNavItem) => void;\n /** Rendered at the bottom of the standalone sidebar (a footer action, say). */\n children?: ReactNode;\n}\n\n/**\n * The app's navigation: sidebar when standalone, host-fed when embedded.\n *\n * Standalone layout: the sidebar is a 260px column at ≥768px and a horizontal\n * strip below that, so put it first inside a `flex flex-col md:flex-row`\n * container. It paints with the app's own theme tokens (`--surface`,\n * `--border`, `--primary`…) and falls back to the platform defaults when a\n * token is missing.\n */\nexport function AppNav({ title, icon, groups, active, onNavigate, children }: AppNavProps) {\n // Always the latest callback, so the host listener never calls a stale one.\n const onNavigateRef = useRef(onNavigate);\n onNavigateRef.current = onNavigate;\n const groupsRef = useRef(groups);\n groupsRef.current = groups;\n\n // Embedded: publish the tree whenever it changes. Serialising is the cheap\n // way to dedupe — apps build `groups` inline every render.\n const payload = embedded\n ? JSON.stringify({ title, icon, groups: sanitize(groups), active })\n : \"\";\n const lastSent = useRef(\"\");\n useEffect(() => {\n if (!embedded || payload === lastSent.current) return;\n lastSent.current = payload;\n const { title, icon, groups, active } = JSON.parse(payload) as {\n title: string;\n icon?: string;\n groups: AppNavGroup[];\n active?: string;\n };\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"nav\", title, icon, groups, active });\n }, [payload]);\n\n // Embedded: handshake + listen for the host's clicks.\n useEffect(\n () =>\n subscribe((d) => {\n if (d.type === \"hello\") {\n // Host (re)mounted its listener after we first published: resend.\n lastSent.current = \"\";\n const g = sanitize(groupsRef.current);\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"nav\", title, icon, groups: g, active });\n } else if (d.type === \"navigate\" && \"id\" in d && typeof d.id === \"string\") {\n const item = groupsRef.current.flatMap((g) => g.items).find((i) => i.id === d.id);\n if (item) onNavigateRef.current(item);\n }\n }),\n // title/active are read from the closure only on `hello`; the publish\n // effect above covers their normal updates.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n );\n\n if (embedded) return null;\n\n const home = groups.flatMap((g) => g.items).find((i) => i.home);\n const brand = (\n <>\n {(() => { const I = iconFor(icon); return I ? <I size={16} aria-hidden /> : null; })()}\n <span>{title}</span>\n </>\n );\n\n return (\n <aside className=\"cn-nav\">\n <style>{CSS}</style>\n {home ? (\n <a\n href={home.href ?? \"/\"}\n className=\"cn-nav-brand\"\n data-active={home.id === active || undefined}\n aria-current={home.id === active ? \"page\" : undefined}\n onClick={(e: MouseEvent) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n e.preventDefault();\n onNavigate(home);\n }}\n >\n {brand}\n </a>\n ) : (\n <div className=\"cn-nav-brand\">{brand}</div>\n )}\n <nav className=\"cn-nav-groups\" aria-label=\"Primary\">\n {groups.map((g, gi) => (\n <div className=\"cn-nav-group\" key={g.label ?? gi}>\n {g.label && <p className=\"cn-nav-eyebrow\">{g.label}</p>}\n {g.items\n .filter((item) => !item.home)\n .map((item) => (\n <NavRow key={item.id} item={item} active={item.id === active} onNavigate={onNavigate} />\n ))}\n </div>\n ))}\n </nav>\n {children && <div className=\"cn-nav-footer\">{children}</div>}\n </aside>\n );\n}\n\nfunction NavRow({\n item,\n active,\n onNavigate,\n}: {\n item: AppNavItem;\n active: boolean;\n onNavigate: (item: AppNavItem) => void;\n}) {\n const body = (\n <>\n <TileIcon icon={item.icon} color={item.color} seed={item.id} />\n <span className=\"cn-nav-label\">{item.label}</span>\n {typeof item.count === \"number\" && <span className=\"cn-nav-count\">{item.count}</span>}\n </>\n );\n // A real link when there is an href — middle-click and copy-link keep\n // working — but the click itself goes through the app's router.\n if (item.href) {\n return (\n <a\n href={item.href}\n className=\"cn-nav-item\"\n data-active={active || undefined}\n aria-current={active ? \"page\" : undefined}\n onClick={(e: MouseEvent) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n e.preventDefault();\n onNavigate(item);\n }}\n >\n {body}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n className=\"cn-nav-item\"\n data-active={active || undefined}\n aria-current={active ? \"page\" : undefined}\n onClick={() => onNavigate(item)}\n >\n {body}\n </button>\n );\n}\n\n/** Our names → Phosphor components; the same map the dashboard holds. */\nconst ICONS: Record<string, PhosphorIcon> = {\n \"activity\": Pulse,\n \"archive\": Archive,\n \"bar-chart-3\": ChartBar,\n \"bell\": Bell,\n \"book-open\": BookOpen,\n \"bot\": Robot,\n \"briefcase\": Briefcase,\n \"building-2\": Building,\n \"calendar\": Calendar,\n \"calendar-days\": CalendarDots,\n \"camera\": Camera,\n \"check-square\": CheckSquare,\n \"clipboard-list\": ClipboardText,\n \"clock\": Clock,\n \"code-2\": Code,\n \"contact\": AddressBook,\n \"credit-card\": CreditCard,\n \"database\": Database,\n \"dollar-sign\": CurrencyDollar,\n \"file\": File,\n \"file-text\": FileText,\n \"folder\": Folder,\n \"globe\": Globe,\n \"hash\": Hash,\n \"history\": ClockCounterClockwise,\n \"home\": House,\n \"image\": Image,\n \"inbox\": Tray,\n \"kanban\": Kanban,\n \"layers\": Stack,\n \"layout-dashboard\": SquaresFour,\n \"layout-grid\": GridFour,\n \"link\": Link,\n \"list\": List,\n \"list-checks\": ListChecks,\n \"mail\": Envelope,\n \"map\": MapTrifold,\n \"message-square\": ChatCircle,\n \"mic\": Microphone,\n \"package\": Package,\n \"phone\": Phone,\n \"pie-chart\": ChartPie,\n \"play\": Play,\n \"search\": MagnifyingGlass,\n \"send\": PaperPlaneTilt,\n \"settings\": Gear,\n \"shopping-cart\": ShoppingCart,\n \"sparkles\": Sparkle,\n \"star\": Star,\n \"table\": Table,\n \"tag\": Tag,\n \"target\": Target,\n \"truck\": Truck,\n \"upload\": Upload,\n \"user\": User,\n \"users\": User,\n \"video\": VideoCamera,\n \"wallet\": Wallet,\n \"zap\": Lightning,\n};\nfunction iconFor(name?: string): PhosphorIcon | undefined {\n return name ? ICONS[name] : undefined;\n}\n\n/** Only the fields the protocol defines, with sane bounds, cross the bridge. */\nfunction sanitize(groups: AppNavGroup[]): AppNavGroup[] {\n return groups.slice(0, 20).map((g) => ({\n ...(g.label ? { label: String(g.label).slice(0, 40) } : {}),\n items: g.items.slice(0, 100).map((i) => ({\n id: String(i.id),\n label: String(i.label).slice(0, 60),\n ...(i.href ? { href: String(i.href) } : {}),\n ...(i.icon ? { icon: String(i.icon) } : {}),\n ...(typeof i.count === \"number\" && Number.isFinite(i.count) ? { count: i.count } : {}),\n ...(i.home ? { home: true } : {}),\n ...(i.color && i.color in NAV_COLORS ? { color: i.color } : {}),\n })),\n }));\n}\n\n// Scoped by class prefix; tokens follow apps/DESIGN.md with its light defaults\n// as fallbacks, so an app with a partial theme still gets a readable sidebar.\nconst CSS = `\n.cn-nav{width:16.25rem;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);color:var(--foreground,#1a202c)}\n.cn-nav-brand{height:3.5rem;flex-shrink:0;display:flex;align-items:center;gap:.5rem;padding:0 1.25rem;border-bottom:1px solid var(--border,#e2e8f0);font-size:.875rem;font-weight:600;color:inherit;text-decoration:none}\na.cn-nav-brand:hover{background:var(--sunken,#f1f5f9)}\n.cn-nav-brand>svg{color:var(--primary,#dd5164)}\n.cn-nav-groups{flex:1;overflow-y:auto;padding:.75rem}\n.cn-nav-group+.cn-nav-group{margin-top:1rem}\n.cn-nav-eyebrow{margin:0 0 .25rem;padding:0 .625rem;font-size:.75rem;font-weight:500;color:var(--muted,#475569)}\n.cn-nav-item{display:flex;align-items:center;gap:.5rem;width:100%;margin:0 0 .125rem;padding:.4375rem .625rem;border:0;border-radius:.375rem;background:none;color:inherit;font:inherit;font-size:.875rem;text-align:left;text-decoration:none;cursor:pointer}\n.cn-nav-item:hover{background:var(--sunken,#f1f5f9)}\n.cn-nav-item[data-active]{background:color-mix(in srgb,var(--primary,#dd5164) 12%,transparent);color:var(--primary,#dd5164);font-weight:600}\n.cn-nav-item:focus-visible{outline:2px solid var(--ring,#2563eb);outline-offset:-2px}\n.cn-nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.cn-nav-count{font-size:.75rem;font-variant-numeric:tabular-nums;color:var(--muted,#475569)}\n.cn-nav-item[data-active] .cn-nav-count{color:inherit}\n.cn-nav-footer{padding:.75rem;border-top:1px solid var(--border,#e2e8f0)}\n@media (max-width:767px){\n.cn-nav{width:100%;flex-direction:row;border-right:0;border-bottom:1px solid var(--border,#e2e8f0)}\n.cn-nav-brand,.cn-nav-eyebrow,.cn-nav-footer{display:none}\n.cn-nav-groups{display:flex;gap:.25rem;padding:.5rem .75rem;overflow-x:auto;overflow-y:hidden}\n.cn-nav-group{display:flex;gap:.25rem}\n.cn-nav-group+.cn-nav-group{margin:0}\n.cn-nav-item{width:auto;margin:0;white-space:nowrap}\n}\n`;\n"],"mappings":"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","names":["title","icon","groups","active"]}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { DiscoveryOptions, OpenAPIHono } from '@clawnify/routes';
1
+ import { z, OpenAPIHono, DiscoveryOptions } from '@clawnify/routes';
2
2
  export { DEFAULT_PAGE_LIMIT, DiscoverableApp, DiscoveryOptions, MAX_PAGE_LIMIT, OpenAPIHono, PageParams, PageQuery, createRoute, describeRoutes, mountDiscovery, pageParams, pagedResponse, z } from '@clawnify/routes';
3
- import { Env } from 'hono';
3
+ import { Env, Context } from 'hono';
4
4
 
5
5
  /**
6
6
  * Identity — who is calling this app.
@@ -104,6 +104,196 @@ declare function orgId(c: RequestLike): string | null;
104
104
  */
105
105
  declare function user(c: RequestLike): AppUser | null;
106
106
 
107
+ /**
108
+ * Dashboard widgets: glanceable tiles an app offers, drawn by the Clawnify
109
+ * dashboard while the app itself is closed.
110
+ *
111
+ * The widget kinds are standard and the dashboard renders them with its own
112
+ * components. An app supplies DATA only — no markup, no CSS, no chart code —
113
+ * so every widget looks native and no app can break the home page.
114
+ *
115
+ * import { createApp, widgets, orgId } from "@clawnify/app";
116
+ * const app = createApp<Env>({ title: "CRM" });
117
+ * widgets(app, async (c) => [
118
+ * { key: "deals-this-month", kind: "metric", title: "Deals created this month",
119
+ * value: await countDealsThisMonth(c), at: "/deals?created=this-month" },
120
+ * ]);
121
+ *
122
+ * Served at GET /api/widgets and listed in /api/openapi.json, so an agent can
123
+ * read the same summary a person sees. The handler runs with the caller's
124
+ * identity (`user(c)` / `orgId(c)`), so return only what that caller may see.
125
+ *
126
+ * The dashboard re-validates everything it receives (apps/api
127
+ * services/app-widgets.ts is the trust boundary); the schema here exists so an
128
+ * author gets a clear error at their own desk instead of a silently dropped
129
+ * tile. Keep the two in step: kinds, fields and caps.
130
+ */
131
+
132
+ declare const WIDGET_LIMITS: {
133
+ readonly widgets: 6;
134
+ readonly seriesPoints: 90;
135
+ readonly breakdownItems: 12;
136
+ readonly listItems: 5;
137
+ readonly text: 80;
138
+ };
139
+ declare const WidgetSchema: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
140
+ kind: z.ZodLiteral<"metric">;
141
+ value: z.ZodNumber;
142
+ format: z.ZodOptional<z.ZodEnum<["number", "currency", "percent"]>>;
143
+ currency: z.ZodOptional<z.ZodString>;
144
+ /** Stable id, unique within the app: lowercase letters, digits, dashes. */
145
+ key: z.ZodString;
146
+ title: z.ZodString;
147
+ at: z.ZodOptional<z.ZodString>;
148
+ }, "strip", z.ZodTypeAny, {
149
+ kind: "metric";
150
+ value: number;
151
+ title: string;
152
+ key: string;
153
+ at?: string | undefined;
154
+ currency?: string | undefined;
155
+ format?: "number" | "currency" | "percent" | undefined;
156
+ }, {
157
+ kind: "metric";
158
+ value: number;
159
+ title: string;
160
+ key: string;
161
+ at?: string | undefined;
162
+ currency?: string | undefined;
163
+ format?: "number" | "currency" | "percent" | undefined;
164
+ }>, z.ZodObject<{
165
+ kind: z.ZodLiteral<"series">;
166
+ /** Oldest first. `x` is an ISO date or a short label. */
167
+ points: z.ZodArray<z.ZodObject<{
168
+ x: z.ZodString;
169
+ y: z.ZodNumber;
170
+ }, "strip", z.ZodTypeAny, {
171
+ x: string;
172
+ y: number;
173
+ }, {
174
+ x: string;
175
+ y: number;
176
+ }>, "many">;
177
+ format: z.ZodOptional<z.ZodEnum<["number", "currency", "percent"]>>;
178
+ currency: z.ZodOptional<z.ZodString>;
179
+ /** Stable id, unique within the app: lowercase letters, digits, dashes. */
180
+ key: z.ZodString;
181
+ title: z.ZodString;
182
+ at: z.ZodOptional<z.ZodString>;
183
+ }, "strip", z.ZodTypeAny, {
184
+ kind: "series";
185
+ title: string;
186
+ key: string;
187
+ points: {
188
+ x: string;
189
+ y: number;
190
+ }[];
191
+ at?: string | undefined;
192
+ currency?: string | undefined;
193
+ format?: "number" | "currency" | "percent" | undefined;
194
+ }, {
195
+ kind: "series";
196
+ title: string;
197
+ key: string;
198
+ points: {
199
+ x: string;
200
+ y: number;
201
+ }[];
202
+ at?: string | undefined;
203
+ currency?: string | undefined;
204
+ format?: "number" | "currency" | "percent" | undefined;
205
+ }>, z.ZodObject<{
206
+ kind: z.ZodLiteral<"breakdown">;
207
+ /** Drawn as ranked horizontal bars in one hue, largest first. */
208
+ items: z.ZodArray<z.ZodObject<{
209
+ label: z.ZodString;
210
+ value: z.ZodNumber;
211
+ }, "strip", z.ZodTypeAny, {
212
+ value: number;
213
+ label: string;
214
+ }, {
215
+ value: number;
216
+ label: string;
217
+ }>, "many">;
218
+ format: z.ZodOptional<z.ZodEnum<["number", "currency", "percent"]>>;
219
+ currency: z.ZodOptional<z.ZodString>;
220
+ /** Stable id, unique within the app: lowercase letters, digits, dashes. */
221
+ key: z.ZodString;
222
+ title: z.ZodString;
223
+ at: z.ZodOptional<z.ZodString>;
224
+ }, "strip", z.ZodTypeAny, {
225
+ kind: "breakdown";
226
+ items: {
227
+ value: number;
228
+ label: string;
229
+ }[];
230
+ title: string;
231
+ key: string;
232
+ at?: string | undefined;
233
+ currency?: string | undefined;
234
+ format?: "number" | "currency" | "percent" | undefined;
235
+ }, {
236
+ kind: "breakdown";
237
+ items: {
238
+ value: number;
239
+ label: string;
240
+ }[];
241
+ title: string;
242
+ key: string;
243
+ at?: string | undefined;
244
+ currency?: string | undefined;
245
+ format?: "number" | "currency" | "percent" | undefined;
246
+ }>, z.ZodObject<{
247
+ kind: z.ZodLiteral<"list">;
248
+ items: z.ZodArray<z.ZodObject<{
249
+ label: z.ZodString;
250
+ meta: z.ZodOptional<z.ZodString>;
251
+ at: z.ZodOptional<z.ZodString>;
252
+ }, "strip", z.ZodTypeAny, {
253
+ label: string;
254
+ at?: string | undefined;
255
+ meta?: string | undefined;
256
+ }, {
257
+ label: string;
258
+ at?: string | undefined;
259
+ meta?: string | undefined;
260
+ }>, "many">;
261
+ /** Stable id, unique within the app: lowercase letters, digits, dashes. */
262
+ key: z.ZodString;
263
+ title: z.ZodString;
264
+ at: z.ZodOptional<z.ZodString>;
265
+ }, "strip", z.ZodTypeAny, {
266
+ kind: "list";
267
+ items: {
268
+ label: string;
269
+ at?: string | undefined;
270
+ meta?: string | undefined;
271
+ }[];
272
+ title: string;
273
+ key: string;
274
+ at?: string | undefined;
275
+ }, {
276
+ kind: "list";
277
+ items: {
278
+ label: string;
279
+ at?: string | undefined;
280
+ meta?: string | undefined;
281
+ }[];
282
+ title: string;
283
+ key: string;
284
+ at?: string | undefined;
285
+ }>]>;
286
+ type Widget = z.infer<typeof WidgetSchema>;
287
+ /**
288
+ * Serve the app's dashboard widgets at GET /api/widgets.
289
+ *
290
+ * `list` is called per request with the Hono context. Throwing (or returning
291
+ * an invalid widget) yields a 500 naming the problem, so a broken widget is
292
+ * loud in development; the dashboard shows that app's tiles as unavailable
293
+ * and keeps rendering everything else.
294
+ */
295
+ declare function widgets<E extends Env>(app: OpenAPIHono<E>, list: (c: Context<E>) => Widget[] | Promise<Widget[]>): void;
296
+
107
297
  /**
108
298
  * @clawnify/app — the standard Clawnify app skeleton in one call.
109
299
  *
@@ -159,4 +349,4 @@ interface CreateAppOptions extends DiscoveryOptions {
159
349
  */
160
350
  declare function createApp<E extends Env = Env>(opts?: CreateAppOptions): OpenAPIHono<E>;
161
351
 
162
- export { type AppUser, type Caller, type CreateAppOptions, type RequestLike, caller, createApp, orgId, user };
352
+ export { type AppUser, type Caller, type CreateAppOptions, type RequestLike, WIDGET_LIMITS, type Widget, WidgetSchema, caller, createApp, orgId, user, widgets };
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ import { OpenAPIHono, mountDiscovery } from "@clawnify/routes";
3
3
  import { initDB } from "@clawnify/db";
4
4
  import {
5
5
  OpenAPIHono as OpenAPIHono2,
6
- createRoute,
7
- z,
6
+ createRoute as createRoute2,
7
+ z as z2,
8
8
  mountDiscovery as mountDiscovery2,
9
9
  describeRoutes,
10
10
  PageQuery,
@@ -58,6 +58,82 @@ function user(c) {
58
58
  };
59
59
  }
60
60
 
61
+ // src/widgets.ts
62
+ import { createRoute, z } from "@clawnify/routes";
63
+ var WIDGET_LIMITS = {
64
+ widgets: 6,
65
+ seriesPoints: 90,
66
+ breakdownItems: 12,
67
+ listItems: 5,
68
+ text: 80
69
+ };
70
+ var text = z.string().min(1).max(WIDGET_LIMITS.text);
71
+ var at = z.string().max(512).regex(/^\/(?!\/)/, "must be an in-app path starting with a single /");
72
+ var format = z.enum(["number", "currency", "percent"]).optional();
73
+ var currency = z.string().regex(/^[A-Z]{3}$/).optional();
74
+ var base = {
75
+ /** Stable id, unique within the app: lowercase letters, digits, dashes. */
76
+ key: z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/),
77
+ title: text,
78
+ at: at.optional()
79
+ };
80
+ var WidgetSchema = z.discriminatedUnion("kind", [
81
+ z.object({
82
+ ...base,
83
+ kind: z.literal("metric"),
84
+ value: z.number().finite(),
85
+ format,
86
+ currency
87
+ }),
88
+ z.object({
89
+ ...base,
90
+ kind: z.literal("series"),
91
+ /** Oldest first. `x` is an ISO date or a short label. */
92
+ points: z.array(z.object({ x: text, y: z.number().finite() })).max(WIDGET_LIMITS.seriesPoints),
93
+ format,
94
+ currency
95
+ }),
96
+ z.object({
97
+ ...base,
98
+ kind: z.literal("breakdown"),
99
+ /** Drawn as ranked horizontal bars in one hue, largest first. */
100
+ items: z.array(z.object({ label: text, value: z.number().finite() })).max(WIDGET_LIMITS.breakdownItems),
101
+ format,
102
+ currency
103
+ }),
104
+ z.object({
105
+ ...base,
106
+ kind: z.literal("list"),
107
+ items: z.array(z.object({ label: text, meta: text.optional(), at: at.optional() })).max(WIDGET_LIMITS.listItems)
108
+ })
109
+ ]);
110
+ var WidgetsResponse = z.object({
111
+ widgets: z.array(WidgetSchema).max(WIDGET_LIMITS.widgets)
112
+ });
113
+ var route = createRoute({
114
+ method: "get",
115
+ path: "/api/widgets",
116
+ summary: "Dashboard widgets: a bounded summary of this app for the current caller",
117
+ responses: {
118
+ 200: {
119
+ description: "The app's widgets with their current data",
120
+ content: { "application/json": { schema: WidgetsResponse } }
121
+ }
122
+ }
123
+ });
124
+ function widgets(app, list) {
125
+ app.openapi(route, async (c) => {
126
+ const parsed = WidgetsResponse.safeParse({ widgets: await list(c) });
127
+ if (!parsed.success) {
128
+ return c.json(
129
+ { error: "invalid widgets", issues: parsed.error.issues.slice(0, 5) },
130
+ 500
131
+ );
132
+ }
133
+ return c.json(parsed.data, 200);
134
+ });
135
+ }
136
+
61
137
  // src/index.ts
62
138
  function createApp(opts = {}) {
63
139
  const app = new OpenAPIHono();
@@ -77,15 +153,18 @@ export {
77
153
  MAX_PAGE_LIMIT,
78
154
  OpenAPIHono2 as OpenAPIHono,
79
155
  PageQuery,
156
+ WIDGET_LIMITS,
157
+ WidgetSchema,
80
158
  caller,
81
159
  createApp,
82
- createRoute,
160
+ createRoute2 as createRoute,
83
161
  describeRoutes,
84
162
  mountDiscovery2 as mountDiscovery,
85
163
  orgId,
86
164
  pageParams,
87
165
  pagedResponse,
88
166
  user,
89
- z
167
+ widgets,
168
+ z2 as z
90
169
  };
91
170
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/identity.ts"],"sourcesContent":["/**\n * @clawnify/app — the standard Clawnify app skeleton in one call.\n *\n * `createApp(opts)` returns a fully-wired OpenAPIHono with every always-required,\n * cross-cutting concern pre-baked, so an app's server file is just imports +\n * routes + `export default app`:\n *\n * import { createApp, createRoute, z } from \"@clawnify/app\";\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(listContacts, handler);\n * export default app;\n *\n * What it bakes in — the boilerplate every app used to re-write in its server\n * file, and could forget:\n * • OpenAPIHono construction (typed to your Env).\n * • Database init — `initDB(c.env)` middleware on every request. @clawnify/db\n * auto-detects the D1 binding (production WfP) or the Storage capability\n * binding (Dynamic Workers preview), so handlers can query immediately.\n * • API discovery — `mountDiscovery` serves GET /api/openapi.json (the machine\n * contract) and GET /llms.txt (the lean agent index), generated from the\n * live routes so they can't drift.\n * • Identity — `user(c)` / `orgId(c)` / `caller(c)` read the platform-injected\n * `X-Clawnify-*` headers, so an app never hand-parses them (see identity.ts).\n *\n * The point: a new always-required concern is added HERE once and every app\n * inherits it on the next rebuild — instead of a line each agent must remember\n * to write (and a build gate to enforce) in every app.\n */\nimport { OpenAPIHono, mountDiscovery, type DiscoveryOptions } from \"@clawnify/routes\";\nimport { initDB } from \"@clawnify/db\";\nimport type { Env } from \"hono\";\n\n// One routing import for app authors: everything needed to declare routes and\n// build the app comes from @clawnify/app. These are re-exports of the same\n// @hono/zod-openapi primitives @clawnify/routes exposes — never reimplemented —\n// so route creation, discovery, and construction all live behind one import.\nexport {\n OpenAPIHono,\n createRoute,\n z,\n mountDiscovery,\n describeRoutes,\n PageQuery,\n pageParams,\n pagedResponse,\n DEFAULT_PAGE_LIMIT,\n MAX_PAGE_LIMIT,\n} from \"@clawnify/routes\";\nexport type { DiscoveryOptions, DiscoverableApp, PageParams } from \"@clawnify/routes\";\n\n// Identity of the caller, read from the platform-injected headers. Apps never\n// authenticate anyone themselves — see identity.ts.\nexport { user, orgId, caller } from \"./identity\";\nexport type { AppUser, Caller, RequestLike } from \"./identity\";\n\nexport interface CreateAppOptions extends DiscoveryOptions {\n /**\n * Wire the per-request database middleware (`initDB(c.env)` on `*`). Default\n * true — every Clawnify app has a D1 / Storage binding. Set false only for the\n * rare app with no database.\n */\n db?: boolean;\n /**\n * Mount GET /api/openapi.json + GET /llms.txt discovery. Default true. Set\n * false only if the app deliberately opts out of API discovery.\n */\n discovery?: boolean;\n}\n\n/**\n * Create a fully-wired Clawnify app.\n *\n * `E` is your Hono env — `{ Bindings: { DB: D1Database, ... } }` — pass it so\n * route handlers get a typed `c.env`. Everything after this call is your app's\n * own routes and business logic:\n *\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(route, handler);\n * export default app;\n */\nexport function createApp<E extends Env = Env>(\n opts: CreateAppOptions = {},\n): OpenAPIHono<E> {\n const app = new OpenAPIHono<E>();\n\n if (opts.db !== false) {\n app.use(\"*\", async (c, next) => {\n // @clawnify/db reads the DB (D1) or Storage binding off the env and caches\n // the Drizzle client for this request. Safe to call on every request.\n initDB(c.env as unknown as Parameters<typeof initDB>[0]);\n await next();\n });\n }\n\n if (opts.discovery !== false) {\n mountDiscovery(app, opts);\n }\n\n return app;\n}\n","/**\n * Identity — who is calling this app.\n *\n * The platform authenticates the caller at the perimeter (app-router, the\n * app-supervisor preview tier, or the /v1/apps/:id/proxy API) and injects a\n * *fresh* set of `X-Clawnify-*` headers. Any client-supplied `X-Clawnify-*` is\n * stripped first, so these headers cannot be spoofed and an app can trust them\n * directly.\n *\n * Apps therefore never authenticate anyone themselves: never read\n * `Authorization`, never parse a cookie, never verify a JWT. Read these\n * helpers instead.\n *\n * import { user, orgId, caller } from \"@clawnify/app\";\n *\n * app.openapi(route, (c) => {\n * const u = user(c); // null when the caller isn't a person\n * return c.json({ hello: u?.name ?? \"there\", org: orgId(c) });\n * });\n */\n\n/**\n * Minimal shape this module needs — Hono's `Context` satisfies it structurally,\n * so `user(c)` works in any handler regardless of the app's Env generics.\n */\nexport interface RequestLike {\n req: { header(name: string): string | undefined };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Only `user`, `api` and `agent` carry a human identity; the rest are machine\n * or anonymous callers, for which `user()` returns null.\n *\n * | Value | Who |\n * |-----------------|------------------------------------------------------------|\n * | `user` | a signed-in person in a browser |\n * | `api` | a person via the REST API / CLI / MCP (their own token) |\n * | `agent` | the org's OpenClaw agent calling the app's API |\n * | `agent-browser` | the agent driving a real browser on its VPS |\n * | `public` | an unauthenticated visitor on a declared public route |\n * | `bypass` | platform-internal fetch (build/screenshot) |\n * | `system` | the build pipeline |\n * | `app` | another app in the same org (or a headless org-token caller)|\n */\nexport type Caller =\n | \"user\"\n | \"api\"\n | \"agent\"\n | \"agent-browser\"\n | \"public\"\n | \"bypass\"\n | \"system\"\n | \"app\";\n\nconst CALLERS = new Set<Caller>([\n \"user\",\n \"api\",\n \"agent\",\n \"agent-browser\",\n \"public\",\n \"bypass\",\n \"system\",\n \"app\",\n]);\n\n/** The signed-in person behind a request. */\nexport interface AppUser {\n /** Supabase user id — the stable key to store on rows (`created_by`). */\n id: string;\n email: string | null;\n /** Display name, e.g. \"Ada Lovelace\". Null if the account has no name set. */\n name: string | null;\n /** Profile picture URL (https). Null if the account has no avatar. */\n avatarUrl: string | null;\n /**\n * Best-effort split of `name` — a HEURISTIC, not platform data.\n *\n * The platform stores one `full_name`; there is no real given/family split\n * behind it. This takes the first whitespace-separated token, which is wrong\n * for many names (mononyms, multi-word given names, family-name-first\n * cultures). Use it for a friendly greeting (\"Hi, Ada\"), never as a stored\n * field or a record of someone's legal name — persist `name` instead.\n */\n firstName: string | null;\n /** Remainder of `name` after the first token. Same heuristic caveat. */\n lastName: string | null;\n}\n\nfunction read(c: RequestLike, name: string): string | null {\n const v = c.req.header(name);\n if (v === undefined) return null;\n const trimmed = v.trim();\n return trimmed === \"\" ? null : trimmed;\n}\n\nfunction splitName(name: string | null): Pick<AppUser, \"firstName\" | \"lastName\"> {\n if (!name) return { firstName: null, lastName: null };\n const parts = name.split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return { firstName: null, lastName: null };\n if (parts.length === 1) return { firstName: parts[0], lastName: null };\n return { firstName: parts[0], lastName: parts.slice(1).join(\" \") };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Defaults to `\"public\"` — the least-privileged value — when the header is\n * missing or unrecognised, which happens when the app is reached outside the\n * platform perimeter (local `pnpm dev`). Gate privileged behaviour on an\n * explicit check (`caller(c) === \"user\"`), never on \"not public\".\n */\nexport function caller(c: RequestLike): Caller {\n const v = read(c, \"X-Clawnify-Caller\");\n return v && CALLERS.has(v as Caller) ? (v as Caller) : \"public\";\n}\n\n/**\n * The organization this request belongs to — the tenant key. Scope every query\n * by it.\n *\n * Present for `user`, `api`, `agent` and `app` callers. **Null** for\n * `agent-browser`, `public` and `bypass` — those reach the app through the\n * perimeter without an org-scoped identity — and null outside the platform.\n *\n * Treat null as \"no access\", never as a wildcard: a query filtered on a null\n * org must return nothing, not everything.\n */\nexport function orgId(c: RequestLike): string | null {\n return read(c, \"X-Clawnify-Org-Id\");\n}\n\n/**\n * The person behind this request, or `null` when the caller isn't one.\n *\n * Null for `agent-browser`, `public`, `bypass`, `system` and `app` — five of\n * the eight caller types — so always handle the null case rather than assuming\n * a user is present:\n *\n * const u = user(c);\n * const label = u ? u.name ?? u.email : \"Automated\";\n */\nexport function user(c: RequestLike): AppUser | null {\n const id = read(c, \"X-Clawnify-User-Id\");\n if (!id) return null;\n\n const name = read(c, \"X-Clawnify-User-Name\");\n return {\n id,\n email: read(c, \"X-Clawnify-User-Email\"),\n name,\n avatarUrl: read(c, \"X-Clawnify-User-Avatar\"),\n ...splitName(name),\n };\n}\n"],"mappings":";AA4BA,SAAS,aAAa,sBAA6C;AACnE,SAAS,cAAc;AAOvB;AAAA,EACE,eAAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAAC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;;;ACSP,IAAM,UAAU,oBAAI,IAAY;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAyBD,SAAS,KAAK,GAAgB,MAA6B;AACzD,QAAM,IAAI,EAAE,IAAI,OAAO,IAAI;AAC3B,MAAI,MAAM,OAAW,QAAO;AAC5B,QAAM,UAAU,EAAE,KAAK;AACvB,SAAO,YAAY,KAAK,OAAO;AACjC;AAEA,SAAS,UAAU,MAA8D;AAC/E,MAAI,CAAC,KAAM,QAAO,EAAE,WAAW,MAAM,UAAU,KAAK;AACpD,QAAM,QAAQ,KAAK,MAAM,KAAK,EAAE,OAAO,OAAO;AAC9C,MAAI,MAAM,WAAW,EAAG,QAAO,EAAE,WAAW,MAAM,UAAU,KAAK;AACjE,MAAI,MAAM,WAAW,EAAG,QAAO,EAAE,WAAW,MAAM,CAAC,GAAG,UAAU,KAAK;AACrE,SAAO,EAAE,WAAW,MAAM,CAAC,GAAG,UAAU,MAAM,MAAM,CAAC,EAAE,KAAK,GAAG,EAAE;AACnE;AAUO,SAAS,OAAO,GAAwB;AAC7C,QAAM,IAAI,KAAK,GAAG,mBAAmB;AACrC,SAAO,KAAK,QAAQ,IAAI,CAAW,IAAK,IAAe;AACzD;AAaO,SAAS,MAAM,GAA+B;AACnD,SAAO,KAAK,GAAG,mBAAmB;AACpC;AAYO,SAAS,KAAK,GAAgC;AACnD,QAAM,KAAK,KAAK,GAAG,oBAAoB;AACvC,MAAI,CAAC,GAAI,QAAO;AAEhB,QAAM,OAAO,KAAK,GAAG,sBAAsB;AAC3C,SAAO;AAAA,IACL;AAAA,IACA,OAAO,KAAK,GAAG,uBAAuB;AAAA,IACtC;AAAA,IACA,WAAW,KAAK,GAAG,wBAAwB;AAAA,IAC3C,GAAG,UAAU,IAAI;AAAA,EACnB;AACF;;;AD3EO,SAAS,UACd,OAAyB,CAAC,GACV;AAChB,QAAM,MAAM,IAAI,YAAe;AAE/B,MAAI,KAAK,OAAO,OAAO;AACrB,QAAI,IAAI,KAAK,OAAO,GAAG,SAAS;AAG9B,aAAO,EAAE,GAA8C;AACvD,YAAM,KAAK;AAAA,IACb,CAAC;AAAA,EACH;AAEA,MAAI,KAAK,cAAc,OAAO;AAC5B,mBAAe,KAAK,IAAI;AAAA,EAC1B;AAEA,SAAO;AACT;","names":["OpenAPIHono","mountDiscovery"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/identity.ts","../src/widgets.ts"],"sourcesContent":["/**\n * @clawnify/app — the standard Clawnify app skeleton in one call.\n *\n * `createApp(opts)` returns a fully-wired OpenAPIHono with every always-required,\n * cross-cutting concern pre-baked, so an app's server file is just imports +\n * routes + `export default app`:\n *\n * import { createApp, createRoute, z } from \"@clawnify/app\";\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(listContacts, handler);\n * export default app;\n *\n * What it bakes in — the boilerplate every app used to re-write in its server\n * file, and could forget:\n * • OpenAPIHono construction (typed to your Env).\n * • Database init — `initDB(c.env)` middleware on every request. @clawnify/db\n * auto-detects the D1 binding (production WfP) or the Storage capability\n * binding (Dynamic Workers preview), so handlers can query immediately.\n * • API discovery — `mountDiscovery` serves GET /api/openapi.json (the machine\n * contract) and GET /llms.txt (the lean agent index), generated from the\n * live routes so they can't drift.\n * • Identity — `user(c)` / `orgId(c)` / `caller(c)` read the platform-injected\n * `X-Clawnify-*` headers, so an app never hand-parses them (see identity.ts).\n *\n * The point: a new always-required concern is added HERE once and every app\n * inherits it on the next rebuild — instead of a line each agent must remember\n * to write (and a build gate to enforce) in every app.\n */\nimport { OpenAPIHono, mountDiscovery, type DiscoveryOptions } from \"@clawnify/routes\";\nimport { initDB } from \"@clawnify/db\";\nimport type { Env } from \"hono\";\n\n// One routing import for app authors: everything needed to declare routes and\n// build the app comes from @clawnify/app. These are re-exports of the same\n// @hono/zod-openapi primitives @clawnify/routes exposes — never reimplemented —\n// so route creation, discovery, and construction all live behind one import.\nexport {\n OpenAPIHono,\n createRoute,\n z,\n mountDiscovery,\n describeRoutes,\n PageQuery,\n pageParams,\n pagedResponse,\n DEFAULT_PAGE_LIMIT,\n MAX_PAGE_LIMIT,\n} from \"@clawnify/routes\";\nexport type { DiscoveryOptions, DiscoverableApp, PageParams } from \"@clawnify/routes\";\n\n// Identity of the caller, read from the platform-injected headers. Apps never\n// authenticate anyone themselves — see identity.ts.\nexport { user, orgId, caller } from \"./identity\";\nexport type { AppUser, Caller, RequestLike } from \"./identity\";\n\n// Dashboard widgets: standard tiles the dashboard draws from data the app\n// serves at GET /api/widgets — see widgets.ts.\nexport { widgets, WidgetSchema, WIDGET_LIMITS } from \"./widgets\";\nexport type { Widget } from \"./widgets\";\n\nexport interface CreateAppOptions extends DiscoveryOptions {\n /**\n * Wire the per-request database middleware (`initDB(c.env)` on `*`). Default\n * true — every Clawnify app has a D1 / Storage binding. Set false only for the\n * rare app with no database.\n */\n db?: boolean;\n /**\n * Mount GET /api/openapi.json + GET /llms.txt discovery. Default true. Set\n * false only if the app deliberately opts out of API discovery.\n */\n discovery?: boolean;\n}\n\n/**\n * Create a fully-wired Clawnify app.\n *\n * `E` is your Hono env — `{ Bindings: { DB: D1Database, ... } }` — pass it so\n * route handlers get a typed `c.env`. Everything after this call is your app's\n * own routes and business logic:\n *\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(route, handler);\n * export default app;\n */\nexport function createApp<E extends Env = Env>(\n opts: CreateAppOptions = {},\n): OpenAPIHono<E> {\n const app = new OpenAPIHono<E>();\n\n if (opts.db !== false) {\n app.use(\"*\", async (c, next) => {\n // @clawnify/db reads the DB (D1) or Storage binding off the env and caches\n // the Drizzle client for this request. Safe to call on every request.\n initDB(c.env as unknown as Parameters<typeof initDB>[0]);\n await next();\n });\n }\n\n if (opts.discovery !== false) {\n mountDiscovery(app, opts);\n }\n\n return app;\n}\n","/**\n * Identity — who is calling this app.\n *\n * The platform authenticates the caller at the perimeter (app-router, the\n * app-supervisor preview tier, or the /v1/apps/:id/proxy API) and injects a\n * *fresh* set of `X-Clawnify-*` headers. Any client-supplied `X-Clawnify-*` is\n * stripped first, so these headers cannot be spoofed and an app can trust them\n * directly.\n *\n * Apps therefore never authenticate anyone themselves: never read\n * `Authorization`, never parse a cookie, never verify a JWT. Read these\n * helpers instead.\n *\n * import { user, orgId, caller } from \"@clawnify/app\";\n *\n * app.openapi(route, (c) => {\n * const u = user(c); // null when the caller isn't a person\n * return c.json({ hello: u?.name ?? \"there\", org: orgId(c) });\n * });\n */\n\n/**\n * Minimal shape this module needs — Hono's `Context` satisfies it structurally,\n * so `user(c)` works in any handler regardless of the app's Env generics.\n */\nexport interface RequestLike {\n req: { header(name: string): string | undefined };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Only `user`, `api` and `agent` carry a human identity; the rest are machine\n * or anonymous callers, for which `user()` returns null.\n *\n * | Value | Who |\n * |-----------------|------------------------------------------------------------|\n * | `user` | a signed-in person in a browser |\n * | `api` | a person via the REST API / CLI / MCP (their own token) |\n * | `agent` | the org's OpenClaw agent calling the app's API |\n * | `agent-browser` | the agent driving a real browser on its VPS |\n * | `public` | an unauthenticated visitor on a declared public route |\n * | `bypass` | platform-internal fetch (build/screenshot) |\n * | `system` | the build pipeline |\n * | `app` | another app in the same org (or a headless org-token caller)|\n */\nexport type Caller =\n | \"user\"\n | \"api\"\n | \"agent\"\n | \"agent-browser\"\n | \"public\"\n | \"bypass\"\n | \"system\"\n | \"app\";\n\nconst CALLERS = new Set<Caller>([\n \"user\",\n \"api\",\n \"agent\",\n \"agent-browser\",\n \"public\",\n \"bypass\",\n \"system\",\n \"app\",\n]);\n\n/** The signed-in person behind a request. */\nexport interface AppUser {\n /** Supabase user id — the stable key to store on rows (`created_by`). */\n id: string;\n email: string | null;\n /** Display name, e.g. \"Ada Lovelace\". Null if the account has no name set. */\n name: string | null;\n /** Profile picture URL (https). Null if the account has no avatar. */\n avatarUrl: string | null;\n /**\n * Best-effort split of `name` — a HEURISTIC, not platform data.\n *\n * The platform stores one `full_name`; there is no real given/family split\n * behind it. This takes the first whitespace-separated token, which is wrong\n * for many names (mononyms, multi-word given names, family-name-first\n * cultures). Use it for a friendly greeting (\"Hi, Ada\"), never as a stored\n * field or a record of someone's legal name — persist `name` instead.\n */\n firstName: string | null;\n /** Remainder of `name` after the first token. Same heuristic caveat. */\n lastName: string | null;\n}\n\nfunction read(c: RequestLike, name: string): string | null {\n const v = c.req.header(name);\n if (v === undefined) return null;\n const trimmed = v.trim();\n return trimmed === \"\" ? null : trimmed;\n}\n\nfunction splitName(name: string | null): Pick<AppUser, \"firstName\" | \"lastName\"> {\n if (!name) return { firstName: null, lastName: null };\n const parts = name.split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return { firstName: null, lastName: null };\n if (parts.length === 1) return { firstName: parts[0], lastName: null };\n return { firstName: parts[0], lastName: parts.slice(1).join(\" \") };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Defaults to `\"public\"` — the least-privileged value — when the header is\n * missing or unrecognised, which happens when the app is reached outside the\n * platform perimeter (local `pnpm dev`). Gate privileged behaviour on an\n * explicit check (`caller(c) === \"user\"`), never on \"not public\".\n */\nexport function caller(c: RequestLike): Caller {\n const v = read(c, \"X-Clawnify-Caller\");\n return v && CALLERS.has(v as Caller) ? (v as Caller) : \"public\";\n}\n\n/**\n * The organization this request belongs to — the tenant key. Scope every query\n * by it.\n *\n * Present for `user`, `api`, `agent` and `app` callers. **Null** for\n * `agent-browser`, `public` and `bypass` — those reach the app through the\n * perimeter without an org-scoped identity — and null outside the platform.\n *\n * Treat null as \"no access\", never as a wildcard: a query filtered on a null\n * org must return nothing, not everything.\n */\nexport function orgId(c: RequestLike): string | null {\n return read(c, \"X-Clawnify-Org-Id\");\n}\n\n/**\n * The person behind this request, or `null` when the caller isn't one.\n *\n * Null for `agent-browser`, `public`, `bypass`, `system` and `app` — five of\n * the eight caller types — so always handle the null case rather than assuming\n * a user is present:\n *\n * const u = user(c);\n * const label = u ? u.name ?? u.email : \"Automated\";\n */\nexport function user(c: RequestLike): AppUser | null {\n const id = read(c, \"X-Clawnify-User-Id\");\n if (!id) return null;\n\n const name = read(c, \"X-Clawnify-User-Name\");\n return {\n id,\n email: read(c, \"X-Clawnify-User-Email\"),\n name,\n avatarUrl: read(c, \"X-Clawnify-User-Avatar\"),\n ...splitName(name),\n };\n}\n","/**\n * Dashboard widgets: glanceable tiles an app offers, drawn by the Clawnify\n * dashboard while the app itself is closed.\n *\n * The widget kinds are standard and the dashboard renders them with its own\n * components. An app supplies DATA only — no markup, no CSS, no chart code —\n * so every widget looks native and no app can break the home page.\n *\n * import { createApp, widgets, orgId } from \"@clawnify/app\";\n * const app = createApp<Env>({ title: \"CRM\" });\n * widgets(app, async (c) => [\n * { key: \"deals-this-month\", kind: \"metric\", title: \"Deals created this month\",\n * value: await countDealsThisMonth(c), at: \"/deals?created=this-month\" },\n * ]);\n *\n * Served at GET /api/widgets and listed in /api/openapi.json, so an agent can\n * read the same summary a person sees. The handler runs with the caller's\n * identity (`user(c)` / `orgId(c)`), so return only what that caller may see.\n *\n * The dashboard re-validates everything it receives (apps/api\n * services/app-widgets.ts is the trust boundary); the schema here exists so an\n * author gets a clear error at their own desk instead of a silently dropped\n * tile. Keep the two in step: kinds, fields and caps.\n */\nimport { createRoute, z, type OpenAPIHono } from \"@clawnify/routes\";\nimport type { Context, Env } from \"hono\";\n\nexport const WIDGET_LIMITS = {\n widgets: 6,\n seriesPoints: 90,\n breakdownItems: 12,\n listItems: 5,\n text: 80,\n} as const;\n\nconst text = z.string().min(1).max(WIDGET_LIMITS.text);\n// An in-app path. The dashboard opens the app there (its `?at=` deep link).\nconst at = z\n .string()\n .max(512)\n .regex(/^\\/(?!\\/)/, \"must be an in-app path starting with a single /\");\n// \"percent\" takes a fraction: 0.42 renders as 42%.\nconst format = z.enum([\"number\", \"currency\", \"percent\"]).optional();\n// ISO 4217, only meaningful with format \"currency\".\nconst currency = z.string().regex(/^[A-Z]{3}$/).optional();\n\nconst base = {\n /** Stable id, unique within the app: lowercase letters, digits, dashes. */\n key: z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/),\n title: text,\n at: at.optional(),\n};\n\nexport const WidgetSchema = z.discriminatedUnion(\"kind\", [\n z.object({\n ...base,\n kind: z.literal(\"metric\"),\n value: z.number().finite(),\n format,\n currency,\n }),\n z.object({\n ...base,\n kind: z.literal(\"series\"),\n /** Oldest first. `x` is an ISO date or a short label. */\n points: z\n .array(z.object({ x: text, y: z.number().finite() }))\n .max(WIDGET_LIMITS.seriesPoints),\n format,\n currency,\n }),\n z.object({\n ...base,\n kind: z.literal(\"breakdown\"),\n /** Drawn as ranked horizontal bars in one hue, largest first. */\n items: z\n .array(z.object({ label: text, value: z.number().finite() }))\n .max(WIDGET_LIMITS.breakdownItems),\n format,\n currency,\n }),\n z.object({\n ...base,\n kind: z.literal(\"list\"),\n items: z\n .array(z.object({ label: text, meta: text.optional(), at: at.optional() }))\n .max(WIDGET_LIMITS.listItems),\n }),\n]);\n\nexport type Widget = z.infer<typeof WidgetSchema>;\n\nconst WidgetsResponse = z.object({\n widgets: z.array(WidgetSchema).max(WIDGET_LIMITS.widgets),\n});\n\nconst route = createRoute({\n method: \"get\",\n path: \"/api/widgets\",\n summary: \"Dashboard widgets: a bounded summary of this app for the current caller\",\n responses: {\n 200: {\n description: \"The app's widgets with their current data\",\n content: { \"application/json\": { schema: WidgetsResponse } },\n },\n },\n});\n\n/**\n * Serve the app's dashboard widgets at GET /api/widgets.\n *\n * `list` is called per request with the Hono context. Throwing (or returning\n * an invalid widget) yields a 500 naming the problem, so a broken widget is\n * loud in development; the dashboard shows that app's tiles as unavailable\n * and keeps rendering everything else.\n */\nexport function widgets<E extends Env>(\n app: OpenAPIHono<E>,\n list: (c: Context<E>) => Widget[] | Promise<Widget[]>,\n): void {\n app.openapi(route, async (c) => {\n const parsed = WidgetsResponse.safeParse({ widgets: await list(c as unknown as Context<E>) });\n if (!parsed.success) {\n return c.json(\n { error: \"invalid widgets\", issues: parsed.error.issues.slice(0, 5) },\n 500,\n ) as never;\n }\n return c.json(parsed.data, 200);\n });\n}\n"],"mappings":"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","names":["OpenAPIHono","createRoute","z","mountDiscovery"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawnify/app",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "The standard Clawnify app skeleton in one call. createApp() returns a fully-wired OpenAPIHono with D1/Storage init + /api/openapi.json + /llms.txt discovery pre-baked, so an app is just imports + routes + export.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",