@loworbitstudio/visor 0.6.0 → 0.9.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/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { Command as Command2 } from "commander";
5
5
 
6
6
  // src/commands/check.ts
7
7
  import { Command } from "commander";
8
+ import { statSync as statSync3 } from "fs";
9
+ import { resolve as resolve3, dirname as dirname2 } from "path";
8
10
 
9
11
  // src/registry/resolve.ts
10
12
  import { readFileSync } from "fs";
@@ -310,7 +312,7 @@ function collectJsxFindings(source, filePath, parse) {
310
312
  }
311
313
  const mapping = NATIVE_TO_VISOR[tagName];
312
314
  if (!mapping) return;
313
- const finding = {
315
+ const finding2 = {
314
316
  file: filePath,
315
317
  line,
316
318
  column,
@@ -318,8 +320,8 @@ function collectJsxFindings(source, filePath, parse) {
318
320
  suggestedPrimitive: mapping.displayName,
319
321
  installCmd: `npx visor add ${mapping.visorName}`
320
322
  };
321
- if (mapping.notes) finding.rationale = mapping.notes;
322
- findings.push(finding);
323
+ if (mapping.notes) finding2.rationale = mapping.notes;
324
+ findings.push(finding2);
323
325
  });
324
326
  return findings;
325
327
  }
@@ -377,6 +379,491 @@ async function scanJsx(pathArg) {
377
379
  };
378
380
  }
379
381
 
