svelte-vitals 0.23.0 → 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
  ```
@@ -40,6 +40,8 @@ Passed (3)
40
40
 
41
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
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
+
43
45
  ### Exit codes
44
46
 
45
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-HYTPZVSU.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,14 +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
  }
137
135
  const verbose = Boolean(argv["verbose"]);
136
+ const noColor = argv.color === false;
137
+ const noAnimation = argv.animation === false;
138
138
  const rulesConfig = buildRulesConfig(allow, ignore);
139
139
  const rules = Object.keys(rulesConfig).length > 0 ? rulesConfig : void 0;
140
140
  if (errors.length > 0) return { options: null, warnings, errors };
@@ -156,6 +156,8 @@ function resolveArgs(argv) {
156
156
  ...categories !== void 0 ? { categories } : {},
157
157
  ...score ? { score } : {},
158
158
  ...verbose ? { verbose } : {},
159
+ ...noColor ? { noColor } : {},
160
+ ...noAnimation ? { noAnimation } : {},
159
161
  ...diffBase !== void 0 ? { diffBase } : {},
160
162
  ...staged ? { staged } : {},
161
163
  ...baselineRef !== void 0 ? { baseline: baselineRef } : {}
@@ -267,9 +269,9 @@ var VITE_TARGETS = [
267
269
  hint: "Fails `vite build` when prerendered pages cross the SEO/Performance threshold"
268
270
  },
269
271
  {
270
- id: "vite-dev-overlay",
271
- label: "Dev overlay",
272
- 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"
273
275
  }
274
276
  ];
275
277
  function viteTargetById(id) {
@@ -529,10 +531,10 @@ function planForVitePlugin(io) {
529
531
  const result = codemodViteConfig(content);
530
532
  return { id: "vite-plugin", label: viteTargetById("vite-plugin").label, path, ...result };
531
533
  }
532
- function planForDevOverlay(io) {
534
+ function planForViteHooks(io) {
533
535
  const { path, content } = resolveCandidate(io, ["src/hooks.server.ts", "src/hooks.server.js"]);
534
536
  const result = codemodHooksServer(content);
535
- 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 };
536
538
  }
537
539
  function planForAgentTarget(target, io, force, version) {
538
540
  const path = join3(io.cwd, target.relPath);
@@ -591,7 +593,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
591
593
  ids = picked;
592
594
  } else {
593
595
  io.errorLog(
594
- "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."
595
597
  );
596
598
  return 2;
597
599
  }
@@ -630,7 +632,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
630
632
  }
631
633
  }
632
634
  for (const viteId of viteIds) {
633
- rows.push(viteId === "vite-plugin" ? planForVitePlugin(io) : planForDevOverlay(io));
635
+ rows.push(viteId === "vite-plugin" ? planForVitePlugin(io) : planForViteHooks(io));
634
636
  }
635
637
  for (const agentId of agentIds) {
636
638
  const target = agentTargetById(agentId);
@@ -745,10 +747,11 @@ Usage:
745
747
  svelte-vitals install [options]
746
748
 
747
749
  Options:
748
- --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
749
751
  (skips the interactive picker)
750
- vite-plugin registers the build-mode plugin in vite.config.{ts,js,mjs}; vite-dev-overlay
751
- 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
752
755
  to either of these two \u2014 an existing registration is always left as-is.
753
756
  claude-skill writes a Claude Code skill (.claude/skills/svelte-vitals/SKILL.md); cursor-rules
754
757
  writes a Cursor rules file (.cursor/rules/svelte-vitals.mdc). Both are generated from the
@@ -882,8 +885,8 @@ function buildWorkflowYaml(opts) {
882
885
  }
883
886
 
884
887
  // src/ci/action-pin.generated.ts
885
- var ACTION_SHA = "caf278cf4030801b017d31b108fa46abc15e8105";
886
- var ACTION_VERSION = "0.2.2";
888
+ var ACTION_SHA = "c9257b68ce2c4006f3631345f66c05342c715fdd";
889
+ var ACTION_VERSION = "0.2.3";
887
890
 
888
891
  // src/ci/cli.ts
889
892
  var CI_HELP = `svelte-vitals ci \u2014 scaffold CI integration
@@ -961,9 +964,7 @@ Options:
961
964
  --by-route Show per-route score breakdown in console output
962
965
  --reporter <fmt> console | json | agent | sarif | github | html | md (auto: agent under AI-agent envs, github under GitHub Actions)
963
966
  --out-file <path> Output path for --reporter html (default: svelte-vitals-report.html; '-' for stdout)
964
- --json Alias for --reporter=json
965
967
  --fail-on <severity> Fail (exit 1) when any finding reaches this severity: critical | warning | info
966
- --fail-on-warning Alias for --fail-on=warning
967
968
  --min-health <0-100> Fail (exit 1) when the combined Health score is below this value
968
969
  --rules <ids> Comma-separated rule ids to enable (all others disabled)
969
970
  --ignore <ids> Comma-separated rule ids to disable
@@ -971,7 +972,7 @@ Options:
971
972
  --weights <pairs> Per-category Health weight overrides, e.g. seo=2,performance=1 (unlisted categories default to 1)
972
973
  --score Print only the combined Health score (works with --min-health for gating)
973
974
  --no-color Disable ANSI color in console output
974
- --no-animation Disable the Health-score reveal animation on an interactive terminal
975
+ --no-animation Disable the Health-score reveal animation and mascot on an interactive terminal
975
976
  --verbose Show every finding uncapped and ungrouped (default: capped, grouped by rule)
976
977
  -h, --help Show this help
977
978
  -v, --version Show version
