@drawnagency/primitives 0.1.69 → 0.1.70

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.
@@ -2,6 +2,10 @@ export interface ChatMessage {
2
2
  role: "user" | "assistant";
3
3
  content: string;
4
4
  }
5
+ /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
6
+ * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
7
+ * through, and model output is untrusted. Exported for tests. */
8
+ export declare function assistantHtml(md: string): string;
5
9
  /** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
6
10
  export declare function streamChat(body: {
7
11
  messages: ChatMessage[];
@@ -1 +1 @@
1
- {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAoB5E,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE;IAChH,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,mFAAmF;IACnF,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B,kDA6EA"}
1
+ {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AA6B5E;;kEAEkE;AAClE,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEhD;AAcD,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE;IAChH,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,mFAAmF;IACnF,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B,kDA6HA"}
@@ -1 +1 @@
1
- {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE,eAAe,2CAoClH"}
1
+ {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE,eAAe,2CA2ClH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -106,6 +106,7 @@
106
106
  "dompurify": "^3.2.6",
107
107
  "jose": "^6.2.2",
108
108
  "lucide-react": "^1.7.0",
109
+ "marked": "^18.0.5",
109
110
  "tailwind-merge": "^3.5.0",
110
111
  "zod": "^4.3.6"
111
112
  },
@@ -1,10 +1,21 @@
1
- import { useRef, useState } from "react";
1
+ import { useEffect, useRef, useState, type MouseEvent } from "react";
2
+ import { marked } from "marked";
2
3
  import { cn } from "../../lib/cn";
4
+ import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
3
5
 
4
6
  export interface ChatMessage { role: "user" | "assistant"; content: string }
5
7
 
6
8
  const SESSION_KEY = "bp-chat-session";
7
9
 
10
+ // Astro's trailingSlash: "ignore" means the viewer can be on "/brand/" while
11
+ // grounding links say "/brand#voice-tone" — the pathnames are equivalent
12
+ // but not literally equal. Per the HTML spec, same-document fragment
13
+ // navigation requires URL equality *excluding* the fragment, so a literal
14
+ // mismatch (e.g. "/brand/" vs "/brand") can't rely on native navigation —
15
+ // this helper only detects that "equivalent but not equal" case so we can
16
+ // handle it ourselves. "/" stays "/" rather than collapsing to "".
17
+ const normalizePathname = (p: string): string => p.replace(/\/+$/, "") || "/";
18
+
8
19
  function sessionId(): string {
9
20
  let id = window.localStorage.getItem(SESSION_KEY);
10
21
  if (!id) {
@@ -21,6 +32,25 @@ const ERROR_COPY: Record<string, string> = {
21
32
  upstream_error: "Something went wrong — please try again.",
22
33
  };
23
34
 
35
+ /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
36
+ * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
37
+ * through, and model output is untrusted. Exported for tests. */
38
+ export function assistantHtml(md: string): string {
39
+ return sanitizeHtml(marked.parse(md, { async: false, gfm: true, breaks: true }) as string);
40
+ }
41
+
42
+ // Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
43
+ // weight + spacing to clamp to the bubble's text scale.
44
+ const MD_STYLES =
45
+ "[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
46
+ "[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
47
+ "[&_a]:underline [&_a]:underline-offset-2 " +
48
+ "[&_code]:rounded [&_code]:bg-base-200 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.8em] " +
49
+ "[&_pre]:mt-2 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-base-200 [&_pre]:p-2 " +
50
+ "[&_h1]:mt-2 [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:font-semibold " +
51
+ "[&_h3]:mt-2 [&_h3]:font-semibold [&_h4]:mt-2 [&_h4]:font-semibold " +
52
+ "[&_blockquote]:mt-2 [&_blockquote]:border-l-2 [&_blockquote]:border-base-200 [&_blockquote]:pl-2";
53
+
24
54
  /** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
25
55
  export async function streamChat(
26
56
  body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown },
@@ -86,6 +116,46 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
86
116
  const [notice, setNotice] = useState<string | null>(null);
87
117
  const streamingRef = useRef("");
88
118
 
119
+ const [mdReady, setMdReady] = useState(false);
120
+ useEffect(() => {
121
+ let alive = true;
122
+ // Failure just leaves mdReady false forever (plain-text messages) — never
123
+ // let it surface as an unhandled rejection.
124
+ void ensureSanitizer().then(() => { if (alive) setMdReady(true); }).catch(() => {});
125
+ return () => { alive = false; };
126
+ }, []);
127
+
128
+ function onMessagesClick(e: MouseEvent<HTMLDivElement>) {
129
+ const link = (e.target as HTMLElement).closest("a");
130
+ if (!link || !link.getAttribute("href")) return;
131
+ const url = new URL(link.href, window.location.href);
132
+ // Native navigation is only safe when there's a hash to scroll to — the
133
+ // grounding emits hashless same-pathname links (e.g. "/") for content
134
+ // before the first heading, and letting those navigate natively would
135
+ // full-reload the page, unmounting the panel and wiping chat history.
136
+ const sameOriginWithHash = variant !== "editor" && url.origin === window.location.origin && url.hash !== "";
137
+ if (sameOriginWithHash) {
138
+ if (url.pathname === window.location.pathname) {
139
+ return; // literally same document: native hash navigation, panel stays open
140
+ }
141
+ if (normalizePathname(url.pathname) === normalizePathname(window.location.pathname)) {
142
+ // Equivalent pathnames (trailing-slash only difference) but NOT
143
+ // literally equal — per spec, same-document fragment navigation
144
+ // requires URL equality excluding the fragment, so the browser would
145
+ // treat a literal mismatch as a full cross-document navigation,
146
+ // unmounting the panel and wiping chat history. Perform the fragment
147
+ // navigation ourselves on the current (already-correct) URL instead.
148
+ e.preventDefault();
149
+ window.location.hash = url.hash;
150
+ return;
151
+ }
152
+ }
153
+ // Cross-page (or same-page but hashless, or any /edit click): new tab so
154
+ // chat history / unsaved editor state survive.
155
+ e.preventDefault();
156
+ window.open(url.href, "_blank", "noopener");
157
+ }
158
+
89
159
  async function send() {
90
160
  const text = input.trim();
91
161
  if (!text || busy) return;
@@ -132,13 +202,21 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
132
202
  )}
133
203
  <button type="button" aria-label="Close chat" onClick={onClose} className="text-base-contrast-light hover:text-base-contrast">×</button>
134
204
  </div>
135
- <div className="flex-1 space-y-2 overflow-y-auto p-3">
136
- {messages.map((m, i) => (
137
- <div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
138
- m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
139
- {m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
140
- </div>
141
- ))}
205
+ <div className="flex-1 space-y-2 overflow-y-auto p-3" onClick={onMessagesClick}>
206
+ {messages.map((m, i) =>
207
+ m.role === "assistant" && mdReady && m.content ? (
208
+ <div
209
+ key={i}
210
+ className={cn("max-w-[85%] rounded-lg bg-base-accent px-3 py-2 text-sm text-base-contrast", MD_STYLES)}
211
+ dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
212
+ />
213
+ ) : (
214
+ <div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
215
+ m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
216
+ {m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
217
+ </div>
218
+ ),
219
+ )}
142
220
  {notice && <p className="text-xs text-base-contrast-light">{notice}</p>}
143
221
  </div>
144
222
  <form className="flex gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
@@ -4,8 +4,8 @@
4
4
  //
5
5
  // Two placements:
6
6
  // - "viewer" (default): bottom-right corner of viewer pages.
7
- // - "editor": stacked above the /edit route's existing FAB column (edit-mode
8
- // toggle at bottom-4, bug report at bottom-16), aligned to the content edge
7
+ // - "editor": BOTTOM of the /edit route's FAB column (edit-mode toggle at
8
+ // bottom-16, bug report at bottom-28 above it), aligned to the content edge
9
9
  // via fab-container-right like its neighbors.
10
10
  import { lazy, Suspense, useState } from "react";
11
11
  import { cn } from "../../lib/cn";
@@ -30,18 +30,25 @@ export default function ChatWidget({ siteName, tasks, isEditor, draftChatbot, va
30
30
  <>
31
31
  <button
32
32
  type="button"
33
- aria-label={`Chat with ${siteName}`}
33
+ aria-label={open ? "Collapse chat" : `Chat with ${siteName}`}
34
+ aria-expanded={open}
34
35
  onClick={() => { setOpen((o) => !o); setEverOpened(true); }}
35
36
  className={cn(
36
37
  "fixed z-40 flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
37
38
  variant === "editor"
38
- ? "bottom-28 right-4 lg:right-auto h-10 w-10 fab-container-right"
39
+ ? "bottom-4 right-4 lg:right-auto h-10 w-10 fab-container-right"
39
40
  : "bottom-4 right-4 h-12 w-12",
40
41
  )}
41
42
  >
42
- <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
43
- <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
44
- </svg>
43
+ {open ? (
44
+ <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
45
+ <path d="m6 9 6 6 6-6" />
46
+ </svg>
47
+ ) : (
48
+ <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
49
+ <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
50
+ </svg>
51
+ )}
45
52
  </button>
46
53
  {everOpened && (
47
54
  <Suspense fallback={null}>
@@ -190,7 +190,7 @@ export function BugReportFAB() {
190
190
  type="button"
191
191
  onClick={handleOpen}
192
192
  aria-label="Report a bug"
193
- className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
193
+ className="cursor-pointer fixed bottom-28 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
194
194
  >
195
195
  {/* Bug / beetle SVG icon */}
196
196
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -1696,7 +1696,7 @@ function EditorToolbar({
1696
1696
  {!historyState && (
1697
1697
  <button
1698
1698
  onClick={toggleEditMode}
1699
- className="cursor-pointer fixed bottom-4 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1699
+ className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1700
1700
  aria-label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
1701
1701
  >
1702
1702
  {isEditMode ? (