From 0bc83a9948e130bbb1ea510517fa1075ff16310d Mon Sep 17 00:00:00 2001 From: dfed25 Date: Sat, 25 Apr 2026 19:21:00 -0700 Subject: [PATCH 1/2] feat: ship Track B page-aware widget intelligence Make the embedded assistant prioritize live page context and hovered feature context over imported docs, add lightweight hover-aware UX cues, and keep responses compact while preserving existing studio/embed flows. Made-with: Cursor --- public/runbook-embed.js | 54 +++++++++++++++- src/app/api/embed/chat/route.ts | 39 +++++++++++- src/components/EmbeddedRunbookAssistant.tsx | 69 +++++++++++++++++++-- src/lib/embedDemoKnowledge.ts | 25 +++++++- src/lib/embedNorthstarChat.ts | 21 ++++++- src/lib/prompts.ts | 1 + 6 files changed, 195 insertions(+), 14 deletions(-) diff --git a/public/runbook-embed.js b/public/runbook-embed.js index 6179489..16acc73 100644 --- a/public/runbook-embed.js +++ b/public/runbook-embed.js @@ -20,7 +20,8 @@ var apiKey = script.getAttribute("data-key") || ""; var includeBodyText = script.hasAttribute("data-include-body-text") || - !!document.querySelector('script[src*="runbook-embed.js"][data-include-body-text]'); + !!document.querySelector('script[src*="runbook-embed.js"][data-include-body-text]') || + true; var originAttr = (script.getAttribute("data-runbook-origin") || "").trim().replace(/\/$/, ""); var base = originAttr || script.src.replace(/\/runbook-embed\.js.*$/, ""); if (!projectId) { @@ -170,6 +171,8 @@ ".rb-chips{display:flex;flex-wrap:wrap;gap:6px;padding:10px 12px;border-bottom:1px solid rgba(148,163,184,.2);}" + ".rb-chip{font-size:11px;padding:6px 10px;border-radius:999px;border:1px solid rgba(129,140,248,.45);background:rgba(79,70,229,.15);color:#c7d2fe;cursor:pointer;}" + ".rb-chip:hover{background:rgba(79,70,229,.3);}" + + ".rb-hover{padding:6px 12px;border-bottom:1px solid rgba(148,163,184,.2);font-size:11px;color:#cbd5e1;display:none;}" + + ".rb-hover strong{color:#86efac;}" + ".rb-body{flex:1;overflow:auto;padding:12px 14px;display:flex;flex-direction:column;gap:10px;}" + ".rb-msg{max-width:100%;padding:10px 12px;border-radius:12px;font-size:13px;line-height:1.5;white-space:pre-wrap;}" + ".rb-user{align-self:flex-end;background:linear-gradient(135deg,#a78bfa,#6366f1);color:#0f172a;}" + @@ -195,6 +198,8 @@ if (titleEl) titleEl.textContent = assistantName; var chipsWrap = document.createElement("div"); chipsWrap.className = "rb-chips"; + var hoverCtx = document.createElement("div"); + hoverCtx.className = "rb-hover"; var body = document.createElement("div"); body.className = "rb-body"; var foot = document.createElement("div"); @@ -203,6 +208,7 @@ ''; panel.appendChild(head); panel.appendChild(chipsWrap); + panel.appendChild(hoverCtx); panel.appendChild(body); panel.appendChild(foot); @@ -222,6 +228,37 @@ var inp = foot.querySelector(".rb-inp"); var sendBtn = foot.querySelector(".rb-send"); var closeBtn = head.querySelector(".rb-close"); + var hoveredFeature = null; + function updateHoveredFeature(next) { + hoveredFeature = next; + if (next && (next.title || next.feature)) { + hoverCtx.style.display = "block"; + hoverCtx.innerHTML = "Looking at: " + escapeHtml(next.title || next.feature) + ""; + } else { + hoverCtx.style.display = "none"; + hoverCtx.innerHTML = ""; + } + } + + function trackHoverEvents() { + function handleOver(evt) { + var target = evt.target instanceof Element ? evt.target : null; + if (!target) return; + var el = target.closest("[data-runbook-feature],[data-runbook-title],[data-runbook-description]"); + if (!el) return; + updateHoveredFeature({ + feature: (el.getAttribute("data-runbook-feature") || "").trim(), + title: (el.getAttribute("data-runbook-title") || "").trim(), + description: (el.getAttribute("data-runbook-description") || "").trim() + }); + } + function handleOut() { + updateHoveredFeature(null); + } + document.addEventListener("pointerover", handleOver, true); + document.addEventListener("pointerout", handleOut, true); + } + var highlightTimeout = null; var highlightedEl = null; var overlayEl = null; @@ -450,6 +487,15 @@ }); chipsWrap.appendChild(c); }); + var pageActions = document.createElement("button"); + pageActions.type = "button"; + pageActions.className = "rb-chip"; + pageActions.textContent = "What can I do here?"; + pageActions.addEventListener("click", function () { + inp.value = "What can I do here?"; + void doSend(); + }); + chipsWrap.insertBefore(pageActions, chipsWrap.firstChild); async function doSend() { var q = (inp.value || "").trim(); @@ -463,7 +509,10 @@ var body = { projectId: projectId, message: q, - pageContext: pageContext() + pageContext: pageContext(), + pageTitle: document.title || "", + pageUrl: location.href || "", + hoveredFeature: hoveredFeature }; if (projectId === DEMO_ID) { var custom = manualSourcesFromBundle(bundle); @@ -563,6 +612,7 @@ } else { addBot("Runbook
Ask a question. Answers use your indexed knowledge."); } + trackHoverEvents(); } if (document.body) mount(); diff --git a/src/app/api/embed/chat/route.ts b/src/app/api/embed/chat/route.ts index a22d077..761ef05 100644 --- a/src/app/api/embed/chat/route.ts +++ b/src/app/api/embed/chat/route.ts @@ -64,6 +64,7 @@ type ChatBody = { customSources?: unknown; /** Optional imported repository docs from Studio/embed demo. */ documents?: unknown; + hoveredFeature?: unknown; }; type StructuredChatPayload = { @@ -105,13 +106,31 @@ function sanitizeDocuments(raw: unknown): { title: string; content: string }[] { } function normalizePageContext(body: ChatBody): string { + const hovered = sanitizeHoveredFeature(body.hoveredFeature); if (typeof body.pageContext === "string" && body.pageContext.trim()) { - return body.pageContext.trim(); + return [ + `Page URL: ${body.pageUrl || "n/a"}`, + `Page title: ${body.pageTitle || "n/a"}`, + hovered ? `Hovered feature: ${hovered}` : "", + body.pageContext.trim() + ] + .filter(Boolean) + .join("\n"); } - const parts = [body.pageUrl, body.pageTitle].filter(Boolean) as string[]; + const parts = [body.pageUrl, body.pageTitle, hovered].filter(Boolean) as string[]; return parts.join("\n"); } +function sanitizeHoveredFeature(raw: unknown): string { + if (!raw || typeof raw !== "object") return ""; + const obj = raw as Record; + const feature = String(obj.feature || "").trim(); + const title = String(obj.title || "").trim(); + const description = String(obj.description || "").trim().slice(0, 400); + const joined = [title || feature, description].filter(Boolean).join(" — "); + return joined.slice(0, 520); +} + function normalizeStructured(payload: Partial): StructuredChatPayload { const normalizedBullets = normalizeBullets(payload.bullets || []); const fallbackBullets = payload.answer ? bulletsFromText(String(payload.answer)) : DEFAULT_FALLBACK_BULLETS; @@ -173,7 +192,12 @@ export async function POST(req: NextRequest) { } const customSources = [...sanitizeCustomSources(body.customSources), ...requestDocs]; - const payload = await runNorthstarEmbedChat({ message, pageContext, customSources }); + const payload = await runNorthstarEmbedChat({ + message, + pageContext, + hoveredFeature: sanitizeHoveredFeature(body.hoveredFeature), + customSources + }); return NextResponse.json(normalizeStructured(payload), { headers: corsHeaders(origin) }); } @@ -213,9 +237,18 @@ export async function POST(req: NextRequest) { ) .join("\n\n"); + const hoveredFeature = sanitizeHoveredFeature(body.hoveredFeature); const userPrompt = `Repository: ${project.githubRepoFullName} (default branch hint: ${project.defaultBranch}) +Answer priority: +1) Current page context +2) Hovered feature details +3) Repository sources +Only use repository context for deeper explanation. + Page context: ${pageContext || "(not provided)"} +Hovered feature context: +${hoveredFeature || "(none)"} Indexed sources: ${context || "(no indexed chunks yet)"} diff --git a/src/components/EmbeddedRunbookAssistant.tsx b/src/components/EmbeddedRunbookAssistant.tsx index 2d1de32..e34402d 100644 --- a/src/components/EmbeddedRunbookAssistant.tsx +++ b/src/components/EmbeddedRunbookAssistant.tsx @@ -19,6 +19,7 @@ type ChatResponse = { }; type SourceItem = { title: string; excerpt?: string; url?: string }; +type HoveredFeatureContext = { feature?: string; title?: string; description?: string }; type Message = | { role: "user"; text: string } @@ -130,10 +131,48 @@ export function EmbeddedRunbookAssistant({ const [messages, setMessages] = useState([]); const [completedStepsByMessage, setCompletedStepsByMessage] = useState>>({}); const [activeSource, setActiveSource] = useState(null); + const [hoveredFeature, setHoveredFeature] = useState(null); const highlightCleanupRef = useRef<() => void>(() => undefined); + const hoveredFeatureRef = useRef(null); + const hoverDebounceRef = useRef(null); useEffect(() => { return () => { highlightCleanupRef.current?.(); + if (hoverDebounceRef.current) window.clearTimeout(hoverDebounceRef.current); + }; + }, []); + + useEffect(() => { + hoveredFeatureRef.current = hoveredFeature; + }, [hoveredFeature]); + + useEffect(() => { + if (typeof document === "undefined") return; + const handlePointerOver = (evt: Event) => { + const target = evt.target instanceof Element ? evt.target : null; + if (!target) return; + const el = target.closest("[data-runbook-feature],[data-runbook-title],[data-runbook-description]"); + if (!el) return; + if (hoverDebounceRef.current) window.clearTimeout(hoverDebounceRef.current); + hoverDebounceRef.current = window.setTimeout(() => { + const next: HoveredFeatureContext = { + feature: (el.getAttribute("data-runbook-feature") || "").trim() || undefined, + title: (el.getAttribute("data-runbook-title") || "").trim() || undefined, + description: (el.getAttribute("data-runbook-description") || "").trim() || undefined + }; + if (!next.feature && !next.title && !next.description) return; + setHoveredFeature(next); + }, 80); + }; + const clearHovered = () => { + if (hoverDebounceRef.current) window.clearTimeout(hoverDebounceRef.current); + hoverDebounceRef.current = window.setTimeout(() => setHoveredFeature(null), 120); + }; + document.addEventListener("pointerover", handlePointerOver, true); + document.addEventListener("pointerout", clearHovered, true); + return () => { + document.removeEventListener("pointerover", handlePointerOver, true); + document.removeEventListener("pointerout", clearHovered, true); }; }, []); @@ -170,15 +209,25 @@ export function EmbeddedRunbookAssistant({ setLoading(true); try { const customSources = manualSources.map(({ title, content }) => ({ title, content })); + const pageBody = + typeof document !== "undefined" + ? document.body.innerText.replace(/\s+/g, " ").trim().slice(0, 10_000) + : ""; + const pageContext = + pageContextOverride || + (typeof window !== "undefined" + ? [window.location.href, document.title, pageBody].filter(Boolean).join("\n") + : pageBody); const res = await fetch(`${base}/api/embed/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, message: q, - pageContext: - pageContextOverride || - (typeof window !== "undefined" ? `${window.location.href}\n${document.title}` : ""), + pageContext, + pageTitle: typeof document !== "undefined" ? document.title : "", + pageUrl: typeof window !== "undefined" ? window.location.href : "", + hoveredFeature: hoveredFeatureRef.current, customSources, documents: effectiveImportedDocs }) @@ -306,9 +355,9 @@ export function EmbeddedRunbookAssistant({ {open ? (
-
+

Runbook

{assistantName}

@@ -322,12 +371,20 @@ export function EmbeddedRunbookAssistant({ ×
+ {hoveredFeature ? ( +
+ Looking at:{" "} + + {hoveredFeature.title || hoveredFeature.feature || "Current feature"} + +
+ ) : null} {!hideQuickActions && chips.length > 0 ? (
diff --git a/src/lib/embedDemoKnowledge.ts b/src/lib/embedDemoKnowledge.ts index 2a530b1..b0a8c58 100644 --- a/src/lib/embedDemoKnowledge.ts +++ b/src/lib/embedDemoKnowledge.ts @@ -87,9 +87,10 @@ function extractLocationTarget(text: string): string { } /** Deterministic demo responses for hackathon reliability. */ -export function buildNorthstarDemoResponse(message: string, pageContext: string): DemoChatResult { +export function buildNorthstarDemoResponse(message: string, pageContext: string, hoveredFeature?: string): DemoChatResult { const m = message.toLowerCase().trim(); const ctx = (pageContext || "").toLowerCase(); + const hovered = (hoveredFeature || "").trim(); const eng = docById("engineering-setup"); const first = docById("first-week"); @@ -97,6 +98,28 @@ export function buildNorthstarDemoResponse(message: string, pageContext: string) const product = docById("product-overview"); const expense = docById("expense-policy"); + if (hovered && (m.includes("what is this") || m.includes("explain this") || m.includes("what does this do"))) { + return createResult({ + answer: "This hovered feature helps complete your current page workflow.", + bullets: [ + hovered.split("—")[0]?.trim() || "Feature details available", + "Use it to progress the flow", + "Ask Guide me for step-by-step help" + ], + sources: product ? [{ title: product.title, excerpt: excerptFromContent(product.content), url: undefined }] : [], + steps: ["Review the hovered feature label.", "Click it to continue the flow.", "Ask for the next step if blocked."] + }); + } + + if (m.includes("what can i do here")) { + return createResult({ + answer: "Here are the key actions available on this page.", + bullets: ["Create a workflow", "Connect integrations", "Set up API keys"], + sources: product ? [{ title: product.title, excerpt: excerptFromContent(product.content), url: undefined }] : [], + steps: ["Start with the primary CTA on this page.", "Complete one setup action.", "Ask What next? for guided progression."] + }); + } + if (isLocationIntent(m)) { const target = extractLocationTarget(message); return createResult({ diff --git a/src/lib/embedNorthstarChat.ts b/src/lib/embedNorthstarChat.ts index 9df1f4d..e4d4267 100644 --- a/src/lib/embedNorthstarChat.ts +++ b/src/lib/embedNorthstarChat.ts @@ -8,6 +8,7 @@ import { clipWords, MAX_ANSWER_WORDS, normalizeBullets, normalizeSuggestions, no const NORTHSTAR_SYSTEM = `You are Runbook, an embedded in-app onboarding assistant for the "Northstar AI" demo product. Use ONLY the knowledge excerpts provided in the user message. If something is not in the excerpts, say briefly that it is not documented and suggest where to look next. Never output long paragraphs. +Prioritize page and hovered feature context first, and use docs as background. Return a short scannable structure as compact JSON on one final line: RUNBOOK_JSON: {"answer":"<=12 words","bullets":["<=14 words","..."],"steps":["..."],"suggestions":["Guide me step-by-step","Explain this page","What can I do next?"]} Rules: @@ -73,6 +74,7 @@ function parseStructured(raw: string): { answer: string; bullets: string[]; step export async function runNorthstarEmbedChat(input: { message: string; pageContext: string; + hoveredFeature?: string; customSources: { title: string; content: string }[]; }): Promise { const extraDocs: SourceDoc[] = input.customSources.map((s, i) => ({ @@ -100,7 +102,7 @@ export async function runNorthstarEmbedChat(input: { .join("\n\n") : demoDocs.map((d) => `### ${d.title}\n${d.content.slice(0, 1_500)}`).join("\n\n"); - const fallback = buildNorthstarDemoResponse(input.message, input.pageContext); + const fallback = buildNorthstarDemoResponse(input.message, input.pageContext, input.hoveredFeature); if (!isServerLlmConfigured()) { return { @@ -109,7 +111,22 @@ export async function runNorthstarEmbedChat(input: { }; } - const userBlock = `Page context:\n${input.pageContext || "(none)"}\n\nKnowledge excerpts (cite only from here):\n${contextBlock}\n\nUser question:\n${input.message}`; + const userBlock = `Priority order: +1) Current page context +2) Hovered feature context +3) Knowledge excerpts + +Page context: +${input.pageContext || "(none)"} + +Hovered feature context: +${input.hoveredFeature || "(none)"} + +Knowledge excerpts (cite only from here): +${contextBlock} + +User question: +${input.message}`; try { const raw = await generateFromGemini(NORTHSTAR_SYSTEM, userBlock); diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index f1eada9..54933c5 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -13,6 +13,7 @@ Formatting (strict): - Output answer as 12 words max. - Output max 3 bullets (each 14 words max). - Add steps only when actionable. +- Prioritize current page context first, hovered feature context second, docs third. - Prefer discoverability language: what user can do here/next. - Output only the single line beginning with RUNBOOK_JSON: no prose, no markdown, no code fences. - Final line must be JSON exactly prefixed with: From 0396c0b4e593719e9032961267c367dba2be1e1a Mon Sep 17 00:00:00 2001 From: dfed25 Date: Sat, 25 Apr 2026 20:13:29 -0700 Subject: [PATCH 2/2] fix: address Track B review comments for context handling Remove duplicated URL/title from widget page context, tighten hovered feature sanitization, stabilize hover pointerout behavior in the embed script, and make "What can I do here" fallback actions context-aware. Made-with: Cursor --- public/runbook-embed.js | 15 ++++--- src/app/api/embed/chat/route.ts | 16 +++++--- src/components/EmbeddedRunbookAssistant.tsx | 6 +-- src/lib/embedDemoKnowledge.ts | 45 ++++++++++++++++++++- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/public/runbook-embed.js b/public/runbook-embed.js index 16acc73..dfb7cf6 100644 --- a/public/runbook-embed.js +++ b/public/runbook-embed.js @@ -20,8 +20,7 @@ var apiKey = script.getAttribute("data-key") || ""; var includeBodyText = script.hasAttribute("data-include-body-text") || - !!document.querySelector('script[src*="runbook-embed.js"][data-include-body-text]') || - true; + !!document.querySelector('script[src*="runbook-embed.js"][data-include-body-text]'); var originAttr = (script.getAttribute("data-runbook-origin") || "").trim().replace(/\/$/, ""); var base = originAttr || script.src.replace(/\/runbook-embed\.js.*$/, ""); if (!projectId) { @@ -252,7 +251,13 @@ description: (el.getAttribute("data-runbook-description") || "").trim() }); } - function handleOut() { + function handleOut(evt) { + var from = evt.target instanceof Element ? evt.target : null; + var to = evt.relatedTarget instanceof Element ? evt.relatedTarget : null; + if (!from) return; + var fromFeature = from.closest("[data-runbook-feature],[data-runbook-title],[data-runbook-description]"); + if (!fromFeature) return; + if (to && fromFeature.contains(to)) return; updateHoveredFeature(null); } document.addEventListener("pointerover", handleOver, true); @@ -406,8 +411,6 @@ } function pageContext() { - var t = document.title || ""; - var u = location.href || ""; var meta = ""; var m = document.querySelector('meta[name="description"]'); if (m) meta = m.getAttribute("content") || ""; @@ -445,7 +448,7 @@ bodyText = ""; } } - return [u, t, meta, headings, bodyText].filter(Boolean).join("\n"); + return [meta, headings, bodyText].filter(Boolean).join("\n"); } function addBot(html) { diff --git a/src/app/api/embed/chat/route.ts b/src/app/api/embed/chat/route.ts index 761ef05..7ce3690 100644 --- a/src/app/api/embed/chat/route.ts +++ b/src/app/api/embed/chat/route.ts @@ -106,27 +106,31 @@ function sanitizeDocuments(raw: unknown): { title: string; content: string }[] { } function normalizePageContext(body: ChatBody): string { - const hovered = sanitizeHoveredFeature(body.hoveredFeature); if (typeof body.pageContext === "string" && body.pageContext.trim()) { return [ `Page URL: ${body.pageUrl || "n/a"}`, `Page title: ${body.pageTitle || "n/a"}`, - hovered ? `Hovered feature: ${hovered}` : "", body.pageContext.trim() ] .filter(Boolean) .join("\n"); } - const parts = [body.pageUrl, body.pageTitle, hovered].filter(Boolean) as string[]; + const parts = [body.pageUrl, body.pageTitle].filter(Boolean) as string[]; return parts.join("\n"); } function sanitizeHoveredFeature(raw: unknown): string { if (!raw || typeof raw !== "object") return ""; const obj = raw as Record; - const feature = String(obj.feature || "").trim(); - const title = String(obj.title || "").trim(); - const description = String(obj.description || "").trim().slice(0, 400); + const asStr = (v: unknown, limit: number): string => + (typeof v === "string" ? v : "") + .replace(/[\u0000-\u001f]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, limit); + const feature = asStr(obj.feature, 120); + const title = asStr(obj.title, 120); + const description = asStr(obj.description, 400); const joined = [title || feature, description].filter(Boolean).join(" — "); return joined.slice(0, 520); } diff --git a/src/components/EmbeddedRunbookAssistant.tsx b/src/components/EmbeddedRunbookAssistant.tsx index e34402d..2e71f52 100644 --- a/src/components/EmbeddedRunbookAssistant.tsx +++ b/src/components/EmbeddedRunbookAssistant.tsx @@ -213,11 +213,7 @@ export function EmbeddedRunbookAssistant({ typeof document !== "undefined" ? document.body.innerText.replace(/\s+/g, " ").trim().slice(0, 10_000) : ""; - const pageContext = - pageContextOverride || - (typeof window !== "undefined" - ? [window.location.href, document.title, pageBody].filter(Boolean).join("\n") - : pageBody); + const pageContext = pageContextOverride || pageBody; const res = await fetch(`${base}/api/embed/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/lib/embedDemoKnowledge.ts b/src/lib/embedDemoKnowledge.ts index b0a8c58..3d4c750 100644 --- a/src/lib/embedDemoKnowledge.ts +++ b/src/lib/embedDemoKnowledge.ts @@ -86,6 +86,46 @@ function extractLocationTarget(text: string): string { return "the relevant action button"; } +function pageLooksLikeDashboard(ctx: string): boolean { + return /(workflow|integration|api key|deployment|settings|onboarding|template|dashboard)/i.test(ctx); +} + +function inferActionsFromContext(ctx: string, hovered: string): { bullets: string[]; steps: string[] } { + if (hovered) { + const hoverTitle = hovered.split("—")[0]?.trim() || hovered; + return { + bullets: [ + `Explore ${hoverTitle}`, + "Use the highlighted feature first", + "Follow page prompts for next action" + ], + steps: [ + "Start with the hovered feature on this page.", + "Complete one action in that section.", + "Ask What next? for step-by-step guidance." + ] + }; + } + if (pageLooksLikeDashboard(ctx)) { + return { + bullets: ["Create a workflow", "Connect integrations", "Set up API keys"], + steps: [ + "Start with the primary CTA on this page.", + "Complete one setup action.", + "Ask What next? for guided progression." + ] + }; + } + return { + bullets: ["Use the primary CTA", "Open navigation to key sections", "Follow visible setup hints"], + steps: [ + "Look for the most prominent action button.", + "Open the related section from page navigation.", + "Complete one visible setup task, then ask for next steps." + ] + }; +} + /** Deterministic demo responses for hackathon reliability. */ export function buildNorthstarDemoResponse(message: string, pageContext: string, hoveredFeature?: string): DemoChatResult { const m = message.toLowerCase().trim(); @@ -112,11 +152,12 @@ export function buildNorthstarDemoResponse(message: string, pageContext: string, } if (m.includes("what can i do here")) { + const inferred = inferActionsFromContext(ctx, hovered); return createResult({ answer: "Here are the key actions available on this page.", - bullets: ["Create a workflow", "Connect integrations", "Set up API keys"], + bullets: inferred.bullets, sources: product ? [{ title: product.title, excerpt: excerptFromContent(product.content), url: undefined }] : [], - steps: ["Start with the primary CTA on this page.", "Complete one setup action.", "Ask What next? for guided progression."] + steps: inferred.steps }); }