382
+ // src/check/design.ts
383
+ import { readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2, existsSync } from "fs";
384
+ import { resolve as resolve2, extname as extname2, join as join3, basename } from "path";
385
+ function loadVisorRc(dir) {
386
+ const rcPath = join3(dir, ".visorrc.json");
387
+ if (!existsSync(rcPath)) return {};
388
+ try {
389
+ const raw = readFileSync3(rcPath, "utf-8");
390
+ return JSON.parse(raw);
391
+ } catch {
392
+ return {};
393
+ }
394
+ }
395
+ var CODE_EXTS = /* @__PURE__ */ new Set([".tsx", ".jsx", ".ts", ".js"]);
396
+ var STYLE_EXTS = /* @__PURE__ */ new Set([".css", ".module.css"]);
397
+ var ALL_EXTS = /* @__PURE__ */ new Set([...CODE_EXTS, ...STYLE_EXTS]);
398
+ function collectFiles2(pathArg) {
399
+ const abs = resolve2(pathArg);
400
+ try {
401
+ const s = statSync2(abs);
402
+ if (s.isDirectory()) {
403
+ let recurse2 = function(dir) {
404
+ for (const entry of readdirSync2(dir)) {
405
+ if (entry.startsWith(".") || entry === "node_modules" || entry === "dist") continue;
406
+ const full = join3(dir, entry);
407
+ const es = statSync2(full);
408
+ if (es.isDirectory()) recurse2(full);
409
+ else if (ALL_EXTS.has(extname2(full))) files.push(full);
410
+ }
411
+ };
412
+ var recurse = recurse2;
413
+ const files = [];
414
+ recurse2(abs);
415
+ return files;
416
+ }
417
+ if (ALL_EXTS.has(extname2(abs))) return [abs];
418
+ } catch {
419
+ }
420
+ return [];
421
+ }
422
+ function lines(source) {
423
+ return source.split("\n");
424
+ }
425
+ function finding(file, line, rule, severity, message, fix) {
426
+ return { file, line, rule, severity, message, ...fix ? { fix } : {} };
427
+ }
428
+ var TIER1_PREFIXES = [
429
+ "--primitive-",
430
+ "--raw-",
431
+ "--base-color-",
432
+ "--palette-"
433
+ ];
434
+ function ruleTier1TokenDirectUsage(source, filePath) {
435
+ const found = [];
436
+ const ext = extname2(filePath);
437
+ if (STYLE_EXTS.has(ext)) return found;
438
+ const src = lines(source);
439
+ for (let i = 0; i < src.length; i++) {
440
+ const l = src[i];
441
+ for (const prefix of TIER1_PREFIXES) {
442
+ if (l.includes(prefix)) {
443
+ found.push(finding(
444
+ filePath,
445
+ i + 1,
446
+ "tier-1-token-direct-usage",
447
+ "error",
448
+ `Direct use of Tier-1 primitive token "${prefix}..." detected. Use a Tier-2 semantic token instead.`,
449
+ "Replace with the equivalent semantic token from the Borealis token registry."
450
+ ));
451
+ break;
452
+ }
453
+ }
454
+ }
455
+ return found;
456
+ }
457
+ function ruleHardcodedHex(source, filePath) {
458
+ const found = [];
459
+ const HEX_RE = /#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6,8})\b/g;
460
+ const src = lines(source);
461
+ for (let i = 0; i < src.length; i++) {
462
+ const l = src[i];
463
+ if (l.trim().startsWith("//") || l.trim().startsWith("*") || l.trim().startsWith("/*")) continue;
464
+ if (basename(filePath).startsWith(".")) continue;
465
+ let m;
466
+ HEX_RE.lastIndex = 0;
467
+ while ((m = HEX_RE.exec(l)) !== null) {
468
+ found.push(finding(
469
+ filePath,
470
+ i + 1,
471
+ "hardcoded-hex",
472
+ "error",
473
+ `Hardcoded hex color "${m[0]}" bypasses the Borealis token system.`,
474
+ "Replace with the appropriate semantic token: var(--color-surface), var(--color-text-primary), etc."
475
+ ));
476
+ }
477
+ }
478
+ return found;
479
+ }
480
+ var PX_WHITELIST = /* @__PURE__ */ new Set(["0px", "1px", "2px", "3px"]);
481
+ function ruleHardcodedPx(source, filePath) {
482
+ const found = [];
483
+ const PX_RE = /\b(\d+(?:\.\d+)?)px\b/g;
484
+ const src = lines(source);
485
+ for (let i = 0; i < src.length; i++) {
486
+ const l = src[i];
487
+ if (l.trim().startsWith("//") || l.trim().startsWith("*") || l.trim().startsWith("/*")) continue;
488
+ if (!/margin|padding|width|height|gap|top:|left:|right:|bottom:|font-size|line-height|min-width|max-width|min-height|max-height/.test(l)) continue;
489
+ let m;
490
+ PX_RE.lastIndex = 0;
491
+ while ((m = PX_RE.exec(l)) !== null) {
492
+ const full = m[0];
493
+ if (PX_WHITELIST.has(full)) continue;
494
+ found.push(finding(
495
+ filePath,
496
+ i + 1,
497
+ "hardcoded-px",
498
+ "error",
499
+ `Hardcoded pixel value "${full}" in spacing/sizing bypasses the Borealis spacing token system.`,
500
+ "Replace with a semantic spacing token: var(--space-1), var(--space-2), var(--space-4), etc."
501
+ ));
502
+ }
503
+ }
504
+ return found;
505
+ }
506
+ function ruleMissingDarkModeBlock(source, filePath) {
507
+ const ext = extname2(filePath);
508
+ if (!STYLE_EXTS.has(ext)) return [];
509
+ if (source.trim().length < 20) return [];
510
+ const hasDarkMediaQuery = /@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)/.test(source);
511
+ const hasDarkAttribute = /\[data-theme\s*=\s*["']?dark["']?\]/.test(source);
512
+ const hasDarkClass = /\.dark\b/.test(source);
513
+ if (!hasDarkMediaQuery && !hasDarkAttribute && !hasDarkClass) {
514
+ return [finding(
515
+ filePath,
516
+ 1,
517
+ "missing-dark-mode-block",
518
+ "error",
519
+ "CSS file has no dark mode block. Borealis requires dark + light support from day one.",
520
+ 'Add @media (prefers-color-scheme: dark) { ... } or [data-theme="dark"] { ... } with dark-mode token overrides.'
521
+ )];
522
+ }
523
+ return [];
524
+ }
525
+ function ruleMissingHoverTransition(source, filePath) {
526
+ const ext = extname2(filePath);
527
+ if (!STYLE_EXTS.has(ext)) return [];
528
+ const hasHover = /:hover/.test(source);
529
+ if (!hasHover) return [];
530
+ const hasTransition = /\btransition\b/.test(source);
531
+ if (!hasTransition) {
532
+ const hoverLine = source.split("\n").findIndex((l) => /:hover/.test(l));
533
+ return [finding(
534
+ filePath,
535
+ hoverLine + 1,
536
+ "missing-hover-transition",
537
+ "error",
538
+ ":hover selector found but no transition property in this file. Borealis requires CSS transitions for hover states.",
539
+ "Add a transition property to the element's base styles, e.g. transition: color 150ms ease, background 150ms ease."
540
+ )];
541
+ }
542
+ return [];
543
+ }
544
+ function ruleDivAsInput(source, filePath) {
545
+ const ext = extname2(filePath);
546
+ if (!CODE_EXTS.has(ext)) return [];
547
+ const found = [];
548
+ const src = lines(source);
549
+ for (let i = 0; i < src.length; i++) {
550
+ const l = src[i];
551
+ if (/<div\b[^>]*onClick/.test(l) && !/role=|type=/.test(l)) {
552
+ found.push(finding(
553
+ filePath,
554
+ i + 1,
555
+ "div-as-input",
556
+ "error",
557
+ "<div onClick> used without role= \u2014 this is a div masquerading as an interactive element.",
558
+ 'Use a <button> element or add role="button" and tabIndex={0} with keyboard handlers.'
559
+ ));
560
+ }
561
+ if (/<div\b[^>]*onChange/.test(l)) {
562
+ found.push(finding(
563
+ filePath,
564
+ i + 1,
565
+ "div-as-input",
566
+ "error",
567
+ "<div onChange> detected. Real form elements only \u2014 no div-as-input.",
568
+ "Use <input>, <select>, or <textarea> with appropriate Visor wrapper components."
569
+ ));
570
+ }
571
+ }
572
+ return found;
573
+ }
574
+ function ruleSetStateHover(source, filePath) {
575
+ const ext = extname2(filePath);
576
+ if (!CODE_EXTS.has(ext)) return [];
577
+ const found = [];
578
+ const src = lines(source);
579
+ for (let i = 0; i < src.length; i++) {
580
+ const l = src[i];
581
+ if (/onMouseEnter|onMouseLeave/.test(l) && /set[A-Z]/.test(l)) {
582
+ found.push(finding(
583
+ filePath,
584
+ i + 1,
585
+ "setstate-hover",
586
+ "error",
587
+ "onMouseEnter/onMouseLeave used to manage hover state via setState. Use CSS :hover instead.",
588
+ "Remove the mouse event handlers and hover state variable. Apply hover styles via CSS :hover."
589
+ ));
590
+ }
591
+ if (/\buse[Ss]tate\b/.test(l) && /[Hh]overed|hover[Ss]tate|isHover/.test(l)) {
592
+ found.push(finding(
593
+ filePath,
594
+ i + 1,
595
+ "setstate-hover",
596
+ "error",
597
+ "useState used to track hover state. CSS :hover is zero-cost and more correct.",
598
+ "Delete this state variable and replace with CSS :hover selector."
599
+ ));
600
+ }
601
+ }
602
+ return found;
603
+ }
604
+ function ruleMissingAriaPressed(source, filePath) {
605
+ const ext = extname2(filePath);
606
+ if (!CODE_EXTS.has(ext)) return [];
607
+ const found = [];
608
+ const src = lines(source);
609
+ for (let i = 0; i < src.length; i++) {
610
+ const l = src[i];
611
+ if (/<button\b[^>]*(isActive|isOpen|isSelected|isToggled|active=|selected=|toggled=)/.test(l) && !/aria-pressed/.test(l)) {
612
+ found.push(finding(
613
+ filePath,
614
+ i + 1,
615
+ "missing-aria-pressed",
616
+ "error",
617
+ "Toggle button appears to be missing aria-pressed. Toggleable buttons must expose their state to assistive technology.",
618
+ "Add aria-pressed={isActive} (or equivalent) to the button element."
619
+ ));
620
+ }
621
+ }
622
+ return found;
623
+ }
624
+ var BANNED_FONT_LIST = ["Inter", "Roboto", "Arial", "system-ui", "'Arial'", '"Arial"', "'Roboto'", '"Roboto"', "'Inter'", '"Inter"'];
625
+ function ruleBannedFonts(source, filePath) {
626
+ const found = [];
627
+ const src = lines(source);
628
+ for (let i = 0; i < src.length; i++) {
629
+ const l = src[i];
630
+ if (l.trim().startsWith("//") || l.trim().startsWith("*")) continue;
631
+ for (const font of BANNED_FONT_LIST) {
632
+ if (l.includes(font)) {
633
+ found.push(finding(
634
+ filePath,
635
+ i + 1,
636
+ "banned-fonts",
637
+ "warn",
638
+ `Banned font "${font}" detected. Borealis projects use Satoshi (or the project's designated typeface).`,
639
+ "Remove this font reference and use the Borealis font stack via var(--font-sans)."
640
+ ));
641
+ break;
642
+ }
643
+ }
644
+ }
645
+ return found;
646
+ }
647
+ function rulePurpleGradientOnWhite(source, filePath) {
648
+ const found = [];
649
+ const src = lines(source);
650
+ const PURPLE_RE = /gradient.*?(?:purple|violet|#[89abcde][0-9a-f]|#[6-9][0-9a-f]{5})/i;
651
+ for (let i = 0; i < src.length; i++) {
652
+ const l = src[i];
653
+ if (PURPLE_RE.test(l)) {
654
+ found.push(finding(
655
+ filePath,
656
+ i + 1,
657
+ "purple-gradient-on-white",
658
+ "warn",
659
+ "Purple gradient detected \u2014 this is a generic SaaS visual cliche.",
660
+ "Replace with a gradient using your project's actual brand tokens."
661
+ ));
662
+ }
663
+ }
664
+ return found;
665
+ }
666
+ function rulePureBlackUntinted(source, filePath) {
667
+ const found = [];
668
+ const src = lines(source);
669
+ for (let i = 0; i < src.length; i++) {
670
+ const l = src[i];
671
+ if (l.trim().startsWith("//") || l.trim().startsWith("*")) continue;
672
+ if (/#000000\b|#000\b|rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)/.test(l) || /:\s*black\b/.test(l) || /color:\s*["']?black["']?/.test(l)) {
673
+ found.push(finding(
674
+ filePath,
675
+ i + 1,
676
+ "pure-black-untinted",
677
+ "warn",
678
+ "Pure black (#000) detected. Use a near-black tinted token for softer, more intentional contrast.",
679
+ "Replace with var(--color-text-primary) or the project's near-black token."
680
+ ));
681
+ }
682
+ }
683
+ return found;
684
+ }
685
+ function ruleBounceEasing(source, filePath) {
686
+ const found = [];
687
+ const src = lines(source);
688
+ const BOUNCE_RE = /cubic-bezier\s*\([^)]*(?:1\.[1-9]|[-]0\.\d+)[^)]*\)/;
689
+ const KEYWORD_RE = /\bease-in-back\b|\bease-out-back\b|\bease-in-out-back\b|\bbounce\b/;
690
+ for (let i = 0; i < src.length; i++) {
691
+ const l = src[i];
692
+ if (BOUNCE_RE.test(l) || KEYWORD_RE.test(l)) {
693
+ found.push(finding(
694
+ filePath,
695
+ i + 1,
696
+ "bounce-easing",
697
+ "warn",
698
+ "Bounce/overshoot easing detected. Bouncy transitions feel playful/cheap in most UI contexts.",
699
+ "Use ease, ease-out, or a subtle cubic-bezier like cubic-bezier(0.4, 0, 0.2, 1)."
700
+ ));
701
+ }
702
+ }
703
+ return found;
704
+ }
705
+ function ruleSub44pxTouchTarget(source, filePath) {
706
+ const found = [];
707
+ const src = lines(source);
708
+ const SMALL_RE = /(?:width|height|min-width|min-height)\s*:\s*(?:[1-9]|[1-3][0-9]|4[0-3])px\b/;
709
+ const INTERACTIVE_SELECTOR_RE = /button|btn|icon|touch|tap/i;
710
+ let currentSelector = "";
711
+ for (let i = 0; i < src.length; i++) {
712
+ const l = src[i];
713
+ if (l.trim().startsWith("//") || l.trim().startsWith("*")) continue;
714
+ if (/\{/.test(l) && !l.trim().startsWith("@")) {
715
+ currentSelector = l;
716
+ }
717
+ const interactiveContext = INTERACTIVE_SELECTOR_RE.test(l) || INTERACTIVE_SELECTOR_RE.test(currentSelector);
718
+ if (SMALL_RE.test(l) && interactiveContext) {
719
+ found.push(finding(
720
+ filePath,
721
+ i + 1,
722
+ "sub-44px-touch-target",
723
+ "warn",
724
+ "Potential sub-44px touch target detected on an interactive element.",
725
+ "Ensure all interactive elements have a minimum 44x44px touch target (WCAG 2.5.5)."
726
+ ));
727
+ }
728
+ }
729
+ return found;
730
+ }
731
+ function ruleLineLengthOver75ch(source, filePath) {
732
+ const found = [];
733
+ const src = lines(source);
734
+ const CH_RE = /max-width\s*:\s*(\d+)ch/;
735
+ for (let i = 0; i < src.length; i++) {
736
+ const l = src[i];
737
+ const m = CH_RE.exec(l);
738
+ if (m && parseInt(m[1], 10) > 75) {
739
+ found.push(finding(
740
+ filePath,
741
+ i + 1,
742
+ "line-length-over-75ch",
743
+ "warn",
744
+ `Text container max-width of ${m[1]}ch exceeds the 75ch readability limit.`,
745
+ "Cap text container max-width at 65-75ch for optimal reading comfort."
746
+ ));
747
+ }
748
+ }
749
+ return found;
750
+ }
751
+ function ruleGradientText(source, filePath) {
752
+ const found = [];
753
+ const src = lines(source);
754
+ for (let i = 0; i < src.length; i++) {
755
+ const l = src[i];
756
+ if (/background-clip\s*:\s*text/.test(l) && /(?:transparent|-webkit-text-fill-color)/.test(source)) {
757
+ found.push(finding(
758
+ filePath,
759
+ i + 1,
760
+ "gradient-text",
761
+ "warn",
762
+ "Gradient text (background-clip: text) detected. Often illegible at small sizes.",
763
+ "Use a solid semantic text color token. Reserve gradient text for display/hero headings only."
764
+ ));
765
+ }
766
+ if (/-webkit-text-fill-color\s*:\s*transparent/.test(l) && /gradient/.test(source)) {
767
+ found.push(finding(
768
+ filePath,
769
+ i + 1,
770
+ "gradient-text",
771
+ "warn",
772
+ "Gradient text via -webkit-text-fill-color: transparent detected.",
773
+ "Use a solid semantic text color token. Reserve gradient text for display/hero headings only."
774
+ ));
775
+ }
776
+ }
777
+ const seen = /* @__PURE__ */ new Set();
778
+ return found.filter((f) => {
779
+ if (seen.has(f.line)) return false;
780
+ seen.add(f.line);
781
+ return true;
782
+ });
783
+ }
784
+ function ruleExcessiveCardNesting(source, filePath) {
785
+ const ext = extname2(filePath);
786
+ if (!CODE_EXTS.has(ext)) return [];
787
+ const found = [];
788
+ const src = lines(source);
789
+ let depth = 0;
790
+ const CARD_OPEN_RE = /<(?:Card|Panel|Box|Surface|Tile|Widget)\b/;
791
+ const CARD_CLOSE_RE = /<\/(?:Card|Panel|Box|Surface|Tile|Widget)>/;
792
+ for (let i = 0; i < src.length; i++) {
793
+ const l = src[i];
794
+ if (CARD_OPEN_RE.test(l)) {
795
+ depth++;
796
+ if (depth >= 3) {
797
+ found.push(finding(
798
+ filePath,
799
+ i + 1,
800
+ "excessive-card-nesting",
801
+ "warn",
802
+ `Card/Panel nested ${depth} levels deep. Deep nesting creates visual noise and unclear hierarchy.`,
803
+ "Flatten the layout. Use spacing, dividers, or type scale to create hierarchy instead of nested containers."
804
+ ));
805
+ }
806
+ }
807
+ if (CARD_CLOSE_RE.test(l)) depth = Math.max(0, depth - 1);
808
+ }
809
+ return found;
810
+ }
811
+ var RULES = [
812
+ // Errors — Borealis non-negotiables
813
+ { name: "tier-1-token-direct-usage", severity: "error", fn: ruleTier1TokenDirectUsage },
814
+ { name: "hardcoded-hex", severity: "error", fn: ruleHardcodedHex },
815
+ { name: "hardcoded-px", severity: "error", fn: ruleHardcodedPx },
816
+ { name: "missing-dark-mode-block", severity: "error", fn: ruleMissingDarkModeBlock },
817
+ { name: "missing-hover-transition", severity: "error", fn: ruleMissingHoverTransition },
818
+ { name: "div-as-input", severity: "error", fn: ruleDivAsInput },
819
+ { name: "setstate-hover", severity: "error", fn: ruleSetStateHover },
820
+ { name: "missing-aria-pressed", severity: "error", fn: ruleMissingAriaPressed },
821
+ // Warns — general anti-patterns
822
+ { name: "banned-fonts", severity: "warn", fn: ruleBannedFonts },
823
+ { name: "purple-gradient-on-white", severity: "warn", fn: rulePurpleGradientOnWhite },
824
+ { name: "pure-black-untinted", severity: "warn", fn: rulePureBlackUntinted },
825
+ { name: "bounce-easing", severity: "warn", fn: ruleBounceEasing },
826
+ { name: "sub-44px-touch-target", severity: "warn", fn: ruleSub44pxTouchTarget },
827
+ { name: "line-length-over-75ch", severity: "warn", fn: ruleLineLengthOver75ch },
828
+ { name: "gradient-text", severity: "warn", fn: ruleGradientText },
829
+ { name: "excessive-card-nesting", severity: "warn", fn: ruleExcessiveCardNesting }
830
+ ];
831
+ function scanDesign(pathArg, options = {}) {
832
+ const files = collectFiles2(pathArg);
833
+ const { disabledRules = [], errorsOnly = false } = options;
834
+ const activeRules = RULES.filter((r) => {
835
+ if (disabledRules.includes(r.name)) return false;
836
+ if (errorsOnly && r.severity !== "error") return false;
837
+ return true;
838
+ });
839
+ const errors = [];
840
+ const warnings = [];
841
+ for (const file of files) {
842
+ let source;
843
+ try {
844
+ source = readFileSync3(file, "utf-8");
845
+ } catch {
846
+ continue;
847
+ }
848
+ for (const rule of activeRules) {
849
+ const ruleFindings = rule.fn(source, file);
850
+ for (const f of ruleFindings) {
851
+ if (f.severity === "error") errors.push(f);
852
+ else warnings.push(f);
853
+ }
854
+ }
855
+ }
856
+ return {
857
+ errors,
858
+ warnings,
859
+ summary: {
860
+ errorCount: errors.length,
861
+ warningCount: warnings.length,
862
+ filesScanned: files.length
863
+ }
864
+ };
865
+ }
866
+
380
867
  // src/utils/logger.ts
381
868
  import pc from "picocolors";
382
869
  var logger = {
@@ -404,6 +891,7 @@ var logger = {
404
891
  };
405
892
 
406
893
  // src/commands/check.ts
894
+ import pc2 from "picocolors";
407
895
  var TYPE_FILTER = {
408
896
  ui: "component",
409
897
  blocks: "block",
@@ -534,6 +1022,58 @@ async function checkDiffCommand(pathArg, options) {
534
1022
  logger.blank();
535
1023
  if (options.failOnHits) process.exit(1);
536
1024
  }
1025
+ function checkDesignCommand(pathArg, options) {
1026
+ const absPath = resolve3(pathArg);
1027
+ const rcDir = statSync3(absPath).isDirectory() ? absPath : dirname2(absPath);
1028
+ const rc = loadVisorRc(rcDir);
1029
+ const result = scanDesign(absPath, {
1030
+ disabledRules: rc.disabledRules ?? [],
1031
+ errorsOnly: options.errorsOnly ?? false
1032
+ });
1033
+ const useJson = options.json || options.format === "json";
1034
+ if (useJson) {
1035
+ console.log(JSON.stringify({ success: true, ...result }, null, 2));
1036
+ if (!options.noFail && result.summary.errorCount > 0) process.exit(1);
1037
+ process.exit(0);
1038
+ return;
1039
+ }
1040
+ const { errors, warnings, summary } = result;
1041
+ if (summary.errorCount === 0 && summary.warningCount === 0) {
1042
+ logger.success(`No design anti-patterns found \u2014 ${summary.filesScanned} file(s) scanned.`);
1043
+ process.exit(0);
1044
+ return;
1045
+ }
1046
+ logger.blank();
1047
+ logger.heading(`visor check design \u2014 ${summary.filesScanned} file(s) scanned`);
1048
+ logger.blank();
1049
+ const byFile = /* @__PURE__ */ new Map();
1050
+ const allFindings = [...errors, ...warnings];
1051
+ for (const f of allFindings) {
1052
+ if (!byFile.has(f.file)) byFile.set(f.file, []);
1053
+ byFile.get(f.file).push(f);
1054
+ }
1055
+ for (const [file, fileFindings] of byFile) {
1056
+ logger.heading(` ${file}`);
1057
+ for (const f of fileFindings) {
1058
+ const loc = pc2.dim(`${f.line}:`);
1059
+ const badge = f.severity === "error" ? pc2.red("error") : pc2.yellow("warn ");
1060
+ const ruleName = pc2.dim(`[${f.rule}]`);
1061
+ console.log(` ${loc} ${badge} ${f.message} ${ruleName}`);
1062
+ if (f.fix) {
1063
+ console.log(` ${pc2.dim("fix:")} ${pc2.cyan(f.fix)}`);
1064
+ }
1065
+ }
1066
+ logger.blank();
1067
+ }
1068
+ logger.blank();
1069
+ if (summary.errorCount > 0) {
1070
+ logger.error(`${summary.errorCount} error(s), ${summary.warningCount} warning(s)`);
1071
+ } else {
1072
+ logger.warn(`${summary.warningCount} warning(s) (0 errors)`);
1073
+ }
1074
+ logger.blank();
1075
+ if (!options.noFail && summary.errorCount > 0) process.exit(1);
1076
+ }
537
1077
  function checkCommand() {
538
1078
  const check = new Command("check").description("Check Visor catalog \u2014 list items, test existence, scan JSX for native HTML");
539
1079
  check.command("list").description("List all catalog items (components, blocks, hooks, patterns)").option("--type <type>", "filter by type: ui, blocks, hooks, patterns, all (default: all)").option("--json", "output structured JSON (for AI agents)").action((options) => {
@@ -545,18 +1085,21 @@ function checkCommand() {
545
1085
  check.command("diff").description("Scan JSX/TSX for native HTML elements that have Visor equivalents").argument("<path>", "file path, directory, or - for stdin").option("--fail-on-hits", "exit 1 if any native HTML usages are found (for CI use)").option("--json", "output structured JSON (for AI agents)").action(async (pathArg, options) => {
546
1086
  await checkDiffCommand(pathArg, options);
547
1087
  });
1088
+ check.command("design").description("Scan frontend code for Borealis design anti-patterns (deterministic, no LLM)").argument("<path>", "file path or directory to scan").option("--format <format>", "output format: json or human (default: human when TTY, json otherwise)").option("--errors-only", "report only error-severity rules (skip warnings)").option("--no-fail", "do not exit 1 on errors (advisory mode)").option("--json", "shorthand for --format json").action((pathArg, options) => {
1089
+ checkDesignCommand(pathArg, options);
1090
+ });
548
1091
  return check;
549
1092
  }
550
1093
 
551
1094
  // src/commands/init.ts
552
- import { existsSync as existsSync3, writeFileSync as writeFileSync2, mkdirSync, readFileSync as readFileSync5 } from "fs";
553
- import { join as join5, dirname as dirname2 } from "path";
1095
+ import { existsSync as existsSync4, writeFileSync as writeFileSync2, mkdirSync, readFileSync as readFileSync6 } from "fs";
1096
+ import { join as join6, dirname as dirname3 } from "path";
554
1097
  import { fileURLToPath as fileURLToPath2 } from "url";
555
1098
  import * as childProcess from "child_process";
556
1099
 
557
1100
  // src/config/config.ts
558
- import { readFileSync as readFileSync3, writeFileSync, existsSync } from "fs";
559
- import { join as join3 } from "path";
1101
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync2 } from "fs";
1102
+ import { join as join4 } from "path";
560
1103
 
561
1104
  // src/config/defaults.ts
562
1105
  var DEFAULT_CONFIG = {
@@ -573,19 +1116,19 @@ var CONFIG_FILE = "visor.json";
573
1116
 
574
1117
  // src/config/config.ts
575
1118
  function getConfigPath(cwd) {
576
- return join3(cwd, CONFIG_FILE);
1119
+ return join4(cwd, CONFIG_FILE);
577
1120
  }
578
1121
  function configExists(cwd) {
579
- return existsSync(getConfigPath(cwd));
1122
+ return existsSync2(getConfigPath(cwd));
580
1123
  }
581
1124
  function loadConfig(cwd) {
582
1125
  const configPath = getConfigPath(cwd);
583
- if (!existsSync(configPath)) {
1126
+ if (!existsSync2(configPath)) {
584
1127
  throw new Error(
585
1128
  `No ${CONFIG_FILE} found. Run "visor init" first.`
586
1129
  );
587
1130
  }
588
- const raw = readFileSync3(configPath, "utf-8");
1131
+ const raw = readFileSync4(configPath, "utf-8");
589
1132
  const parsed = JSON.parse(raw);
590
1133
  const knownKeys = /* @__PURE__ */ new Set(["paths"]);
591
1134
  for (const key of Object.keys(parsed)) {
@@ -622,12 +1165,12 @@ function writeConfig(cwd, config) {
622
1165
 
623
1166
  // src/utils/packages.ts
624
1167
  import { execFileSync } from "child_process";
625
- import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
626
- import { join as join4 } from "path";
1168
+ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
1169
+ import { join as join5 } from "path";
627
1170
  function readPackageJson(cwd) {
628
- const pkgPath = join4(cwd, "package.json");
629
- if (!existsSync2(pkgPath)) return null;
630
- return JSON.parse(readFileSync4(pkgPath, "utf-8"));
1171
+ const pkgPath = join5(cwd, "package.json");
1172
+ if (!existsSync3(pkgPath)) return null;
1173
+ return JSON.parse(readFileSync5(pkgPath, "utf-8"));
631
1174
  }
632
1175
  function isPackageInstalled(packageName, cwd) {
633
1176
  const pkg = readPackageJson(cwd);
@@ -704,7 +1247,7 @@ function initCommand(cwd, options) {
704
1247
  emitError(json, `Unknown template: ${options.template}. Available templates: nextjs`);
705
1248
  process.exit(1);
706
1249
  }
707
- if (options?.template === "nextjs" && existsSync3(join5(cwd, "package.json"))) {
1250
+ if (options?.template === "nextjs" && existsSync4(join6(cwd, "package.json"))) {
708
1251
  emitError(
709
1252
  json,
710
1253
  "package.json already exists in this directory. visor init --template nextjs only scaffolds into empty directories. For an existing app, see the retrofit flow: https://visor.loworbit.studio/docs/guides/migration"
@@ -787,8 +1330,8 @@ function scaffoldNextjs(cwd, json, filesCreated, filesSkipped, warnings) {
787
1330
  }
788
1331
  runCreateNextApp(cwd, json);
789
1332
  runInstallVisorDeps(cwd, json);
790
- const yamlPath = join5(cwd, ".visor.yaml");
791
- if (existsSync3(yamlPath)) {
1333
+ const yamlPath = join6(cwd, ".visor.yaml");
1334
+ if (existsSync4(yamlPath)) {
792
1335
  filesSkipped.push(".visor.yaml");
793
1336
  if (!json) {
794
1337
  logger.warn(".visor.yaml already exists. Skipping.");
@@ -806,10 +1349,10 @@ function scaffoldNextjs(cwd, json, filesCreated, filesSkipped, warnings) {
806
1349
  tokens: data.tokens,
807
1350
  config: data.config
808
1351
  });
809
- const globalsPath = join5(cwd, "app", "globals.css");
810
- const globalsDir = dirname2(globalsPath);
1352
+ const globalsPath = join6(cwd, "app", "globals.css");
1353
+ const globalsDir = dirname3(globalsPath);
811
1354
  mkdirSync(globalsDir, { recursive: true });
812
- if (existsSync3(globalsPath)) {
1355
+ if (existsSync4(globalsPath)) {
813
1356
  writeFileSync2(globalsPath, css, "utf-8");
814
1357
  filesCreated.push("app/globals.css");
815
1358
  } else {
@@ -819,15 +1362,15 @@ function scaffoldNextjs(cwd, json, filesCreated, filesSkipped, warnings) {
819
1362
  if (!json) {
820
1363
  logger.success("Created app/globals.css with theme tokens");
821
1364
  }
822
- const layoutPath = join5(cwd, "app", "layout.tsx");
1365
+ const layoutPath = join6(cwd, "app", "layout.tsx");
823
1366
  writeFileSync2(layoutPath, generateNextjsLayout(), "utf-8");
824
1367
  filesCreated.push("app/layout.tsx");
825
1368
  if (!json) {
826
1369
  logger.success("Wired app/layout.tsx with FOWT prevention and theme tokens");
827
1370
  }
828
- const stampDir = join5(cwd, ".lo");
829
- const stampPath = join5(stampDir, "borealis.json");
830
- if (existsSync3(stampPath)) {
1371
+ const stampDir = join6(cwd, ".lo");
1372
+ const stampPath = join6(stampDir, "borealis.json");
1373
+ if (existsSync4(stampPath)) {
831
1374
  filesSkipped.push(".lo/borealis.json");
832
1375
  if (!json) {
833
1376
  logger.warn(".lo/borealis.json already exists. Skipping.");
@@ -891,12 +1434,12 @@ function assertSpawnSuccess(result, label) {
891
1434
  }
892
1435
  function readVisorCliVersion() {
893
1436
  try {
894
- const here = dirname2(fileURLToPath2(import.meta.url));
1437
+ const here = dirname3(fileURLToPath2(import.meta.url));
895
1438
  for (let i = 0; i < 5; i++) {
896
1439
  const segments = new Array(i).fill("..");
897
- const candidate = join5(here, ...segments, "package.json");
1440
+ const candidate = join6(here, ...segments, "package.json");
898
1441
  try {
899
- const pkg = JSON.parse(readFileSync5(candidate, "utf-8"));
1442
+ const pkg = JSON.parse(readFileSync6(candidate, "utf-8"));
900
1443
  if (pkg.name === "@loworbitstudio/visor" && pkg.version) {
901
1444
  return pkg.version;
902
1445
  }
@@ -911,52 +1454,52 @@ function readVisorCliVersion() {
911
1454
  // src/utils/fs.ts
912
1455
  import {
913
1456
  writeFileSync as writeFileSync3,
914
- readFileSync as readFileSync6,
915
- existsSync as existsSync4,
1457
+ readFileSync as readFileSync7,
1458
+ existsSync as existsSync5,
916
1459
  mkdirSync as mkdirSync2
917
1460
  } from "fs";
918
- import { dirname as dirname3, join as join6 } from "path";
1461
+ import { dirname as dirname4, join as join7 } from "path";
919
1462
  function resolveOutputPath(registryPath, type, config, cwd) {
920
1463
  let relativePath;
921
1464
  if (type === "registry:block") {
922
1465
  relativePath = registryPath.replace(/^blocks\//, "");
923
- return join6(cwd, config.paths.blocks, relativePath);
1466
+ return join7(cwd, config.paths.blocks, relativePath);
924
1467
  }
925
1468
  if (type === "registry:ui") {
926
1469
  if (registryPath.startsWith("components/deck/")) {
927
1470
  relativePath = registryPath.replace(/^components\/deck\//, "");
928
- return join6(cwd, config.paths.deckComponents, relativePath);
1471
+ return join7(cwd, config.paths.deckComponents, relativePath);
929
1472
  }
930
1473
  if (registryPath.startsWith("components/flutter/")) {
931
1474
  relativePath = registryPath.replace(/^components\/flutter\//, "");
932
- return join6(cwd, config.paths.flutterComponents, relativePath);
1475
+ return join7(cwd, config.paths.flutterComponents, relativePath);
933
1476
  }
934
1477
  relativePath = registryPath.replace(/^components\/ui\//, "");
935
- return join6(cwd, config.paths.components, relativePath);
1478
+ return join7(cwd, config.paths.components, relativePath);
936
1479
  }
937
1480
  if (type === "registry:hook") {
938
1481
  relativePath = registryPath.replace(/^hooks\//, "");
939
- return join6(cwd, config.paths.hooks, relativePath);
1482
+ return join7(cwd, config.paths.hooks, relativePath);
940
1483
  }
941
1484
  if (type === "registry:lib") {
942
1485
  relativePath = registryPath.replace(/^lib\//, "");
943
- return join6(cwd, config.paths.lib, relativePath);
1486
+ return join7(cwd, config.paths.lib, relativePath);
944
1487
  }
945
- return join6(cwd, registryPath);
1488
+ return join7(cwd, registryPath);
946
1489
  }
947
1490
  function writeFile(filePath, content) {
948
- const dir = dirname3(filePath);
949
- if (!existsSync4(dir)) {
1491
+ const dir = dirname4(filePath);
1492
+ if (!existsSync5(dir)) {
950
1493
  mkdirSync2(dir, { recursive: true });
951
1494
  }
952
1495
  writeFileSync3(filePath, content, "utf-8");
953
1496
  }
954
1497
  function readFile(filePath) {
955
- if (!existsSync4(filePath)) return null;
956
- return readFileSync6(filePath, "utf-8");
1498
+ if (!existsSync5(filePath)) return null;
1499
+ return readFileSync7(filePath, "utf-8");
957
1500
  }
958
1501
  function fileExists(filePath) {
959
- return existsSync4(filePath);
1502
+ return existsSync5(filePath);
960
1503
  }
961
1504
 
962
1505
  // src/commands/list.ts
@@ -1097,8 +1640,8 @@ function listCommand(cwd, options = {}) {
1097
1640
  }
1098
1641
 
1099
1642
  // src/utils/pubspec.ts
1100
- import { existsSync as existsSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
1101
- import { join as join7 } from "path";
1643
+ import { existsSync as existsSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
1644
+ import { join as join8 } from "path";
1102
1645
  import { parseDocument, YAMLMap } from "yaml";
1103
1646
  function mergePubspec(pubspecText, deps) {
1104
1647
  const doc = parseDocument(pubspecText);
@@ -1120,14 +1663,14 @@ function mergePubspec(pubspecText, deps) {
1120
1663
  return { text: doc.toString(), added, skipped };
1121
1664
  }
1122
1665
  function pubspecPath(cwd) {
1123
- return join7(cwd, "pubspec.yaml");
1666
+ return join8(cwd, "pubspec.yaml");
1124
1667
  }
1125
1668
  function pubspecExists(cwd) {
1126
- return existsSync5(pubspecPath(cwd));
1669
+ return existsSync6(pubspecPath(cwd));
1127
1670
  }
1128
1671
  function isPubPackageInstalled(packageName, cwd) {
1129
1672
  if (!pubspecExists(cwd)) return false;
1130
- const text = readFileSync7(pubspecPath(cwd), "utf-8");
1673
+ const text = readFileSync8(pubspecPath(cwd), "utf-8");
1131
1674
  const doc = parseDocument(text);
1132
1675
  const depsNode = doc.get("dependencies");
1133
1676
  if (!(depsNode instanceof YAMLMap)) return false;
@@ -1138,12 +1681,12 @@ function getUninstalledPubDeps(deps, cwd) {
1138
1681
  }
1139
1682
  function addPubDependencies(deps, cwd) {
1140
1683
  const path2 = pubspecPath(cwd);
1141
- if (!existsSync5(path2)) {
1684
+ if (!existsSync6(path2)) {
1142
1685
  throw new Error(
1143
1686
  `No pubspec.yaml found at ${path2}. Run this command from a Flutter project root.`
1144
1687
  );
1145
1688
  }
1146
- const text = readFileSync7(path2, "utf-8");
1689
+ const text = readFileSync8(path2, "utf-8");
1147
1690
  const result = mergePubspec(text, deps);
1148
1691
  if (result.added.length > 0) {
1149
1692
  writeFileSync4(path2, result.text, "utf-8");
@@ -1153,12 +1696,12 @@ function addPubDependencies(deps, cwd) {
1153
1696
 
1154
1697
  // src/utils/flutter.ts
1155
1698
  import { execFileSync as execFileSync2 } from "child_process";
1156
- import { existsSync as existsSync6, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
1699
+ import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
1157
1700
  import { homedir } from "os";
1158
- import { join as join8 } from "path";
1701
+ import { join as join9 } from "path";
1159
1702
  function isExecutable(path2) {
1160
1703
  try {
1161
- const s = statSync2(path2);
1704
+ const s = statSync4(path2);
1162
1705
  return s.isFile();
1163
1706
  } catch {
1164
1707
  return false;
@@ -1166,24 +1709,24 @@ function isExecutable(path2) {
1166
1709
  }
1167
1710
  function fromPath(env) {
1168
1711
  const pathVar = env.PATH ?? "";
1169
- const sep = process.platform === "win32" ? ";" : ":";
1712
+ const sep2 = process.platform === "win32" ? ";" : ":";
1170
1713
  const bin = process.platform === "win32" ? "flutter.bat" : "flutter";
1171
- for (const dir of pathVar.split(sep)) {
1714
+ for (const dir of pathVar.split(sep2)) {
1172
1715
  if (!dir) continue;
1173
- const candidate = join8(dir, bin);
1716
+ const candidate = join9(dir, bin);
1174
1717
  if (isExecutable(candidate)) return candidate;
1175
1718
  }
1176
1719
  return null;
1177
1720
  }
1178
1721
  function fromFvm(home) {
1179
- const fvmDefault = join8(home, "fvm", "default", "bin", "flutter");
1722
+ const fvmDefault = join9(home, "fvm", "default", "bin", "flutter");
1180
1723
  if (isExecutable(fvmDefault)) return fvmDefault;
1181
- const versionsDir = join8(home, "fvm", "versions");
1182
- if (!existsSync6(versionsDir)) return null;
1724
+ const versionsDir = join9(home, "fvm", "versions");
1725
+ if (!existsSync7(versionsDir)) return null;
1183
1726
  let best = null;
1184
1727
  try {
1185
- for (const name of readdirSync2(versionsDir)) {
1186
- const candidate = join8(versionsDir, name, "bin", "flutter");
1728
+ for (const name of readdirSync3(versionsDir)) {
1729
+ const candidate = join9(versionsDir, name, "bin", "flutter");
1187
1730
  if (!isExecutable(candidate)) continue;
1188
1731
  if (!best || compareSemver(name, best.version) > 0) {
1189
1732
  best = { version: name, path: candidate };
@@ -1209,7 +1752,7 @@ function findFlutterBin(options = {}) {
1209
1752
  const home = options.home ?? homedir();
1210
1753
  const envRoot = env.FLUTTER_ROOT;
1211
1754
  if (envRoot) {
1212
- const bin = join8(envRoot, "bin", "flutter");
1755
+ const bin = join9(envRoot, "bin", "flutter");
1213
1756
  if (isExecutable(bin)) return bin;
1214
1757
  }
1215
1758
  const fromPathBin = fromPath(env);
@@ -1852,8 +2395,8 @@ function infoCommand(name, cwd, options = {}) {
1852
2395
  }
1853
2396
 
1854
2397
  // src/commands/theme-apply.ts
1855
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, mkdirSync as mkdirSync3 } from "fs";
1856
- import { resolve as resolve2, dirname as dirname4, join as join9 } from "path";
2398
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync5, mkdirSync as mkdirSync3 } from "fs";
2399
+ import { resolve as resolve4, dirname as dirname5, join as join10 } from "path";
1857
2400
  import { generateTheme, generateThemeData as generateThemeData2 } from "@loworbitstudio/visor-theme-engine";
1858
2401
  import {
1859
2402
  nextjsAdapter as nextjsAdapter2,
@@ -1883,10 +2426,10 @@ function defaultOutputPath(adapter, themeName) {
1883
2426
  }
1884
2427
  }
1885
2428
  function themeApplyCommand(file, cwd, options) {
1886
- const filePath = resolve2(cwd, file);
2429
+ const filePath = resolve4(cwd, file);
1887
2430
  let yamlContent;
1888
2431
  try {
1889
- yamlContent = readFileSync8(filePath, "utf-8");
2432
+ yamlContent = readFileSync9(filePath, "utf-8");
1890
2433
  } catch {
1891
2434
  if (options.json) {
1892
2435
  console.log(
@@ -1963,14 +2506,14 @@ function themeApplyCommand(file, cwd, options) {
1963
2506
  process.exit(1);
1964
2507
  }
1965
2508
  const outputTarget = options.output ?? defaultOutputPath(options.adapter, themeName);
1966
- const outputPath = resolve2(cwd, outputTarget);
2509
+ const outputPath = resolve4(cwd, outputTarget);
1967
2510
  if (fileMap) {
1968
2511
  try {
1969
2512
  mkdirSync3(outputPath, { recursive: true });
1970
2513
  let totalBytes = 0;
1971
2514
  for (const [relPath, content] of Object.entries(fileMap.files)) {
1972
- const filePath2 = join9(outputPath, relPath);
1973
- mkdirSync3(dirname4(filePath2), { recursive: true });
2515
+ const filePath2 = join10(outputPath, relPath);
2516
+ mkdirSync3(dirname5(filePath2), { recursive: true });
1974
2517
  writeFileSync5(filePath2, content, "utf-8");
1975
2518
  totalBytes += content.length;
1976
2519
  }
@@ -2008,7 +2551,7 @@ function themeApplyCommand(file, cwd, options) {
2008
2551
  if (css === null) {
2009
2552
  process.exit(1);
2010
2553
  }
2011
- const outputDir = dirname4(outputPath);
2554
+ const outputDir = dirname5(outputPath);
2012
2555
  try {
2013
2556
  mkdirSync3(outputDir, { recursive: true });
2014
2557
  writeFileSync5(outputPath, css, "utf-8");
@@ -2052,8 +2595,8 @@ function formatSize(bytes) {
2052
2595
  }
2053
2596
 
2054
2597
  // src/commands/theme-export.ts
2055
- import { readFileSync as readFileSync9 } from "fs";
2056
- import { resolve as resolve3 } from "path";
2598
+ import { readFileSync as readFileSync10 } from "fs";
2599
+ import { resolve as resolve5 } from "path";
2057
2600
  import {
2058
2601
  parseConfig,
2059
2602
  resolveConfig,
@@ -2061,10 +2604,10 @@ import {
2061
2604
  exportTheme
2062
2605
  } from "@loworbitstudio/visor-theme-engine";
2063
2606
  function themeExportCommand(file, cwd, options) {
2064
- const filePath = resolve3(cwd, file ?? ".visor.yaml");
2607
+ const filePath = resolve5(cwd, file ?? ".visor.yaml");
2065
2608
  let yamlContent;
2066
2609
  try {
2067
- yamlContent = readFileSync9(filePath, "utf-8");
2610
+ yamlContent = readFileSync10(filePath, "utf-8");
2068
2611
  } catch {
2069
2612
  if (options.json) {
2070
2613
  console.log(
@@ -2116,16 +2659,16 @@ function themeExportCommand(file, cwd, options) {
2116
2659
  }
2117
2660
 
2118
2661
  // src/commands/theme-validate.ts
2119
- import { readFileSync as readFileSync10 } from "fs";
2120
- import { resolve as resolve4 } from "path";
2662
+ import { readFileSync as readFileSync11 } from "fs";
2663
+ import { resolve as resolve6 } from "path";
2121
2664
  import { parse as parseYaml } from "yaml";
2122
2665
  import { validate } from "@loworbitstudio/visor-theme-engine";
2123
- import pc2 from "picocolors";
2666
+ import pc3 from "picocolors";
2124
2667
  function themeValidateCommand(file, cwd, options) {
2125
- const filePath = resolve4(cwd, file);
2668
+ const filePath = resolve6(cwd, file);
2126
2669
  let fileContent;
2127
2670
  try {
2128
- fileContent = readFileSync10(filePath, "utf-8");
2671
+ fileContent = readFileSync11(filePath, "utf-8");
2129
2672
  } catch {
2130
2673
  if (options.json) {
2131
2674
  console.log(
@@ -2205,15 +2748,127 @@ function themeValidateCommand(file, cwd, options) {
2205
2748
  }
2206
2749
  }
2207
2750
  function printIssue(issue) {
2208
- const prefix = issue.severity === "error" ? pc2.red(" ERROR") : pc2.yellow(" WARN ");
2209
- const code = pc2.dim(`[${issue.code}]`);
2210
- const path2 = issue.path ? pc2.dim(` (${issue.path})`) : "";
2751
+ const prefix = issue.severity === "error" ? pc3.red(" ERROR") : pc3.yellow(" WARN ");
2752
+ const code = pc3.dim(`[${issue.code}]`);
2753
+ const path2 = issue.path ? pc3.dim(` (${issue.path})`) : "";
2211
2754
  console.log(`${prefix} ${code} ${issue.message}${path2}`);
2212
2755
  }
2213
2756
 
2757
+ // src/commands/theme-verify.ts
2758
+ import { spawnSync as _spawnSync } from "child_process";
2759
+ import { existsSync as existsSync8 } from "fs";
2760
+ import { resolve as resolve7 } from "path";
2761
+ function themeVerifyCommand(dir, cwd, options, _spawnFn = _spawnSync) {
2762
+ const target = options.target ?? "flutter";
2763
+ if (target !== "flutter") {
2764
+ if (options.json) {
2765
+ console.log(
2766
+ JSON.stringify({
2767
+ valid: false,
2768
+ target,
2769
+ errors: [
2770
+ {
2771
+ code: "UNSUPPORTED_TARGET",
2772
+ message: `Unsupported target: "${target}". Only "flutter" is supported.`
2773
+ }
2774
+ ]
2775
+ })
2776
+ );
2777
+ } else {
2778
+ logger.error(`Unsupported target: "${target}". Only "flutter" is supported.`);
2779
+ }
2780
+ process.exit(1);
2781
+ }
2782
+ const dirPath = resolve7(cwd, dir);
2783
+ if (!existsSync8(dirPath)) {
2784
+ if (options.json) {
2785
+ console.log(
2786
+ JSON.stringify({
2787
+ valid: false,
2788
+ target,
2789
+ dir: dirPath,
2790
+ errors: [
2791
+ {
2792
+ code: "DIR_NOT_FOUND",
2793
+ message: `Directory not found: ${dirPath}`
2794
+ }
2795
+ ]
2796
+ })
2797
+ );
2798
+ } else {
2799
+ logger.error(`Directory not found: ${dirPath}`);
2800
+ logger.info("Make sure the path exists and is readable.");
2801
+ }
2802
+ process.exit(1);
2803
+ }
2804
+ const flutterBin = findFlutterBin();
2805
+ if (!flutterBin) {
2806
+ if (options.json) {
2807
+ console.log(
2808
+ JSON.stringify({
2809
+ valid: false,
2810
+ target,
2811
+ dir: dirPath,
2812
+ errors: [
2813
+ {
2814
+ code: "FLUTTER_NOT_FOUND",
2815
+ message: "Flutter binary not found. Set FLUTTER_ROOT, add flutter to PATH, or install via FVM."
2816
+ }
2817
+ ]
2818
+ })
2819
+ );
2820
+ } else {
2821
+ logger.error("Flutter binary not found.");
2822
+ logger.info(
2823
+ "Set FLUTTER_ROOT, add flutter to PATH, or install via FVM."
2824
+ );
2825
+ }
2826
+ process.exit(1);
2827
+ }
2828
+ if (!options.json) {
2829
+ logger.info(`Verifying Flutter output at: ${dirPath}`);
2830
+ logger.item(`Using Flutter binary: ${flutterBin}`);
2831
+ }
2832
+ const result = _spawnFn(flutterBin, ["analyze", "--no-pub"], {
2833
+ cwd: dirPath,
2834
+ stdio: options.json ? "pipe" : "inherit",
2835
+ encoding: "utf-8"
2836
+ });
2837
+ if (options.json) {
2838
+ const stdout = (result.stdout ?? "").toString();
2839
+ const stderr = (result.stderr ?? "").toString();
2840
+ const success = result.status === 0;
2841
+ console.log(
2842
+ JSON.stringify({
2843
+ valid: success,
2844
+ target,
2845
+ dir: dirPath,
2846
+ exitCode: result.status,
2847
+ stdout: stdout.trim() || void 0,
2848
+ stderr: stderr.trim() || void 0,
2849
+ errors: success ? [] : [
2850
+ {
2851
+ code: "DART_ANALYZE_FAILED",
2852
+ message: stderr.trim() || stdout.trim() || "dart analyze reported errors"
2853
+ }
2854
+ ]
2855
+ })
2856
+ );
2857
+ process.exit(success ? 0 : 1);
2858
+ }
2859
+ if (result.status === 0) {
2860
+ logger.success("Flutter output is clean \u2014 dart analyze passed.");
2861
+ process.exit(0);
2862
+ } else {
2863
+ logger.error("dart analyze reported errors in the generated output.");
2864
+ logger.info("Fix the errors above, then re-run: visor theme apply --target flutter");
2865
+ process.exit(1);
2866
+ }
2867
+ }
2868
+
2214
2869
  // src/commands/theme-extract.ts
2215
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync6, existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
2216
- import { resolve as resolve5, join as join10, basename, extname as extname2, relative } from "path";
2870
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync6, existsSync as existsSync9, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
2871
+ import { resolve as resolve8, join as join11, basename as basename2, extname as extname3, relative } from "path";
2217
2872
  import { stringify as stringifyYaml } from "yaml";
2218
2873
  import {
2219
2874
  extractFromCSS,
@@ -2242,8 +2897,8 @@ var CSS_DIRS = [
2242
2897
  "packages/design-tokens"
2243
2898
  ];
2244
2899
  function themeExtractCommand(cwd, options) {
2245
- const targetDir = resolve5(cwd, options.from ?? ".");
2246
- if (!existsSync7(targetDir)) {
2900
+ const targetDir = resolve8(cwd, options.from ?? ".");
2901
+ if (!existsSync9(targetDir)) {
2247
2902
  if (options.json) {
2248
2903
  console.log(JSON.stringify({ success: false, error: `Directory not found: ${targetDir}` }));
2249
2904
  } else {
@@ -2297,16 +2952,16 @@ function collectCSSFiles(targetDir) {
2297
2952
  const files = [];
2298
2953
  const seen = /* @__PURE__ */ new Set();
2299
2954
  for (const pattern2 of CSS_FILE_PATTERNS) {
2300
- const rootPath = join10(targetDir, pattern2);
2955
+ const rootPath = join11(targetDir, pattern2);
2301
2956
  addFileIfExists(rootPath, files, seen);
2302
2957
  for (const dir of CSS_DIRS) {
2303
- const dirPath = join10(targetDir, dir, pattern2);
2958
+ const dirPath = join11(targetDir, dir, pattern2);
2304
2959
  addFileIfExists(dirPath, files, seen);
2305
2960
  }
2306
2961
  }
2307
2962
  for (const dir of CSS_DIRS) {
2308
- const dirPath = join10(targetDir, dir);
2309
- if (existsSync7(dirPath) && statSync3(dirPath).isDirectory()) {
2963
+ const dirPath = join11(targetDir, dir);
2964
+ if (existsSync9(dirPath) && statSync5(dirPath).isDirectory()) {
2310
2965
  scanDirForCSS(dirPath, files, seen, 2);
2311
2966
  }
2312
2967
  }
@@ -2314,11 +2969,11 @@ function collectCSSFiles(targetDir) {
2314
2969
  return files;
2315
2970
  }
2316
2971
  function addFileIfExists(filePath, files, seen) {
2317
- const resolved = resolve5(filePath);
2972
+ const resolved = resolve8(filePath);
2318
2973
  if (seen.has(resolved)) return;
2319
- if (!existsSync7(resolved)) return;
2974
+ if (!existsSync9(resolved)) return;
2320
2975
  try {
2321
- const content = readFileSync11(resolved, "utf-8");
2976
+ const content = readFileSync12(resolved, "utf-8");
2322
2977
  if (content.includes("--")) {
2323
2978
  files.push({ path: resolved, content });
2324
2979
  seen.add(resolved);
@@ -2327,7 +2982,7 @@ function addFileIfExists(filePath, files, seen) {
2327
2982
  }
2328
2983
  }
2329
2984
  function scanDirForCSS(dir, files, seen, maxDepth) {
2330
- if (!existsSync7(dir)) return;
2985
+ if (!existsSync9(dir)) return;
2331
2986
  const SKIP_DIRS = /* @__PURE__ */ new Set([
2332
2987
  "node_modules",
2333
2988
  ".next",
@@ -2341,15 +2996,15 @@ function scanDirForCSS(dir, files, seen, maxDepth) {
2341
2996
  ".vercel"
2342
2997
  ]);
2343
2998
  try {
2344
- const entries = readdirSync3(dir, { withFileTypes: true });
2999
+ const entries = readdirSync4(dir, { withFileTypes: true });
2345
3000
  for (const entry of entries) {
2346
3001
  if (entry.isDirectory()) {
2347
3002
  if (SKIP_DIRS.has(entry.name)) continue;
2348
3003
  if (maxDepth > 0) {
2349
- scanDirForCSS(join10(dir, entry.name), files, seen, maxDepth - 1);
3004
+ scanDirForCSS(join11(dir, entry.name), files, seen, maxDepth - 1);
2350
3005
  }
2351
- } else if (entry.isFile() && extname2(entry.name) === ".css") {
2352
- addFileIfExists(join10(dir, entry.name), files, seen);
3006
+ } else if (entry.isFile() && extname3(entry.name) === ".css") {
3007
+ addFileIfExists(join11(dir, entry.name), files, seen);
2353
3008
  }
2354
3009
  }
2355
3010
  } catch {
@@ -2431,10 +3086,10 @@ function extractVarName(varExpr) {
2431
3086
  function parseNextFontFromLayouts(targetDir) {
2432
3087
  const fontMap = /* @__PURE__ */ new Map();
2433
3088
  for (const relPath of LAYOUT_FILE_PATHS) {
2434
- const fullPath = join10(targetDir, relPath);
2435
- if (!existsSync7(fullPath)) continue;
3089
+ const fullPath = join11(targetDir, relPath);
3090
+ if (!existsSync9(fullPath)) continue;
2436
3091
  try {
2437
- const content = readFileSync11(fullPath, "utf-8");
3092
+ const content = readFileSync12(fullPath, "utf-8");
2438
3093
  parseNextFontDeclarations(content, fontMap);
2439
3094
  } catch {
2440
3095
  }
@@ -2481,7 +3136,7 @@ function parseNextFontDeclarations(content, fontMap) {
2481
3136
  const srcMatch = block.match(/src\s*:\s*["']([^"']+)["']/);
2482
3137
  if (srcMatch) {
2483
3138
  const srcPath = srcMatch[1];
2484
- const fileName = basename(srcPath, extname2(srcPath));
3139
+ const fileName = basename2(srcPath, extname3(srcPath));
2485
3140
  const fontBaseName = fileName.replace(/[-_](Variable|Regular|Bold|Light|Medium|SemiBold|ExtraBold|Thin|Black|Italic).*$/i, "").replace(/[-_]/g, " ").trim();
2486
3141
  if (fontBaseName) {
2487
3142
  fontMap.set(varName, fontBaseName);
@@ -2519,10 +3174,10 @@ var MONO_FONT_NAMES = /* @__PURE__ */ new Set([
2519
3174
  "IBM Plex Mono"
2520
3175
  ]);
2521
3176
  function extractFontHints(targetDir) {
2522
- const pkgPath = join10(targetDir, "package.json");
2523
- if (!existsSync7(pkgPath)) return void 0;
3177
+ const pkgPath = join11(targetDir, "package.json");
3178
+ if (!existsSync9(pkgPath)) return void 0;
2524
3179
  try {
2525
- const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
3180
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
2526
3181
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
2527
3182
  const fonts2 = [];
2528
3183
  for (const [dep, _] of Object.entries(allDeps)) {
@@ -2558,10 +3213,10 @@ function extractFontHints(targetDir) {
2558
3213
  }
2559
3214
  }
2560
3215
  function inferThemeName(targetDir) {
2561
- const pkgPath = join10(targetDir, "package.json");
2562
- if (existsSync7(pkgPath)) {
3216
+ const pkgPath = join11(targetDir, "package.json");
3217
+ if (existsSync9(pkgPath)) {
2563
3218
  try {
2564
- const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
3219
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
2565
3220
  if (pkg.name) {
2566
3221
  const name = pkg.name.replace(/^@[\w-]+\//, "");
2567
3222
  return `${name}-theme`;
@@ -2569,7 +3224,7 @@ function inferThemeName(targetDir) {
2569
3224
  } catch {
2570
3225
  }
2571
3226
  }
2572
- return `${basename(targetDir)}-theme`;
3227
+ return `${basename2(targetDir)}-theme`;
2573
3228
  }
2574
3229
  function confidenceComment(confidence) {
2575
3230
  return `# confidence: ${confidence}`;
@@ -2598,7 +3253,7 @@ function outputJSON(result, validationResult) {
2598
3253
  }
2599
3254
  function outputYAML(result, outputPath, cwd, validationResult) {
2600
3255
  const yamlStr = buildAnnotatedYAML(result);
2601
- const outFile = resolve5(cwd, outputPath ?? ".visor.yaml");
3256
+ const outFile = resolve8(cwd, outputPath ?? ".visor.yaml");
2602
3257
  const high = result.tokens.filter((t) => t.confidence === "high").length;
2603
3258
  const med = result.tokens.filter((t) => t.confidence === "medium").length;
2604
3259
  const low = result.tokens.filter((t) => t.confidence === "low").length;
@@ -2651,11 +3306,11 @@ function buildAnnotatedYAML(result) {
2651
3306
  for (const token of result.tokens) {
2652
3307
  confidenceMap.set(token.name, token.confidence);
2653
3308
  }
2654
- const lines = baseYaml.split("\n");
3309
+ const lines2 = baseYaml.split("\n");
2655
3310
  const annotated = [];
2656
3311
  let inColors = false;
2657
3312
  let inColorsDark = false;
2658
- for (const line of lines) {
3313
+ for (const line of lines2) {
2659
3314
  if (/^colors:/.test(line)) {
2660
3315
  inColors = true;
2661
3316
  inColorsDark = false;
@@ -2684,14 +3339,15 @@ function buildAnnotatedYAML(result) {
2684
3339
  }
2685
3340
 
2686
3341
  // src/commands/theme-register.ts
2687
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync7, mkdirSync as mkdirSync4, existsSync as existsSync9 } from "fs";
2688
- import { resolve as resolve7, join as join12 } from "path";
3342
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync7, mkdirSync as mkdirSync4, existsSync as existsSync11 } from "fs";
3343
+ import { resolve as resolve10, join as join13 } from "path";
2689
3344
  import { generateThemeData as generateThemeData3 } from "@loworbitstudio/visor-theme-engine";
2690
3345
  import { docsAdapter as docsAdapter2 } from "@loworbitstudio/visor-theme-engine/adapters";
2691
3346
 
2692
3347
  // src/utils/theme-helpers.ts
2693
- import { existsSync as existsSync8 } from "fs";
2694
- import { resolve as resolve6, dirname as dirname5, join as join11 } from "path";
3348
+ import { existsSync as existsSync10, lstatSync, readdirSync as readdirSync5, readFileSync as readFileSync13, readlinkSync, realpathSync, statSync as statSync6 } from "fs";
3349
+ import { resolve as resolve9, dirname as dirname6, join as join12 } from "path";
3350
+ import { execFileSync as execFileSync3 } from "child_process";
2695
3351
  function toSlug(name) {
2696
3352
  return name.toLowerCase().replace(/\s+/g, "-");
2697
3353
  }
@@ -2699,17 +3355,132 @@ function toLabel(name) {
2699
3355
  return name.split(/[\s-]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
2700
3356
  }
2701
3357
  function findRepoRoot(startDir) {
2702
- let current = resolve6(startDir);
3358
+ let current = resolve9(startDir);
2703
3359
  while (true) {
2704
- if (existsSync8(join11(current, "packages", "docs"))) {
3360
+ if (existsSync10(join12(current, "packages", "docs"))) {
2705
3361
  return current;
2706
3362
  }
2707
- const parent = dirname5(current);
3363
+ const parent = dirname6(current);
2708
3364
  if (parent === current) break;
2709
3365
  current = parent;
2710
3366
  }
2711
3367
  return null;
2712
3368
  }
3369
+ function findMainRepoRoot(startDir) {
3370
+ try {
3371
+ const commonDir = execFileSync3("git", ["rev-parse", "--git-common-dir"], {
3372
+ cwd: startDir,
3373
+ encoding: "utf-8",
3374
+ stdio: ["ignore", "pipe", "ignore"]
3375
+ }).trim();
3376
+ if (commonDir) {
3377
+ const absoluteCommonDir = resolve9(startDir, commonDir);
3378
+ const candidate = dirname6(absoluteCommonDir);
3379
+ if (existsSync10(join12(candidate, "packages", "docs"))) {
3380
+ return candidate;
3381
+ }
3382
+ }
3383
+ } catch {
3384
+ }
3385
+ return findRepoRoot(startDir);
3386
+ }
3387
+ var BrokenSymlinkError = class extends Error {
3388
+ constructor(path2, target) {
3389
+ super(`Broken symlink: ${path2} \u2192 ${target}`);
3390
+ this.path = path2;
3391
+ this.target = target;
3392
+ }
3393
+ path;
3394
+ target;
3395
+ code = "BROKEN_SYMLINK";
3396
+ };
3397
+ function assertNoBrokenSymlinks(dir) {
3398
+ if (!existsSync10(dir)) return;
3399
+ const entries = readdirSync5(dir, { withFileTypes: true });
3400
+ for (const entry of entries) {
3401
+ const entryPath = join12(dir, entry.name);
3402
+ let lst;
3403
+ try {
3404
+ lst = lstatSync(entryPath);
3405
+ } catch {
3406
+ continue;
3407
+ }
3408
+ if (lst.isSymbolicLink()) {
3409
+ const target = readlinkSync(entryPath);
3410
+ try {
3411
+ statSync6(entryPath);
3412
+ } catch {
3413
+ throw new BrokenSymlinkError(entryPath, target);
3414
+ }
3415
+ }
3416
+ }
3417
+ }
3418
+ function scanParentForPrivateThemes(parentDir) {
3419
+ if (!existsSync10(parentDir)) return [];
3420
+ const entries = readdirSync5(parentDir, { withFileTypes: true });
3421
+ const matches = [];
3422
+ for (const entry of entries) {
3423
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
3424
+ const candidate = join12(parentDir, entry.name, "visor-themes-private", "themes");
3425
+ if (existsSync10(candidate)) {
3426
+ matches.push(candidate);
3427
+ }
3428
+ }
3429
+ return matches.sort();
3430
+ }
3431
+ function scanNestedThemeDir(dir) {
3432
+ if (!existsSync10(dir)) return [];
3433
+ assertNoBrokenSymlinks(dir);
3434
+ const entries = readdirSync5(dir, { withFileTypes: true });
3435
+ const out = [];
3436
+ for (const entry of entries) {
3437
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
3438
+ const themeFile = join12(dir, entry.name, "theme.visor.yaml");
3439
+ if (existsSync10(themeFile)) {
3440
+ out.push({ filePath: themeFile, slug: entry.name });
3441
+ }
3442
+ }
3443
+ return out;
3444
+ }
3445
+ function detectVisorWorkspace(cwd) {
3446
+ let current = resolve9(cwd);
3447
+ while (true) {
3448
+ const pkgPath = join12(current, "package.json");
3449
+ if (existsSync10(pkgPath)) {
3450
+ try {
3451
+ const pkg = JSON.parse(readFileSync13(pkgPath, "utf-8"));
3452
+ const workspaces = pkg.workspaces ?? [];
3453
+ const hasCli = workspaces.some((w) => w.includes("packages/cli"));
3454
+ const hasEngine = workspaces.some((w) => w.includes("packages/theme-engine"));
3455
+ if (pkg.name === "visor" && hasCli && hasEngine) {
3456
+ return current;
3457
+ }
3458
+ } catch {
3459
+ }
3460
+ }
3461
+ const parent = dirname6(current);
3462
+ if (parent === current) break;
3463
+ current = parent;
3464
+ }
3465
+ return null;
3466
+ }
3467
+ function isLocalVisorBinary(workspaceRoot, scriptPath) {
3468
+ if (!scriptPath) return false;
3469
+ const expectedPrefix = join12(workspaceRoot, "packages", "cli", "dist");
3470
+ let resolvedScript;
3471
+ let resolvedPrefix;
3472
+ try {
3473
+ resolvedScript = realpathSync(scriptPath);
3474
+ } catch {
3475
+ resolvedScript = resolve9(scriptPath);
3476
+ }
3477
+ try {
3478
+ resolvedPrefix = realpathSync(expectedPrefix);
3479
+ } catch {
3480
+ resolvedPrefix = resolve9(expectedPrefix);
3481
+ }
3482
+ return resolvedScript.startsWith(resolvedPrefix + "/") || resolvedScript === resolvedPrefix;
3483
+ }
2713
3484
 
2714
3485
  // src/commands/theme-register.ts
2715
3486
  function insertGlobalsImport(content, slug2) {
@@ -2717,32 +3488,32 @@ function insertGlobalsImport(content, slug2) {
2717
3488
  if (content.includes(importLine)) {
2718
3489
  return { updated: content, changed: false };
2719
3490
  }
2720
- const lines = content.split("\n");
3491
+ const lines2 = content.split("\n");
2721
3492
  const themeImportPattern = /^@import '\.\/[\w-]+-theme\.css';/;
2722
3493
  const themeImportIndices = [];
2723
- for (let i = 0; i < lines.length; i++) {
2724
- if (themeImportPattern.test(lines[i])) {
3494
+ for (let i = 0; i < lines2.length; i++) {
3495
+ if (themeImportPattern.test(lines2[i])) {
2725
3496
  themeImportIndices.push(i);
2726
3497
  }
2727
3498
  }
2728
3499
  if (themeImportIndices.length === 0) {
2729
- const lastImportIdx = lines.reduce(
3500
+ const lastImportIdx = lines2.reduce(
2730
3501
  (last, line, i) => line.startsWith("@import") ? i : last,
2731
3502
  -1
2732
3503
  );
2733
3504
  const insertAt2 = lastImportIdx + 1;
2734
- lines.splice(insertAt2, 0, importLine);
2735
- return { updated: lines.join("\n"), changed: true };
3505
+ lines2.splice(insertAt2, 0, importLine);
3506
+ return { updated: lines2.join("\n"), changed: true };
2736
3507
  }
2737
3508
  let insertAt = themeImportIndices[themeImportIndices.length - 1] + 1;
2738
3509
  for (const idx of themeImportIndices) {
2739
- if (importLine < lines[idx]) {
3510
+ if (importLine < lines2[idx]) {
2740
3511
  insertAt = idx;
2741
3512
  break;
2742
3513
  }
2743
3514
  }
2744
- lines.splice(insertAt, 0, importLine);
2745
- return { updated: lines.join("\n"), changed: true };
3515
+ lines2.splice(insertAt, 0, importLine);
3516
+ return { updated: lines2.join("\n"), changed: true };
2746
3517
  }
2747
3518
  function insertThemeConfig(content, slug2, label, group) {
2748
3519
  if (content.includes(`value: "${slug2}"`)) {
@@ -2796,10 +3567,10 @@ ${indent}${newEntry},
2796
3567
  return { updated, changed: true };
2797
3568
  }
2798
3569
  function themeRegisterCommand(file, cwd, options) {
2799
- const filePath = resolve7(cwd, file);
3570
+ const filePath = resolve10(cwd, file);
2800
3571
  let yamlContent;
2801
3572
  try {
2802
- yamlContent = readFileSync12(filePath, "utf-8");
3573
+ yamlContent = readFileSync14(filePath, "utf-8");
2803
3574
  } catch {
2804
3575
  if (options.json) {
2805
3576
  console.log(JSON.stringify({ success: false, error: `Could not read file: ${filePath}` }));
@@ -2842,11 +3613,11 @@ function themeRegisterCommand(file, cwd, options) {
2842
3613
  process.exit(1);
2843
3614
  return;
2844
3615
  }
2845
- const docsAppDir = join12(repoRoot, "packages", "docs", "app");
2846
- const cssFilePath = join12(docsAppDir, `${slug2}-theme.css`);
2847
- const globalsPath = join12(docsAppDir, "globals.css");
2848
- const themeConfigPath = join12(repoRoot, "packages", "docs", "lib", "theme-config.ts");
2849
- if (!existsSync9(docsAppDir)) {
3616
+ const docsAppDir = join13(repoRoot, "packages", "docs", "app");
3617
+ const cssFilePath = join13(docsAppDir, `${slug2}-theme.css`);
3618
+ const globalsPath = join13(docsAppDir, "globals.css");
3619
+ const themeConfigPath = join13(repoRoot, "packages", "docs", "lib", "theme-config.ts");
3620
+ if (!existsSync11(docsAppDir)) {
2850
3621
  const msg = `Docs app directory not found: ${docsAppDir}`;
2851
3622
  if (options.json) {
2852
3623
  console.log(JSON.stringify({ success: false, error: msg }));
@@ -2859,8 +3630,8 @@ function themeRegisterCommand(file, cwd, options) {
2859
3630
  let globalsContent = "";
2860
3631
  let themeConfigContent = "";
2861
3632
  try {
2862
- globalsContent = readFileSync12(globalsPath, "utf-8");
2863
- themeConfigContent = readFileSync12(themeConfigPath, "utf-8");
3633
+ globalsContent = readFileSync14(globalsPath, "utf-8");
3634
+ themeConfigContent = readFileSync14(themeConfigPath, "utf-8");
2864
3635
  } catch (err) {
2865
3636
  const msg = err instanceof Error ? err.message : "Could not read docs files";
2866
3637
  if (options.json) {
@@ -2871,8 +3642,8 @@ function themeRegisterCommand(file, cwd, options) {
2871
3642
  process.exit(1);
2872
3643
  return;
2873
3644
  }
2874
- const cssExists = existsSync9(cssFilePath);
2875
- const cssChanged = !cssExists || readFileSync12(cssFilePath, "utf-8") !== css;
3645
+ const cssExists = existsSync11(cssFilePath);
3646
+ const cssChanged = !cssExists || readFileSync14(cssFilePath, "utf-8") !== css;
2876
3647
  const { updated: newGlobals, changed: globalsChanged } = insertGlobalsImport(globalsContent, slug2);
2877
3648
  const { updated: newThemeConfig, changed: themeConfigChanged, error: configError } = insertThemeConfig(
2878
3649
  themeConfigContent,
@@ -2956,8 +3727,8 @@ function themeRegisterCommand(file, cwd, options) {
2956
3727
  }
2957
3728
 
2958
3729
  // src/commands/theme-unregister.ts
2959
- import { readFileSync as readFileSync13, writeFileSync as writeFileSync8, existsSync as existsSync10, unlinkSync } from "fs";
2960
- import { join as join13 } from "path";
3730
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync8, existsSync as existsSync12, unlinkSync } from "fs";
3731
+ import { join as join14 } from "path";
2961
3732
  function removeGlobalsImport(content, slug2) {
2962
3733
  const importLine = `@import './${slug2}-theme.css';`;
2963
3734
  if (!content.includes(importLine)) {
@@ -2989,11 +3760,11 @@ function themeUnregisterCommand(slug2, cwd, options) {
2989
3760
  process.exit(1);
2990
3761
  return;
2991
3762
  }
2992
- const docsAppDir = join13(repoRoot, "packages", "docs", "app");
2993
- const cssFilePath = join13(docsAppDir, `${slug2}-theme.css`);
2994
- const globalsPath = join13(docsAppDir, "globals.css");
2995
- const themeConfigPath = join13(repoRoot, "packages", "docs", "lib", "theme-config.ts");
2996
- if (!existsSync10(docsAppDir)) {
3763
+ const docsAppDir = join14(repoRoot, "packages", "docs", "app");
3764
+ const cssFilePath = join14(docsAppDir, `${slug2}-theme.css`);
3765
+ const globalsPath = join14(docsAppDir, "globals.css");
3766
+ const themeConfigPath = join14(repoRoot, "packages", "docs", "lib", "theme-config.ts");
3767
+ if (!existsSync12(docsAppDir)) {
2997
3768
  const msg = `Docs app directory not found: ${docsAppDir}`;
2998
3769
  if (options.json) {
2999
3770
  console.log(JSON.stringify({ success: false, error: msg }));
@@ -3006,8 +3777,8 @@ function themeUnregisterCommand(slug2, cwd, options) {
3006
3777
  let globalsContent = "";
3007
3778
  let themeConfigContent = "";
3008
3779
  try {
3009
- globalsContent = readFileSync13(globalsPath, "utf-8");
3010
- themeConfigContent = readFileSync13(themeConfigPath, "utf-8");
3780
+ globalsContent = readFileSync15(globalsPath, "utf-8");
3781
+ themeConfigContent = readFileSync15(themeConfigPath, "utf-8");
3011
3782
  } catch (err) {
3012
3783
  const msg = err instanceof Error ? err.message : "Could not read docs files";
3013
3784
  if (options.json) {
@@ -3018,7 +3789,7 @@ function themeUnregisterCommand(slug2, cwd, options) {
3018
3789
  process.exit(1);
3019
3790
  return;
3020
3791
  }
3021
- const cssExists = existsSync10(cssFilePath);
3792
+ const cssExists = existsSync12(cssFilePath);
3022
3793
  const { updated: newGlobals, changed: globalsChanged } = removeGlobalsImport(globalsContent, slug2);
3023
3794
  const { updated: newThemeConfig, changed: themeConfigChanged } = removeThemeConfigEntry(themeConfigContent, slug2);
3024
3795
  if (!cssExists && !globalsChanged && !themeConfigChanged) {
@@ -3059,30 +3830,150 @@ function themeUnregisterCommand(slug2, cwd, options) {
3059
3830
 
3060
3831
  // src/commands/theme-sync.ts
3061
3832
  import {
3062
- readFileSync as readFileSync14,
3833
+ readFileSync as readFileSync16,
3063
3834
  writeFileSync as writeFileSync9,
3064
3835
  mkdirSync as mkdirSync5,
3065
- existsSync as existsSync11,
3066
- readdirSync as readdirSync4,
3836
+ existsSync as existsSync13,
3837
+ readdirSync as readdirSync6,
3067
3838
  unlinkSync as unlinkSync2,
3068
3839
  copyFileSync
3069
3840
  } from "fs";
3070
- import { join as join14, basename as basename2 } from "path";
3841
+ import { join as join15, basename as basename3, resolve as resolve11, sep } from "path";
3071
3842
  import { parse as parseYaml2 } from "yaml";
3072
3843
  import { generateThemeData as generateThemeData4 } from "@loworbitstudio/visor-theme-engine";
3073
3844
  import { docsAdapter as docsAdapter3 } from "@loworbitstudio/visor-theme-engine/adapters";
3845
+ var PRIVATE_THEMES_REPO_URL = "git@github.com:low-orbit-studio/visor-themes-private.git";
3846
+ var PRIVATE_THEMES_ENV_VAR = "VISOR_THEMES_PRIVATE_PATH";
3074
3847
  var GLOBALS_BEGIN_MARKER = "/* BEGIN visor-theme-imports \u2014 managed by `visor theme sync` */";
3075
3848
  var GLOBALS_END_MARKER = "/* END visor-theme-imports */";
3076
3849
  var STOCK_GROUPS_BEGIN_MARKER = "/* BEGIN visor-stock-themes \u2014 managed by `visor theme sync` */";
3077
3850
  var STOCK_GROUPS_END_MARKER = "/* END visor-stock-themes */";
3078
- var GITIGNORE_BEGIN_MARKER = "# BEGIN visor-custom-theme-css (managed by `visor theme sync` \u2014 do not edit manually)";
3079
- var GITIGNORE_END_MARKER = "# END visor-custom-theme-css";
3080
3851
  var CUSTOM_OVERLAY_CSS_PATH = "packages/docs/app/custom-themes.generated.css";
3081
3852
  var CUSTOM_OVERLAY_TS_PATH = "packages/docs/lib/theme-config.custom.generated.ts";
3082
3853
  var CUSTOM_OVERLAY_IMPORT_LINE = "@import './custom-themes.generated.css';";
3083
3854
  function scanThemeDir(dir) {
3084
- if (!existsSync11(dir)) return [];
3085
- return readdirSync4(dir).filter((f) => f.endsWith(".visor.yaml")).map((f) => join14(dir, f));
3855
+ if (!existsSync13(dir)) return [];
3856
+ assertNoBrokenSymlinks(dir);
3857
+ return readdirSync6(dir).filter((f) => f.endsWith(".visor.yaml")).map((f) => join15(dir, f));
3858
+ }
3859
+ function resolveCustomSources(repoRoot, mainRepoRoot, warn) {
3860
+ const merged = /* @__PURE__ */ new Map();
3861
+ const deprecationWarnings = [];
3862
+ const addNested = (dir, origin) => {
3863
+ const entries = scanNestedThemeDir(dir);
3864
+ for (const entry of entries) {
3865
+ const existing = merged.get(entry.slug);
3866
+ if (existing) {
3867
+ warn(
3868
+ `Duplicate theme slug "${entry.slug}" \u2014 keeping ${existing.origin} source (${existing.filePath}); ignoring ${origin} source (${entry.filePath}).`
3869
+ );
3870
+ continue;
3871
+ }
3872
+ merged.set(entry.slug, {
3873
+ filePath: entry.filePath,
3874
+ slug: entry.slug,
3875
+ origin
3876
+ });
3877
+ }
3878
+ };
3879
+ const envPath = process.env[PRIVATE_THEMES_ENV_VAR];
3880
+ if (envPath && envPath.trim() !== "") {
3881
+ const resolved = resolve11(envPath);
3882
+ if (!existsSync13(resolved)) {
3883
+ throw new Error(
3884
+ `${PRIVATE_THEMES_ENV_VAR} is set to "${envPath}" but the path does not exist. Expected a directory containing {slug}/theme.visor.yaml entries.`
3885
+ );
3886
+ }
3887
+ addNested(resolved, "env");
3888
+ }
3889
+ const siblingPath = join15(mainRepoRoot, "..", "visor-themes-private", "themes");
3890
+ const siblingExists = existsSync13(siblingPath);
3891
+ if (siblingExists) {
3892
+ addNested(siblingPath, "sibling");
3893
+ }
3894
+ const parentDir = join15(mainRepoRoot, "..");
3895
+ const parentGlobMatches = scanParentForPrivateThemes(parentDir).filter(
3896
+ (path2) => !path2.startsWith(mainRepoRoot + sep)
3897
+ );
3898
+ if (parentGlobMatches.length > 0) {
3899
+ if (siblingExists) {
3900
+ for (const path2 of parentGlobMatches) {
3901
+ warn(
3902
+ `Found one-level-deeper theme source ${path2} but using true-sibling at ${siblingPath} (preferred per BO-29 D2).`
3903
+ );
3904
+ }
3905
+ } else {
3906
+ const [first, ...rest] = parentGlobMatches;
3907
+ addNested(first, "parent-glob");
3908
+ for (const other of rest) {
3909
+ warn(
3910
+ `Multiple one-level-deeper theme sources found; using ${first} and ignoring ${other}. Set ${PRIVATE_THEMES_ENV_VAR} to override.`
3911
+ );
3912
+ }
3913
+ }
3914
+ }
3915
+ const legacyDir = join15(repoRoot, "custom-themes");
3916
+ const legacyFiles = scanThemeDir(legacyDir);
3917
+ for (const legacyFile of legacyFiles) {
3918
+ const slug2 = basename3(legacyFile).replace(/\.visor\.yaml$/, "");
3919
+ const existing = merged.get(slug2);
3920
+ if (existing) {
3921
+ warn(
3922
+ `Duplicate theme slug "${slug2}" \u2014 keeping ${existing.origin} source (${existing.filePath}); ignoring legacy source (${legacyFile}).`
3923
+ );
3924
+ continue;
3925
+ }
3926
+ deprecationWarnings.push(
3927
+ `Deprecated legacy custom-themes/ source: ${legacyFile} \u2014 migrate to visor-themes-private (see docs).`
3928
+ );
3929
+ merged.set(slug2, {
3930
+ filePath: legacyFile,
3931
+ slug: slug2,
3932
+ origin: "legacy"
3933
+ });
3934
+ }
3935
+ return { files: [...merged.values()], deprecationWarnings };
3936
+ }
3937
+ function reportBrokenSymlink(err, options) {
3938
+ const msg = `Broken symlink in theme source: ${err.path} \u2192 ${err.target}`;
3939
+ if (options.json) {
3940
+ console.log(JSON.stringify({ success: false, error: msg, path: err.path, target: err.target }));
3941
+ } else {
3942
+ logger.error(msg);
3943
+ }
3944
+ }
3945
+ function buildEmptySourcesMessage(mainRepoRoot) {
3946
+ const expectedSibling = join15(mainRepoRoot, "..", "visor-themes-private");
3947
+ return [
3948
+ "No theme sources discovered. Cannot proceed \u2014 refusing to wipe generated CSS.",
3949
+ "",
3950
+ "Resolution order checked:",
3951
+ ` 1. Env var ${PRIVATE_THEMES_ENV_VAR} (unset or empty)`,
3952
+ ` 2. Sibling checkout at ${expectedSibling}/themes/ (not found)`,
3953
+ ` 3. One-level-deeper at ${join15(mainRepoRoot, "..")}/<parent>/visor-themes-private/themes/ (not found)`,
3954
+ " 4. Legacy custom-themes/ (no .visor.yaml files)",
3955
+ "",
3956
+ "To fix, clone the private themes repo as a sibling:",
3957
+ ` git clone ${PRIVATE_THEMES_REPO_URL} ${expectedSibling}`,
3958
+ "",
3959
+ `Or set ${PRIVATE_THEMES_ENV_VAR} to a directory containing {slug}/theme.visor.yaml entries.`
3960
+ ].join("\n");
3961
+ }
3962
+ function enforceWorkspaceGuard(cwd) {
3963
+ if (process.env.VITEST) return null;
3964
+ if (process.env.VISOR_SKIP_WORKSPACE_GUARD) return null;
3965
+ const workspaceRoot = detectVisorWorkspace(cwd);
3966
+ if (!workspaceRoot) return null;
3967
+ if (isLocalVisorBinary(workspaceRoot, process.argv[1])) return null;
3968
+ return [
3969
+ `Detected Visor workspace at ${workspaceRoot}.`,
3970
+ "The global `visor` CLI bundles a published theme-engine that lags HEAD and",
3971
+ "can regress stock theme CSS files. Run the workspace command instead:",
3972
+ "",
3973
+ " npm run theme:sync",
3974
+ "",
3975
+ `(Override with VISOR_SKIP_WORKSPACE_GUARD=1 if you really know what you're doing.)`
3976
+ ].join("\n");
3086
3977
  }
3087
3978
  function extractGroup(yamlContent) {
3088
3979
  const parsed = parseYaml2(yamlContent);
@@ -3119,28 +4010,28 @@ ${GLOBALS_END_MARKER}`;
3119
4010
  updated = content.slice(0, beginIdx) + newBlock + content.slice(endIdx + GLOBALS_END_MARKER.length);
3120
4011
  } else {
3121
4012
  const themeImportPattern = /^@import '\.\/[\w-]+-theme\.css';\n?/gm;
3122
- const lines = content.split("\n");
4013
+ const lines2 = content.split("\n");
3123
4014
  let firstThemeIdx = -1;
3124
4015
  let lastThemeIdx = -1;
3125
- for (let i = 0; i < lines.length; i++) {
3126
- if (/^@import '\.\/[\w-]+-theme\.css';/.test(lines[i])) {
4016
+ for (let i = 0; i < lines2.length; i++) {
4017
+ if (/^@import '\.\/[\w-]+-theme\.css';/.test(lines2[i])) {
3127
4018
  if (firstThemeIdx === -1) firstThemeIdx = i;
3128
4019
  lastThemeIdx = i;
3129
4020
  }
3130
4021
  }
3131
4022
  if (firstThemeIdx !== -1) {
3132
- const before = lines.slice(0, firstThemeIdx);
3133
- const after = lines.slice(lastThemeIdx + 1);
4023
+ const before = lines2.slice(0, firstThemeIdx);
4024
+ const after = lines2.slice(lastThemeIdx + 1);
3134
4025
  updated = [...before, newBlock, ...after].join("\n");
3135
4026
  } else {
3136
4027
  void themeImportPattern;
3137
- const lastImportIdx = lines.reduce(
4028
+ const lastImportIdx = lines2.reduce(
3138
4029
  (last, line, i) => line.startsWith("@import") ? i : last,
3139
4030
  -1
3140
4031
  );
3141
4032
  const insertAt = lastImportIdx + 1;
3142
- lines.splice(insertAt, 0, newBlock);
3143
- updated = lines.join("\n");
4033
+ lines2.splice(insertAt, 0, newBlock);
4034
+ updated = lines2.join("\n");
3144
4035
  }
3145
4036
  }
3146
4037
  updated = ensureCustomOverlayImport(updated);
@@ -3241,19 +4132,17 @@ ${groupsTs}
3241
4132
  ];
3242
4133
  `;
3243
4134
  }
3244
- function updateGitignoreBlock(content, customSlugs) {
3245
- const cssLines = customSlugs.sort().map((slug2) => `packages/docs/app/${slug2}-theme.css`).join("\n");
3246
- const newBlock = `${GITIGNORE_BEGIN_MARKER}
3247
- ${cssLines}
3248
- ${GITIGNORE_END_MARKER}`;
3249
- const beginIdx = content.indexOf(GITIGNORE_BEGIN_MARKER);
3250
- const endIdx = content.indexOf(GITIGNORE_END_MARKER);
3251
- if (beginIdx !== -1 && endIdx !== -1) {
3252
- return content.slice(0, beginIdx) + newBlock + content.slice(endIdx + GITIGNORE_END_MARKER.length);
3253
- }
3254
- return content.trimEnd() + "\n\n" + newBlock + "\n";
3255
- }
3256
4135
  function themeSyncCommand(cwd, options) {
4136
+ const guardError = enforceWorkspaceGuard(cwd);
4137
+ if (guardError) {
4138
+ if (options.json) {
4139
+ console.log(JSON.stringify({ success: false, error: guardError }));
4140
+ } else {
4141
+ logger.error(guardError);
4142
+ }
4143
+ process.exit(1);
4144
+ return;
4145
+ }
3257
4146
  const repoRoot = findRepoRoot(cwd);
3258
4147
  if (!repoRoot) {
3259
4148
  const msg = "Could not locate repo root (packages/docs/ not found). Run from within the visor repo.";
@@ -3265,33 +4154,72 @@ function themeSyncCommand(cwd, options) {
3265
4154
  process.exit(1);
3266
4155
  return;
3267
4156
  }
3268
- const themesDir = join14(repoRoot, "themes");
3269
- const customThemesDir = join14(repoRoot, "custom-themes");
3270
- const docsAppDir = join14(repoRoot, "packages", "docs", "app");
3271
- const docsLibDir = join14(repoRoot, "packages", "docs", "lib");
3272
- const docsPublicThemesDir = join14(repoRoot, "packages", "docs", "public", "themes");
3273
- const themeConfigPath = join14(repoRoot, "packages", "docs", "lib", "theme-config.ts");
3274
- const globalsPath = join14(docsAppDir, "globals.css");
3275
- const gitignorePath = join14(repoRoot, ".gitignore");
3276
- const customOverlayCssPath = join14(repoRoot, CUSTOM_OVERLAY_CSS_PATH);
3277
- const customOverlayTsPath = join14(repoRoot, CUSTOM_OVERLAY_TS_PATH);
3278
- const stockFiles = scanThemeDir(themesDir);
3279
- const customFiles = scanThemeDir(customThemesDir);
3280
- if (stockFiles.length === 0 && customFiles.length === 0) {
3281
- const msg = `No .visor.yaml files found in themes/ or custom-themes/. Nothing to sync.`;
4157
+ const mainRepoRoot = findMainRepoRoot(cwd) ?? repoRoot;
4158
+ const themesDir = join15(repoRoot, "themes");
4159
+ const docsAppDir = join15(repoRoot, "packages", "docs", "app");
4160
+ const docsLibDir = join15(repoRoot, "packages", "docs", "lib");
4161
+ const docsPublicThemesDir = join15(repoRoot, "packages", "docs", "public", "themes");
4162
+ const themeConfigPath = join15(repoRoot, "packages", "docs", "lib", "theme-config.ts");
4163
+ const globalsPath = join15(docsAppDir, "globals.css");
4164
+ const customOverlayCssPath = join15(repoRoot, CUSTOM_OVERLAY_CSS_PATH);
4165
+ const customOverlayTsPath = join15(repoRoot, CUSTOM_OVERLAY_TS_PATH);
4166
+ let stockFiles;
4167
+ try {
4168
+ stockFiles = scanThemeDir(themesDir);
4169
+ } catch (err) {
4170
+ if (err instanceof BrokenSymlinkError) {
4171
+ reportBrokenSymlink(err, options);
4172
+ process.exit(1);
4173
+ return;
4174
+ }
4175
+ throw err;
4176
+ }
4177
+ let customSources = [];
4178
+ let deprecationWarnings = [];
4179
+ const discoveryWarnings = [];
4180
+ try {
4181
+ const result = resolveCustomSources(
4182
+ repoRoot,
4183
+ mainRepoRoot,
4184
+ (msg) => discoveryWarnings.push(msg)
4185
+ );
4186
+ customSources = result.files;
4187
+ deprecationWarnings = result.deprecationWarnings;
4188
+ } catch (err) {
4189
+ if (err instanceof BrokenSymlinkError) {
4190
+ reportBrokenSymlink(err, options);
4191
+ process.exit(1);
4192
+ return;
4193
+ }
4194
+ const msg = err instanceof Error ? err.message : "Custom theme discovery failed";
3282
4195
  if (options.json) {
3283
4196
  console.log(JSON.stringify({ success: false, error: msg }));
3284
4197
  } else {
3285
- logger.warn(msg);
4198
+ logger.error(msg);
4199
+ }
4200
+ process.exit(1);
4201
+ return;
4202
+ }
4203
+ if (stockFiles.length === 0 && customSources.length === 0) {
4204
+ const msg = buildEmptySourcesMessage(mainRepoRoot);
4205
+ if (options.json) {
4206
+ console.log(JSON.stringify({ success: false, error: msg }));
4207
+ } else {
4208
+ logger.error(msg);
3286
4209
  }
4210
+ process.exit(1);
3287
4211
  return;
3288
4212
  }
4213
+ if (!options.json) {
4214
+ for (const w of deprecationWarnings) logger.warn(w);
4215
+ for (const w of discoveryWarnings) logger.warn(w);
4216
+ }
3289
4217
  const manifest = [];
3290
4218
  const errors = [];
3291
- const processFile = (filePath, isCustom) => {
4219
+ const processFile = (filePath, isCustom, slugOverride) => {
3292
4220
  let yamlContent;
3293
4221
  try {
3294
- yamlContent = readFileSync14(filePath, "utf-8");
4222
+ yamlContent = readFileSync16(filePath, "utf-8");
3295
4223
  } catch {
3296
4224
  errors.push(`Could not read: ${filePath}`);
3297
4225
  return;
@@ -3300,19 +4228,22 @@ function themeSyncCommand(cwd, options) {
3300
4228
  try {
3301
4229
  data = generateThemeData4(yamlContent);
3302
4230
  } catch (err) {
3303
- errors.push(`Failed to parse ${basename2(filePath)}: ${err instanceof Error ? err.message : "Unknown error"}`);
4231
+ errors.push(`Failed to parse ${basename3(filePath)}: ${err instanceof Error ? err.message : "Unknown error"}`);
3304
4232
  return;
3305
4233
  }
3306
- const slug2 = toSlug(data.config.name);
4234
+ const slug2 = slugOverride ?? toSlug(data.config.name);
3307
4235
  const label = extractLabel(yamlContent) ?? toLabel(data.config.name);
3308
4236
  const group = extractGroup(yamlContent) ?? (isCustom ? "Custom" : "Visor");
3309
4237
  const defaultMode = extractDefaultMode(yamlContent);
3310
4238
  const css = docsAdapter3({ primitives: data.primitives, tokens: data.tokens, config: data.config });
3311
- const yamlFilename = basename2(filePath).replace(/\.visor\.yaml$/, "");
4239
+ const yamlFilename = slugOverride ?? basename3(filePath).replace(/\.visor\.yaml$/, "");
3312
4240
  manifest.push({ slug: slug2, label, group, defaultMode, css, yamlFilename, isCustom });
3313
4241
  };
3314
4242
  for (const f of stockFiles) processFile(f, false);
3315
- for (const f of customFiles) processFile(f, true);
4243
+ for (const c of customSources) {
4244
+ const isNested = c.origin !== "legacy";
4245
+ processFile(c.filePath, true, isNested ? c.slug : void 0);
4246
+ }
3316
4247
  if (errors.length > 0) {
3317
4248
  if (options.json) {
3318
4249
  console.log(JSON.stringify({ success: false, errors }));
@@ -3325,15 +4256,12 @@ function themeSyncCommand(cwd, options) {
3325
4256
  const stockManifest = manifest.filter((e) => !e.isCustom);
3326
4257
  const customManifest = manifest.filter((e) => e.isCustom);
3327
4258
  const stockSlugs = stockManifest.map((e) => e.slug);
3328
- const customSlugs = customManifest.map((e) => e.slug);
3329
4259
  const allSlugs = manifest.map((e) => e.slug);
3330
4260
  let globalsContent;
3331
4261
  let themeConfigContent;
3332
- let gitignoreContent;
3333
4262
  try {
3334
- globalsContent = readFileSync14(globalsPath, "utf-8");
3335
- themeConfigContent = readFileSync14(themeConfigPath, "utf-8");
3336
- gitignoreContent = existsSync11(gitignorePath) ? readFileSync14(gitignorePath, "utf-8") : "";
4263
+ globalsContent = readFileSync16(globalsPath, "utf-8");
4264
+ themeConfigContent = readFileSync16(themeConfigPath, "utf-8");
3337
4265
  } catch (err) {
3338
4266
  const msg = err instanceof Error ? err.message : "Could not read docs files";
3339
4267
  if (options.json) {
@@ -3346,15 +4274,14 @@ function themeSyncCommand(cwd, options) {
3346
4274
  }
3347
4275
  const newGlobals = updateGlobalsImports(globalsContent, stockSlugs);
3348
4276
  const newThemeConfig = updateStockThemeConfigBlock(themeConfigContent, stockManifest);
3349
- const newGitignore = customSlugs.length > 0 ? updateGitignoreBlock(gitignoreContent, customSlugs) : gitignoreContent;
3350
4277
  const newCustomOverlayCss = generateCustomOverlayCss(customManifest);
3351
4278
  const newCustomOverlayTs = generateCustomOverlayTs(customManifest);
3352
- const existingCssFiles = existsSync11(docsAppDir) ? readdirSync4(docsAppDir).filter(
4279
+ const existingCssFiles = existsSync13(docsAppDir) ? readdirSync6(docsAppDir).filter(
3353
4280
  (f) => f.endsWith("-theme.css") && f !== "custom-themes.generated.css"
3354
4281
  ) : [];
3355
4282
  const newCssSet = new Set(allSlugs.map((s) => `${s}-theme.css`));
3356
4283
  const staleCssFiles = existingCssFiles.filter((f) => !newCssSet.has(f));
3357
- const existingPublicYamls = existsSync11(docsPublicThemesDir) ? readdirSync4(docsPublicThemesDir).filter((f) => f.endsWith(".visor.yaml")) : [];
4284
+ const existingPublicYamls = existsSync13(docsPublicThemesDir) ? readdirSync6(docsPublicThemesDir).filter((f) => f.endsWith(".visor.yaml")) : [];
3358
4285
  const newPublicYamlSet = new Set(manifest.map((e) => `${e.yamlFilename}.visor.yaml`));
3359
4286
  const stalePublicYamls = existingPublicYamls.filter((f) => !newPublicYamlSet.has(f));
3360
4287
  if (options.dryRun) {
@@ -3366,7 +4293,6 @@ function themeSyncCommand(cwd, options) {
3366
4293
  globalsCSS: globalsPath,
3367
4294
  customOverlayCss: CUSTOM_OVERLAY_CSS_PATH,
3368
4295
  customOverlayTs: CUSTOM_OVERLAY_TS_PATH,
3369
- gitignore: gitignorePath,
3370
4296
  publicYamlsCopied: manifest.map((e) => `packages/docs/public/themes/${e.yamlFilename}.visor.yaml`),
3371
4297
  publicYamlsDeleted: stalePublicYamls.map((f) => `packages/docs/public/themes/${f}`)
3372
4298
  };
@@ -3386,25 +4312,24 @@ function themeSyncCommand(cwd, options) {
3386
4312
  mkdirSync5(docsLibDir, { recursive: true });
3387
4313
  mkdirSync5(docsPublicThemesDir, { recursive: true });
3388
4314
  for (const entry of manifest) {
3389
- writeFileSync9(join14(docsAppDir, `${entry.slug}-theme.css`), entry.css, "utf-8");
4315
+ writeFileSync9(join15(docsAppDir, `${entry.slug}-theme.css`), entry.css, "utf-8");
3390
4316
  }
3391
4317
  for (const stale of staleCssFiles) {
3392
- unlinkSync2(join14(docsAppDir, stale));
4318
+ unlinkSync2(join15(docsAppDir, stale));
3393
4319
  }
3394
4320
  writeFileSync9(customOverlayCssPath, newCustomOverlayCss, "utf-8");
3395
4321
  writeFileSync9(customOverlayTsPath, newCustomOverlayTs, "utf-8");
3396
4322
  writeFileSync9(themeConfigPath, newThemeConfig, "utf-8");
3397
4323
  writeFileSync9(globalsPath, newGlobals, "utf-8");
3398
- if (existsSync11(gitignorePath)) {
3399
- writeFileSync9(gitignorePath, newGitignore, "utf-8");
4324
+ for (const srcFile of stockFiles) {
4325
+ copyFileSync(srcFile, join15(docsPublicThemesDir, basename3(srcFile)));
3400
4326
  }
3401
- const allSourceFiles = [...stockFiles, ...customFiles];
3402
- for (const srcFile of allSourceFiles) {
3403
- const filename = basename2(srcFile);
3404
- copyFileSync(srcFile, join14(docsPublicThemesDir, filename));
4327
+ for (const c of customSources) {
4328
+ const targetName = c.origin === "legacy" ? basename3(c.filePath) : `${c.slug}.visor.yaml`;
4329
+ copyFileSync(c.filePath, join15(docsPublicThemesDir, targetName));
3405
4330
  }
3406
4331
  for (const stale of stalePublicYamls) {
3407
- unlinkSync2(join14(docsPublicThemesDir, stale));
4332
+ unlinkSync2(join15(docsPublicThemesDir, stale));
3408
4333
  }
3409
4334
  } catch (err) {
3410
4335
  const msg = err instanceof Error ? err.message : "Write failed";
@@ -3417,6 +4342,7 @@ function themeSyncCommand(cwd, options) {
3417
4342
  return;
3418
4343
  }
3419
4344
  if (options.json) {
4345
+ const warnings = [...deprecationWarnings, ...discoveryWarnings];
3420
4346
  console.log(JSON.stringify({
3421
4347
  success: true,
3422
4348
  themes: manifest.length,
@@ -3424,7 +4350,8 @@ function themeSyncCommand(cwd, options) {
3424
4350
  custom: customManifest.length,
3425
4351
  staleCssDeleted: staleCssFiles.length,
3426
4352
  staleYamlsDeleted: stalePublicYamls.length,
3427
- slugs: allSlugs
4353
+ slugs: allSlugs,
4354
+ ...warnings.length > 0 ? { warnings } : {}
3428
4355
  }));
3429
4356
  } else {
3430
4357
  logger.success(`Theme sync complete \u2014 ${manifest.length} themes registered`);
@@ -3440,19 +4367,19 @@ function themeSyncCommand(cwd, options) {
3440
4367
 
3441
4368
  // src/commands/theme-batch-apply-flutter.ts
3442
4369
  import {
3443
- readFileSync as readFileSync15,
4370
+ readFileSync as readFileSync17,
3444
4371
  writeFileSync as writeFileSync10,
3445
4372
  mkdirSync as mkdirSync6,
3446
- existsSync as existsSync12,
3447
- readdirSync as readdirSync5,
4373
+ existsSync as existsSync14,
4374
+ readdirSync as readdirSync7,
3448
4375
  rmSync
3449
4376
  } from "fs";
3450
- import { join as join15, basename as basename3, dirname as dirname6 } from "path";
4377
+ import { join as join16, basename as basename4, dirname as dirname7 } from "path";
3451
4378
  import { generateThemeData as generateThemeData5 } from "@loworbitstudio/visor-theme-engine";
3452
4379
  import { flutterAdapter as flutterAdapter2 } from "@loworbitstudio/visor-theme-engine/adapters";
3453
4380
  function scanThemeDir2(dir) {
3454
- if (!existsSync12(dir)) return [];
3455
- return readdirSync5(dir).filter((f) => f.endsWith(".visor.yaml")).map((f) => join15(dir, f)).sort();
4381
+ if (!existsSync14(dir)) return [];
4382
+ return readdirSync7(dir).filter((f) => f.endsWith(".visor.yaml")).map((f) => join16(dir, f)).sort();
3456
4383
  }
3457
4384
  function slugToCamel(slug2) {
3458
4385
  return slug2.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -3515,7 +4442,7 @@ function slugToDartPrefix(slug2) {
3515
4442
  return slug2.replace(/-/g, "_") + "_t";
3516
4443
  }
3517
4444
  function emitMetaBarrel(slugs) {
3518
- const lines = [
4445
+ const lines2 = [
3519
4446
  `// GENERATED BY visor \u2014 DO NOT EDIT.`,
3520
4447
  `// Regenerate with \`npm run themes:apply-flutter\`.`,
3521
4448
  `//`,
@@ -3531,37 +4458,37 @@ function emitMetaBarrel(slugs) {
3531
4458
  ];
3532
4459
  for (const slug2 of slugs) {
3533
4460
  const prefix = slugToDartPrefix(slug2);
3534
- lines.push(`import 'src/${slug2}/theme/visor_theme.dart' as ${prefix};`);
3535
- }
3536
- lines.push(``);
3537
- lines.push(`/// A light/dark [ThemeData] pair for a single Visor theme.`);
3538
- lines.push(`class VisorThemePair {`);
3539
- lines.push(` final ThemeData light;`);
3540
- lines.push(` final ThemeData dark;`);
3541
- lines.push(` const VisorThemePair({required this.light, required this.dark});`);
3542
- lines.push(`}`);
3543
- lines.push(``);
3544
- lines.push(`/// Static access to all Visor-generated Flutter themes.`);
3545
- lines.push(`///`);
3546
- lines.push(`/// Usage:`);
3547
- lines.push(`/// \`\`\`dart`);
3548
- lines.push(`/// MaterialApp(`);
3549
- lines.push(`/// theme: VisorThemes.blackout.light,`);
3550
- lines.push(`/// darkTheme: VisorThemes.blackout.dark,`);
3551
- lines.push(`/// );`);
3552
- lines.push(`/// \`\`\``);
3553
- lines.push(`sealed class VisorThemes {`);
4461
+ lines2.push(`import 'src/${slug2}/theme/visor_theme.dart' as ${prefix};`);
4462
+ }
4463
+ lines2.push(``);
4464
+ lines2.push(`/// A light/dark [ThemeData] pair for a single Visor theme.`);
4465
+ lines2.push(`class VisorThemePair {`);
4466
+ lines2.push(` final ThemeData light;`);
4467
+ lines2.push(` final ThemeData dark;`);
4468
+ lines2.push(` const VisorThemePair({required this.light, required this.dark});`);
4469
+ lines2.push(`}`);
4470
+ lines2.push(``);
4471
+ lines2.push(`/// Static access to all Visor-generated Flutter themes.`);
4472
+ lines2.push(`///`);
4473
+ lines2.push(`/// Usage:`);
4474
+ lines2.push(`/// \`\`\`dart`);
4475
+ lines2.push(`/// MaterialApp(`);
4476
+ lines2.push(`/// theme: VisorThemes.blackout.light,`);
4477
+ lines2.push(`/// darkTheme: VisorThemes.blackout.dark,`);
4478
+ lines2.push(`/// );`);
4479
+ lines2.push(`/// \`\`\``);
4480
+ lines2.push(`sealed class VisorThemes {`);
3554
4481
  for (const slug2 of slugs) {
3555
4482
  const camel = slugToCamel(slug2);
3556
4483
  const prefix = slugToDartPrefix(slug2);
3557
- lines.push(` static VisorThemePair get ${camel} => VisorThemePair(`);
3558
- lines.push(` light: ${prefix}.VisorAppTheme.light,`);
3559
- lines.push(` dark: ${prefix}.VisorAppTheme.dark,`);
3560
- lines.push(` );`);
4484
+ lines2.push(` static VisorThemePair get ${camel} => VisorThemePair(`);
4485
+ lines2.push(` light: ${prefix}.VisorAppTheme.light,`);
4486
+ lines2.push(` dark: ${prefix}.VisorAppTheme.dark,`);
4487
+ lines2.push(` );`);
3561
4488
  }
3562
- lines.push(`}`);
3563
- lines.push(``);
3564
- return lines.join("\n");
4489
+ lines2.push(`}`);
4490
+ lines2.push(``);
4491
+ return lines2.join("\n");
3565
4492
  }
3566
4493
  function emitGitignore() {
3567
4494
  return [
@@ -3585,9 +4512,9 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3585
4512
  process.exit(1);
3586
4513
  return;
3587
4514
  }
3588
- const themesDir = join15(repoRoot, "themes");
3589
- const customThemesDir = join15(repoRoot, "custom-themes");
3590
- const outputDir = join15(repoRoot, "packages", "visor_themes");
4515
+ const themesDir = join16(repoRoot, "themes");
4516
+ const customThemesDir = join16(repoRoot, "custom-themes");
4517
+ const outputDir = join16(repoRoot, "packages", "visor_themes");
3591
4518
  const stockFiles = scanThemeDir2(themesDir);
3592
4519
  const customFiles = scanThemeDir2(customThemesDir);
3593
4520
  const allFiles = [...stockFiles, ...customFiles];
@@ -3608,7 +4535,7 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3608
4535
  for (const filePath of allFiles) {
3609
4536
  let yamlContent;
3610
4537
  try {
3611
- yamlContent = readFileSync15(filePath, "utf-8");
4538
+ yamlContent = readFileSync17(filePath, "utf-8");
3612
4539
  } catch {
3613
4540
  errors.push(`Could not read: ${filePath}`);
3614
4541
  continue;
@@ -3618,7 +4545,7 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3618
4545
  data = generateThemeData5(yamlContent);
3619
4546
  } catch (err) {
3620
4547
  errors.push(
3621
- `Failed to parse ${basename3(filePath)}: ${err instanceof Error ? err.message : "Unknown error"}`
4548
+ `Failed to parse ${basename4(filePath)}: ${err instanceof Error ? err.message : "Unknown error"}`
3622
4549
  );
3623
4550
  continue;
3624
4551
  }
@@ -3687,8 +4614,8 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3687
4614
  return;
3688
4615
  }
3689
4616
  const slugs = processed.map((p) => p.slug);
3690
- const libSrcDir = join15(outputDir, "lib", "src");
3691
- if (existsSync12(libSrcDir)) {
4617
+ const libSrcDir = join16(outputDir, "lib", "src");
4618
+ if (existsSync14(libSrcDir)) {
3692
4619
  rmSync(libSrcDir, { recursive: true, force: true });
3693
4620
  }
3694
4621
  const packageFiles = {
@@ -3700,16 +4627,16 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3700
4627
  };
3701
4628
  let totalFiles = 0;
3702
4629
  for (const [relPath, content] of Object.entries(packageFiles)) {
3703
- const absPath = join15(outputDir, relPath);
3704
- mkdirSync6(dirname6(absPath), { recursive: true });
4630
+ const absPath = join16(outputDir, relPath);
4631
+ mkdirSync6(dirname7(absPath), { recursive: true });
3705
4632
  writeFileSync10(absPath, content, "utf-8");
3706
4633
  totalFiles++;
3707
4634
  }
3708
4635
  for (const { slug: slug2, tokenFiles } of processed) {
3709
- const themeBaseDir = join15(outputDir, "lib", "src", slug2);
4636
+ const themeBaseDir = join16(outputDir, "lib", "src", slug2);
3710
4637
  for (const [relPath, content] of Object.entries(tokenFiles)) {
3711
- const absPath = join15(themeBaseDir, relPath);
3712
- mkdirSync6(dirname6(absPath), { recursive: true });
4638
+ const absPath = join16(themeBaseDir, relPath);
4639
+ mkdirSync6(dirname7(absPath), { recursive: true });
3713
4640
  writeFileSync10(absPath, content, "utf-8");
3714
4641
  totalFiles++;
3715
4642
  }
@@ -3731,11 +4658,11 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3731
4658
  }
3732
4659
 
3733
4660
  // src/commands/fonts-add.ts
3734
- import { existsSync as existsSync13, statSync as statSync4, readdirSync as readdirSync6, readFileSync as readFileSync16 } from "fs";
3735
- import { resolve as resolve8, basename as basename4, extname as extname3 } from "path";
4661
+ import { existsSync as existsSync15, statSync as statSync7, readdirSync as readdirSync8, readFileSync as readFileSync18 } from "fs";
4662
+ import { resolve as resolve12, basename as basename5, extname as extname4 } from "path";
3736
4663
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
3737
4664
  function deriveFamilySlug(filename) {
3738
- const name = basename4(filename, extname3(filename));
4665
+ const name = basename5(filename, extname4(filename));
3739
4666
  const WEIGHT_STYLE_SUFFIXES = /* @__PURE__ */ new Set([
3740
4667
  "thin",
3741
4668
  "hairline",
@@ -3776,28 +4703,28 @@ function deriveFamilySlug(filename) {
3776
4703
  return parts.join("-").toLowerCase();
3777
4704
  }
3778
4705
  function collectWoff2Files(inputPath) {
3779
- const resolved = resolve8(inputPath);
3780
- if (!existsSync13(resolved)) {
4706
+ const resolved = resolve12(inputPath);
4707
+ if (!existsSync15(resolved)) {
3781
4708
  throw new Error(`Path not found: ${resolved}`);
3782
4709
  }
3783
- const stat = statSync4(resolved);
4710
+ const stat = statSync7(resolved);
3784
4711
  if (stat.isFile()) {
3785
- if (extname3(resolved).toLowerCase() !== ".woff2") {
4712
+ if (extname4(resolved).toLowerCase() !== ".woff2") {
3786
4713
  throw new Error(
3787
- `Invalid file format: ${basename4(resolved)}. Only .woff2 files are accepted.`
4714
+ `Invalid file format: ${basename5(resolved)}. Only .woff2 files are accepted.`
3788
4715
  );
3789
4716
  }
3790
4717
  return [resolved];
3791
4718
  }
3792
4719
  if (stat.isDirectory()) {
3793
- const files = readdirSync6(resolved).filter((f) => extname3(f).toLowerCase() === ".woff2").map((f) => resolve8(resolved, f));
4720
+ const files = readdirSync8(resolved).filter((f) => extname4(f).toLowerCase() === ".woff2").map((f) => resolve12(resolved, f));
3794
4721
  if (files.length === 0) {
3795
4722
  throw new Error(
3796
4723
  `No .woff2 files found in directory: ${resolved}`
3797
4724
  );
3798
4725
  }
3799
- const nonWoff2Fonts = readdirSync6(resolved).filter((f) => {
3800
- const ext = extname3(f).toLowerCase();
4726
+ const nonWoff2Fonts = readdirSync8(resolved).filter((f) => {
4727
+ const ext = extname4(f).toLowerCase();
3801
4728
  return [".ttf", ".otf", ".woff", ".eot"].includes(ext);
3802
4729
  });
3803
4730
  return files.sort();
@@ -3805,12 +4732,12 @@ function collectWoff2Files(inputPath) {
3805
4732
  throw new Error(`Path is neither a file nor a directory: ${resolved}`);
3806
4733
  }
3807
4734
  function getNonWoff2Fonts(inputPath) {
3808
- const resolved = resolve8(inputPath);
3809
- if (!existsSync13(resolved) || !statSync4(resolved).isDirectory()) {
4735
+ const resolved = resolve12(inputPath);
4736
+ if (!existsSync15(resolved) || !statSync7(resolved).isDirectory()) {
3810
4737
  return [];
3811
4738
  }
3812
- return readdirSync6(resolved).filter((f) => {
3813
- const ext = extname3(f).toLowerCase();
4739
+ return readdirSync8(resolved).filter((f) => {
4740
+ const ext = extname4(f).toLowerCase();
3814
4741
  return [".ttf", ".otf", ".woff", ".eot"].includes(ext);
3815
4742
  });
3816
4743
  }
@@ -3843,7 +4770,7 @@ function createR2Client(config) {
3843
4770
  });
3844
4771
  }
3845
4772
  async function uploadFile(client, bucket, key, filePath) {
3846
- const body = readFileSync16(filePath);
4773
+ const body = readFileSync18(filePath);
3847
4774
  await client.send(
3848
4775
  new PutObjectCommand({
3849
4776
  Bucket: bucket,
@@ -3858,9 +4785,9 @@ async function fontsAddCommand(inputPath, options) {
3858
4785
  try {
3859
4786
  const r2Config = getR2Config();
3860
4787
  const files = collectWoff2Files(inputPath);
3861
- const familySlug = options.family ?? deriveFamilySlug(basename4(files[0]));
3862
- const resolved = resolve8(inputPath);
3863
- const nonWoff2 = statSync4(resolved).isDirectory() ? getNonWoff2Fonts(resolved) : [];
4788
+ const familySlug = options.family ?? deriveFamilySlug(basename5(files[0]));
4789
+ const resolved = resolve12(inputPath);
4790
+ const nonWoff2 = statSync7(resolved).isDirectory() ? getNonWoff2Fonts(resolved) : [];
3864
4791
  if (!json) {
3865
4792
  logger.heading("Visor Font Upload");
3866
4793
  logger.info(`Organization: ${org}`);
@@ -3878,13 +4805,13 @@ async function fontsAddCommand(inputPath, options) {
3878
4805
  const bucket = "visor-fonts";
3879
4806
  const results = [];
3880
4807
  for (const filePath of files) {
3881
- const filename = basename4(filePath);
4808
+ const filename = basename5(filePath);
3882
4809
  const key = buildS3Key(org, familySlug, filename);
3883
4810
  if (!json) {
3884
4811
  logger.info(`Uploading ${filename}...`);
3885
4812
  }
3886
4813
  await uploadFile(client, bucket, key, filePath);
3887
- const size = statSync4(filePath).size;
4814
+ const size = statSync7(filePath).size;
3888
4815
  results.push({ file: filename, key, size });
3889
4816
  if (!json) {
3890
4817
  logger.success(`Uploaded: ${key} (${formatBytes(size)})`);
@@ -3934,7 +4861,7 @@ function formatBytes(bytes) {
3934
4861
  // src/commands/doctor.ts
3935
4862
  import * as fs from "fs";
3936
4863
  import * as path from "path";
3937
- import { execFileSync as execFileSync3 } from "child_process";
4864
+ import { execFileSync as execFileSync4 } from "child_process";
3938
4865
  async function doctorCommand(cwd, options, cliVersion) {
3939
4866
  const checks = [];
3940
4867
  const visorJsonPath = path.join(cwd, "visor.json");
@@ -4069,9 +4996,9 @@ async function doctorCommand(cwd, options, cliVersion) {
4069
4996
  }
4070
4997
  if (process.platform !== "win32") {
4071
4998
  try {
4072
- const globalPath = execFileSync3("which", ["visor"], { encoding: "utf-8" }).trim();
4999
+ const globalPath = execFileSync4("which", ["visor"], { encoding: "utf-8" }).trim();
4073
5000
  if (globalPath) {
4074
- const globalVersionRaw = execFileSync3(globalPath, ["--version"], { encoding: "utf-8" }).trim();
5001
+ const globalVersionRaw = execFileSync4(globalPath, ["--version"], { encoding: "utf-8" }).trim();
4075
5002
  const globalVersion = globalVersionRaw.split(/\s+/).pop() ?? "";
4076
5003
  if (isOlder(globalVersion, cliVersion)) {
4077
5004
  checks.push({
@@ -4147,27 +5074,27 @@ function findCssFiles(dir, maxDepth = 3) {
4147
5074
  }
4148
5075
 
4149
5076
  // src/utils/patterns.ts
4150
- import { existsSync as existsSync15, readdirSync as readdirSync8, readFileSync as readFileSync18 } from "fs";
4151
- import { join as join17 } from "path";
5077
+ import { existsSync as existsSync17, readdirSync as readdirSync10, readFileSync as readFileSync20 } from "fs";
5078
+ import { join as join18 } from "path";
4152
5079
  import { parse as parseYAML } from "yaml";
4153
5080
  function loadPatternsFromYaml(repoRoot) {
4154
- const patternsDir = join17(repoRoot, "patterns");
4155
- if (!existsSync15(patternsDir)) return [];
4156
- const files = readdirSync8(patternsDir).filter(
5081
+ const patternsDir = join18(repoRoot, "patterns");
5082
+ if (!existsSync17(patternsDir)) return [];
5083
+ const files = readdirSync10(patternsDir).filter(
4157
5084
  (f) => f.endsWith(".visor-pattern.yaml")
4158
5085
  );
4159
5086
  return files.map((file) => {
4160
- const content = readFileSync18(join17(patternsDir, file), "utf-8");
5087
+ const content = readFileSync20(join18(patternsDir, file), "utf-8");
4161
5088
  return parseYAML(content);
4162
5089
  }).filter(Boolean);
4163
5090
  }
4164
5091
  function findRepoRoot2(startDir) {
4165
5092
  let current = startDir;
4166
5093
  while (true) {
4167
- if (existsSync15(join17(current, "patterns"))) {
5094
+ if (existsSync17(join18(current, "patterns"))) {
4168
5095
  return current;
4169
5096
  }
4170
- const parent = join17(current, "..");
5097
+ const parent = join18(current, "..");
4171
5098
  if (parent === current) return null;
4172
5099
  current = parent;
4173
5100
  }
@@ -4545,6 +5472,244 @@ Visor Tokens (${categoryLabel}) \u2014 ${tokens2.length} tokens
4545
5472
  );
4546
5473
  }
4547
5474
 
5475
+ // src/commands/migrate-token-substitution.ts
5476
+ import { readFileSync as readFileSync21, writeFileSync as writeFileSync11, readdirSync as readdirSync11, statSync as statSync8, existsSync as existsSync18 } from "fs";
5477
+ import { resolve as resolve13, join as join19, relative as relative3 } from "path";
5478
+ import { parse as parseYaml3 } from "yaml";
5479
+ import pc4 from "picocolors";
5480
+ var V7_ENTR_SUBSTITUTION_MAP = {
5481
+ "--panel": "--surface-card",
5482
+ "--panel-2": "--surface-interactive-default",
5483
+ "--panel-3": "--surface-interactive-active",
5484
+ "--text": "--text-primary",
5485
+ "--text-2": "--text-secondary",
5486
+ "--text-3": "--text-tertiary",
5487
+ "--text-4": "--text-tertiary",
5488
+ "--mint": "--accent-primary",
5489
+ "--mint-soft": "--surface-accent-subtle",
5490
+ "--warn": "--text-warning",
5491
+ "--warn-soft": "--surface-warning-subtle"
5492
+ };
5493
+ var BUILT_IN_SUBSTITUTION_MAPS = {
5494
+ "entr": V7_ENTR_SUBSTITUTION_MAP
5495
+ // future themes: "kaiah": KAIAH_SUBSTITUTION_MAP, etc.
5496
+ };
5497
+ var DEFAULT_THEME_ID = "entr";
5498
+ function readMapFromThemeFile(themeId, cwd) {
5499
+ const candidates = [
5500
+ join19(cwd, "themes", `${themeId}.visor.yaml`),
5501
+ join19(cwd, "custom-themes", `${themeId}.visor.yaml`),
5502
+ join19(cwd, "packages", "docs", "public", "themes", `${themeId}.visor.yaml`)
5503
+ ];
5504
+ for (const candidate of candidates) {
5505
+ if (existsSync18(candidate)) {
5506
+ try {
5507
+ const raw = readFileSync21(candidate, "utf-8");
5508
+ const parsed = parseYaml3(raw);
5509
+ if (parsed?.migrate?.["token-substitution"]) {
5510
+ return parsed.migrate["token-substitution"];
5511
+ }
5512
+ } catch {
5513
+ }
5514
+ }
5515
+ }
5516
+ return void 0;
5517
+ }
5518
+ function resolveSubstitutionMap(themeId, cwd) {
5519
+ const fromYaml = readMapFromThemeFile(themeId, cwd);
5520
+ if (fromYaml) return fromYaml;
5521
+ return BUILT_IN_SUBSTITUTION_MAPS[themeId];
5522
+ }
5523
+ function applySubstitutionsToContent(content, map) {
5524
+ const substitutions = [];
5525
+ const lines2 = content.split("\n");
5526
+ const newLines = lines2.map((line, lineIndex) => {
5527
+ let newLine = line;
5528
+ for (const [from, to] of Object.entries(map)) {
5529
+ const pattern2 = new RegExp(`var\\(\\s*${escapeRegex(from)}\\s*(?:,[^)]*)?\\)`, "g");
5530
+ let match;
5531
+ while ((match = pattern2.exec(newLine)) !== null) {
5532
+ const column = match.index;
5533
+ const originalLine = line;
5534
+ const fullMatch = match[0];
5535
+ const replaced = fullMatch.replace(
5536
+ new RegExp(escapeRegex(from)),
5537
+ to
5538
+ );
5539
+ newLine = newLine.slice(0, match.index) + replaced + newLine.slice(match.index + fullMatch.length);
5540
+ substitutions.push({
5541
+ line: lineIndex + 1,
5542
+ column,
5543
+ from,
5544
+ to,
5545
+ originalLine,
5546
+ replacedLine: newLine
5547
+ });
5548
+ pattern2.lastIndex = match.index + replaced.length;
5549
+ }
5550
+ }
5551
+ return newLine;
5552
+ });
5553
+ return { newContent: newLines.join("\n"), substitutions };
5554
+ }
5555
+ function escapeRegex(s) {
5556
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5557
+ }
5558
+ function collectCssFiles(dirPath) {
5559
+ const results = [];
5560
+ function walk2(current) {
5561
+ const entries = readdirSync11(current, { withFileTypes: true });
5562
+ for (const entry of entries) {
5563
+ const fullPath = join19(current, entry.name);
5564
+ if (entry.isDirectory()) {
5565
+ if (["node_modules", ".git", ".next", "dist", "build", ".cache"].includes(entry.name)) {
5566
+ continue;
5567
+ }
5568
+ walk2(fullPath);
5569
+ } else if (entry.isFile()) {
5570
+ const name = entry.name;
5571
+ if (name.endsWith(".module.css") || name.endsWith(".scss") || name.endsWith(".css")) {
5572
+ results.push(fullPath);
5573
+ }
5574
+ }
5575
+ }
5576
+ }
5577
+ walk2(dirPath);
5578
+ return results;
5579
+ }
5580
+ function runSubstitutionPass(targetPath, map, themeId) {
5581
+ let files;
5582
+ const stat = statSync8(targetPath);
5583
+ if (stat.isFile()) {
5584
+ files = [targetPath];
5585
+ } else {
5586
+ files = collectCssFiles(targetPath);
5587
+ }
5588
+ const fileResults = [];
5589
+ let totalSubstitutions = 0;
5590
+ for (const file of files) {
5591
+ const content = readFileSync21(file, "utf-8");
5592
+ const { newContent, substitutions } = applySubstitutionsToContent(content, map);
5593
+ if (substitutions.length > 0) {
5594
+ fileResults.push({ file, substitutions, originalContent: content, newContent });
5595
+ totalSubstitutions += substitutions.length;
5596
+ }
5597
+ }
5598
+ return {
5599
+ themeId,
5600
+ targetPath,
5601
+ filesScanned: files.length,
5602
+ filesChanged: fileResults.length,
5603
+ totalSubstitutions,
5604
+ files: fileResults
5605
+ };
5606
+ }
5607
+ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
5608
+ const themeId = options.themeId ?? DEFAULT_THEME_ID;
5609
+ const apply = options.apply ?? false;
5610
+ const dryRun = !apply;
5611
+ const map = resolveSubstitutionMap(themeId, cwd);
5612
+ if (!map) {
5613
+ const available = Object.keys(BUILT_IN_SUBSTITUTION_MAPS).join(", ");
5614
+ if (options.json) {
5615
+ console.log(JSON.stringify({
5616
+ success: false,
5617
+ error: `Unknown theme-id: "${themeId}". Available: ${available}`
5618
+ }));
5619
+ process.exit(1);
5620
+ }
5621
+ logger.error(`Unknown theme-id: "${themeId}". Available: ${available}`);
5622
+ process.exit(1);
5623
+ return;
5624
+ }
5625
+ const targetPath = resolve13(cwd, targetArg ?? ".");
5626
+ try {
5627
+ statSync8(targetPath);
5628
+ } catch {
5629
+ if (options.json) {
5630
+ console.log(JSON.stringify({
5631
+ success: false,
5632
+ error: `Target path not found: ${targetPath}`
5633
+ }));
5634
+ process.exit(1);
5635
+ }
5636
+ logger.error(`Target path not found: ${targetPath}`);
5637
+ process.exit(1);
5638
+ return;
5639
+ }
5640
+ const result = runSubstitutionPass(targetPath, map, themeId);
5641
+ if (result.filesChanged === 0) {
5642
+ if (options.json) {
5643
+ console.log(JSON.stringify({ success: true, ...result, message: "No V7 primitives found \u2014 already up to date." }));
5644
+ process.exit(0);
5645
+ }
5646
+ logger.success(`No V7 primitives found \u2014 ${result.filesScanned} file(s) scanned. Already up to date.`);
5647
+ process.exit(0);
5648
+ return;
5649
+ }
5650
+ if (options.json) {
5651
+ if (apply) {
5652
+ for (const f of result.files) {
5653
+ writeFileSync11(f.file, f.newContent, "utf-8");
5654
+ }
5655
+ console.log(JSON.stringify({
5656
+ success: true,
5657
+ applied: true,
5658
+ ...result
5659
+ }));
5660
+ } else {
5661
+ console.log(JSON.stringify({
5662
+ success: true,
5663
+ dryRun: true,
5664
+ ...result
5665
+ }));
5666
+ }
5667
+ process.exit(0);
5668
+ return;
5669
+ }
5670
+ const relTarget = relative3(cwd, targetPath) || ".";
5671
+ if (dryRun) {
5672
+ logger.heading(`visor migrate token-substitution \u2014 dry run`);
5673
+ logger.blank();
5674
+ logger.info(` Theme: ${pc4.bold(themeId)}`);
5675
+ logger.info(` Target: ${pc4.dim(relTarget)}`);
5676
+ logger.info(` Scanned: ${result.filesScanned} file(s)`);
5677
+ logger.blank();
5678
+ logger.heading(`Proposed changes (${result.filesChanged} file(s), ${result.totalSubstitutions} substitution(s)):`);
5679
+ logger.blank();
5680
+ for (const f of result.files) {
5681
+ const relFile = relative3(cwd, f.file);
5682
+ logger.info(pc4.bold(` ${relFile}`));
5683
+ for (const sub of f.substitutions) {
5684
+ logger.info(
5685
+ ` line ${String(sub.line).padEnd(4)} ${pc4.red(`var(${sub.from})`)} \u2192 ${pc4.green(`var(${sub.to})`)}`
5686
+ );
5687
+ }
5688
+ logger.blank();
5689
+ }
5690
+ logger.warn(`Dry run \u2014 no files written. Re-run with ${pc4.bold("--apply")} to commit changes.`);
5691
+ } else {
5692
+ for (const f of result.files) {
5693
+ writeFileSync11(f.file, f.newContent, "utf-8");
5694
+ }
5695
+ logger.heading(`visor migrate token-substitution \u2014 applied`);
5696
+ logger.blank();
5697
+ logger.info(` Theme: ${pc4.bold(themeId)}`);
5698
+ logger.info(` Target: ${pc4.dim(relTarget)}`);
5699
+ logger.info(` Scanned: ${result.filesScanned} file(s)`);
5700
+ logger.blank();
5701
+ for (const f of result.files) {
5702
+ const relFile = relative3(cwd, f.file);
5703
+ logger.success(` ${relFile} (${f.substitutions.length} substitution(s))`);
5704
+ }
5705
+ logger.blank();
5706
+ logger.success(
5707
+ `Done \u2014 ${result.filesChanged} file(s) updated, ${result.totalSubstitutions} substitution(s) applied.`
5708
+ );
5709
+ }
5710
+ process.exit(0);
5711
+ }
5712
+
4548
5713
  // src/index.ts
4549
5714
  var program = new Command2();
4550
5715
  program.name("visor").description("CLI for the Visor design system").version("0.3.0");
@@ -4618,6 +5783,11 @@ theme.command("validate").description("Run full validation ruleset on a .visor.y
4618
5783
  themeValidateCommand(file, process.cwd(), options);
4619
5784
  }
4620
5785
  );
5786
+ theme.command("verify").description("Verify generated theme output for a target platform").argument("<dir>", "path to generated output directory").option("--target <platform>", "target platform (flutter)", "flutter").option("--json", "output structured JSON (for AI agents)").action(
5787
+ (dir, options) => {
5788
+ themeVerifyCommand(dir, process.cwd(), options);
5789
+ }
5790
+ );
4621
5791
  theme.command("extract").description(
4622
5792
  "Scan an existing project's CSS and produce a best-effort .visor.yaml theme file"
4623
5793
  ).option("--from <path>", "path to project directory to scan").option("--json", "output structured JSON (for AI agents)").option("-o, --output <path>", "output file path (default: .visor.yaml)").option("--validate", "run validator on the extracted theme").action(
@@ -4676,4 +5846,16 @@ var tokens = program.command("tokens").description("Explore design tokens");
4676
5846
  tokens.command("list").description("List all design tokens").option("--json", "output as JSON (for AI agents)").option("--category <category>", "filter by tier: primitives, semantic, adaptive").action(async (options) => {
4677
5847
  await tokensListCommand(process.cwd(), options);
4678
5848
  });
5849
+ var migrate = program.command("migrate").description("Migration helpers \u2014 mechanically transform source files during design-system adoption");
5850
+ migrate.command("token-substitution").description(
5851
+ "Apply the \xA73.1 V7-primitive \u2192 Visor-semantic substitution table across a target directory. Dry-run by default; use --apply to commit changes. Idempotent \u2014 running twice is a no-op."
5852
+ ).argument("[path]", "path to file or directory to migrate (default: current directory)").option("--theme-id <id>", "theme whose substitution map to apply (default: entr)", "entr").option("--dry-run", "preview proposed changes without writing files (default when --apply is omitted)").option("--apply", "write changes to disk").option("--json", "output structured JSON (for AI agents)").action(
5853
+ (pathArg, options) => {
5854
+ migrateTokenSubstitutionCommand(pathArg, process.cwd(), {
5855
+ themeId: options.themeId,
5856
+ apply: options.apply,
5857
+ json: options.json
5858
+ });
5859
+ }
5860
+ );
4679
5861
  program.parse();