//===========================================
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
//===========================================
// Shared renderer for Hexclave's in-page issue cards: the modal-style card (plus its minimized pill) that the SDK uses
// to tell developers about problems it cannot recover from, like a broken pushed config or an untrusted auth domain.
// Every issue surface renders the same shape, so the markup, styles and clipboard handling live here and each feature
// only supplies its copy and callbacks.
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { getInPageUiBaseCSS } from "./base-styles";
import { h, setHtml } from "./dom";
import { HEXCLAVE_LOGO_SVG } from "./logo";
export type IssueCardKind = "error" | "warning";
const COPY_ICON_SVG = '';
export function getIssueCardCSS(scopeSelector: string): string {
return getInPageUiBaseCSS(scopeSelector) + `
${scopeSelector} .hic-backdrop {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.46);
backdrop-filter: blur(6px);
overflow: auto;
}
${scopeSelector} .hic-card {
--hic-status: var(--sdt-error);
width: min(720px, calc(100vw - 32px));
max-height: min(640px, calc(100dvh - 48px));
border: 1px solid color-mix(in srgb, var(--hic-status) 35%, var(--sdt-border));
border-radius: 18px;
background: var(--sdt-overlay-bg);
box-shadow: var(--sdt-shadow);
backdrop-filter: blur(18px);
display: flex;
overflow: hidden;
}
${scopeSelector} .hic-card-warning {
--hic-status: var(--sdt-warning);
}
${scopeSelector} .hic-card-inner {
padding: 18px;
width: 100%;
overflow: auto;
}
${scopeSelector} .hic-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
${scopeSelector} .hic-title-row {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
${scopeSelector} .hic-logo {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--hic-status);
color: white;
box-shadow: 0 10px 30px color-mix(in srgb, var(--hic-status) 32%, transparent);
}
${scopeSelector} .hic-badge {
display: inline-flex;
flex-shrink: 0;
padding: 2px 6px;
border-radius: 999px;
background: var(--hic-status);
color: white;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
${scopeSelector} .hic-title {
color: var(--sdt-text);
margin-top: 4px;
font-size: 18px;
font-weight: 700;
line-height: 1.25;
}
${scopeSelector} .hic-actions {
display: flex;
gap: 4px;
}
${scopeSelector} .hic-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--sdt-border);
border-radius: 8px;
background: var(--sdt-bg-elevated);
color: var(--sdt-text-secondary);
cursor: pointer;
font: inherit;
line-height: 1;
vertical-align: top;
}
${scopeSelector} .hic-icon-btn svg {
display: block;
flex-shrink: 0;
}
${scopeSelector} .hic-text-btn {
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
width: auto;
font-size: 12px;
line-height: 1;
}
${scopeSelector} .hic-icon-btn:hover {
background: var(--sdt-bg-hover);
color: var(--sdt-text);
}
${scopeSelector} .hic-body {
color: var(--sdt-text-secondary);
font-size: 14px;
line-height: 1.5;
}
${scopeSelector} .hic-message-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
margin-bottom: 8px;
}
${scopeSelector} .hic-message-label {
color: var(--sdt-text);
font-size: 12px;
font-weight: 650;
}
${scopeSelector} .hic-message {
padding: 12px;
max-height: min(260px, max(96px, 30dvh));
overflow: auto;
border: 1px solid var(--sdt-border-subtle);
border-radius: 10px;
background: var(--sdt-bg-subtle);
color: var(--sdt-text);
font-family: var(--sdt-font-mono);
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
${scopeSelector} .hic-fix-list {
margin: 8px 0 0;
padding-left: 18px;
color: var(--sdt-text);
font-size: 13px;
}
${scopeSelector} .hic-fix-list li + li {
margin-top: 4px;
}
${scopeSelector} .hic-footer {
margin-top: 10px;
color: var(--sdt-text-tertiary);
font-size: 12px;
}
${scopeSelector} .hic-pill {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483647;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 8px;
--hic-status: var(--sdt-error);
border: 1px solid color-mix(in srgb, var(--hic-status) 35%, var(--sdt-border));
border-radius: 999px;
background: var(--sdt-overlay-bg);
box-shadow: var(--sdt-trigger-shadow);
color: var(--sdt-text);
cursor: pointer;
font: inherit;
backdrop-filter: blur(18px);
}
${scopeSelector} .hic-pill-warning {
--hic-status: var(--sdt-warning);
}
${scopeSelector} .hic-pill-logo {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--hic-status);
color: white;
}
@media (max-height: 520px) {
${scopeSelector} .hic-backdrop {
align-items: flex-start;
padding: 12px;
}
${scopeSelector} .hic-card {
width: min(720px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
}
${scopeSelector} .hic-card-inner {
padding: 12px;
}
${scopeSelector} .hic-header {
margin-bottom: 8px;
}
${scopeSelector} .hic-title {
font-size: 16px;
}
${scopeSelector} .hic-body {
font-size: 13px;
}
${scopeSelector} .hic-message {
max-height: max(80px, 24dvh);
}
}
`;
}
export async function copyTextToClipboard(text: string): Promise {
const clipboard: unknown = Reflect.get(navigator, "clipboard");
const writeText = clipboard != null && typeof clipboard === "object"
? Reflect.get(clipboard, "writeText")
: null;
if (typeof writeText === "function") {
await writeText.call(clipboard, text);
return;
}
const textarea = h("textarea", {
style: {
position: "fixed",
left: "-9999px",
top: "0",
opacity: "0",
},
readonly: "true",
});
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) {
throw new Error("Browser refused to copy the message.");
}
}
/**
* A button that copies `getText()` and briefly reports the outcome in its own label. Copying can fail for reasons
* outside our control (permissions, insecure contexts), so the failure is reported inline instead of being thrown into
* a click handler where nothing would show it.
*/
function renderCopyButton(options: {
variant: "icon" | "text",
title: string,
ariaLabel: string,
getText: () => string,
onCopyError: (error: unknown) => void,
}): HTMLElement {
const idleHtml = options.variant === "text" ? `${COPY_ICON_SVG}Copy` : COPY_ICON_SVG;
const button = h("button", {
className: options.variant === "text" ? "hic-icon-btn hic-text-btn" : "hic-icon-btn",
type: "button",
title: options.title,
"aria-label": options.ariaLabel,
onClick: () => {
runAsynchronously(async () => {
await copyTextToClipboard(options.getText());
button.textContent = options.variant === "text" ? "Copied" : "✓";
setTimeout(() => setHtml(button, idleHtml), 1500);
}, {
noErrorLogging: true,
onError: (error) => {
options.onCopyError(error);
button.textContent = options.variant === "text" ? "Copy failed" : "!";
setTimeout(() => setHtml(button, idleHtml), 1500);
},
});
},
});
setHtml(button, idleHtml);
return button;
}
export type IssueCardOptions = {
kind: IssueCardKind,
/** Short uppercase label in the card's badge, eg. "Config error". */
badge: string,
title: string,
/** One or two sentences on what this means for the running app. */
bodyText: string,
messageLabel: string,
message: string,
/** Concrete steps the developer should take; rendered as a list above the footer. */
howToFix?: readonly string[],
footerText: string,
ariaLabel: string,
/** Prompt for the "copy AI prompt" button, ie. the message plus enough context for a coding agent to fix it. */
aiPrompt: string,
onCopyError: (error: unknown) => void,
onCopyAiPromptError: (error: unknown) => void,
onMinimize: () => void,
};
export function renderIssueCard(options: IssueCardOptions): HTMLElement {
const logoSpan = h("span", { className: "hic-logo" });
setHtml(logoSpan, HEXCLAVE_LOGO_SVG);
return h("div", { className: "hic-backdrop" },
h("div", { className: options.kind === "error" ? "hic-card" : "hic-card hic-card-warning", role: "alertdialog", "aria-modal": "true", "aria-label": options.ariaLabel, tabindex: "-1" },
h("div", { className: "hic-card-inner" },
h("div", { className: "hic-header" },
h("div", { className: "hic-title-row" },
logoSpan,
h("div", null,
h("span", { className: "hic-badge" }, options.badge),
h("div", { className: "hic-title" }, options.title),
),
),
h("div", { className: "hic-actions" },
renderCopyButton({
variant: "icon",
title: "Copy AI prompt",
ariaLabel: `Copy AI prompt for ${options.ariaLabel}`,
getText: () => options.aiPrompt,
onCopyError: options.onCopyAiPromptError,
}),
h("button", {
className: "hic-icon-btn",
type: "button",
title: "Minimize",
"aria-label": `Minimize ${options.ariaLabel}`,
onClick: options.onMinimize,
}, "–"),
),
),
h("div", { className: "hic-body" },
options.bodyText,
h("div", { className: "hic-message-header" },
h("div", { className: "hic-message-label" }, options.messageLabel),
renderCopyButton({
variant: "text",
title: `Copy ${options.messageLabel.toLowerCase()}`,
ariaLabel: `Copy ${options.ariaLabel} message`,
getText: () => options.message,
onCopyError: options.onCopyError,
}),
),
h("div", { className: "hic-message" }, options.message),
options.howToFix == null || options.howToFix.length === 0 ? null : h("div", null,
h("div", { className: "hic-message-header" },
h("div", { className: "hic-message-label" }, "How to fix"),
),
h("ul",
{ className: "hic-fix-list" },
...options.howToFix.map((step) => h("li", null, step)),
),
),
h("div", { className: "hic-footer" }, options.footerText),
),
),
),
);
}
const FOCUSABLE_SELECTOR = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
export type IssueCardFocusTrap = {
/**
* Whatever had focus before the card first appeared. Callers that rebind the trap to a re-rendered card pass it back
* in, so focus still returns to where the developer left it once the card is gone for good.
*/
previouslyFocused: HTMLElement | null,
release: (releaseOptions?: { restoreFocus?: boolean }) => void,
};
/**
* Keeps keyboard focus inside a mounted card, which claims to be an `aria-modal` alertdialog and therefore has to behave
* like one: the flow behind the card is dead until the problem is fixed, so tabbing into it would only let a keyboard
* user wander through a page that cannot work while the card stays invisible to them.
*
* Call right after mounting the card, and release the trap when the card is minimized, replaced or removed. Releasing
* hands focus back to `previouslyFocused` unless `restoreFocus: false` is passed, which is what a caller that is about
* to rebind the trap to a freshly rendered card wants.
*/
export function trapFocusInIssueCard(cardRoot: HTMLElement, options: {
moveFocusIntoCard?: boolean,
previouslyFocused?: HTMLElement | null,
} = {}): IssueCardFocusTrap {
const previouslyFocused = options.previouslyFocused !== undefined
? options.previouslyFocused
: document.activeElement instanceof HTMLElement ? document.activeElement : null;
const card = cardRoot.querySelector(".hic-card");
if (!(card instanceof HTMLElement)) return { previouslyFocused, release: () => {} };
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab") return;
const focusable = [...card.querySelectorAll(FOCUSABLE_SELECTOR)].filter((element): element is HTMLElement => element instanceof HTMLElement);
const first = focusable[0] ?? card;
const last = focusable[focusable.length - 1] ?? card;
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let target: HTMLElement | null = null;
if (active == null || !card.contains(active)) {
// Focus is outside the card (the page moved it, or it never got in): pull it back to the card's near end.
target = event.shiftKey ? last : first;
} else if (active === last && !event.shiftKey) {
target = first;
} else if (active === first && event.shiftKey) {
target = last;
}
if (target == null) return;
event.preventDefault();
target.focus();
};
document.addEventListener("keydown", onKeyDown, true);
if (options.moveFocusIntoCard !== false) {
card.focus();
}
return {
previouslyFocused,
release: (releaseOptions) => {
document.removeEventListener("keydown", onKeyDown, true);
if (releaseOptions?.restoreFocus === false) return;
if (previouslyFocused != null && previouslyFocused.isConnected) {
previouslyFocused.focus();
}
},
};
}
export function renderIssuePill(options: {
kind: IssueCardKind,
label: string,
ariaLabel: string,
onClick: () => void,
}): HTMLElement {
const logoSpan = h("span", { className: "hic-pill-logo" });
setHtml(logoSpan, HEXCLAVE_LOGO_SVG);
return h("button", {
className: options.kind === "error" ? "hic-pill" : "hic-pill hic-pill-warning",
type: "button",
"aria-label": options.ariaLabel,
onClick: options.onClick,
},
logoSpan,
h("span", null, options.label),
);
}