@@ -1003,7 +1004,7 @@ async function main() {
1003
1004
  }
1004
1005
  const argv = mri3(process.argv.slice(2), {
1005
1006
  alias: { h: "help", v: "version" },
1006
- boolean: ["by-route", "json", "fail-on-warning", "staged", "no-color", "score", "verbose", "no-animation"],
1007
+ boolean: ["by-route", "staged", "score", "verbose"],
1007
1008
  string: [
1008
1009
  "meta-components",
1009
1010
  "treat-dynamic-as",
@@ -1045,8 +1046,6 @@ async function main() {
1045
1046
  const code = await run({
1046
1047
  ...options,
1047
1048
  minHealth,
1048
- noColor: argv["no-color"],
1049
- noAnimation: argv["no-animation"],
1050
1049
  selectApp
1051
1050
  });
1052
1051
  process.exit(code);
@@ -835,6 +835,157 @@ function startSpinner(text, opts) {
835
835
  };
836
836
  }
837
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
+
838
989
  // src/config-file.ts
839
990
  import { existsSync as existsSync2 } from "fs";
840
991
  import { join as join4 } from "path";
@@ -958,6 +1109,7 @@ async function loadConfigFile(cwd) {
958
1109
 
959
1110
  // src/pulse-animation.ts
960
1111
  import { scoreColor } from "@svelte-vitals/core";
1112
+ import { createLogUpdate as createLogUpdate3 } from "log-update";
961
1113
  var FRAME_COUNT = 6;
962
1114
  var FRAME_DELAY_MS = 200;
963
1115
  var WAVE_FRAMES = [
@@ -965,26 +1117,46 @@ var WAVE_FRAMES = [
965
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",
966
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",
967
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",
968
- "\u2500\u2500\u2500\u2500\u2571\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2572\u2500\u2500\u2500\u2500",
969
- "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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"
970
1121
  ];
971
- function sleep(ms) {
1122
+ var WAVE_ORANGE_DIM = "\x1B[38;2;153;37;0m";
1123
+ var WAVE_RESET = "\x1B[0m";
1124
+ function sleep2(ms) {
972
1125
  return new Promise((resolve) => setTimeout(resolve, ms));
973
1126
  }
1127
+ var REACTION_HOLD_MS = 500;
1128
+ var CONFETTI_FRAME_COUNT = 4;
1129
+ var CONFETTI_FRAME_DELAY_MS = 220;
974
1130
  async function playScoreAnimation(opts) {
975
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);
976
1139
  for (let frame = 0; frame < FRAME_COUNT; frame++) {
977
1140
  const progress = frame / (FRAME_COUNT - 1);
978
1141
  const displayScore = Math.round(opts.score * progress);
979
1142
  const isFinalFrame = frame === FRAME_COUNT - 1;
980
- const wave = WAVE_FRAMES[frame];
981
1143
  const scoreText = isFinalFrame ? scoreColor(opts.palette, opts.score)(`${displayScore}/100`) : opts.palette.dim(`${displayScore}/100`);
982
- const cursorUp = frame === 0 ? "" : "\x1B[2A";
983
- opts.stream.write(`${cursorUp}\r ${wave}\x1B[K
984
- \r Health: ${scoreText}\x1B[K
985
- `);
986
- if (!isFinalFrame) await sleep(frameDelayMs);
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);
987
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();
988
1160
  }
989
1161
  function scoreAnimationEnabled(opts) {
990
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 });
@@ -1072,15 +1244,19 @@ async function run(opts = {}) {
1072
1244
  }
1073
1245
  const env = opts.env ?? process.env;
1074
1246
  const reporter = resolveReporter(opts.reporter, env);
1075
- const spinner = startSpinner("Analyzing\u2026", {
1076
- enabled: !opts.score && spinnerEnabled({
1077
- reporter,
1078
- rawReporter: opts.reporter,
1079
- stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY,
1080
- env,
1081
- noColorFlag: opts.noColor
1082
- })
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
1083
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 });
1084
1260
  let cwd = opts.cwd ?? process.cwd();
1085
1261
  let analysis;
1086
1262
  try {
@@ -1171,6 +1347,7 @@ async function run(opts = {}) {
1171
1347
  categories: opts.categories
1172
1348
  }
1173
1349
  });
1350
+ const summary = summarize(results, config);
1174
1351
  if (opts.score) {
1175
1352
  log(String(computeHealth(results, config).health));
1176
1353
  } else {
@@ -1242,7 +1419,6 @@ async function run(opts = {}) {
1242
1419
  );
1243
1420
  }
1244
1421
  }
1245
- const summary = summarize(results, config);
1246
1422
  const failBySeverity = hasFailureAtOrAbove(summary, config.failOn);
1247
1423
  const failByHealth = opts.minHealth != null && computeHealth(results, config).health < opts.minHealth;
1248
1424
  return failBySeverity || failByHealth ? 1 : 0;
package/dist/index.d.ts CHANGED
@@ -95,6 +95,8 @@ interface RunOptions {
95
95
  noAnimation?: boolean;
96
96
  /** Override the stream the score animation writes to (tests). Defaults to process.stdout. */
97
97
  stdoutStream?: NodeJS.WriteStream;
98
+ /** Override the stream the analysis-phase progress indicator writes to (tests). Defaults to process.stderr. */
99
+ stderrStream?: NodeJS.WriteStream;
98
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. */
99
101
  animationFrameDelayMs?: number;
100
102
  }
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  routeMatcher,
11
11
  run,
12
12
  spinnerEnabled
13
- } from "./chunk-HYTPZVSU.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.23.0",
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,6 +40,7 @@
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",
@@ -48,7 +49,7 @@
48
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",