svelte-vitals 0.22.1 → 0.24.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
@@ -9,7 +9,7 @@
9
9
  > **ESM-only** (Node 18+). Ships ES modules only; `require()` is unsupported by design.
10
10
 
11
11
  ```bash
12
- npx svelte-vitals
12
+ npx svelte-vitals@latest
13
13
  ```
14
14
 
15
15
  > [!NOTE]
@@ -20,8 +20,8 @@ npx svelte-vitals
20
20
  Run inside any SvelteKit project:
21
21
 
22
22
  ```bash
23
- npx svelte-vitals # analyze the current directory
24
- npx svelte-vitals ./apps/web # or a specific path
23
+ npx svelte-vitals@latest # analyze the current directory
24
+ npx svelte-vitals@latest ./apps/web # or a specific path
25
25
  ```
26
26
 
27
27
  ```
@@ -34,14 +34,14 @@ Critical (1)
34
34
  src/routes/none/+page.svelte
35
35
 
36
36
  Passed (3)
37
- ────────────────────────
38
- ✓ SEO001 <title> /blog
39
- ✓ SEO001 <title> ↯ dynamic /dynamic
40
- ✓ SEO001 <title> /static
41
37
 
42
38
  ↯ = set dynamically (verified at runtime).
43
39
  ```
44
40
 
41
+ By default, console output groups failures by rule (top 5 per severity, each with one example location and an "…and N more" count) and collapses the Passed section to a bare count, so large projects don't flood the terminal. Pass `--verbose` to see every finding uncapped and ungrouped, with each passed item listed individually. On an interactive, color-capable terminal the Health score plays a short reveal animation; pass `--no-animation` to disable it.
42
+
43
+ On an interactive terminal wide enough for the mascot (20+ columns), a small line-art face appears alongside both the analysis spinner and the Health-score reveal, reacting to the score (a perfect 100 gets a confetti flourish). On a wider terminal still (55+ columns) it also greets you with a short line in a speech bubble at startup, and again with a matching reaction line at the score reveal. `--no-animation` disables all of it, falling back to the plain spinner and plain score animation.
44
+
45
45
  ### Exit codes
46
46
 
47
47
  | Code | Meaning |
package/dist/bin.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  readCoreVersion,
8
8
  readPackageVersion,
9
9
  run
10
- } from "./chunk-KSJZLF5U.js";
10
+ } from "./chunk-TCZ6OF6J.js";
11
11
 
12
12
  // src/bin.ts
13
13
  import mri3 from "mri";
@@ -109,9 +109,7 @@ function resolveArgs(argv) {
109
109
  errors.push(`Known rule ids: ${knownRuleIds().join(", ")}`);
110
110
  }
111
111
  let reporter;
