@svelte-vitals/core 0.23.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
@@ -247,6 +247,11 @@ interface ComponentFacts {
247
247
  propCount: number;
248
248
  /** Module specifiers of every `import` in the instance + module scripts (Bundle PERF009). */
249
249
  imports: string[];
250
+ /** Module specifiers of every `import`, each with its source line (Bundle PERF009). */
251
+ importSpans: {
252
+ source: string;
253
+ line: number;
254
+ }[];
250
255
  /** Value `import * as X from '<bare pkg>'` namespace imports (type-only excluded) — Bundle PERF010. */
251
256
  namespaceImports: {
252
257
  source: string;
@@ -275,6 +280,10 @@ declare function parseComponentFacts(source: string, filename: string): {
275
280
  loc: number;
276
281
  propCount: number;
277
282
  imports: string[];
283
+ importSpans: {
284
+ source: string;
285
+ line: number;
286
+ }[];
278
287
  namespaceImports: {
279
288
  source: string;
280
289
  line: number;
@@ -731,6 +740,57 @@ declare function formatMarkdownReport(results: Result[], config: Config, meta: {
731
740
  version: string;
732
741
  }): string;
733
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
+
734
794
  type Band = 'good' | 'warn' | 'poor';
735
795
  declare const BAND_COLOR: Record<Band, string>;
736
796
  declare function scoreBand(score: number): Band;
@@ -745,20 +805,10 @@ declare function escapeHtml(s: string): string;
745
805
  * keeping core runtime-agnostic and lib-minimal.
746
806
  */
747
807
  declare function safeHref(url: string): string | null;
748
- declare function buildHtmlDocument(report: JsonReport, meta: {
749
- version: string;
750
- coreVersion?: string;
751
- }, opts?: {
752
- routeBadges?: Record<string, 'measured' | 'static'>;
753
- }): string;
754
- declare function formatHtmlReport(results: Result[], config: Config, meta: {
755
- version: string;
756
- coreVersion?: string;
757
- }): string;
758
808
 
759
809
  /** Drop rules disabled via config (design §6). */
760
810
  declare function selectRules(rules: Rule[], config: Config): Rule[];
761
811
  /** Apply per-rule severity overrides to results (design §6). */
762
812
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
763
813
 
764
- 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
@@ -419,9 +419,11 @@ function countLines(source) {
419
419
  if (source.length === 0) return 0;
420
420
  return source.split("\n").length - (source.endsWith("\n") ? 1 : 0);
421
421
  }
422
- function collectImportSources(program, acc) {
422
+ function collectImportSources(program, source, acc) {
423
423
  walkEstree(program, (n) => {
424
- if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") acc.push(n.source.value);
424
+ if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") {
425
+ acc.push({ source: n.source.value, line: lineOf(source, n.start) });
426
+ }
425
427
  });
426
428
  }
427
429
  function isBareSpecifier(s) {
@@ -459,10 +461,10 @@ function parseComponentFacts(source, filename) {
459
461
  collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
460
462
  const loc = countLines(source);
461
463
  const suppressions = collectSuppressions(source);
462
- const imports = [];
464
+ const importSpans = [];
463
465
  const namespaceImports = [];
464
466
  if (ast.module?.content) {
465
- collectImportSources(ast.module.content, imports);
467
+ collectImportSources(ast.module.content, source, importSpans);
466
468
  collectNamespaceImports(ast.module.content, source, namespaceImports);
467
469
  }
468
470
  const effects = [];
@@ -471,7 +473,7 @@ function parseComponentFacts(source, filename) {
471
473
  let propCount = 0;
472
474
  const program = ast.instance?.content;
473
475
  if (program) {
474
- collectImportSources(program, imports);
476
+ collectImportSources(program, source, importSpans);
475
477
  collectNamespaceImports(program, source, namespaceImports);
476
478
  propCount = countProps(program);
477
479
  const nonBindableProps = collectNonBindableProps(program);
@@ -509,6 +511,7 @@ function parseComponentFacts(source, filename) {
509
511
  if (!writtenOrEscaped.has(d.name)) constableStates.push(d);
510
512
  }
511
513
  }
514
+ const imports = importSpans.map((s) => s.source);
512
515
  return {
513
516
  eachBlocks,
514
517
  effects,
@@ -517,6 +520,7 @@ function parseComponentFacts(source, filename) {
517
520
  loc,
518
521
  propCount,
519
522
  imports,
523
+ importSpans,
520
524
  namespaceImports,
521
525
  constableStates,
522
526
  mutatedProps,
@@ -535,6 +539,7 @@ function emptyComponentFacts(file) {
535
539
  loc: 0,
536
540
  propCount: 0,
537
541
  imports: [],
542
+ importSpans: [],
538
543
  namespaceImports: [],
539
544
  constableStates: [],
540
545
  mutatedProps: [],
@@ -2143,7 +2148,7 @@ var arch002PropCount = componentRule({
2143
2148
  bad: (c) => c.propCount > MAX_PROPS ? [{ line: 1, message: `Component takes ${c.propCount} props (over ${MAX_PROPS})` }] : []
2144
2149
  });
2145
2150
 
2146
- // src/rules/performance/perf009-heavy-import.ts
2151
+ // src/rules/perf/perf009-heavy-import.ts
2147
2152
  var HEAVY_PACKAGES = {
2148
2153
  lodash: "import a submodule (lodash/debounce) or use lodash-es for tree-shaking",
2149
2154
  moment: "use a lighter date library (date-fns or dayjs) \u2014 moment is large and not tree-shakeable"
@@ -2156,20 +2161,24 @@ var perf009HeavyImport = componentRule({
2156
2161
  label: "No heavy imports",
2157
2162
  recommendation: "Import a submodule or switch to a lighter, tree-shakeable alternative.",
2158
2163
  rationale: "Importing a large, non-tree-shakeable package pulls its whole weight into the bundle even when only a fraction is used, slowing load.",
2159
- applies: (c) => c.imports.length > 0,
2164
+ // ComponentFacts is a public @svelte-vitals/core export — an external caller compiled
2165
+ // against an older version may still construct one without importSpans. Fall back to the
2166
+ // line-less `imports` (line: 0, the pre-fix behavior) instead of crashing on `undefined`.
2167
+ applies: (c) => (c.importSpans ?? c.imports).length > 0,
2160
2168
  bad: (c) => {
2161
2169
  const seen = /* @__PURE__ */ new Set();
2162
2170
  const out = [];
2163
- for (const src of c.imports) {
2171
+ const spans = c.importSpans ?? c.imports.map((source) => ({ source, line: 0 }));
2172
+ for (const { source: src, line } of spans) {
2164
2173
  if (!Object.hasOwn(HEAVY_PACKAGES, src) || seen.has(src)) continue;
2165
2174
  seen.add(src);
2166
- out.push({ line: 0, message: `Heavy import "${src}" \u2014 ${HEAVY_PACKAGES[src]}` });
2175
+ out.push({ line, message: `Heavy import "${src}" \u2014 ${HEAVY_PACKAGES[src]}` });
2167
2176
  }
2168
2177
  return out;
2169
2178
  }
2170
2179
  });
2171
2180
 
2172
- // src/rules/performance/perf010-namespace-import.ts
2181
+ // src/rules/perf/perf010-namespace-import.ts
2173
2182
  var perf010NamespaceImport = componentRule({
2174
2183
  id: "PERF010",
2175
2184
  title: "Namespace import",
@@ -2516,7 +2525,7 @@ function formatConsoleReport(results, config, options = {}) {
2516
2525
  lines.push("");
2517
2526
  }
2518
2527
  if (options.byRoute) lines.push(...byRouteTree(p, results, config, options.verbose ?? false));
2519
- if (summary.dynamic > 0) lines.push(p.dim("\u21AF = set dynamically (verified at runtime)."));
2528
+ if (options.verbose && summary.dynamic > 0) lines.push(p.dim("\u21AF = set dynamically (verified at runtime)."));
2520
2529
  return lines.join("\n").replace(/\n+$/, "\n");
2521
2530
  }
2522
2531
 
@@ -2784,200 +2793,693 @@ function formatMarkdownReport(results, config, meta) {
2784
2793
  return lines.join("\n");
2785
2794
  }
2786
2795
 
2787
- // src/reporter/html.ts
2788
- var BAND_COLOR = {
2789
- good: "#2FA968",
2790
- warn: "#E8A317",
2791
- poor: "#E5484D"
2792
- };
2793
- function scoreBand(score) {
2794
- 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");
2795
2799
  }
2796
- function escapeHtml(s) {
2797
- return s.replace(
2798
- /[&<>"']/g,
2799
- (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;"
2800
- );
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 };
2801
2804
  }
2802
- function safeHref(url) {
2803
- const normalized = url.replace(/\s/g, "").toLowerCase();
2804
- 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
+ };
2805
2811
  }
2806
- var slug = (route) => "route-" + route.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2807
- var categoryLabel = (cat) => cat === "seo" ? "SEO" : cat.charAt(0).toUpperCase() + cat.slice(1);
2808
- var SEVERITIES = ["critical", "warning", "info"];
2809
- function renderFinding(issue) {
2810
- const sev = SEVERITIES.includes(issue.severity) ? issue.severity : "info";
2811
- const dyn = issue.detection.value === "dynamic" ? ' <span class="dyn" title="set dynamically (verified at runtime)">\u21AF</span>' : "";
2812
- const line = issue.line !== void 0 ? `:${issue.line}` : "";
2813
- const fix = issue.fix?.snippet !== void 0 ? `<div class="fix"><div class="label">fix</div><pre><code>${escapeHtml(issue.fix.snippet)}</code></pre></div>` : "";
2814
- const href = issue.docsUrl ? safeHref(issue.docsUrl) : null;
2815
- const docs = href ? `<a class="f-link" href="${escapeHtml(href)}">Learn more</a>` : "";
2816
- 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>`;
2817
- }
2818
- function renderTopbar(report, meta) {
2819
- const findings = report.routes.reduce((n, r) => n + r.issues.length, 0) + report.siteIssues.length;
2820
- const core = meta.coreVersion ? `<span title="@svelte-vitals/core version">core v${escapeHtml(meta.coreVersion)}</span>` : "";
2821
- 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>`;
2822
- }
2823
- function renderHero(report) {
2824
- const C = 2 * Math.PI * 58;
2825
- const offset = (C * (1 - report.score / 100)).toFixed(1);
2826
- const hb = scoreBand(report.score);
2827
- const s = report.summary;
2828
- const dynNote = s.dynamic > 0 ? `<span class="tally"><span class="dot dyn-dot">\u21AF</span>Dynamic <span class="n">${s.dynamic}</span></span>` : "";
2829
- const cats = Object.entries(report.categories).map(([cat, { score }]) => {
2830
- const b = scoreBand(score);
2831
- const weight = report.weights[cat];
2832
- const w = weight !== void 0 ? `<span class="w">weight ${weight}</span>` : "";
2833
- const name = categoryLabel(cat);
2834
- 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>`;
2835
- }).join("");
2836
- 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>`;
2837
- }
2838
- var ROUTE_BADGES = ["measured", "static"];
2839
- function renderRoutes(report, routeBadges) {
2840
- if (report.routes.length === 0) return "";
2841
- const rows = report.routes.map((r) => {
2842
- const b = scoreBand(r.score);
2843
- const crit = r.issues.filter((i) => i.severity === "critical").length;
2844
- const warn = r.issues.filter((i) => i.severity === "warning").length;
2845
- const info = r.issues.filter((i) => i.severity === "info").length;
2846
- const parts = [];
2847
- if (crit) parts.push(`${crit} critical`);
2848
- if (warn) parts.push(`${warn} warning${warn > 1 ? "s" : ""}`);
2849
- if (info) parts.push(`${info} info`);
2850
- const sum = parts.length ? parts.join(" \xB7 ") : '<span class="none">no issues</span>';
2851
- const body = r.issues.length ? r.issues.map(renderFinding).join("") : '<p class="empty">No issues found on this route.</p>';
2852
- const rawBadge = routeBadges?.[r.route];
2853
- const badge = rawBadge && ROUTE_BADGES.includes(rawBadge) ? rawBadge : void 0;
2854
- const badgeHtml = badge ? ` <span class="badge badge-${badge}">${escapeHtml(badge)}</span>` : "";
2855
- 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>`;
2856
- }).join("");
2857
- return `<section class="section"><h2>Routes</h2><div class="routes">${rows}</div></section>`;
2858
- }
2859
- function renderSiteChecks(report) {
2860
- if (report.siteIssues.length === 0) return "";
2861
- const cards = report.siteIssues.map(renderFinding).join("");
2862
- return `<section class="section"><h2>Site checks</h2>${cards}</section>`;
2863
- }
2864
- function renderFilters(report) {
2865
- const chip = (filter, label, pressed = false) => `<button class="chip" type="button" aria-pressed="${pressed}" data-filter="${escapeHtml(filter)}">${escapeHtml(label)}</button>`;
2866
- const catChips = Object.keys(report.categories).map((cat) => chip(cat, categoryLabel(cat))).join("");
2867
- 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>`;
2868
- }
2869
- var STYLE = `
2870
- :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}}
2871
2816
  *{box-sizing:border-box}
2872
- body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);line-height:1.5;-webkit-font-smoothing:antialiased}
2873
- .wrap{max-width:960px;margin:0 auto;padding:0 20px 96px}
2874
- .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)}
2875
- .brand{display:flex;align-items:baseline;gap:8px;font-weight:700;font-size:18px;letter-spacing:-.02em}
2876
- .brand .bolt{color:var(--accent);font-size:20px}.brand .v{color:var(--accent)}
2877
- .meta{font-family:var(--mono);font-size:12.5px;color:var(--muted);display:flex;gap:14px;flex-wrap:wrap}
2878
- .hero{display:grid;grid-template-columns:auto 1fr;gap:28px;align-items:center;padding:30px 0 24px}
2879
- .gauge{position:relative;width:132px;height:132px}.gauge svg{transform:rotate(-90deg);display:block}
2880
- .gauge .num{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
2881
- .gauge .num strong{font-family:var(--mono);font-size:40px;font-weight:600;letter-spacing:-.03em;line-height:1;font-variant-numeric:tabular-nums}
2882
- .gauge .num span{font-size:11px;text-transform:uppercase;letter-spacing:.14em;color:var(--muted);margin-top:6px}
2883
- .readout{min-width:0}
2884
- .eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--accent);font-weight:700}
2885
- .eyebrow::before{content:"\u21AF "}
2886
- .tallies{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0 18px}
2887
- .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}
2888
- .tally .dot{width:9px;height:9px;border-radius:50%}.tally .n{font-family:var(--mono);font-variant-numeric:tabular-nums}
2889
- .dot.crit{background:var(--poor)}.dot.warn{background:var(--warn)}.dot.info{background:var(--faint)}.dot.pass{background:var(--good)}
2890
- .dot.dyn-dot{background:transparent;color:var(--accent);font-weight:700;width:auto;height:auto}
2891
- .cats{display:flex;gap:22px;flex-wrap:wrap}
2892
- .cat{min-width:190px;flex:1}.cat .top{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px}
2893
- .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}
2894
- .cat .sc{font-family:var(--mono);font-weight:600;font-variant-numeric:tabular-nums}
2895
- .bar{height:7px;border-radius:999px;background:var(--line);overflow:hidden}.bar>i{display:block;height:100%;border-radius:999px}
2896
- .section{margin-top:40px}
2897
- .section>h2{font-size:13px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:700;margin:0 0 14px}
2898
- .filters{display:flex;gap:8px;flex-wrap:wrap;margin-top:28px}
2899
- .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}
2900
- .chip:hover{border-color:var(--faint);color:var(--ink)}
2901
- .chip[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
2902
- .chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
2903
- .routes{border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;background:var(--panel)}
2904
- .route{border-top:1px solid var(--line)}.route:first-child{border-top:0}
2905
- .route>summary{display:grid;grid-template-columns:1fr auto auto auto;gap:16px;align-items:center;padding:13px 16px;cursor:pointer;list-style:none}
2906
- .route>summary::-webkit-details-marker{display:none}
2907
- .route>summary:hover{background:#fbfcfd}
2908
- .route-name{font-family:var(--mono);font-size:13.5px;min-width:0;display:flex;align-items:center;gap:8px}
2909
- .route-name .path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2910
- .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}
2911
- .badge-measured{background:rgba(47,169,104,.14);color:var(--good)}
2912
- .badge-static{background:rgba(140,149,163,.18);color:var(--muted)}
2913
- .issue-sum{font-size:12.5px;color:var(--muted);font-family:var(--mono);white-space:nowrap}.issue-sum .none{color:var(--good)}
2914
- .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}
2915
- .score-chip .ring{width:10px;height:10px;border-radius:50%}
2916
- .chev{color:var(--faint);font-family:var(--mono);transition:transform .15s ease}
2917
- .route[open]>summary .chev{transform:rotate(90deg)}
2918
- .route-body{padding:4px 16px 16px}.empty{color:var(--muted);font-size:13px;margin:6px 0}
2919
- .finding{background:var(--panel);border:1px solid var(--line);border-left-width:3px;border-radius:10px;padding:16px 18px;margin:0 0 12px}
2920
- .finding.sev-critical{border-left-color:var(--poor)}.finding.sev-warning{border-left-color:var(--warn)}.finding.sev-info{border-left-color:var(--faint)}
2921
- .f-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
2922
- .ruleid{font-family:var(--mono);font-size:12px;font-weight:600;background:#eef1f5;padding:2px 8px;border-radius:6px}
2923
- .f-title{font-weight:650;font-size:15px}
2924
- .sev-tag{margin-left:auto;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
2925
- .sev-tag.critical{color:var(--poor)}.sev-tag.warning{color:var(--warn)}.sev-tag.info{color:var(--faint)}
2926
- .f-loc{font-family:var(--mono);font-size:12.5px;color:var(--muted);margin:8px 0 0}.dyn{color:var(--accent);font-weight:700}
2927
- .f-rec{font-size:14px;color:#2b3340;margin:10px 0 0}
2928
- .fix{margin:12px 0 0;background:var(--code-bg);border-radius:8px;overflow:hidden}
2929
- .fix .label{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8da0bd;padding:8px 14px 0}
2930
- .fix pre{margin:0;padding:8px 14px 14px;overflow-x:auto}
2931
- .fix code{font-family:var(--mono);font-size:12.5px;color:var(--code-ink);line-height:1.65;white-space:pre}
2932
- .f-link{display:inline-block;margin-top:12px;font-size:13px;font-weight:600;color:var(--accent);text-decoration:none}
2933
- .f-link:hover{text-decoration:underline}.f-link::after{content:" \u2192"}
2934
- .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}
2935
- .foot a{color:var(--accent);text-decoration:none}
2936
- @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)}}
2937
2910
  @media (prefers-reduced-motion:reduce){*{transition:none!important}}
2938
2911
  `;
2939
- var SCRIPT = `
2912
+ var APP_SCRIPT = `
2940
2913
  (function(){
2941
- var arc=document.getElementById('arc'),num=document.getElementById('hnum');
2942
- if(arc&&num){
2943
- var C=2*Math.PI*58,score=parseInt(num.textContent,10)||0;
2944
- var reduce=window.matchMedia('(prefers-reduced-motion: reduce)').matches;
2945
- if(!reduce){
2946
- arc.style.transition='stroke-dashoffset 1.1s cubic-bezier(.22,.61,.36,1)';
2947
- arc.style.strokeDashoffset=C.toFixed(1);
2948
- var start=null;
2949
- requestAnimationFrame(function step(t){
2950
- if(start===null)start=t;
2951
- var p=Math.min((t-start)/1100,1);
2952
- num.textContent=Math.round(score*(p<1?1-Math.pow(1-p,3):1));
2953
- 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 }, []));
2954
3010
  });
2955
- 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
+ }
2956
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();
2957
3147
  }
2958
- var chips=document.querySelectorAll('.chip'),findings=document.querySelectorAll('.finding');
2959
- function apply(f){
2960
- findings.forEach(function(el){
2961
- var ok=f==='all'||el.getAttribute('data-severity')===f||el.getAttribute('data-category')===f;
2962
- el.style.display=ok?'':'none';
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;
2963
3155
  });
2964
3156
  }
2965
- chips.forEach(function(c){
2966
- c.addEventListener('click',function(){
2967
- chips.forEach(function(o){o.setAttribute('aria-pressed','false');});
2968
- c.setAttribute('aria-pressed','true');
2969
- apply(c.getAttribute('data-filter')||'all');
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));
2970
3239
  });
2971
- });
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
+ }
3249
+ }
3250
+ }
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);
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;
3325
+ }
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
+ ]);
3341
+ });
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();
2972
3439
  })();
2973
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
+ }
2974
3446
  function buildHtmlDocument(report, meta, opts) {
2975
- 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
+ });
2976
3458
  }
2977
3459
  function formatHtmlReport(results, config, meta) {
2978
3460
  return buildHtmlDocument(buildJsonReport(results, config, meta), meta);
2979
3461
  }
2980
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
+
2981
3483
  // src/config-apply.ts
2982
3484
  function selectRules(rules, config) {
2983
3485
  return rules.filter((rule) => config.rules[rule.id] !== "off");
@@ -2989,6 +3491,8 @@ function applyRuleSeverities(results, config) {
2989
3491
  });
2990
3492
  }
2991
3493
  export {
3494
+ APP_SCRIPT,
3495
+ APP_STYLE,
2992
3496
  BAND_COLOR,
2993
3497
  CHILD_NODE_KEYS,
2994
3498
  ROBOTS_SOURCE_PATHS,
@@ -3046,6 +3550,7 @@ export {
3046
3550
  perf008Preconnect,
3047
3551
  perf009HeavyImport,
3048
3552
  perf010NamespaceImport,
3553
+ renderAppShell,
3049
3554
  runRules,
3050
3555
  safeHref,
3051
3556
  scoreBand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.23.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",