@svelte-vitals/core 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  [![npm](https://img.shields.io/npm/v/@svelte-vitals/core)](https://www.npmjs.com/package/@svelte-vitals/core)
4
4
  [![MIT](https://img.shields.io/npm/l/@svelte-vitals/core)](https://opensource.org/licenses/MIT)
5
5
 
6
- Runtime-agnostic core for [svelte-vitals](https://github.com/oekazuma/svelte-vitals): shared types, the rule engine, scorer, reporter, and the SEO rule set.
6
+ Runtime-agnostic core for [svelte-vitals](https://github.com/oekazuma/svelte-vitals): shared types, the rule engine, scorer, and reporters, plus the full rule set across five categories — SEO, Performance, Correctness, Security, Architecture.
7
7
 
8
8
  This package is **mode-independent** and contains no I/O — it operates on a normalized `ResolvedHead[]` intermediate representation, so the same rules run unchanged whether heads come from static source analysis (the `svelte-vitals` CLI) or from prerendered HTML (the `@svelte-vitals/vite` plugin). It has zero runtime dependencies and no `node:` imports.
9
9
 
10
10
  > Most users don't depend on this directly — install [`svelte-vitals`](https://www.npmjs.com/package/svelte-vitals) instead. This package is for building tools on top of the shared engine.
11
11
 
12
- > **ESM-only** (Node 18+). Ships ES modules only; `require()` is unsupported by design.
12
+ > **ESM-only** (Node 22.13+). Ships ES modules only; `require()` is unsupported by design.
13
13
 
14
14
  ## License
15
15
 
package/dist/index.d.ts CHANGED
@@ -740,6 +740,57 @@ declare function formatMarkdownReport(results: Result[], config: Config, meta: {
740
740
  version: string;
741
741
  }): string;
742
742
 
743
+ /** Provenance of a route's findings: real rendered page vs. source-only analysis. */
744
+ type RouteBadge = 'measured' | 'static';
745
+ interface AppSnapshot {
746
+ report: JsonReport;
747
+ badges: Record<string, RouteBadge>;
748
+ analyzing: boolean;
749
+ /** Monotonically increasing; lets the client discard an out-of-order /data.json response. */
750
+ sequence: number;
751
+ /** Whether a dev server is behind this page (SSE updates, /data.json refetch, connection dot). */
752
+ live: boolean;
753
+ meta: {
754
+ version: string;
755
+ coreVersion?: string;
756
+ };
757
+ }
758
+ /**
759
+ * Hand-authored CSS for the master/detail shell. Reuses the same design-token
760
+ * names/values as the rest of the project, and adds a dark theme via
761
+ * `:root[data-theme="dark"]` plus a `prefers-color-scheme` fallback for a
762
+ * first-ever visit with no stored preference.
763
+ */
764
+ declare const APP_STYLE: string;
765
+ /**
766
+ * Hand-authored client script for the shell — no bundler, no framework. Parses the
767
+ * AppSnapshot embedded by renderAppShell, then owns all rendering: sidebar
768
+ * (search/sort/route list) and detail pane (Overview or a selected route). When the
769
+ * snapshot says `live`, it additionally re-fetches /data.json on every SSE `update`
770
+ * and on the EventSource's `open` event (covers the initial connection and every
771
+ * auto-reconnect, since EventSource replays no missed events) — discarding any
772
+ * response whose `sequence` isn't newer than what's already rendered.
773
+ */
774
+ declare const APP_SCRIPT: string;
775
+ /** The shell HTML: empty sidebar/detail/topbar containers, the stylesheet, the
776
+ * client script, and the snapshot embedded as JSON for the client's first paint. */
777
+ declare function renderAppShell(snapshot: AppSnapshot): string;
778
+ /**
779
+ * Static (non-live) document over a prebuilt JsonReport — kept as the public name the
780
+ * html reporter has always exported. `routeBadges` preserves the old opts shape.
781
+ */
782
+ declare function buildHtmlDocument(report: JsonReport, meta: {
783
+ version: string;
784
+ coreVersion?: string;
785
+ }, opts?: {
786
+ routeBadges?: Record<string, RouteBadge>;
787
+ }): string;
788
+ /** Render results as the self-contained HTML report (the CLI's `--reporter html`). */
789
+ declare function formatHtmlReport(results: Result[], config: Config, meta: {
790
+ version: string;
791
+ coreVersion?: string;
792
+ }): string;
793
+
743
794
  type Band = 'good' | 'warn' | 'poor';
744
795
  declare const BAND_COLOR: Record<Band, string>;
745
796
  declare function scoreBand(score: number): Band;
@@ -754,20 +805,10 @@ declare function escapeHtml(s: string): string;
754
805
  * keeping core runtime-agnostic and lib-minimal.
755
806
  */
756
807
  declare function safeHref(url: string): string | null;
757
- declare function buildHtmlDocument(report: JsonReport, meta: {
758
- version: string;
759
- coreVersion?: string;
760
- }, opts?: {
761
- routeBadges?: Record<string, 'measured' | 'static'>;
762
- }): string;
763
- declare function formatHtmlReport(results: Result[], config: Config, meta: {
764
- version: string;
765
- coreVersion?: string;
766
- }): string;
767
808
 
768
809
  /** Drop rules disabled via config (design §6). */
769
810
  declare function selectRules(rules: Rule[], config: Config): Rule[];
770
811
  /** Apply per-rule severity overrides to results (design §6). */
771
812
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
772
813
 
773
- export { BAND_COLOR, CHILD_NODE_KEYS, type Category, type Classification, type ComponentFacts, type Config, type ConsoleReportOptions, type Detection, type EachBlockFact, type EffectFact, type Fix, type HeadProvider, type HeadTag, type HeadingInfo, type HealthResult, type ImageInfo, type JsonReport, type Palette, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedHeadings, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type SourceSpan, type Summary, type SuppressionDirective, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, arch001ComponentSize, arch002PropCount, attrText, attrTextOf, attrValue, attrValueOf, buildHtmlDocument, buildJsonReport, classify, collectComponentFacts, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, correct004UnmutatedState, correct005PropMutation, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, emptyComponentFacts, escapeHtml, explainRule, findAttr, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatMarkdownReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, lineOf, linkRule, noColorPalette, parseComponentFacts, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, perf009HeavyImport, perf010NamespaceImport, runRules, safeHref, scoreBand, scoreColor, scoresByCategory, sec001Html, sec002JavascriptUrl, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, seo016JsonLdValidity, seo017DeprecatedType, seo018RelativeUrl, seo019DateFormat, seo020Placeholder, seo021RequiredProps, seo022TitleLength, seo023DescriptionLength, seo024Charset, seo025ImageAlt, seo026Hreflang, seo027Heading, seo028TitleUnique, seo029DescriptionUnique, seo030HeadingOrder, summarize, textFromNodes, valueFromNodes };
814
+ export { APP_SCRIPT, APP_STYLE, type AppSnapshot, BAND_COLOR, CHILD_NODE_KEYS, type Category, type Classification, type ComponentFacts, type Config, type ConsoleReportOptions, type Detection, type EachBlockFact, type EffectFact, type Fix, type HeadProvider, type HeadTag, type HeadingInfo, type HealthResult, type ImageInfo, type JsonReport, type Palette, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedHeadings, type ResolvedImages, type Result, type RouteBadge, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type SourceSpan, type Summary, type SuppressionDirective, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, arch001ComponentSize, arch002PropCount, attrText, attrTextOf, attrValue, attrValueOf, buildHtmlDocument, buildJsonReport, classify, collectComponentFacts, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, correct004UnmutatedState, correct005PropMutation, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, emptyComponentFacts, escapeHtml, explainRule, findAttr, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatMarkdownReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, lineOf, linkRule, noColorPalette, parseComponentFacts, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, perf009HeavyImport, perf010NamespaceImport, renderAppShell, runRules, safeHref, scoreBand, scoreColor, scoresByCategory, sec001Html, sec002JavascriptUrl, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, seo016JsonLdValidity, seo017DeprecatedType, seo018RelativeUrl, seo019DateFormat, seo020Placeholder, seo021RequiredProps, seo022TitleLength, seo023DescriptionLength, seo024Charset, seo025ImageAlt, seo026Hreflang, seo027Heading, seo028TitleUnique, seo029DescriptionUnique, seo030HeadingOrder, summarize, textFromNodes, valueFromNodes };
package/dist/index.js CHANGED
@@ -2793,200 +2793,693 @@ function formatMarkdownReport(results, config, meta) {
2793
2793
  return lines.join("\n");
2794
2794
  }
2795
2795
 
2796
- // src/reporter/html.ts
2797
- var BAND_COLOR = {
2798
- good: "#2FA968",
2799
- warn: "#E8A317",
2800
- poor: "#E5484D"
2801
- };
2802
- function scoreBand(score) {
2803
- return score >= 90 ? "good" : score >= 50 ? "warn" : "poor";
2796
+ // src/reporter/app-shell.ts
2797
+ function embedJson(value) {
2798
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
2804
2799
  }
2805
- function escapeHtml(s) {
2806
- return s.replace(
2807
- /[&<>"']/g,
2808
- (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;"
2809
- );
2800
+ function sanitizeDocsUrl(issue) {
2801
+ if (issue.docsUrl === void 0) return issue;
2802
+ if (safeHref(issue.docsUrl) !== null) return issue;
2803
+ return { ...issue, docsUrl: void 0 };
2810
2804
  }
2811
- function safeHref(url) {
2812
- const normalized = url.replace(/\s/g, "").toLowerCase();
2813
- return /^https?:\/\//.test(normalized) ? url : null;
2805
+ function sanitizeReport(report) {
2806
+ return {
2807
+ ...report,
2808
+ routes: report.routes.map((route) => ({ ...route, issues: route.issues.map(sanitizeDocsUrl) })),
2809
+ siteIssues: report.siteIssues.map(sanitizeDocsUrl)
2810
+ };
2814
2811
  }
2815
- var slug = (route) => "route-" + route.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2816
- var categoryLabel = (cat) => cat === "seo" ? "SEO" : cat.charAt(0).toUpperCase() + cat.slice(1);
2817
- var SEVERITIES = ["critical", "warning", "info"];
2818
- function renderFinding(issue) {
2819
- const sev = SEVERITIES.includes(issue.severity) ? issue.severity : "info";
2820
- const dyn = issue.detection.value === "dynamic" ? ' <span class="dyn" title="set dynamically (verified at runtime)">\u21AF</span>' : "";
2821
- const line = issue.line !== void 0 ? `:${issue.line}` : "";
2822
- const fix = issue.fix?.snippet !== void 0 ? `<div class="fix"><div class="label">fix</div><pre><code>${escapeHtml(issue.fix.snippet)}</code></pre></div>` : "";
2823
- const href = issue.docsUrl ? safeHref(issue.docsUrl) : null;
2824
- const docs = href ? `<a class="f-link" href="${escapeHtml(href)}">Learn more</a>` : "";
2825
- return `<article class="finding sev-${sev}" data-severity="${sev}" data-category="${escapeHtml(issue.category)}"><div class="f-head"><span class="ruleid">${escapeHtml(issue.id)}</span><span class="f-title">${escapeHtml(issue.title)}</span><span class="sev-tag ${sev}">${sev}</span></div>` + (issue.location ? `<p class="f-loc">${escapeHtml(issue.location)}${line}${dyn}</p>` : "") + (issue.recommendation ? `<p class="f-rec">${escapeHtml(issue.recommendation)}</p>` : "") + fix + docs + `</article>`;
2826
- }
2827
- function renderTopbar(report, meta) {
2828
- const findings = report.routes.reduce((n, r) => n + r.issues.length, 0) + report.siteIssues.length;
2829
- const core = meta.coreVersion ? `<span title="@svelte-vitals/core version">core v${escapeHtml(meta.coreVersion)}</span>` : "";
2830
- return `<header class="topbar"><div class="brand"><span class="bolt">\u21AF</span>svelte-<span class="v">vitals</span></div><div class="meta"><span>v${escapeHtml(meta.version)}</span>` + core + `<span>${report.routes.length} routes</span><span>${findings} findings</span></div></header>`;
2831
- }
2832
- function renderHero(report) {
2833
- const C = 2 * Math.PI * 58;
2834
- const offset = (C * (1 - report.score / 100)).toFixed(1);
2835
- const hb = scoreBand(report.score);
2836
- const s = report.summary;
2837
- const dynNote = s.dynamic > 0 ? `<span class="tally"><span class="dot dyn-dot">\u21AF</span>Dynamic <span class="n">${s.dynamic}</span></span>` : "";
2838
- const cats = Object.entries(report.categories).map(([cat, { score }]) => {
2839
- const b = scoreBand(score);
2840
- const weight = report.weights[cat];
2841
- const w = weight !== void 0 ? `<span class="w">weight ${weight}</span>` : "";
2842
- const name = categoryLabel(cat);
2843
- return `<div class="cat"><div class="top"><span class="name">${escapeHtml(name)} ${w}</span><span class="sc" style="color:${BAND_COLOR[b]}">${score}</span></div><div class="bar"><i style="width:${score}%;background:${BAND_COLOR[b]}"></i></div></div>`;
2844
- }).join("");
2845
- return `<section class="hero"><div class="gauge"><svg width="132" height="132" viewBox="0 0 132 132" aria-hidden="true"><circle cx="66" cy="66" r="58" fill="none" stroke="#e4e7ec" stroke-width="11"></circle><circle id="arc" cx="66" cy="66" r="58" fill="none" stroke="${BAND_COLOR[hb]}" stroke-width="11" stroke-linecap="round" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${offset}"></circle></svg><div class="num"><strong id="hnum">${report.score}</strong><span>Health</span></div></div><div class="readout"><div class="eyebrow">SvelteKit \xB7 SEO &amp; Performance</div><div class="tallies"><span class="tally"><span class="dot crit"></span>Critical <span class="n">${s.critical}</span></span><span class="tally"><span class="dot warn"></span>Warning <span class="n">${s.warning}</span></span><span class="tally"><span class="dot info"></span>Info <span class="n">${s.info}</span></span><span class="tally"><span class="dot pass"></span>Passed <span class="n">${s.passed}</span></span>` + dynNote + `</div><div class="cats">${cats}</div></div></section>`;
2846
- }
2847
- var ROUTE_BADGES = ["measured", "static"];
2848
- function renderRoutes(report, routeBadges) {
2849
- if (report.routes.length === 0) return "";
2850
- const rows = report.routes.map((r) => {
2851
- const b = scoreBand(r.score);
2852
- const crit = r.issues.filter((i) => i.severity === "critical").length;
2853
- const warn = r.issues.filter((i) => i.severity === "warning").length;
2854
- const info = r.issues.filter((i) => i.severity === "info").length;
2855
- const parts = [];
2856
- if (crit) parts.push(`${crit} critical`);
2857
- if (warn) parts.push(`${warn} warning${warn > 1 ? "s" : ""}`);
2858
- if (info) parts.push(`${info} info`);
2859
- const sum = parts.length ? parts.join(" \xB7 ") : '<span class="none">no issues</span>';
2860
- const body = r.issues.length ? r.issues.map(renderFinding).join("") : '<p class="empty">No issues found on this route.</p>';
2861
- const rawBadge = routeBadges?.[r.route];
2862
- const badge = rawBadge && ROUTE_BADGES.includes(rawBadge) ? rawBadge : void 0;
2863
- const badgeHtml = badge ? ` <span class="badge badge-${badge}">${escapeHtml(badge)}</span>` : "";
2864
- return `<details class="route" id="${slug(r.route)}" data-score="${r.score}"${r.issues.length ? " open" : ""}><summary><span class="route-name"><span class="path">${escapeHtml(r.route)}</span>${badgeHtml}</span><span class="issue-sum">${sum}</span><span class="score-chip"><span class="ring" style="background:${BAND_COLOR[b]}"></span>${r.score}</span><span class="chev">\u203A</span></summary><div class="route-body">${body}</div></details>`;
2865
- }).join("");
2866
- return `<section class="section"><h2>Routes</h2><div class="routes">${rows}</div></section>`;
2867
- }
2868
- function renderSiteChecks(report) {
2869
- if (report.siteIssues.length === 0) return "";
2870
- const cards = report.siteIssues.map(renderFinding).join("");
2871
- return `<section class="section"><h2>Site checks</h2>${cards}</section>`;
2872
- }
2873
- function renderFilters(report) {
2874
- const chip = (filter, label, pressed = false) => `<button class="chip" type="button" aria-pressed="${pressed}" data-filter="${escapeHtml(filter)}">${escapeHtml(label)}</button>`;
2875
- const catChips = Object.keys(report.categories).map((cat) => chip(cat, categoryLabel(cat))).join("");
2876
- return `<div class="filters" role="group" aria-label="Filter findings">` + chip("all", "All", true) + chip("critical", "Critical") + chip("warning", "Warning") + chip("info", "Info") + catChips + `</div>`;
2877
- }
2878
- var STYLE = `
2879
- :root{--ground: #f6f7f9;--panel: #fff;--ink: #0c1322;--muted: #5a6472;--faint: #8c95a3;--line: #e4e7ec;--line-strong: #d3d8e0;--accent: #ff3e00;--good: #2fa968;--warn: #e8a317;--poor: #e5484d;--code-bg: #0e1525;--code-ink: #e7ecf4;--radius: 12px;--mono: ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;--sans: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
2812
+ var APP_STYLE = `
2813
+ :root{--ground:#f6f7f9;--panel:#fff;--ink:#0c1322;--muted:#5a6472;--faint:#8c95a3;--line:#e4e7ec;--line-strong:#d3d8e0;--accent:#ff3e00;--good:#2fa968;--warn:#e8a317;--poor:#e5484d;--code-bg:#0e1525;--code-ink:#e7ecf4;--active-bg:#0c1322;--active-ink:#fff;--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
2814
+ :root[data-theme="dark"]{--ground:#0b0e14;--panel:#12161f;--ink:#e7ecf4;--muted:#9aa4b2;--faint:#6b7484;--line:#232838;--line-strong:#2d3345;--code-bg:#05070c;--code-ink:#e7ecf4;--active-bg:#e7ecf4;--active-ink:#0b0e14}
2815
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--ground:#0b0e14;--panel:#12161f;--ink:#e7ecf4;--muted:#9aa4b2;--faint:#6b7484;--line:#232838;--line-strong:#2d3345;--code-bg:#05070c;--code-ink:#e7ecf4;--active-bg:#e7ecf4;--active-ink:#0b0e14}}
2880
2816
  *{box-sizing:border-box}
2881
- body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);line-height:1.5;-webkit-font-smoothing:antialiased}
2882
- .wrap{max-width:960px;margin:0 auto;padding:0 20px 96px}
2883
- .topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:18px 0 16px;border-bottom:1px solid var(--line)}
2884
- .brand{display:flex;align-items:baseline;gap:8px;font-weight:700;font-size:18px;letter-spacing:-.02em}
2885
- .brand .bolt{color:var(--accent);font-size:20px}.brand .v{color:var(--accent)}
2886
- .meta{font-family:var(--mono);font-size:12.5px;color:var(--muted);display:flex;gap:14px;flex-wrap:wrap}
2887
- .hero{display:grid;grid-template-columns:auto 1fr;gap:28px;align-items:center;padding:30px 0 24px}
2888
- .gauge{position:relative;width:132px;height:132px}.gauge svg{transform:rotate(-90deg);display:block}
2889
- .gauge .num{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
2890
- .gauge .num strong{font-family:var(--mono);font-size:40px;font-weight:600;letter-spacing:-.03em;line-height:1;font-variant-numeric:tabular-nums}
2891
- .gauge .num span{font-size:11px;text-transform:uppercase;letter-spacing:.14em;color:var(--muted);margin-top:6px}
2892
- .readout{min-width:0}
2893
- .eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--accent);font-weight:700}
2894
- .eyebrow::before{content:"\u21AF "}
2895
- .tallies{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0 18px}
2896
- .tally{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;background:var(--panel);border:1px solid var(--line);padding:5px 11px;border-radius:999px}
2897
- .tally .dot{width:9px;height:9px;border-radius:50%}.tally .n{font-family:var(--mono);font-variant-numeric:tabular-nums}
2898
- .dot.crit{background:var(--poor)}.dot.warn{background:var(--warn)}.dot.info{background:var(--faint)}.dot.pass{background:var(--good)}
2899
- .dot.dyn-dot{background:transparent;color:var(--accent);font-weight:700;width:auto;height:auto}
2900
- .cats{display:flex;gap:22px;flex-wrap:wrap}
2901
- .cat{min-width:190px;flex:1}.cat .top{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px}
2902
- .cat .name{font-size:13px;font-weight:600}.cat .name .w{color:var(--faint);font-family:var(--mono);font-size:11px;font-weight:500;margin-left:6px}
2903
- .cat .sc{font-family:var(--mono);font-weight:600;font-variant-numeric:tabular-nums}
2904
- .bar{height:7px;border-radius:999px;background:var(--line);overflow:hidden}.bar>i{display:block;height:100%;border-radius:999px}
2905
- .section{margin-top:40px}
2906
- .section>h2{font-size:13px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:700;margin:0 0 14px}
2907
- .filters{display:flex;gap:8px;flex-wrap:wrap;margin-top:28px}
2908
- .chip{font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;background:var(--panel);border:1px solid var(--line-strong);color:var(--muted);padding:5px 12px;border-radius:999px}
2909
- .chip:hover{border-color:var(--faint);color:var(--ink)}
2910
- .chip[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
2911
- .chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
2912
- .routes{border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;background:var(--panel)}
2913
- .route{border-top:1px solid var(--line)}.route:first-child{border-top:0}
2914
- .route>summary{display:grid;grid-template-columns:1fr auto auto auto;gap:16px;align-items:center;padding:13px 16px;cursor:pointer;list-style:none}
2915
- .route>summary::-webkit-details-marker{display:none}
2916
- .route>summary:hover{background:#fbfcfd}
2917
- .route-name{font-family:var(--mono);font-size:13.5px;min-width:0;display:flex;align-items:center;gap:8px}
2918
- .route-name .path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2919
- .badge{font-family:var(--sans);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:2px 7px;border-radius:999px;flex-shrink:0}
2920
- .badge-measured{background:rgba(47,169,104,.14);color:var(--good)}
2921
- .badge-static{background:rgba(140,149,163,.18);color:var(--muted)}
2922
- .issue-sum{font-size:12.5px;color:var(--muted);font-family:var(--mono);white-space:nowrap}.issue-sum .none{color:var(--good)}
2923
- .score-chip{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-weight:600;font-variant-numeric:tabular-nums;font-size:14px}
2924
- .score-chip .ring{width:10px;height:10px;border-radius:50%}
2925
- .chev{color:var(--faint);font-family:var(--mono);transition:transform .15s ease}
2926
- .route[open]>summary .chev{transform:rotate(90deg)}
2927
- .route-body{padding:4px 16px 16px}.empty{color:var(--muted);font-size:13px;margin:6px 0}
2928
- .finding{background:var(--panel);border:1px solid var(--line);border-left-width:3px;border-radius:10px;padding:16px 18px;margin:0 0 12px}
2929
- .finding.sev-critical{border-left-color:var(--poor)}.finding.sev-warning{border-left-color:var(--warn)}.finding.sev-info{border-left-color:var(--faint)}
2930
- .f-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
2931
- .ruleid{font-family:var(--mono);font-size:12px;font-weight:600;background:#eef1f5;padding:2px 8px;border-radius:6px}
2932
- .f-title{font-weight:650;font-size:15px}
2933
- .sev-tag{margin-left:auto;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
2934
- .sev-tag.critical{color:var(--poor)}.sev-tag.warning{color:var(--warn)}.sev-tag.info{color:var(--faint)}
2935
- .f-loc{font-family:var(--mono);font-size:12.5px;color:var(--muted);margin:8px 0 0}.dyn{color:var(--accent);font-weight:700}
2936
- .f-rec{font-size:14px;color:#2b3340;margin:10px 0 0}
2937
- .fix{margin:12px 0 0;background:var(--code-bg);border-radius:8px;overflow:hidden}
2938
- .fix .label{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8da0bd;padding:8px 14px 0}
2939
- .fix pre{margin:0;padding:8px 14px 14px;overflow-x:auto}
2940
- .fix code{font-family:var(--mono);font-size:12.5px;color:var(--code-ink);line-height:1.65;white-space:pre}
2941
- .f-link{display:inline-block;margin-top:12px;font-size:13px;font-weight:600;color:var(--accent);text-decoration:none}
2942
- .f-link:hover{text-decoration:underline}.f-link::after{content:" \u2192"}
2943
- .foot{margin-top:48px;padding-top:18px;border-top:1px solid var(--line);font-family:var(--mono);font-size:12px;color:var(--faint);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
2944
- .foot a{color:var(--accent);text-decoration:none}
2945
- @media (max-width:640px){.hero{grid-template-columns:1fr;justify-items:start}.route>summary{grid-template-columns:1fr auto}.chev{display:none}}
2817
+ html,body{margin:0;height:100%}
2818
+ body{background:var(--ground);color:var(--ink);font-family:var(--sans);line-height:1.5;-webkit-font-smoothing:antialiased}
2819
+ .dv-app{display:grid;grid-template-rows:auto 1fr;grid-template-columns:280px 1fr;grid-template-areas:"top top" "side main";height:100vh}
2820
+ .dv-topbar{grid-area:top;border-bottom:1px solid var(--line);background:var(--panel)}
2821
+ .dv-topbar-inner{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 20px}
2822
+ .dv-brand{display:flex;align-items:center;background:none;border:none;padding:0;cursor:pointer;border-radius:6px}
2823
+ .dv-brand svg{height:28px;width:auto;display:block}
2824
+ .dv-brand:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
2825
+ .dv-meta{font-family:var(--mono);font-size:12px;color:var(--muted);display:flex;gap:12px;flex-wrap:wrap}
2826
+ .dv-status{display:flex;align-items:center;gap:10px}
2827
+ .dv-analyzing{font-size:12px;color:var(--accent);font-weight:600}
2828
+ .dv-conn{width:8px;height:8px;border-radius:50%;background:var(--faint);display:inline-block}
2829
+ .dv-conn-connected{background:var(--good)}
2830
+ .dv-conn-reconnecting{background:var(--warn)}
2831
+ .dv-menu-toggle{display:none;border:1px solid var(--line-strong);background:var(--panel);color:var(--ink);border-radius:8px;width:28px;height:28px;cursor:pointer}
2832
+ .dv-theme-toggle{border:1px solid var(--line-strong);background:var(--panel);color:var(--ink);border-radius:999px;width:28px;height:28px;cursor:pointer}
2833
+ .dv-theme-toggle:focus-visible,.dv-menu-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
2834
+ .dv-sidebar{grid-area:side;border-right:1px solid var(--line);background:var(--panel);overflow-y:auto}
2835
+ .dv-sidebar-inner{display:flex;flex-direction:column;gap:10px;padding:14px}
2836
+ .dv-search{font:inherit;font-size:13px;padding:7px 10px;border:1px solid var(--line-strong);border-radius:8px;background:var(--ground);color:var(--ink)}
2837
+ .dv-sort{font:inherit;font-size:12.5px;padding:6px 8px;border:1px solid var(--line-strong);border-radius:8px;background:var(--ground);color:var(--ink)}
2838
+ .dv-nav{display:flex;flex-direction:column;gap:2px}
2839
+ .dv-nav-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border-radius:8px;cursor:pointer}
2840
+ .dv-nav-item:hover{background:var(--ground)}
2841
+ .dv-nav-item.active{background:var(--active-bg);color:var(--active-ink)}
2842
+ .dv-nav-item:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
2843
+ .dv-nav-label{font-family:var(--mono);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2844
+ .dv-nav-meta{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
2845
+ .dv-nav-item.active .dv-nav-meta{color:inherit}
2846
+ .dv-nav-score{font-family:var(--mono);font-weight:700}
2847
+ .dv-badge{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px}
2848
+ .dv-badge-measured{background:rgba(47,169,104,.16);color:var(--good)}
2849
+ .dv-badge-static{background:rgba(140,149,163,.2);color:var(--muted)}
2850
+ .dv-detail{grid-area:main;overflow-y:auto;padding:24px 28px 80px}
2851
+ .dv-gauge{position:relative;width:132px;height:132px;margin-bottom:20px}
2852
+ .dv-gauge svg{position:absolute;inset:0;transform:rotate(-90deg)}
2853
+ .dv-gauge-track{stroke:var(--line)}
2854
+ .dv-gauge-num{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
2855
+ .dv-gauge-num strong{font-family:var(--mono);font-size:36px;font-weight:600}
2856
+ .dv-gauge-num span{font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:var(--muted)}
2857
+ .dv-cats{display:flex;gap:22px;flex-wrap:wrap;margin-bottom:20px}
2858
+ .dv-cat{min-width:180px;flex:1}
2859
+ .dv-cat-top{display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px}
2860
+ .dv-bar{height:7px;border-radius:999px;background:var(--line);overflow:hidden}
2861
+ .dv-bar>i{display:block;height:100%;border-radius:999px}
2862
+ .dv-filters{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}
2863
+ .dv-chip{font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;background:var(--panel);border:1px solid var(--line-strong);color:var(--muted);padding:5px 12px;border-radius:999px}
2864
+ .dv-chip[aria-pressed="true"]{background:var(--active-bg);border-color:var(--active-bg);color:var(--active-ink)}
2865
+ .dv-chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
2866
+ .dv-section h2{font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:24px 0 12px}
2867
+ .dv-detail-header{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
2868
+ .dv-route-path{font-family:var(--mono);font-size:16px;font-weight:600}
2869
+ .dv-score-chip{font-family:var(--mono);font-weight:700}
2870
+ .dv-finding{background:var(--panel);border:1px solid var(--line);border-left-width:3px;border-radius:10px;padding:16px 18px;margin:0 0 12px}
2871
+ .dv-finding-critical{border-left-color:var(--poor)}
2872
+ .dv-finding-warning{border-left-color:var(--warn)}
2873
+ .dv-finding-info{border-left-color:var(--faint)}
2874
+ .dv-f-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
2875
+ .dv-ruleid{font-family:var(--mono);font-size:12px;font-weight:600;background:var(--ground);padding:2px 8px;border-radius:6px}
2876
+ .dv-f-title{font-weight:650;font-size:15px}
2877
+ .dv-sev-tag{margin-left:auto;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
2878
+ .dv-sev-critical{color:var(--poor)}
2879
+ .dv-sev-warning{color:var(--warn)}
2880
+ .dv-sev-info{color:var(--faint)}
2881
+ .dv-f-route{display:block;font:inherit;font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--accent);background:none;border:none;padding:0;margin:8px 0 0;cursor:pointer;text-align:left}
2882
+ .dv-f-route:hover{text-decoration:underline}
2883
+ .dv-f-route:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
2884
+ .dv-f-loc{font-family:var(--mono);font-size:12.5px;color:var(--muted);margin:8px 0 0}
2885
+ .dv-f-rec{font-size:14px;margin:10px 0 0}
2886
+ .dv-fix{margin:12px 0 0;background:var(--code-bg);border-radius:8px;overflow:hidden}
2887
+ .dv-fix-label{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8da0bd;padding:8px 14px 0}
2888
+ .dv-fix pre{margin:0;padding:8px 14px 14px;overflow-x:auto}
2889
+ .dv-fix code{font-family:var(--mono);font-size:12.5px;color:var(--code-ink);line-height:1.65;white-space:pre}
2890
+ .tok-kw{color:#ff7ab8}
2891
+ .tok-str{color:#9ece6a}
2892
+ .tok-num{color:#ff9e64}
2893
+ .tok-cm{color:#6b7280;font-style:italic}
2894
+ .tok-id{color:var(--code-ink)}
2895
+ .tok-pn{color:#8da0bd}
2896
+ .dv-f-link{display:inline-block;margin-top:12px;font-size:13px;font-weight:600;color:var(--accent);text-decoration:none}
2897
+ .dv-f-link:hover{text-decoration:underline}
2898
+ .dv-ai-prompt{margin-top:12px;border:1px solid var(--line);border-radius:8px;overflow:hidden}
2899
+ .dv-ai-prompt>summary{cursor:pointer;list-style:none;padding:8px 12px;font-size:12px;font-weight:600;color:var(--muted);display:flex;align-items:center;gap:6px;user-select:none}
2900
+ .dv-ai-prompt>summary::-webkit-details-marker{display:none}
2901
+ .dv-ai-prompt>summary::before{content:"\u25B8";display:inline-block;transition:transform .15s ease}
2902
+ .dv-ai-prompt[open]>summary::before{transform:rotate(90deg)}
2903
+ .dv-ai-prompt-body{padding:0 12px 12px;display:flex;flex-direction:column;gap:8px}
2904
+ .dv-ai-prompt-pre{margin:0;padding:10px 12px;background:var(--code-bg);color:var(--code-ink);border-radius:8px;font-family:var(--mono);font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow-y:auto}
2905
+ .dv-ai-copy-btn{align-self:flex-start;font:inherit;font-size:12px;font-weight:600;cursor:pointer;background:var(--panel);border:1px solid var(--line-strong);color:var(--ink);padding:5px 12px;border-radius:999px}
2906
+ .dv-ai-copy-btn:hover{border-color:var(--faint)}
2907
+ .dv-ai-copy-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
2908
+ .dv-empty{color:var(--muted);font-size:13px}
2909
+ @media (max-width:640px){.dv-app{grid-template-columns:1fr;grid-template-areas:"top" "main"}.dv-menu-toggle{display:inline-flex}.dv-sidebar{position:fixed;inset:0 20% 0 0;transform:translateX(-100%);transition:transform .2s ease;z-index:10}.dv-sidebar.open{transform:translateX(0)}}
2946
2910
  @media (prefers-reduced-motion:reduce){*{transition:none!important}}
2947
2911
  `;
2948
- var SCRIPT = `
2912
+ var APP_SCRIPT = `
2949
2913
  (function(){
2950
- var arc=document.getElementById('arc'),num=document.getElementById('hnum');
2951
- if(arc&&num){
2952
- var C=2*Math.PI*58,score=parseInt(num.textContent,10)||0;
2953
- var reduce=window.matchMedia('(prefers-reduced-motion: reduce)').matches;
2954
- if(!reduce){
2955
- arc.style.transition='stroke-dashoffset 1.1s cubic-bezier(.22,.61,.36,1)';
2956
- arc.style.strokeDashoffset=C.toFixed(1);
2957
- var start=null;
2958
- requestAnimationFrame(function step(t){
2959
- if(start===null)start=t;
2960
- var p=Math.min((t-start)/1100,1);
2961
- num.textContent=Math.round(score*(p<1?1-Math.pow(1-p,3):1));
2962
- if(p<1)requestAnimationFrame(step);
2914
+ var BAND_COLOR = { good: '#2fa968', warn: '#e8a317', poor: '#e5484d' };
2915
+ function scoreBand(score) { return score >= 90 ? 'good' : score >= 50 ? 'warn' : 'poor'; }
2916
+
2917
+ // Same mark as the docs site's hero wordmark (docs/public/wordmark.svg) \u2014 an inline
2918
+ // copy, not an <img src>, since the dashboard is a single self-contained HTML response
2919
+ // with no other static assets to serve alongside it. Fixed brand colors (not CSS custom
2920
+ // properties), matching the docs usage: the wordmark reads the same in both themes.
2921
+ var WORDMARK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 380 56" role="img" aria-labelledby="dv-wordmark-title"><title id="dv-wordmark-title">svelte-vitals</title><defs><clipPath id="dv-wordmark-clip"><rect x="2" y="2" width="52" height="52" rx="14"/></clipPath></defs><rect x="2" y="2" width="52" height="52" rx="14" fill="#FF3E00"/><polyline clip-path="url(#dv-wordmark-clip)" points="4,28 15,28 17.5,23.5 20,28 23,28 26,7 29,49 32,28 35,28 37,24.5 39.5,28 52,28" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><text x="70" y="38" font-family="ui-sans-serif, system-ui, -apple-system, \\'Segoe UI\\', Roboto, sans-serif" font-size="30" font-weight="700" fill="#FF3E00">svelte-vitals</text></svg>';
2922
+
2923
+ function h(tag, attrs, kids) {
2924
+ var n = document.createElement(tag);
2925
+ if (attrs) {
2926
+ for (var k in attrs) {
2927
+ if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue;
2928
+ var v = attrs[k];
2929
+ if (v === undefined || v === null || v === false) continue;
2930
+ if (k === 'class') n.className = v;
2931
+ else if (k === 'text') n.textContent = v;
2932
+ else if (k.indexOf('on') === 0 && typeof v === 'function') n.addEventListener(k.slice(2), v);
2933
+ else n.setAttribute(k, v === true ? '' : String(v));
2934
+ }
2935
+ }
2936
+ (kids || []).forEach(function (c) {
2937
+ if (c === undefined || c === null || c === false) return;
2938
+ n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
2939
+ });
2940
+ return n;
2941
+ }
2942
+ function clear(n) { while (n.firstChild) n.removeChild(n.firstChild); }
2943
+ function mount(id, node) { var el = document.getElementById(id); clear(el); el.appendChild(node); }
2944
+
2945
+ var HL_KEYWORDS = ['import','export','from','const','let','var','function','return','if','else','for','while','class','new','await','async','default','type','interface','extends','implements','this','typeof','instanceof','of','in','true','false','null','undefined'];
2946
+ var HL_LANGS = { js: 1, javascript: 1, ts: 1, typescript: 1, svelte: 1, html: 1, css: 1 };
2947
+
2948
+ function highlightTokens(code) {
2949
+ var tokens = [];
2950
+ var i = 0;
2951
+ var n = code.length;
2952
+ var reIdent = /[A-Za-z_$][A-Za-z0-9_$]*/y;
2953
+ var reNum = /\\d+(\\.\\d+)?/y;
2954
+ while (i < n) {
2955
+ var ch = code[i];
2956
+ if (ch === '/' && code[i + 1] === '/') {
2957
+ var end = code.indexOf('\\n', i);
2958
+ if (end === -1) end = n;
2959
+ tokens.push({ text: code.slice(i, end), cls: 'cm' });
2960
+ i = end;
2961
+ continue;
2962
+ }
2963
+ if (ch === '/' && code[i + 1] === '*') {
2964
+ var end2 = code.indexOf('*/', i + 2);
2965
+ end2 = end2 === -1 ? n : end2 + 2;
2966
+ tokens.push({ text: code.slice(i, end2), cls: 'cm' });
2967
+ i = end2;
2968
+ continue;
2969
+ }
2970
+ if (ch === '"' || ch === "'" || ch === '\`') {
2971
+ var quote = ch;
2972
+ var j = i + 1;
2973
+ while (j < n && code[j] !== quote) {
2974
+ if (code[j] === '\\\\') j++;
2975
+ j++;
2976
+ }
2977
+ j = Math.min(j + 1, n);
2978
+ tokens.push({ text: code.slice(i, j), cls: 'str' });
2979
+ i = j;
2980
+ continue;
2981
+ }
2982
+ reIdent.lastIndex = i;
2983
+ var mIdent = reIdent.exec(code);
2984
+ if (mIdent && mIdent.index === i) {
2985
+ var word = mIdent[0];
2986
+ tokens.push({ text: word, cls: HL_KEYWORDS.indexOf(word) !== -1 ? 'kw' : 'id' });
2987
+ i += word.length;
2988
+ continue;
2989
+ }
2990
+ reNum.lastIndex = i;
2991
+ var mNum = reNum.exec(code);
2992
+ if (mNum && mNum.index === i) {
2993
+ tokens.push({ text: mNum[0], cls: 'num' });
2994
+ i += mNum[0].length;
2995
+ continue;
2996
+ }
2997
+ tokens.push({ text: ch, cls: 'pn' });
2998
+ i += 1;
2999
+ }
3000
+ return tokens;
3001
+ }
3002
+
3003
+ function renderFixSnippet(fix) {
3004
+ var pre = h('pre', null, []);
3005
+ var code = h('code', null, []);
3006
+ var lang = (fix.lang || 'svelte').toLowerCase();
3007
+ if (HL_LANGS[lang]) {
3008
+ highlightTokens(fix.snippet).forEach(function (t) {
3009
+ code.appendChild(h('span', { class: 'tok-' + t.cls, text: t.text }, []));
2963
3010
  });
2964
- requestAnimationFrame(function(){arc.style.strokeDashoffset=(C*(1-score/100)).toFixed(1);});
3011
+ } else {
3012
+ code.textContent = fix.snippet;
3013
+ }
3014
+ pre.appendChild(code);
3015
+ return pre;
3016
+ }
3017
+
3018
+ // Plain-text, copy-pasteable prompt for a single finding \u2014 same ingredients as the
3019
+ // agent reporter's per-finding block (rule id, location, recommendation, fix, docs),
3020
+ // reshaped for a standalone request rather than a whole-project remediation doc.
3021
+ function buildAiPrompt(issue, route) {
3022
+ var lines = ['Fix this svelte-vitals finding:', ''];
3023
+ lines.push('- Rule: ' + issue.id + ' \u2014 ' + issue.title + ' (' + issue.severity + ')');
3024
+ if (route) lines.push('- Route: ' + route);
3025
+ if (issue.location) {
3026
+ lines.push('- Location: ' + issue.location + (issue.line !== undefined ? ':' + issue.line : ''));
3027
+ }
3028
+ if (issue.recommendation) lines.push('- Recommendation: ' + issue.recommendation);
3029
+ if (issue.fix) {
3030
+ lines.push('- Fix: ' + issue.fix.description);
3031
+ if (issue.fix.snippet) {
3032
+ lines.push('', '\`\`\`' + (issue.fix.lang || 'svelte'), issue.fix.snippet, '\`\`\`');
3033
+ }
3034
+ }
3035
+ if (issue.docsUrl) lines.push('- Docs: ' + issue.docsUrl);
3036
+ lines.push(
3037
+ '',
3038
+ 'After fixing, re-run \`svelte-vitals --diff\` (or revisit this route) to confirm ' +
3039
+ issue.id +
3040
+ ' passes' +
3041
+ (route ? ' for ' + route : '') +
3042
+ '.'
3043
+ );
3044
+ return lines.join('\\n');
3045
+ }
3046
+
3047
+ function copyToClipboard(text, btn) {
3048
+ var original = 'Copy';
3049
+ function reset(label) {
3050
+ btn.textContent = label;
3051
+ setTimeout(function () { btn.textContent = original; }, 1500);
3052
+ }
3053
+ function done() { reset('Copied!'); }
3054
+ function fail() { reset('Copy failed'); }
3055
+ function fallbackCopy() {
3056
+ var ta = document.createElement('textarea');
3057
+ ta.value = text;
3058
+ ta.setAttribute('readonly', '');
3059
+ ta.style.position = 'fixed';
3060
+ ta.style.opacity = '0';
3061
+ document.body.appendChild(ta);
3062
+ ta.select();
3063
+ var ok = false;
3064
+ try { ok = document.execCommand('copy'); } catch (e) {}
3065
+ document.body.removeChild(ta);
3066
+ return ok;
3067
+ }
3068
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3069
+ navigator.clipboard.writeText(text).then(done, function () { fallbackCopy() ? done() : fail(); });
3070
+ } else {
3071
+ fallbackCopy() ? done() : fail();
3072
+ }
3073
+ }
3074
+
3075
+ function renderAiPrompt(issue, route) {
3076
+ var text = buildAiPrompt(issue, route);
3077
+ var btn = h('button', { type: 'button', class: 'dv-ai-copy-btn', text: 'Copy' }, []);
3078
+ btn.addEventListener('click', function () { copyToClipboard(text, btn); });
3079
+ return h('details', { class: 'dv-ai-prompt' }, [
3080
+ h('summary', { text: 'AI Prompt' }, []),
3081
+ h('div', { class: 'dv-ai-prompt-body' }, [
3082
+ h('pre', { class: 'dv-ai-prompt-pre', text: text }, []),
3083
+ btn
3084
+ ])
3085
+ ]);
3086
+ }
3087
+
3088
+ var state = {
3089
+ snapshot: null,
3090
+ selected: 'overview',
3091
+ search: '',
3092
+ sort: 'score-asc',
3093
+ filter: 'all',
3094
+ theme: initialTheme(),
3095
+ connection: 'connecting',
3096
+ routeBySlug: {}
3097
+ };
3098
+
3099
+ function initialTheme() {
3100
+ try {
3101
+ var stored = localStorage.getItem('svelte-vitals-theme');
3102
+ if (stored === 'dark' || stored === 'light') return stored;
3103
+ } catch (e) {}
3104
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
3105
+ }
3106
+ function applyTheme() { document.documentElement.setAttribute('data-theme', state.theme); }
3107
+ function toggleTheme() {
3108
+ state.theme = state.theme === 'dark' ? 'light' : 'dark';
3109
+ try { localStorage.setItem('svelte-vitals-theme', state.theme); } catch (e) {}
3110
+ applyTheme();
3111
+ renderTopbar();
3112
+ }
3113
+ function toggleSidebar() {
3114
+ var sb = document.getElementById('dv-sidebar');
3115
+ if (sb) sb.classList.toggle('open');
3116
+ }
3117
+
3118
+ function brandEl() {
3119
+ var el = h('button', { type: 'button', class: 'dv-brand', 'aria-label': 'Go to Overview', onclick: function () { selectItem('overview'); } }, []);
3120
+ el.innerHTML = WORDMARK_SVG;
3121
+ return el;
3122
+ }
3123
+
3124
+ function renderTopbar() {
3125
+ var s = state.snapshot;
3126
+ var findings = s.report.routes.reduce(function (n, r) { return n + r.issues.length; }, 0) + s.report.siteIssues.length;
3127
+ var kids = [
3128
+ h('button', { type: 'button', class: 'dv-menu-toggle', 'aria-label': 'Toggle route list', onclick: toggleSidebar, text: '\u2261' }, []),
3129
+ brandEl(),
3130
+ h('div', { class: 'dv-meta' }, [
3131
+ h('span', { text: 'v' + s.meta.version }, []),
3132
+ s.meta.coreVersion ? h('span', { title: '@svelte-vitals/core version', text: 'core v' + s.meta.coreVersion }, []) : null,
3133
+ h('span', { text: s.report.routes.length + ' routes' }, []),
3134
+ h('span', { text: findings + ' findings' }, [])
3135
+ ].filter(Boolean)),
3136
+ h('div', { class: 'dv-status' }, [
3137
+ s.live && s.analyzing ? h('span', { class: 'dv-analyzing', text: 'Analyzing\u2026' }, []) : null,
3138
+ s.live ? h('span', { class: 'dv-conn dv-conn-' + state.connection, title: state.connection }, []) : null,
3139
+ h('button', { type: 'button', class: 'dv-theme-toggle', 'aria-label': 'Toggle dark mode', onclick: toggleTheme, text: state.theme === 'dark' ? '\u2600' : '\u263E' }, [])
3140
+ ].filter(Boolean))
3141
+ ];
3142
+ mount('dv-topbar', h('div', { class: 'dv-topbar-inner' }, kids));
3143
+ }
3144
+
3145
+ function slugify(route) {
3146
+ return 'route-' + route.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase();
3147
+ }
3148
+
3149
+ function matchesSearch(route, q) {
3150
+ if (!q) return true;
3151
+ q = q.toLowerCase();
3152
+ if (route.route.toLowerCase().indexOf(q) !== -1) return true;
3153
+ return route.issues.some(function (iss) {
3154
+ return (iss.id + ' ' + iss.title + ' ' + (iss.location || '')).toLowerCase().indexOf(q) !== -1;
3155
+ });
3156
+ }
3157
+
3158
+ function sortedRoutes() {
3159
+ var s = state.snapshot;
3160
+ var q = state.search.trim();
3161
+ var list = s.report.routes.filter(function (r) { return matchesSearch(r, q); }).slice();
3162
+ var sort = state.sort;
3163
+ if (sort === 'score-asc') list.sort(function (a, b) { return a.score - b.score; });
3164
+ else if (sort === 'score-desc') list.sort(function (a, b) { return b.score - a.score; });
3165
+ else if (sort === 'alpha') list.sort(function (a, b) { return a.route.localeCompare(b.route); });
3166
+ else if (sort === 'most-findings') list.sort(function (a, b) { return b.issues.length - a.issues.length; });
3167
+ return list;
3168
+ }
3169
+
3170
+ function renderNavItem(label, key, route, active) {
3171
+ var kids = [h('span', { class: 'dv-nav-label', text: label }, [])];
3172
+ if (route) {
3173
+ var band = scoreBand(route.score);
3174
+ var crit = route.issues.filter(function (i) { return i.severity === 'critical'; }).length;
3175
+ var warn = route.issues.filter(function (i) { return i.severity === 'warning'; }).length;
3176
+ var info = route.issues.filter(function (i) { return i.severity === 'info'; }).length;
3177
+ var summary = [];
3178
+ if (crit) summary.push(crit + ' critical');
3179
+ if (warn) summary.push(warn + ' warning' + (warn > 1 ? 's' : ''));
3180
+ if (info) summary.push(info + ' info');
3181
+ var badge = state.snapshot.badges[route.route];
3182
+ kids.push(h('span', { class: 'dv-nav-meta' }, [
3183
+ badge ? h('span', { class: 'dv-badge dv-badge-' + badge, text: badge }, []) : null,
3184
+ h('span', { class: 'dv-nav-score', style: 'color:' + BAND_COLOR[band], text: String(route.score) }, []),
3185
+ h('span', { class: 'dv-nav-sum', text: summary.length ? summary.join(' \xB7 ') : 'no issues' }, [])
3186
+ ].filter(Boolean)));
3187
+ }
3188
+ return h('div', {
3189
+ class: 'dv-nav-item' + (active ? ' active' : ''),
3190
+ role: 'option',
3191
+ 'aria-selected': active ? 'true' : 'false',
3192
+ tabindex: '0',
3193
+ onclick: function () { selectItem(key); },
3194
+ onkeydown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectItem(key); } }
3195
+ }, kids);
3196
+ }
3197
+
3198
+ function selectItem(key) {
3199
+ state.selected = key;
3200
+ location.hash = key === 'overview' ? 'overview' : 'route/' + slugify(key);
3201
+ var sb = document.getElementById('dv-sidebar');
3202
+ if (sb) sb.classList.remove('open');
3203
+ renderSidebar();
3204
+ renderDetail();
3205
+ }
3206
+
3207
+ function renderSidebar() {
3208
+ var s = state.snapshot;
3209
+ state.routeBySlug = {};
3210
+
3211
+ // mount() clears and rebuilds the whole sidebar, including the <input> itself, so
3212
+ // a naive re-render on every keystroke drops focus and cursor position \u2014 capture
3213
+ // them before rebuilding and restore them on the freshly-created input afterward.
3214
+ var prevSearch = document.querySelector('.dv-search');
3215
+ var hadFocus = !!prevSearch && document.activeElement === prevSearch;
3216
+ var selStart = hadFocus ? prevSearch.selectionStart : null;
3217
+ var selEnd = hadFocus ? prevSearch.selectionEnd : null;
3218
+
3219
+ var searchInput = h('input', {
3220
+ type: 'search',
3221
+ class: 'dv-search',
3222
+ placeholder: 'Search routes or rules\u2026',
3223
+ value: state.search,
3224
+ oninput: function (e) { state.search = e.target.value; renderSidebar(); }
3225
+ }, []);
3226
+
3227
+ var sortSelect = h('select', { class: 'dv-sort', 'aria-label': 'Sort routes', onchange: function (e) { state.sort = e.target.value; renderSidebar(); } }, [
3228
+ h('option', { value: 'score-asc', selected: state.sort === 'score-asc' || undefined, text: 'Score (worst first)' }, []),
3229
+ h('option', { value: 'score-desc', selected: state.sort === 'score-desc' || undefined, text: 'Score (best first)' }, []),
3230
+ h('option', { value: 'alpha', selected: state.sort === 'alpha' || undefined, text: 'Alphabetical' }, []),
3231
+ h('option', { value: 'most-findings', selected: state.sort === 'most-findings' || undefined, text: 'Most findings' }, [])
3232
+ ]);
3233
+
3234
+ var items = [renderNavItem('Overview', 'overview', null, state.selected === 'overview')];
3235
+ sortedRoutes().forEach(function (r) {
3236
+ var slug = slugify(r.route);
3237
+ state.routeBySlug[slug] = r.route;
3238
+ items.push(renderNavItem(r.route, r.route, r, state.selected === r.route));
3239
+ });
3240
+
3241
+ var nav = h('div', { class: 'dv-nav', role: 'listbox', 'aria-label': 'Routes' }, items);
3242
+ mount('dv-sidebar', h('div', { class: 'dv-sidebar-inner' }, [searchInput, sortSelect, nav]));
3243
+
3244
+ if (hadFocus) {
3245
+ searchInput.focus();
3246
+ if (selStart !== null && searchInput.setSelectionRange) {
3247
+ searchInput.setSelectionRange(selStart, selEnd);
3248
+ }
2965
3249
  }
2966
3250
  }
2967
- var chips=document.querySelectorAll('.chip'),findings=document.querySelectorAll('.finding');
2968
- function apply(f){
2969
- findings.forEach(function(el){
2970
- var ok=f==='all'||el.getAttribute('data-severity')===f||el.getAttribute('data-category')===f;
2971
- el.style.display=ok?'':'none';
3251
+
3252
+ function renderFilterChips(categories) {
3253
+ var chip = function (filter, label) {
3254
+ return h('button', {
3255
+ type: 'button', class: 'dv-chip', 'aria-pressed': state.filter === filter ? 'true' : 'false',
3256
+ onclick: function () { state.filter = filter; renderDetail(); },
3257
+ text: label
3258
+ }, []);
3259
+ };
3260
+ var catChips = Object.keys(categories).map(function (cat) {
3261
+ var name = cat === 'seo' ? 'SEO' : cat.charAt(0).toUpperCase() + cat.slice(1);
3262
+ return chip(cat, name);
2972
3263
  });
3264
+ return h('div', { class: 'dv-filters', role: 'group', 'aria-label': 'Filter findings' },
3265
+ [chip('all', 'All'), chip('critical', 'Critical'), chip('warning', 'Warning'), chip('info', 'Info')].concat(catChips));
3266
+ }
3267
+
3268
+ function passesFilter(issue) {
3269
+ var f = state.filter;
3270
+ return f === 'all' || issue.severity === f || issue.category === f;
3271
+ }
3272
+
3273
+ var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
3274
+
3275
+ function renderFinding(issue, route, promptRoute) {
3276
+ var kids = [
3277
+ h('div', { class: 'dv-f-head' }, [
3278
+ h('span', { class: 'dv-ruleid', text: issue.id }, []),
3279
+ h('span', { class: 'dv-f-title', text: issue.title }, []),
3280
+ h('span', { class: 'dv-sev-tag dv-sev-' + issue.severity, text: issue.severity }, [])
3281
+ ])
3282
+ ];
3283
+ if (route) {
3284
+ kids.push(h('button', { type: 'button', class: 'dv-f-route', onclick: function () { selectItem(route); }, text: route }, []));
3285
+ }
3286
+ if (issue.location) {
3287
+ kids.push(h('p', { class: 'dv-f-loc', text: issue.location + (issue.line !== undefined ? ':' + issue.line : '') }, []));
3288
+ }
3289
+ if (issue.recommendation) {
3290
+ kids.push(h('p', { class: 'dv-f-rec', text: issue.recommendation }, []));
3291
+ }
3292
+ if (issue.fix && issue.fix.snippet) {
3293
+ kids.push(h('div', { class: 'dv-fix' }, [h('div', { class: 'dv-fix-label', text: 'fix' }, []), renderFixSnippet(issue.fix)]));
3294
+ }
3295
+ if (issue.docsUrl) {
3296
+ kids.push(h('a', { class: 'dv-f-link', href: issue.docsUrl, text: 'Learn more' }, []));
3297
+ }
3298
+ kids.push(renderAiPrompt(issue, promptRoute !== undefined ? promptRoute : route));
3299
+ return h('article', { class: 'dv-finding dv-finding-' + issue.severity }, kids);
3300
+ }
3301
+
3302
+ function renderGauge(score) {
3303
+ var band = scoreBand(score);
3304
+ var svgNs = 'http://www.w3.org/2000/svg';
3305
+ var C = 2 * Math.PI * 58;
3306
+ var offset = (C * (1 - score / 100)).toFixed(1);
3307
+ var svg = document.createElementNS(svgNs, 'svg');
3308
+ svg.setAttribute('width', '132');
3309
+ svg.setAttribute('height', '132');
3310
+ svg.setAttribute('viewBox', '0 0 132 132');
3311
+ var bg = document.createElementNS(svgNs, 'circle');
3312
+ bg.setAttribute('cx', '66'); bg.setAttribute('cy', '66'); bg.setAttribute('r', '58');
3313
+ bg.setAttribute('fill', 'none'); bg.setAttribute('class', 'dv-gauge-track'); bg.setAttribute('stroke-width', '11');
3314
+ var arc = document.createElementNS(svgNs, 'circle');
3315
+ arc.setAttribute('cx', '66'); arc.setAttribute('cy', '66'); arc.setAttribute('r', '58');
3316
+ arc.setAttribute('fill', 'none'); arc.setAttribute('stroke', BAND_COLOR[band]); arc.setAttribute('stroke-width', '11');
3317
+ arc.setAttribute('stroke-linecap', 'round');
3318
+ arc.setAttribute('stroke-dasharray', C.toFixed(1));
3319
+ arc.setAttribute('stroke-dashoffset', offset);
3320
+ svg.appendChild(bg);
3321
+ svg.appendChild(arc);
3322
+ var wrap = h('div', { class: 'dv-gauge' }, [h('div', { class: 'dv-gauge-num' }, [h('strong', { text: String(score) }, []), h('span', { text: 'Health' }, [])])]);
3323
+ wrap.insertBefore(svg, wrap.firstChild);
3324
+ return wrap;
2973
3325
  }
2974
- chips.forEach(function(c){
2975
- c.addEventListener('click',function(){
2976
- chips.forEach(function(o){o.setAttribute('aria-pressed','false');});
2977
- c.setAttribute('aria-pressed','true');
2978
- apply(c.getAttribute('data-filter')||'all');
3326
+
3327
+ function renderOverview(s) {
3328
+ var gauge = renderGauge(s.report.score);
3329
+ var cats = Object.keys(s.report.categories).map(function (cat) {
3330
+ var c = s.report.categories[cat];
3331
+ var band = scoreBand(c.score);
3332
+ var weight = s.report.weights[cat];
3333
+ var name = cat === 'seo' ? 'SEO' : cat.charAt(0).toUpperCase() + cat.slice(1);
3334
+ return h('div', { class: 'dv-cat' }, [
3335
+ h('div', { class: 'dv-cat-top' }, [
3336
+ h('span', { text: name + (weight !== undefined ? ' (weight ' + weight + ')' : '') }, []),
3337
+ h('span', { style: 'color:' + BAND_COLOR[band], text: String(c.score) }, [])
3338
+ ]),
3339
+ h('div', { class: 'dv-bar' }, [h('i', { style: 'width:' + c.score + '%;background:' + BAND_COLOR[band] }, [])])
3340
+ ]);
2979
3341
  });
2980
- });
3342
+ var chips = renderFilterChips(s.report.categories);
3343
+ var totalCount = s.report.routes.reduce(function (n, r) { return n + r.issues.length; }, 0) + s.report.siteIssues.length;
3344
+ var entries = [];
3345
+ s.report.routes.forEach(function (r) {
3346
+ r.issues.forEach(function (issue) { entries.push({ issue: issue, route: r.route }); });
3347
+ });
3348
+ s.report.siteIssues.forEach(function (issue) { entries.push({ issue: issue, route: null }); });
3349
+ entries = entries.filter(function (e) { return passesFilter(e.issue); });
3350
+ entries.sort(function (a, b) {
3351
+ var sd = SEVERITY_ORDER[a.issue.severity] - SEVERITY_ORDER[b.issue.severity];
3352
+ if (sd !== 0) return sd;
3353
+ return (a.route || '').localeCompare(b.route || '');
3354
+ });
3355
+ var body = entries.length
3356
+ ? entries.map(function (e) { return renderFinding(e.issue, e.route); })
3357
+ : [h('p', { class: 'dv-empty', text: totalCount ? 'No issues match the current filter.' : 'No issues found \u2014 nice work!' }, [])];
3358
+ var findings = h('section', { class: 'dv-section' }, [h('h2', { text: 'Findings' }, [])].concat(body));
3359
+ return h('div', { class: 'dv-overview' }, [gauge, h('div', { class: 'dv-cats' }, cats), chips, findings].filter(Boolean));
3360
+ }
3361
+
3362
+ function renderRouteDetail(route) {
3363
+ var badge = state.snapshot.badges[route.route];
3364
+ var band = scoreBand(route.score);
3365
+ var header = h('div', { class: 'dv-detail-header' }, [
3366
+ h('span', { class: 'dv-route-path', text: route.route }, []),
3367
+ badge ? h('span', { class: 'dv-badge dv-badge-' + badge, text: badge }, []) : null,
3368
+ h('span', { class: 'dv-score-chip', style: 'color:' + BAND_COLOR[band], text: String(route.score) }, [])
3369
+ ].filter(Boolean));
3370
+ var chips = renderFilterChips(state.snapshot.report.categories);
3371
+ var findings = route.issues.filter(passesFilter);
3372
+ var body = findings.length
3373
+ ? findings.map(function (issue) { return renderFinding(issue, undefined, route.route); })
3374
+ : [h('p', { class: 'dv-empty', text: 'No issues match the current filter.' }, [])];
3375
+ return h('div', { class: 'dv-route-detail' }, [header, chips].concat(body));
3376
+ }
3377
+
3378
+ function renderDetail() {
3379
+ var s = state.snapshot;
3380
+ if (state.selected === 'overview') {
3381
+ mount('dv-detail', renderOverview(s));
3382
+ return;
3383
+ }
3384
+ var route = s.report.routes.filter(function (r) { return r.route === state.selected; })[0];
3385
+ if (!route) {
3386
+ state.selected = 'overview';
3387
+ mount('dv-detail', renderOverview(s));
3388
+ return;
3389
+ }
3390
+ mount('dv-detail', renderRouteDetail(route));
3391
+ }
3392
+
3393
+ function renderAll() {
3394
+ renderTopbar();
3395
+ renderSidebar();
3396
+ renderDetail();
3397
+ }
3398
+
3399
+ function restoreSelectionFromHash() {
3400
+ var raw = location.hash.replace(/^#/, '');
3401
+ if (!raw || raw === 'overview') { state.selected = 'overview'; return; }
3402
+ var m = /^route\\/(.+)$/.exec(raw);
3403
+ if (m && state.routeBySlug[m[1]]) state.selected = state.routeBySlug[m[1]];
3404
+ }
3405
+
3406
+ function fetchSnapshot() {
3407
+ fetch('/__svelte-vitals/data.json').then(function (r) { return r.json(); }).then(function (data) {
3408
+ if (state.snapshot && data.sequence <= state.snapshot.sequence) return;
3409
+ state.snapshot = data;
3410
+ renderAll();
3411
+ }).catch(function () {});
3412
+ }
3413
+
3414
+ function boot() {
3415
+ var raw = document.getElementById('svelte-vitals-data');
3416
+ state.snapshot = JSON.parse(raw.textContent);
3417
+ applyTheme();
3418
+ renderSidebar(); // populates routeBySlug before the hash can be trusted
3419
+ restoreSelectionFromHash();
3420
+ renderAll();
3421
+
3422
+ window.addEventListener('hashchange', function () {
3423
+ restoreSelectionFromHash();
3424
+ renderSidebar();
3425
+ renderDetail();
3426
+ });
3427
+
3428
+ // Static export (the CLI's --reporter html): no dev server behind the page, so no
3429
+ // SSE connection, no /data.json refetch, no connection indicator.
3430
+ if (state.snapshot.live && typeof EventSource !== 'undefined') {
3431
+ var es = new EventSource('/__svelte-vitals/events');
3432
+ es.addEventListener('open', function () { state.connection = 'connected'; renderTopbar(); fetchSnapshot(); });
3433
+ es.addEventListener('update', fetchSnapshot);
3434
+ es.addEventListener('error', function () { state.connection = 'reconnecting'; renderTopbar(); });
3435
+ }
3436
+ }
3437
+
3438
+ boot();
2981
3439
  })();
2982
3440
  `;
3441
+ function renderAppShell(snapshot) {
3442
+ const safe = { ...snapshot, report: sanitizeReport(snapshot.report) };
3443
+ const title = snapshot.live ? "svelte-vitals dashboard" : "svelte-vitals report";
3444
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title><style>${APP_STYLE}</style></head><body><div class="dv-app" id="dv-app"><header class="dv-topbar" id="dv-topbar"></header><nav class="dv-sidebar" id="dv-sidebar"></nav><main class="dv-detail" id="dv-detail"></main></div><script type="application/json" id="svelte-vitals-data">${embedJson(safe)}</script><script>${APP_SCRIPT}</script></body></html>`;
3445
+ }
2983
3446
  function buildHtmlDocument(report, meta, opts) {
2984
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>svelte-vitals report</title><style>${STYLE}</style></head><body><div class="wrap">` + renderTopbar(report, meta) + renderHero(report) + renderFilters(report) + renderRoutes(report, opts?.routeBadges) + renderSiteChecks(report) + `<footer class="foot"><span>Generated by svelte-vitals \xB7 static analysis, no browser</span><span>oekazuma.github.io/svelte-vitals</span></footer></div><script>${SCRIPT}</script></body></html>`;
3447
+ const badges = Object.fromEntries(
3448
+ Object.entries(opts?.routeBadges ?? {}).filter(([, b]) => b === "measured" || b === "static")
3449
+ );
3450
+ return renderAppShell({
3451
+ report,
3452
+ badges,
3453
+ analyzing: false,
3454
+ sequence: 0,
3455
+ live: false,
3456
+ meta
3457
+ });
2985
3458
  }
2986
3459
  function formatHtmlReport(results, config, meta) {
2987
3460
  return buildHtmlDocument(buildJsonReport(results, config, meta), meta);
2988
3461
  }
2989
3462
 
3463
+ // src/reporter/html.ts
3464
+ var BAND_COLOR = {
3465
+ good: "#2FA968",
3466
+ warn: "#E8A317",
3467
+ poor: "#E5484D"
3468
+ };
3469
+ function scoreBand(score) {
3470
+ return score >= 90 ? "good" : score >= 50 ? "warn" : "poor";
3471
+ }
3472
+ function escapeHtml(s) {
3473
+ return s.replace(
3474
+ /[&<>"']/g,
3475
+ (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;"
3476
+ );
3477
+ }
3478
+ function safeHref(url) {
3479
+ const normalized = url.replace(/\s/g, "").toLowerCase();
3480
+ return /^https?:\/\//.test(normalized) ? url : null;
3481
+ }
3482
+
2990
3483
  // src/config-apply.ts
2991
3484
  function selectRules(rules, config) {
2992
3485
  return rules.filter((rule) => config.rules[rule.id] !== "off");
@@ -2998,6 +3491,8 @@ function applyRuleSeverities(results, config) {
2998
3491
  });
2999
3492
  }
3000
3493
  export {
3494
+ APP_SCRIPT,
3495
+ APP_STYLE,
3001
3496
  BAND_COLOR,
3002
3497
  CHILD_NODE_KEYS,
3003
3498
  ROBOTS_SOURCE_PATHS,
@@ -3055,6 +3550,7 @@ export {
3055
3550
  perf008Preconnect,
3056
3551
  perf009HeavyImport,
3057
3552
  perf010NamespaceImport,
3553
+ renderAppShell,
3058
3554
  runRules,
3059
3555
  safeHref,
3060
3556
  scoreBand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",