112
- if (argv.json) {
113
- reporter = "json";
114
- } else if (typeof argv.reporter === "string") {
112
+ if (typeof argv.reporter === "string") {
115
113
  if (!isReporterName(argv.reporter)) {
116
114
  errors.push(
117
115
  `svelte-vitals: unknown reporter '${argv.reporter}'. Valid values: console, json, agent, sarif, github, html, md.`
@@ -127,13 +125,16 @@ function resolveArgs(argv) {
127
125
  `svelte-vitals: unknown --fail-on '${failOnRaw}'; expected critical|warning|info. No threshold applied.`
128
126
  );
129
127
  }
130
- const failOn = argv["fail-on-warning"] ? "warning" : failOnValid ? failOnRaw : void 0;
128
+ const failOn = failOnValid ? failOnRaw : void 0;
131
129
  const weights = parseWeights(argv.weights, errors);
132
130
  const categories = parseCategories(argv.category, errors);
133
131
  const score = Boolean(argv.score);
134
- if (score && (argv.json || typeof argv.reporter === "string")) {
132
+ if (score && typeof argv.reporter === "string") {
135
133
  warnings.push("svelte-vitals: --score overrides --reporter; reporter output suppressed.");
136
134
  }
135
+ const verbose = Boolean(argv["verbose"]);
136
+ const noColor = argv.color === false;
137
+ const noAnimation = argv.animation === false;
137
138
  const rulesConfig = buildRulesConfig(allow, ignore);
138
139
  const rules = Object.keys(rulesConfig).length > 0 ? rulesConfig : void 0;
139
140
  if (errors.length > 0) return { options: null, warnings, errors };
@@ -154,6 +155,9 @@ function resolveArgs(argv) {
154
155
  ...weights !== void 0 ? { weights } : {},
155
156
  ...categories !== void 0 ? { categories } : {},
156
157
  ...score ? { score } : {},
158
+ ...verbose ? { verbose } : {},
159
+ ...noColor ? { noColor } : {},
160
+ ...noAnimation ? { noAnimation } : {},
157
161
  ...diffBase !== void 0 ? { diffBase } : {},
158
162
  ...staged ? { staged } : {},
159
163
  ...baselineRef !== void 0 ? { baseline: baselineRef } : {}
@@ -265,9 +269,9 @@ var VITE_TARGETS = [
265
269
  hint: "Fails `vite build` when prerendered pages cross the SEO/Performance threshold"
266
270
  },
267
271
  {
268
- id: "vite-dev-overlay",
269
- label: "Dev overlay",
270
- hint: "Live warnings in `vite dev` only \u2014 never fails a build or CI"
272
+ id: "vite-hooks",
273
+ label: "Live dashboard accuracy",
274
+ hint: "Feeds real rendered results into the live dashboard as you browse \u2014 improves per-route accuracy, never fails a build"
271
275
  }
272
276
  ];
273
277
  function viteTargetById(id) {
@@ -527,10 +531,10 @@ function planForVitePlugin(io) {
527
531
  const result = codemodViteConfig(content);
528
532
  return { id: "vite-plugin", label: viteTargetById("vite-plugin").label, path, ...result };
529
533
  }
530
- function planForDevOverlay(io) {
534
+ function planForViteHooks(io) {
531
535
  const { path, content } = resolveCandidate(io, ["src/hooks.server.ts", "src/hooks.server.js"]);
532
536
  const result = codemodHooksServer(content);
533
- return { id: "vite-dev-overlay", label: viteTargetById("vite-dev-overlay").label, path, ...result };
537
+ return { id: "vite-hooks", label: viteTargetById("vite-hooks").label, path, ...result };
534
538
  }
535
539
  function planForAgentTarget(target, io, force, version) {
536
540
  const path = join3(io.cwd, target.relPath);
@@ -589,7 +593,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
589
593
  ids = picked;
590
594
  } else {
591
595
  io.errorLog(
592
- "svelte-vitals: no TTY; pass --client <claude-code,cursor,codex,vite-plugin,vite-dev-overlay,claude-skill,cursor-rules> to install non-interactively."
596
+ "svelte-vitals: no TTY; pass --client <claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules> to install non-interactively."
593
597
  );
594
598
  return 2;
595
599
  }
@@ -628,7 +632,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
628
632
  }
629
633
  }
630
634
  for (const viteId of viteIds) {
631
- rows.push(viteId === "vite-plugin" ? planForVitePlugin(io) : planForDevOverlay(io));
635
+ rows.push(viteId === "vite-plugin" ? planForVitePlugin(io) : planForViteHooks(io));
632
636
  }
633
637
  for (const agentId of agentIds) {
634
638
  const target = agentTargetById(agentId);
@@ -743,10 +747,11 @@ Usage:
743
747
  svelte-vitals install [options]
744
748
 
745
749
  Options:
746
- --client <ids> Comma-separated: claude-code,cursor,codex,vite-plugin,vite-dev-overlay,claude-skill,cursor-rules
750
+ --client <ids> Comma-separated: claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules
747
751
  (skips the interactive picker)
748
- vite-plugin registers the build-mode plugin in vite.config.{ts,js,mjs}; vite-dev-overlay
749
- wires up the dev-overlay hook in src/hooks.server.{ts,js}. --force does not apply
752
+ vite-plugin registers the build-mode plugin in vite.config.{ts,js,mjs}; vite-hooks
753
+ wires up the svelteVitalsHandle hook in src/hooks.server.{ts,js}, which improves the
754
+ live dashboard's per-route accuracy as you browse. --force does not apply
750
755
  to either of these two \u2014 an existing registration is always left as-is.
751
756
  claude-skill writes a Claude Code skill (.claude/skills/svelte-vitals/SKILL.md); cursor-rules
752
757
  writes a Cursor rules file (.cursor/rules/svelte-vitals.mdc). Both are generated from the
@@ -880,8 +885,8 @@ function buildWorkflowYaml(opts) {
880
885
  }
881
886
 
882
887
  // src/ci/action-pin.generated.ts
883
- var ACTION_SHA = "f16e414797a2ee42c3b1fb6a14dc6fdb9dca4132";
884
- var ACTION_VERSION = "0.2.1";
888
+ var ACTION_SHA = "c9257b68ce2c4006f3631345f66c05342c715fdd";
889
+ var ACTION_VERSION = "0.2.3";
885
890
 
886
891
  // src/ci/cli.ts
887
892
  var CI_HELP = `svelte-vitals ci \u2014 scaffold CI integration
@@ -959,9 +964,7 @@ Options:
959
964
  --by-route Show per-route score breakdown in console output
960
965
  --reporter <fmt> console | json | agent | sarif | github | html | md (auto: agent under AI-agent envs, github under GitHub Actions)
961
966
  --out-file <path> Output path for --reporter html (default: svelte-vitals-report.html; '-' for stdout)
962
- --json Alias for --reporter=json
963
967
  --fail-on <severity> Fail (exit 1) when any finding reaches this severity: critical | warning | info
964
- --fail-on-warning Alias for --fail-on=warning
965
968
  --min-health <0-100> Fail (exit 1) when the combined Health score is below this value
966
969
  --rules <ids> Comma-separated rule ids to enable (all others disabled)
967
970
  --ignore <ids> Comma-separated rule ids to disable
@@ -969,6 +972,8 @@ Options:
969
972
  --weights <pairs> Per-category Health weight overrides, e.g. seo=2,performance=1 (unlisted categories default to 1)
970
973
  --score Print only the combined Health score (works with --min-health for gating)
971
974
  --no-color Disable ANSI color in console output
975
+ --no-animation Disable the Health-score reveal animation and mascot on an interactive terminal
976
+ --verbose Show every finding uncapped and ungrouped (default: capped, grouped by rule)
972
977
  -h, --help Show this help
973
978
  -v, --version Show version
974
979
 
@@ -999,7 +1004,7 @@ async function main() {
999
1004
  }
1000
1005
  const argv = mri3(process.argv.slice(2), {
1001
1006
  alias: { h: "help", v: "version" },
1002
- boolean: ["by-route", "json", "fail-on-warning", "staged", "no-color", "score"],
1007
+ boolean: ["by-route", "staged", "score", "verbose"],
1003
1008
  string: [
1004
1009
  "meta-components",
1005
1010
  "treat-dynamic-as",
@@ -1038,7 +1043,11 @@ async function main() {
1038
1043
  }
1039
1044
  minHealth = n;
1040
1045
  }
1041
- const code = await run({ ...options, minHealth, noColor: argv["no-color"], selectApp });
1046
+ const code = await run({
1047
+ ...options,
1048
+ minHealth,
1049
+ selectApp
1050
+ });
1042
1051
  process.exit(code);
1043
1052
  }
1044
1053
  void main();
@@ -702,6 +702,9 @@ function isAgentEnv(env = process.env) {
702
702
  function isGithubActionsEnv(env = process.env) {
703
703
  return env.GITHUB_ACTIONS === "true";
704
704
  }
705
+ function isCiEnv(env = process.env) {
706
+ return env.CI === "true" || env.CI === "1";
707
+ }
705
708
  function resolveReporter(explicit, env = process.env) {
706
709
  if (explicit) return explicit;
707
710
  const fromEnv = env.SVELTE_VITALS_REPORTER;
@@ -832,6 +835,157 @@ function startSpinner(text, opts) {
832
835
  };
833
836
  }
834
837
 
838
+ // src/mascot.ts
839
+ import { createLogUpdate } from "log-update";
840
+ function mascotStateFor(score) {
841
+ if (score === 100) return "ecstatic";
842
+ if (score >= 90) return "happy";
843
+ return "content";
844
+ }
845
+ var MIN_MASCOT_COLUMNS = 20;
846
+ function mascotFitsWidth(columns) {
847
+ return (columns ?? 80) >= MIN_MASCOT_COLUMNS;
848
+ }
849
+ var ORANGE_FG = "\x1B[38;2;255;62;0m";
850
+ var RESET = "\x1B[0m";
851
+ function renderFace(lines) {
852
+ return lines.map((line) => `${ORANGE_FG}${line}${RESET}`).join("\n");
853
+ }
854
+ var FACE_OPEN_EYES_NEUTRAL_MOUTH = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u25CF \u25CF \u2502", "\u2502 \u2500\u2500 \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
855
+ var FACE_CLOSED_EYES_NEUTRAL_MOUTH = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u2500 \u2500 \u2502", "\u2502 \u2500\u2500 \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
856
+ var FACE_WINK_BOTH = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 > < \u2502", "\u2502 \u2500\u2500 \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
857
+ var FACE_WINK_ONE = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u25CF < \u2502", "\u2502 \u2500\u2500 \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
858
+ var FACE_CONTENT = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u25CF \u25CF \u2502", "\u2502 \u25E1\u25E1 \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
859
+ var FACE_HAPPY = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u25CF \u25CF \u2502", "\u2502 \u2570\u2500\u2500\u256F \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
860
+ var FACE_ECSTATIC = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 ^ ^ \u2502", "\u2502 \u2570\u2500\u2500\u2500\u256F \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
861
+ var REACTION_FACES = {
862
+ content: FACE_CONTENT,
863
+ happy: FACE_HAPPY,
864
+ ecstatic: FACE_ECSTATIC
865
+ };
866
+ function renderMascotReaction(state) {
867
+ return renderFace(REACTION_FACES[state]);
868
+ }
869
+ function renderMascotAnticipating() {
870
+ return renderFace(FACE_OPEN_EYES_NEUTRAL_MOUTH);
871
+ }
872
+ var IDLE_FRAME_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 2, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 3];
873
+ var IDLE_FACES = {
874
+ 0: FACE_OPEN_EYES_NEUTRAL_MOUTH,
875
+ 1: FACE_CLOSED_EYES_NEUTRAL_MOUTH,
876
+ 2: FACE_WINK_BOTH,
877
+ 3: FACE_WINK_ONE
878
+ };
879
+ function renderMascotIdleFrame(frameIndex) {
880
+ const code = IDLE_FRAME_SEQUENCE[frameIndex % IDLE_FRAME_SEQUENCE.length];
881
+ return renderFace(IDLE_FACES[code]);
882
+ }
883
+ var CONFETTI_COLORS = [
884
+ [255, 62, 0],
885
+ // orange (identity accent)
886
+ [255, 145, 175],
887
+ // blush pink
888
+ [255, 214, 0],
889
+ // gold
890
+ [255, 255, 255]
891
+ // white
892
+ ];
893
+ var CONFETTI_CHARS = ["*", ".", "\xB7", "+"];
894
+ var CONFETTI_WIDTH = 24;
895
+ function confettiFg(rgb) {
896
+ return `\x1B[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
897
+ }
898
+ function confettiRow(offset) {
899
+ let out = "";
900
+ for (let col = 0; col < CONFETTI_WIDTH; col++) {
901
+ if ((col + offset) % 5 === 0) {
902
+ const glyph = CONFETTI_CHARS[(col + offset) % CONFETTI_CHARS.length];
903
+ out += confettiFg(CONFETTI_COLORS[(col + offset) % CONFETTI_COLORS.length]) + glyph + RESET;
904
+ } else {
905
+ out += " ";
906
+ }
907
+ }
908
+ return out;
909
+ }
910
+ function renderConfettiFrame(offset, mascotBlock) {
911
+ return [confettiRow(offset), mascotBlock, confettiRow(offset + 2)].join("\n");
912
+ }
913
+ var IDLE_TICK_MS = 160;
914
+ function startMascotSpinner(text, opts) {
915
+ if (!opts.enabled) return { stop() {
916
+ } };
917
+ const stream = opts.stream ?? process.stderr;
918
+ const render = createLogUpdate(stream);
919
+ let i = 0;
920
+ const tick = () => {
921
+ render(`${renderMascotIdleFrame(i)}
922
+ ${text}`);
923
+ i++;
924
+ };
925
+ tick();
926
+ const timer = setInterval(tick, IDLE_TICK_MS);
927
+ if (typeof timer.unref === "function") timer.unref();
928
+ return {
929
+ stop() {
930
+ clearInterval(timer);
931
+ render.clear();
932
+ }
933
+ };
934
+ }
935
+
936
+ // src/speech-bubble.ts
937
+ import { createLogUpdate as createLogUpdate2 } from "log-update";
938
+ var MIN_BUBBLE_COLUMNS = 55;
939
+ function bubbleFitsWidth(columns) {
940
+ return (columns ?? 80) >= MIN_BUBBLE_COLUMNS;
941
+ }
942
+ function renderSpeechBubble(text) {
943
+ const border = "\u2500".repeat(text.length + 2);
944
+ return [`\u256D${border}\u256E`, `\u2502 ${text} \u2502`, `\u2570${border}\u256F`];
945
+ }
946
+ function withSpeechBubble(mascotBlock, bubbleLines) {
947
+ const mascotLines = mascotBlock.split("\n");
948
+ const bubbleWidth = bubbleLines[0]?.length ?? 0;
949
+ const blankBubbleLine = " ".repeat(bubbleWidth);
950
+ const padTop = Math.floor((mascotLines.length - bubbleLines.length) / 2);
951
+ const padBottom = mascotLines.length - bubbleLines.length - padTop;
952
+ const paddedBubble = [
953
+ ...Array(Math.max(padTop, 0)).fill(blankBubbleLine),
954
+ ...bubbleLines,
955
+ ...Array(Math.max(padBottom, 0)).fill(blankBubbleLine)
956
+ ];
957
+ return mascotLines.map((line, i) => `${line} ${paddedBubble[i] ?? blankBubbleLine}`).join("\n");
958
+ }
959
+ var GREETING_MESSAGES = [
960
+ "Welcome to Svelte Vitals!",
961
+ "Let's check your project!",
962
+ "Ready when you are!",
963
+ "Hi there! Let's dig in."
964
+ ];
965
+ var REACTION_MESSAGES = {
966
+ ecstatic: ["Perfect score!", "Flawless!", "You nailed it!"],
967
+ happy: ["Nice work!", "Looking great!", "Almost perfect!"],
968
+ content: ["Keep going!", "Room to grow!", "Let's improve this!"]
969
+ };
970
+ function pickMessage(pool, random = Math.random) {
971
+ return pool[Math.floor(random() * pool.length)];
972
+ }
973
+ function renderMascotWithSpeech(mascotBlock, message) {
974
+ return withSpeechBubble(mascotBlock, renderSpeechBubble(message));
975
+ }
976
+ function sleep(ms) {
977
+ return new Promise((resolve) => setTimeout(resolve, ms));
978
+ }
979
+ var GREETING_HOLD_MS = 800;
980
+ async function playMascotGreeting(opts) {
981
+ if (!opts.enabled) return;
982
+ const holdMs = opts.holdMs ?? GREETING_HOLD_MS;
983
+ const render = createLogUpdate2(opts.stream);
984
+ render(renderMascotWithSpeech(renderMascotAnticipating(), pickMessage(GREETING_MESSAGES)));
985
+ if (holdMs > 0) await sleep(holdMs);
986
+ render.clear();
987
+ }
988
+
835
989
  // src/config-file.ts
836
990
  import { existsSync as existsSync2 } from "fs";
837
991
  import { join as join4 } from "path";
@@ -953,6 +1107,61 @@ async function loadConfigFile(cwd) {
953
1107
  return validateConfigFile(mod.default, found);
954
1108
  }
955
1109
 
1110
+ // src/pulse-animation.ts
1111
+ import { scoreColor } from "@svelte-vitals/core";
1112
+ import { createLogUpdate as createLogUpdate3 } from "log-update";
1113
+ var FRAME_COUNT = 6;
1114
+ var FRAME_DELAY_MS = 200;
1115
+ var WAVE_FRAMES = [
1116
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2571\u2572\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
1117
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2571\u2572\u2500\u2500\u2571\u2572\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
1118
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2571\u2572\u2500\u2500\u2500\u2500\u2500\u2500\u2571\u2572\u2500\u2500\u2500\u2500\u2500\u2500",
1119
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2571\u2572\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2572\u2500\u2500\u2500\u2500\u2500",
1120
+ "\u2500\u2500\u2500\u2500\u2571\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2572\u2500\u2500\u2500\u2500"
1121
+ ];
1122
+ var WAVE_ORANGE_DIM = "\x1B[38;2;153;37;0m";
1123
+ var WAVE_RESET = "\x1B[0m";
1124
+ function sleep2(ms) {
1125
+ return new Promise((resolve) => setTimeout(resolve, ms));
1126
+ }
1127
+ var REACTION_HOLD_MS = 500;
1128
+ var CONFETTI_FRAME_COUNT = 4;
1129
+ var CONFETTI_FRAME_DELAY_MS = 220;
1130
+ async function playScoreAnimation(opts) {
1131
+ const frameDelayMs = opts.frameDelayMs ?? FRAME_DELAY_MS;
1132
+ const holdMs = opts.frameDelayMs ?? REACTION_HOLD_MS;
1133
+ const confettiDelayMs = opts.frameDelayMs ?? CONFETTI_FRAME_DELAY_MS;
1134
+ const render = createLogUpdate3(opts.stream);
1135
+ const showMascot = mascotFitsWidth(opts.stream.columns);
1136
+ const state = mascotStateFor(opts.score);
1137
+ const reactionMessage = showMascot && bubbleFitsWidth(opts.stream.columns) ? pickMessage(REACTION_MESSAGES[state]) : void 0;
1138
+ const finalMascotBlock = reactionMessage ? renderMascotWithSpeech(renderMascotReaction(state), reactionMessage) : renderMascotReaction(state);
1139
+ for (let frame = 0; frame < FRAME_COUNT; frame++) {
1140
+ const progress = frame / (FRAME_COUNT - 1);
1141
+ const displayScore = Math.round(opts.score * progress);
1142
+ const isFinalFrame = frame === FRAME_COUNT - 1;
1143
+ const scoreText = isFinalFrame ? scoreColor(opts.palette, opts.score)(`${displayScore}/100`) : opts.palette.dim(`${displayScore}/100`);
1144
+ const waveBlock = isFinalFrame ? `Health: ${scoreText}` : `${WAVE_ORANGE_DIM}${WAVE_FRAMES[frame]}${WAVE_RESET}
1145
+ Health: ${scoreText}`;
1146
+ const mascotBlock = showMascot ? (isFinalFrame ? finalMascotBlock : renderMascotAnticipating()) + "\n" : "";
1147
+ render(`${mascotBlock}${waveBlock}`);
1148
+ if (!isFinalFrame) await sleep2(frameDelayMs);
1149
+ }
1150
+ if (holdMs > 0) await sleep2(holdMs);
1151
+ if (showMascot && state === "ecstatic") {
1152
+ for (let i = 0; i < CONFETTI_FRAME_COUNT; i++) {
1153
+ const waveBlock = `Health: ${scoreColor(opts.palette, opts.score)("100/100")}`;
1154
+ render(`${renderConfettiFrame(i, finalMascotBlock)}
1155
+ ${waveBlock}`);
1156
+ if (i < CONFETTI_FRAME_COUNT - 1 && confettiDelayMs > 0) await sleep2(confettiDelayMs);
1157
+ }
1158
+ }
1159
+ render.done();
1160
+ }
1161
+ function scoreAnimationEnabled(opts) {
1162
+ return opts.reporter === "console" && opts.stdoutIsTTY && !opts.noAnimationFlag && !isAgentEnv(opts.env) && !isCiEnv(opts.env) && colorEnabled({ reporter: opts.reporter, isTTY: opts.stdoutIsTTY, env: opts.env, noColorFlag: opts.noColorFlag });
1163
+ }
1164
+
956
1165
  // src/index.ts
957
1166
  import { defineConfig as defineConfig2 } from "@svelte-vitals/core";
958
1167
  function spinnerEnabled(opts) {
@@ -1035,15 +1244,19 @@ async function run(opts = {}) {
1035
1244
  }
1036
1245
  const env = opts.env ?? process.env;
1037
1246
  const reporter = resolveReporter(opts.reporter, env);
1038
- const spinner = startSpinner("Analyzing\u2026", {
1039
- enabled: !opts.score && spinnerEnabled({
1040
- reporter,
1041
- rawReporter: opts.reporter,
1042
- stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY,
1043
- env,
1044
- noColorFlag: opts.noColor
1045
- })
1247
+ const stderrStream = opts.stderrStream ?? process.stderr;
1248
+ const spinnerBaseEnabled = !opts.score && spinnerEnabled({
1249
+ reporter,
1250
+ rawReporter: opts.reporter,
1251
+ stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY,
1252
+ env,
1253
+ noColorFlag: opts.noColor
1046
1254
  });
1255
+ const useMascotSpinner = spinnerBaseEnabled && !opts.noAnimation && mascotFitsWidth(stderrStream.columns);
1256
+ if (useMascotSpinner && bubbleFitsWidth(stderrStream.columns)) {
1257
+ await playMascotGreeting({ enabled: true, stream: stderrStream, holdMs: opts.animationFrameDelayMs });
1258
+ }
1259
+ const spinner = useMascotSpinner ? startMascotSpinner("Analyzing\u2026", { enabled: true, stream: stderrStream }) : startSpinner("Analyzing\u2026", { enabled: spinnerBaseEnabled, stream: stderrStream });
1047
1260
  let cwd = opts.cwd ?? process.cwd();
1048
1261
  let analysis;
1049
1262
  try {
@@ -1134,6 +1347,7 @@ async function run(opts = {}) {
1134
1347
  categories: opts.categories
1135
1348
  }
1136
1349
  });
1350
+ const summary = summarize(results, config);
1137
1351
  if (opts.score) {
1138
1352
  log(String(computeHealth(results, config).health));
1139
1353
  } else {
@@ -1172,16 +1386,39 @@ async function run(opts = {}) {
1172
1386
  } else if (reporter === "md") {
1173
1387
  log(formatMarkdownReport(results, config, { version }));
1174
1388
  } else {
1389
+ const stdoutIsTTY = opts.stdoutIsTTY ?? !!process.stdout.isTTY;
1175
1390
  const colorOn = colorEnabled({
1176
1391
  reporter,
1177
- isTTY: opts.stdoutIsTTY ?? !!process.stdout.isTTY,
1392
+ isTTY: stdoutIsTTY,
1178
1393
  env,
1179
1394
  noColorFlag: opts.noColor
1180
1395
  });
1181
- log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false, palette: paletteFor(colorOn) }));
1396
+ const palette = paletteFor(colorOn);
1397
+ const animate = scoreAnimationEnabled({
1398
+ reporter,
1399
+ stdoutIsTTY,
1400
+ env,
1401
+ noColorFlag: opts.noColor,
1402
+ noAnimationFlag: opts.noAnimation
1403
+ });
1404
+ if (animate) {
1405
+ await playScoreAnimation({
1406
+ score: computeHealth(results, config).health,
1407
+ palette,
1408
+ stream: opts.stdoutStream ?? process.stdout,
1409
+ frameDelayMs: opts.animationFrameDelayMs
1410
+ });
1411
+ }
1412
+ log(
1413
+ formatConsoleReport(results, config, {
1414
+ byRoute: opts.byRoute ?? false,
1415
+ verbose: opts.verbose ?? false,
1416
+ palette,
1417
+ omitHeader: animate
1418
+ })
1419
+ );
1182
1420
  }
1183
1421
  }
1184
- const summary = summarize(results, config);
1185
1422
  const failBySeverity = hasFailureAtOrAbove(summary, config.failOn);
1186
1423
  const failByHealth = opts.minHealth != null && computeHealth(results, config).health < opts.minHealth;
1187
1424
  return failBySeverity || failByHealth ? 1 : 0;
package/dist/index.d.ts CHANGED
@@ -89,6 +89,16 @@ interface RunOptions {
89
89
  explicitPath?: boolean;
90
90
  /** Injected picker for the monorepo app selector (bin.ts wires a clack implementation; null = cancelled). */
91
91
  selectApp?: (apps: string[]) => Promise<string | null>;
92
+ /** Show every finding uncapped and ungrouped in console output (default false — capped, grouped by rule). */
93
+ verbose?: boolean;
94
+ /** Disable the Health-score reveal animation even on an interactive stdout. */
95
+ noAnimation?: boolean;
96
+ /** Override the stream the score animation writes to (tests). Defaults to process.stdout. */
97
+ stdoutStream?: NodeJS.WriteStream;
98
+ /** Override the stream the analysis-phase progress indicator writes to (tests). Defaults to process.stderr. */
99
+ stderrStream?: NodeJS.WriteStream;
100
+ /** Override the animation's per-frame delay in ms (tests — 0 runs the real frame loop near-instantly). Defaults to the animation module's own constant. */
101
+ animationFrameDelayMs?: number;
92
102
  }
93
103
  /**
94
104
  * Whether the "Analyzing…" spinner should run. Unlike color, the spinner animates
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  routeMatcher,
11
11
  run,
12
12
  spinnerEnabled
13
- } from "./chunk-KSJZLF5U.js";
13
+ } from "./chunk-TCZ6OF6J.js";
14
14
  export {
15
15
  ProjectError,
16
16
  analyzeProject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "A SvelteKit SEO checker — not a runtime Web Vitals reporter. Static analysis of your routes' head metadata.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,15 +40,16 @@
40
40
  ],
41
41
  "dependencies": {
42
42
  "@clack/prompts": "^1.7.0",
43
+ "log-update": "^8.0.0",
43
44
  "magicast": "^0.5.3",
44
45
  "mri": "^1.2.0",
45
46
  "smol-toml": "^1.7.0",
46
47
  "svelte": "^5.56.4",
47
48
  "tinyglobby": "^0.2.17",
48
- "@svelte-vitals/core": "0.22.1"
49
+ "@svelte-vitals/core": "0.23.0"
49
50
  },
50
51
  "devDependencies": {
51
- "@types/node": "^24.13.2"
52
+ "@types/node": "^24.13.3"
52
53
  },
53
54
  "scripts": {
54
55
  "build": "node scripts/gen-action-pin.mjs && tsup",