@loworbitstudio/visor 0.7.0 → 0.9.1

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,16 +2748,16 @@ 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
 
2214
2757
  // src/commands/theme-verify.ts
2215
2758
  import { spawnSync as _spawnSync } from "child_process";
2216
- import { existsSync as existsSync7 } from "fs";
2217
- import { resolve as resolve5 } from "path";
2759
+ import { existsSync as existsSync8 } from "fs";
2760
+ import { resolve as resolve7 } from "path";
2218
2761
  function themeVerifyCommand(dir, cwd, options, _spawnFn = _spawnSync) {
2219
2762
  const target = options.target ?? "flutter";
2220
2763
  if (target !== "flutter") {
@@ -2236,8 +2779,8 @@ function themeVerifyCommand(dir, cwd, options, _spawnFn = _spawnSync) {
2236
2779
  }
2237
2780
  process.exit(1);
2238
2781
  }
2239
- const dirPath = resolve5(cwd, dir);
2240
- if (!existsSync7(dirPath)) {
2782
+ const dirPath = resolve7(cwd, dir);
2783
+ if (!existsSync8(dirPath)) {
2241
2784
  if (options.json) {
2242
2785
  console.log(
2243
2786
  JSON.stringify({
@@ -2324,8 +2867,8 @@ function themeVerifyCommand(dir, cwd, options, _spawnFn = _spawnSync) {
2324
2867
  }
2325
2868
 
2326
2869
  // src/commands/theme-extract.ts
2327
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync6, existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
2328
- import { resolve as resolve6, 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";
2329
2872
  import { stringify as stringifyYaml } from "yaml";
2330
2873
  import {
2331
2874
  extractFromCSS,
@@ -2354,8 +2897,8 @@ var CSS_DIRS = [
2354
2897
  "packages/design-tokens"
2355
2898
  ];
2356
2899
  function themeExtractCommand(cwd, options) {
2357
- const targetDir = resolve6(cwd, options.from ?? ".");
2358
- if (!existsSync8(targetDir)) {
2900
+ const targetDir = resolve8(cwd, options.from ?? ".");
2901
+ if (!existsSync9(targetDir)) {
2359
2902
  if (options.json) {
2360
2903
  console.log(JSON.stringify({ success: false, error: `Directory not found: ${targetDir}` }));
2361
2904
  } else {
@@ -2409,16 +2952,16 @@ function collectCSSFiles(targetDir) {
2409
2952
  const files = [];
2410
2953
  const seen = /* @__PURE__ */ new Set();
2411
2954
  for (const pattern2 of CSS_FILE_PATTERNS) {
2412
- const rootPath = join10(targetDir, pattern2);
2955
+ const rootPath = join11(targetDir, pattern2);
2413
2956
  addFileIfExists(rootPath, files, seen);
2414
2957
  for (const dir of CSS_DIRS) {
2415
- const dirPath = join10(targetDir, dir, pattern2);
2958
+ const dirPath = join11(targetDir, dir, pattern2);
2416
2959
  addFileIfExists(dirPath, files, seen);
2417
2960
  }
2418
2961
  }
2419
2962
  for (const dir of CSS_DIRS) {
2420
- const dirPath = join10(targetDir, dir);
2421
- if (existsSync8(dirPath) && statSync3(dirPath).isDirectory()) {
2963
+ const dirPath = join11(targetDir, dir);
2964
+ if (existsSync9(dirPath) && statSync5(dirPath).isDirectory()) {
2422
2965
  scanDirForCSS(dirPath, files, seen, 2);
2423
2966
  }
2424
2967
  }
@@ -2426,11 +2969,11 @@ function collectCSSFiles(targetDir) {
2426
2969
  return files;
2427
2970
  }
2428
2971
  function addFileIfExists(filePath, files, seen) {
2429
- const resolved = resolve6(filePath);
2972
+ const resolved = resolve8(filePath);
2430
2973
  if (seen.has(resolved)) return;
2431
- if (!existsSync8(resolved)) return;
2974
+ if (!existsSync9(resolved)) return;
2432
2975
  try {
2433
- const content = readFileSync11(resolved, "utf-8");
2976
+ const content = readFileSync12(resolved, "utf-8");
2434
2977
  if (content.includes("--")) {
2435
2978
  files.push({ path: resolved, content });
2436
2979
  seen.add(resolved);
@@ -2439,7 +2982,7 @@ function addFileIfExists(filePath, files, seen) {
2439
2982
  }
2440
2983
  }
2441
2984
  function scanDirForCSS(dir, files, seen, maxDepth) {
2442
- if (!existsSync8(dir)) return;
2985
+ if (!existsSync9(dir)) return;
2443
2986
  const SKIP_DIRS = /* @__PURE__ */ new Set([
2444
2987
  "node_modules",
2445
2988
  ".next",
@@ -2453,15 +2996,15 @@ function scanDirForCSS(dir, files, seen, maxDepth) {
2453
2996
  ".vercel"
2454
2997
  ]);
2455
2998
  try {
2456
- const entries = readdirSync3(dir, { withFileTypes: true });
2999
+ const entries = readdirSync4(dir, { withFileTypes: true });
2457
3000
  for (const entry of entries) {
2458
3001
  if (entry.isDirectory()) {
2459
3002
  if (SKIP_DIRS.has(entry.name)) continue;
2460
3003
  if (maxDepth > 0) {
2461
- scanDirForCSS(join10(dir, entry.name), files, seen, maxDepth - 1);
3004
+ scanDirForCSS(join11(dir, entry.name), files, seen, maxDepth - 1);
2462
3005
  }
2463
- } else if (entry.isFile() && extname2(entry.name) === ".css") {
2464
- addFileIfExists(join10(dir, entry.name), files, seen);
3006
+ } else if (entry.isFile() && extname3(entry.name) === ".css") {
3007
+ addFileIfExists(join11(dir, entry.name), files, seen);
2465
3008
  }
2466
3009
  }
2467
3010
  } catch {
@@ -2543,10 +3086,10 @@ function extractVarName(varExpr) {
2543
3086
  function parseNextFontFromLayouts(targetDir) {
2544
3087
  const fontMap = /* @__PURE__ */ new Map();
2545
3088
  for (const relPath of LAYOUT_FILE_PATHS) {
2546
- const fullPath = join10(targetDir, relPath);
2547
- if (!existsSync8(fullPath)) continue;
3089
+ const fullPath = join11(targetDir, relPath);
3090
+ if (!existsSync9(fullPath)) continue;
2548
3091
  try {
2549
- const content = readFileSync11(fullPath, "utf-8");
3092
+ const content = readFileSync12(fullPath, "utf-8");
2550
3093
  parseNextFontDeclarations(content, fontMap);
2551
3094
  } catch {
2552
3095
  }
@@ -2593,7 +3136,7 @@ function parseNextFontDeclarations(content, fontMap) {
2593
3136
  const srcMatch = block.match(/src\s*:\s*["']([^"']+)["']/);
2594
3137
  if (srcMatch) {
2595
3138
  const srcPath = srcMatch[1];
2596
- const fileName = basename(srcPath, extname2(srcPath));
3139
+ const fileName = basename2(srcPath, extname3(srcPath));
2597
3140
  const fontBaseName = fileName.replace(/[-_](Variable|Regular|Bold|Light|Medium|SemiBold|ExtraBold|Thin|Black|Italic).*$/i, "").replace(/[-_]/g, " ").trim();
2598
3141
  if (fontBaseName) {
2599
3142
  fontMap.set(varName, fontBaseName);
@@ -2631,10 +3174,10 @@ var MONO_FONT_NAMES = /* @__PURE__ */ new Set([
2631
3174
  "IBM Plex Mono"
2632
3175
  ]);
2633
3176
  function extractFontHints(targetDir) {
2634
- const pkgPath = join10(targetDir, "package.json");
2635
- if (!existsSync8(pkgPath)) return void 0;
3177
+ const pkgPath = join11(targetDir, "package.json");
3178
+ if (!existsSync9(pkgPath)) return void 0;
2636
3179
  try {
2637
- const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
3180
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
2638
3181
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
2639
3182
  const fonts2 = [];
2640
3183
  for (const [dep, _] of Object.entries(allDeps)) {
@@ -2670,10 +3213,10 @@ function extractFontHints(targetDir) {
2670
3213
  }
2671
3214
  }
2672
3215
  function inferThemeName(targetDir) {
2673
- const pkgPath = join10(targetDir, "package.json");
2674
- if (existsSync8(pkgPath)) {
3216
+ const pkgPath = join11(targetDir, "package.json");
3217
+ if (existsSync9(pkgPath)) {
2675
3218
  try {
2676
- const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
3219
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
2677
3220
  if (pkg.name) {
2678
3221
  const name = pkg.name.replace(/^@[\w-]+\//, "");
2679
3222
  return `${name}-theme`;
@@ -2681,7 +3224,7 @@ function inferThemeName(targetDir) {
2681
3224
  } catch {
2682
3225
  }
2683
3226
  }
2684
- return `${basename(targetDir)}-theme`;
3227
+ return `${basename2(targetDir)}-theme`;
2685
3228
  }
2686
3229
  function confidenceComment(confidence) {
2687
3230
  return `# confidence: ${confidence}`;
@@ -2710,7 +3253,7 @@ function outputJSON(result, validationResult) {
2710
3253
  }
2711
3254
  function outputYAML(result, outputPath, cwd, validationResult) {
2712
3255
  const yamlStr = buildAnnotatedYAML(result);
2713
- const outFile = resolve6(cwd, outputPath ?? ".visor.yaml");
3256
+ const outFile = resolve8(cwd, outputPath ?? ".visor.yaml");
2714
3257
  const high = result.tokens.filter((t) => t.confidence === "high").length;
2715
3258
  const med = result.tokens.filter((t) => t.confidence === "medium").length;
2716
3259
  const low = result.tokens.filter((t) => t.confidence === "low").length;
@@ -2763,11 +3306,11 @@ function buildAnnotatedYAML(result) {
2763
3306
  for (const token of result.tokens) {
2764
3307
  confidenceMap.set(token.name, token.confidence);
2765
3308
  }
2766
- const lines = baseYaml.split("\n");
3309
+ const lines2 = baseYaml.split("\n");
2767
3310
  const annotated = [];
2768
3311
  let inColors = false;
2769
3312
  let inColorsDark = false;
2770
- for (const line of lines) {
3313
+ for (const line of lines2) {
2771
3314
  if (/^colors:/.test(line)) {
2772
3315
  inColors = true;
2773
3316
  inColorsDark = false;
@@ -2796,14 +3339,15 @@ function buildAnnotatedYAML(result) {
2796
3339
  }
2797
3340
 
2798
3341
  // src/commands/theme-register.ts
2799
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync7, mkdirSync as mkdirSync4, existsSync as existsSync10 } from "fs";
2800
- import { resolve as resolve8, 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";
2801
3344
  import { generateThemeData as generateThemeData3 } from "@loworbitstudio/visor-theme-engine";
2802
3345
  import { docsAdapter as docsAdapter2 } from "@loworbitstudio/visor-theme-engine/adapters";
2803
3346
 
2804
3347
  // src/utils/theme-helpers.ts
2805
- import { existsSync as existsSync9 } from "fs";
2806
- import { resolve as resolve7, 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";
2807
3351
  function toSlug(name) {
2808
3352
  return name.toLowerCase().replace(/\s+/g, "-");
2809
3353
  }
@@ -2811,17 +3355,132 @@ function toLabel(name) {
2811
3355
  return name.split(/[\s-]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
2812
3356
  }
2813
3357
  function findRepoRoot(startDir) {
2814
- let current = resolve7(startDir);
3358
+ let current = resolve9(startDir);
2815
3359
  while (true) {
2816
- if (existsSync9(join11(current, "packages", "docs"))) {
3360
+ if (existsSync10(join12(current, "packages", "docs"))) {
2817
3361
  return current;
2818
3362
  }
2819
- const parent = dirname5(current);
3363
+ const parent = dirname6(current);
2820
3364
  if (parent === current) break;
2821
3365
  current = parent;
2822
3366
  }
2823
3367
  return null;
2824
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
+ }
2825
3484
 
2826
3485
  // src/commands/theme-register.ts
2827
3486
  function insertGlobalsImport(content, slug2) {
@@ -2829,32 +3488,32 @@ function insertGlobalsImport(content, slug2) {
2829
3488
  if (content.includes(importLine)) {
2830
3489
  return { updated: content, changed: false };
2831
3490
  }
2832
- const lines = content.split("\n");
3491
+ const lines2 = content.split("\n");
2833
3492
  const themeImportPattern = /^@import '\.\/[\w-]+-theme\.css';/;
2834
3493
  const themeImportIndices = [];
2835
- for (let i = 0; i < lines.length; i++) {
2836
- if (themeImportPattern.test(lines[i])) {
3494
+ for (let i = 0; i < lines2.length; i++) {
3495
+ if (themeImportPattern.test(lines2[i])) {
2837
3496
  themeImportIndices.push(i);
2838
3497
  }
2839
3498
  }
2840
3499
  if (themeImportIndices.length === 0) {
2841
- const lastImportIdx = lines.reduce(
3500
+ const lastImportIdx = lines2.reduce(
2842
3501
  (last, line, i) => line.startsWith("@import") ? i : last,
2843
3502
  -1
2844
3503
  );
2845
3504
  const insertAt2 = lastImportIdx + 1;
2846
- lines.splice(insertAt2, 0, importLine);
2847
- return { updated: lines.join("\n"), changed: true };
3505
+ lines2.splice(insertAt2, 0, importLine);
3506
+ return { updated: lines2.join("\n"), changed: true };
2848
3507
  }
2849
3508
  let insertAt = themeImportIndices[themeImportIndices.length - 1] + 1;
2850
3509
  for (const idx of themeImportIndices) {
2851
- if (importLine < lines[idx]) {
3510
+ if (importLine < lines2[idx]) {
2852
3511
  insertAt = idx;
2853
3512
  break;
2854
3513
  }
2855
3514
  }
2856
- lines.splice(insertAt, 0, importLine);
2857
- return { updated: lines.join("\n"), changed: true };
3515
+ lines2.splice(insertAt, 0, importLine);
3516
+ return { updated: lines2.join("\n"), changed: true };
2858
3517
  }
2859
3518
  function insertThemeConfig(content, slug2, label, group) {
2860
3519
  if (content.includes(`value: "${slug2}"`)) {
@@ -2908,10 +3567,10 @@ ${indent}${newEntry},
2908
3567
  return { updated, changed: true };
2909
3568
  }
2910
3569
  function themeRegisterCommand(file, cwd, options) {
2911
- const filePath = resolve8(cwd, file);
3570
+ const filePath = resolve10(cwd, file);
2912
3571
  let yamlContent;
2913
3572
  try {
2914
- yamlContent = readFileSync12(filePath, "utf-8");
3573
+ yamlContent = readFileSync14(filePath, "utf-8");
2915
3574
  } catch {
2916
3575
  if (options.json) {
2917
3576
  console.log(JSON.stringify({ success: false, error: `Could not read file: ${filePath}` }));
@@ -2954,11 +3613,11 @@ function themeRegisterCommand(file, cwd, options) {
2954
3613
  process.exit(1);
2955
3614
  return;
2956
3615
  }
2957
- const docsAppDir = join12(repoRoot, "packages", "docs", "app");
2958
- const cssFilePath = join12(docsAppDir, `${slug2}-theme.css`);
2959
- const globalsPath = join12(docsAppDir, "globals.css");
2960
- const themeConfigPath = join12(repoRoot, "packages", "docs", "lib", "theme-config.ts");
2961
- if (!existsSync10(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)) {
2962
3621
  const msg = `Docs app directory not found: ${docsAppDir}`;
2963
3622
  if (options.json) {
2964
3623
  console.log(JSON.stringify({ success: false, error: msg }));
@@ -2971,8 +3630,8 @@ function themeRegisterCommand(file, cwd, options) {
2971
3630
  let globalsContent = "";
2972
3631
  let themeConfigContent = "";
2973
3632
  try {
2974
- globalsContent = readFileSync12(globalsPath, "utf-8");
2975
- themeConfigContent = readFileSync12(themeConfigPath, "utf-8");
3633
+ globalsContent = readFileSync14(globalsPath, "utf-8");
3634
+ themeConfigContent = readFileSync14(themeConfigPath, "utf-8");
2976
3635
  } catch (err) {
2977
3636
  const msg = err instanceof Error ? err.message : "Could not read docs files";
2978
3637
  if (options.json) {
@@ -2983,8 +3642,8 @@ function themeRegisterCommand(file, cwd, options) {
2983
3642
  process.exit(1);
2984
3643
  return;
2985
3644
  }
2986
- const cssExists = existsSync10(cssFilePath);
2987
- const cssChanged = !cssExists || readFileSync12(cssFilePath, "utf-8") !== css;
3645
+ const cssExists = existsSync11(cssFilePath);
3646
+ const cssChanged = !cssExists || readFileSync14(cssFilePath, "utf-8") !== css;
2988
3647
  const { updated: newGlobals, changed: globalsChanged } = insertGlobalsImport(globalsContent, slug2);
2989
3648
  const { updated: newThemeConfig, changed: themeConfigChanged, error: configError } = insertThemeConfig(
2990
3649
  themeConfigContent,
@@ -3068,8 +3727,8 @@ function themeRegisterCommand(file, cwd, options) {
3068
3727
  }
3069
3728
 
3070
3729
  // src/commands/theme-unregister.ts
3071
- import { readFileSync as readFileSync13, writeFileSync as writeFileSync8, existsSync as existsSync11, unlinkSync } from "fs";
3072
- 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";
3073
3732
  function removeGlobalsImport(content, slug2) {
3074
3733
  const importLine = `@import './${slug2}-theme.css';`;
3075
3734
  if (!content.includes(importLine)) {
@@ -3101,11 +3760,11 @@ function themeUnregisterCommand(slug2, cwd, options) {
3101
3760
  process.exit(1);
3102
3761
  return;
3103
3762
  }
3104
- const docsAppDir = join13(repoRoot, "packages", "docs", "app");
3105
- const cssFilePath = join13(docsAppDir, `${slug2}-theme.css`);
3106
- const globalsPath = join13(docsAppDir, "globals.css");
3107
- const themeConfigPath = join13(repoRoot, "packages", "docs", "lib", "theme-config.ts");
3108
- if (!existsSync11(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)) {
3109
3768
  const msg = `Docs app directory not found: ${docsAppDir}`;
3110
3769
  if (options.json) {
3111
3770
  console.log(JSON.stringify({ success: false, error: msg }));
@@ -3118,8 +3777,8 @@ function themeUnregisterCommand(slug2, cwd, options) {
3118
3777
  let globalsContent = "";
3119
3778
  let themeConfigContent = "";
3120
3779
  try {
3121
- globalsContent = readFileSync13(globalsPath, "utf-8");
3122
- themeConfigContent = readFileSync13(themeConfigPath, "utf-8");
3780
+ globalsContent = readFileSync15(globalsPath, "utf-8");
3781
+ themeConfigContent = readFileSync15(themeConfigPath, "utf-8");
3123
3782
  } catch (err) {
3124
3783
  const msg = err instanceof Error ? err.message : "Could not read docs files";
3125
3784
  if (options.json) {
@@ -3130,7 +3789,7 @@ function themeUnregisterCommand(slug2, cwd, options) {
3130
3789
  process.exit(1);
3131
3790
  return;
3132
3791
  }
3133
- const cssExists = existsSync11(cssFilePath);
3792
+ const cssExists = existsSync12(cssFilePath);
3134
3793
  const { updated: newGlobals, changed: globalsChanged } = removeGlobalsImport(globalsContent, slug2);
3135
3794
  const { updated: newThemeConfig, changed: themeConfigChanged } = removeThemeConfigEntry(themeConfigContent, slug2);
3136
3795
  if (!cssExists && !globalsChanged && !themeConfigChanged) {
@@ -3171,30 +3830,150 @@ function themeUnregisterCommand(slug2, cwd, options) {
3171
3830
 
3172
3831
  // src/commands/theme-sync.ts
3173
3832
  import {
3174
- readFileSync as readFileSync14,
3833
+ readFileSync as readFileSync16,
3175
3834
  writeFileSync as writeFileSync9,
3176
3835
  mkdirSync as mkdirSync5,
3177
- existsSync as existsSync12,
3178
- readdirSync as readdirSync4,
3836
+ existsSync as existsSync13,
3837
+ readdirSync as readdirSync6,
3179
3838
  unlinkSync as unlinkSync2,
3180
3839
  copyFileSync
3181
3840
  } from "fs";
3182
- import { join as join14, basename as basename2 } from "path";
3841
+ import { join as join15, basename as basename3, resolve as resolve11, sep } from "path";
3183
3842
  import { parse as parseYaml2 } from "yaml";
3184
3843
  import { generateThemeData as generateThemeData4 } from "@loworbitstudio/visor-theme-engine";
3185
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";
3186
3847
  var GLOBALS_BEGIN_MARKER = "/* BEGIN visor-theme-imports \u2014 managed by `visor theme sync` */";
3187
3848
  var GLOBALS_END_MARKER = "/* END visor-theme-imports */";
3188
3849
  var STOCK_GROUPS_BEGIN_MARKER = "/* BEGIN visor-stock-themes \u2014 managed by `visor theme sync` */";
3189
3850
  var STOCK_GROUPS_END_MARKER = "/* END visor-stock-themes */";
3190
- var GITIGNORE_BEGIN_MARKER = "# BEGIN visor-custom-theme-css (managed by `visor theme sync` \u2014 do not edit manually)";
3191
- var GITIGNORE_END_MARKER = "# END visor-custom-theme-css";
3192
3851
  var CUSTOM_OVERLAY_CSS_PATH = "packages/docs/app/custom-themes.generated.css";
3193
3852
  var CUSTOM_OVERLAY_TS_PATH = "packages/docs/lib/theme-config.custom.generated.ts";
3194
3853
  var CUSTOM_OVERLAY_IMPORT_LINE = "@import './custom-themes.generated.css';";
3195
3854
  function scanThemeDir(dir) {
3196
- if (!existsSync12(dir)) return [];
3197
- 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");
3198
3977
  }
3199
3978
  function extractGroup(yamlContent) {
3200
3979
  const parsed = parseYaml2(yamlContent);
@@ -3231,28 +4010,28 @@ ${GLOBALS_END_MARKER}`;
3231
4010
  updated = content.slice(0, beginIdx) + newBlock + content.slice(endIdx + GLOBALS_END_MARKER.length);
3232
4011
  } else {
3233
4012
  const themeImportPattern = /^@import '\.\/[\w-]+-theme\.css';\n?/gm;
3234
- const lines = content.split("\n");
4013
+ const lines2 = content.split("\n");
3235
4014
  let firstThemeIdx = -1;
3236
4015
  let lastThemeIdx = -1;
3237
- for (let i = 0; i < lines.length; i++) {
3238
- 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])) {
3239
4018
  if (firstThemeIdx === -1) firstThemeIdx = i;
3240
4019
  lastThemeIdx = i;
3241
4020
  }
3242
4021
  }
3243
4022
  if (firstThemeIdx !== -1) {
3244
- const before = lines.slice(0, firstThemeIdx);
3245
- const after = lines.slice(lastThemeIdx + 1);
4023
+ const before = lines2.slice(0, firstThemeIdx);
4024
+ const after = lines2.slice(lastThemeIdx + 1);
3246
4025
  updated = [...before, newBlock, ...after].join("\n");
3247
4026
  } else {
3248
4027
  void themeImportPattern;
3249
- const lastImportIdx = lines.reduce(
4028
+ const lastImportIdx = lines2.reduce(
3250
4029
  (last, line, i) => line.startsWith("@import") ? i : last,
3251
4030
  -1
3252
4031
  );
3253
4032
  const insertAt = lastImportIdx + 1;
3254
- lines.splice(insertAt, 0, newBlock);
3255
- updated = lines.join("\n");
4033
+ lines2.splice(insertAt, 0, newBlock);
4034
+ updated = lines2.join("\n");
3256
4035
  }
3257
4036
  }
3258
4037
  updated = ensureCustomOverlayImport(updated);
@@ -3353,19 +4132,17 @@ ${groupsTs}
3353
4132
  ];
3354
4133
  `;
3355
4134
  }
3356
- function updateGitignoreBlock(content, customSlugs) {
3357
- const cssLines = customSlugs.sort().map((slug2) => `packages/docs/app/${slug2}-theme.css`).join("\n");
3358
- const newBlock = `${GITIGNORE_BEGIN_MARKER}
3359
- ${cssLines}
3360
- ${GITIGNORE_END_MARKER}`;
3361
- const beginIdx = content.indexOf(GITIGNORE_BEGIN_MARKER);
3362
- const endIdx = content.indexOf(GITIGNORE_END_MARKER);
3363
- if (beginIdx !== -1 && endIdx !== -1) {
3364
- return content.slice(0, beginIdx) + newBlock + content.slice(endIdx + GITIGNORE_END_MARKER.length);
3365
- }
3366
- return content.trimEnd() + "\n\n" + newBlock + "\n";
3367
- }
3368
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
+ }
3369
4146
  const repoRoot = findRepoRoot(cwd);
3370
4147
  if (!repoRoot) {
3371
4148
  const msg = "Could not locate repo root (packages/docs/ not found). Run from within the visor repo.";
@@ -3377,33 +4154,72 @@ function themeSyncCommand(cwd, options) {
3377
4154
  process.exit(1);
3378
4155
  return;
3379
4156
  }
3380
- const themesDir = join14(repoRoot, "themes");
3381
- const customThemesDir = join14(repoRoot, "custom-themes");
3382
- const docsAppDir = join14(repoRoot, "packages", "docs", "app");
3383
- const docsLibDir = join14(repoRoot, "packages", "docs", "lib");
3384
- const docsPublicThemesDir = join14(repoRoot, "packages", "docs", "public", "themes");
3385
- const themeConfigPath = join14(repoRoot, "packages", "docs", "lib", "theme-config.ts");
3386
- const globalsPath = join14(docsAppDir, "globals.css");
3387
- const gitignorePath = join14(repoRoot, ".gitignore");
3388
- const customOverlayCssPath = join14(repoRoot, CUSTOM_OVERLAY_CSS_PATH);
3389
- const customOverlayTsPath = join14(repoRoot, CUSTOM_OVERLAY_TS_PATH);
3390
- const stockFiles = scanThemeDir(themesDir);
3391
- const customFiles = scanThemeDir(customThemesDir);
3392
- if (stockFiles.length === 0 && customFiles.length === 0) {
3393
- 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";
3394
4195
  if (options.json) {
3395
4196
  console.log(JSON.stringify({ success: false, error: msg }));
3396
4197
  } else {
3397
- logger.warn(msg);
4198
+ logger.error(msg);
3398
4199
  }
4200
+ process.exit(1);
3399
4201
  return;
3400
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);
4209
+ }
4210
+ process.exit(1);
4211
+ return;
4212
+ }
4213
+ if (!options.json) {
4214
+ for (const w of deprecationWarnings) logger.warn(w);
4215
+ for (const w of discoveryWarnings) logger.warn(w);
4216
+ }
3401
4217
  const manifest = [];
3402
4218
  const errors = [];
3403
- const processFile = (filePath, isCustom) => {
4219
+ const processFile = (filePath, isCustom, slugOverride) => {
3404
4220
  let yamlContent;
3405
4221
  try {
3406
- yamlContent = readFileSync14(filePath, "utf-8");
4222
+ yamlContent = readFileSync16(filePath, "utf-8");
3407
4223
  } catch {
3408
4224
  errors.push(`Could not read: ${filePath}`);
3409
4225
  return;
@@ -3412,19 +4228,22 @@ function themeSyncCommand(cwd, options) {
3412
4228
  try {
3413
4229
  data = generateThemeData4(yamlContent);
3414
4230
  } catch (err) {
3415
- 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"}`);
3416
4232
  return;
3417
4233
  }
3418
- const slug2 = toSlug(data.config.name);
4234
+ const slug2 = slugOverride ?? toSlug(data.config.name);
3419
4235
  const label = extractLabel(yamlContent) ?? toLabel(data.config.name);
3420
4236
  const group = extractGroup(yamlContent) ?? (isCustom ? "Custom" : "Visor");
3421
4237
  const defaultMode = extractDefaultMode(yamlContent);
3422
4238
  const css = docsAdapter3({ primitives: data.primitives, tokens: data.tokens, config: data.config });
3423
- const yamlFilename = basename2(filePath).replace(/\.visor\.yaml$/, "");
4239
+ const yamlFilename = slugOverride ?? basename3(filePath).replace(/\.visor\.yaml$/, "");
3424
4240
  manifest.push({ slug: slug2, label, group, defaultMode, css, yamlFilename, isCustom });
3425
4241
  };
3426
4242
  for (const f of stockFiles) processFile(f, false);
3427
- 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
+ }
3428
4247
  if (errors.length > 0) {
3429
4248
  if (options.json) {
3430
4249
  console.log(JSON.stringify({ success: false, errors }));
@@ -3437,15 +4256,12 @@ function themeSyncCommand(cwd, options) {
3437
4256
  const stockManifest = manifest.filter((e) => !e.isCustom);
3438
4257
  const customManifest = manifest.filter((e) => e.isCustom);
3439
4258
  const stockSlugs = stockManifest.map((e) => e.slug);
3440
- const customSlugs = customManifest.map((e) => e.slug);
3441
4259
  const allSlugs = manifest.map((e) => e.slug);
3442
4260
  let globalsContent;
3443
4261
  let themeConfigContent;
3444
- let gitignoreContent;
3445
4262
  try {
3446
- globalsContent = readFileSync14(globalsPath, "utf-8");
3447
- themeConfigContent = readFileSync14(themeConfigPath, "utf-8");
3448
- gitignoreContent = existsSync12(gitignorePath) ? readFileSync14(gitignorePath, "utf-8") : "";
4263
+ globalsContent = readFileSync16(globalsPath, "utf-8");
4264
+ themeConfigContent = readFileSync16(themeConfigPath, "utf-8");
3449
4265
  } catch (err) {
3450
4266
  const msg = err instanceof Error ? err.message : "Could not read docs files";
3451
4267
  if (options.json) {
@@ -3458,15 +4274,14 @@ function themeSyncCommand(cwd, options) {
3458
4274
  }
3459
4275
  const newGlobals = updateGlobalsImports(globalsContent, stockSlugs);
3460
4276
  const newThemeConfig = updateStockThemeConfigBlock(themeConfigContent, stockManifest);
3461
- const newGitignore = customSlugs.length > 0 ? updateGitignoreBlock(gitignoreContent, customSlugs) : gitignoreContent;
3462
4277
  const newCustomOverlayCss = generateCustomOverlayCss(customManifest);
3463
4278
  const newCustomOverlayTs = generateCustomOverlayTs(customManifest);
3464
- const existingCssFiles = existsSync12(docsAppDir) ? readdirSync4(docsAppDir).filter(
4279
+ const existingCssFiles = existsSync13(docsAppDir) ? readdirSync6(docsAppDir).filter(
3465
4280
  (f) => f.endsWith("-theme.css") && f !== "custom-themes.generated.css"
3466
4281
  ) : [];
3467
4282
  const newCssSet = new Set(allSlugs.map((s) => `${s}-theme.css`));
3468
4283
  const staleCssFiles = existingCssFiles.filter((f) => !newCssSet.has(f));
3469
- const existingPublicYamls = existsSync12(docsPublicThemesDir) ? readdirSync4(docsPublicThemesDir).filter((f) => f.endsWith(".visor.yaml")) : [];
4284
+ const existingPublicYamls = existsSync13(docsPublicThemesDir) ? readdirSync6(docsPublicThemesDir).filter((f) => f.endsWith(".visor.yaml")) : [];
3470
4285
  const newPublicYamlSet = new Set(manifest.map((e) => `${e.yamlFilename}.visor.yaml`));
3471
4286
  const stalePublicYamls = existingPublicYamls.filter((f) => !newPublicYamlSet.has(f));
3472
4287
  if (options.dryRun) {
@@ -3478,7 +4293,6 @@ function themeSyncCommand(cwd, options) {
3478
4293
  globalsCSS: globalsPath,
3479
4294
  customOverlayCss: CUSTOM_OVERLAY_CSS_PATH,
3480
4295
  customOverlayTs: CUSTOM_OVERLAY_TS_PATH,
3481
- gitignore: gitignorePath,
3482
4296
  publicYamlsCopied: manifest.map((e) => `packages/docs/public/themes/${e.yamlFilename}.visor.yaml`),
3483
4297
  publicYamlsDeleted: stalePublicYamls.map((f) => `packages/docs/public/themes/${f}`)
3484
4298
  };
@@ -3498,25 +4312,24 @@ function themeSyncCommand(cwd, options) {
3498
4312
  mkdirSync5(docsLibDir, { recursive: true });
3499
4313
  mkdirSync5(docsPublicThemesDir, { recursive: true });
3500
4314
  for (const entry of manifest) {
3501
- writeFileSync9(join14(docsAppDir, `${entry.slug}-theme.css`), entry.css, "utf-8");
4315
+ writeFileSync9(join15(docsAppDir, `${entry.slug}-theme.css`), entry.css, "utf-8");
3502
4316
  }
3503
4317
  for (const stale of staleCssFiles) {
3504
- unlinkSync2(join14(docsAppDir, stale));
4318
+ unlinkSync2(join15(docsAppDir, stale));
3505
4319
  }
3506
4320
  writeFileSync9(customOverlayCssPath, newCustomOverlayCss, "utf-8");
3507
4321
  writeFileSync9(customOverlayTsPath, newCustomOverlayTs, "utf-8");
3508
4322
  writeFileSync9(themeConfigPath, newThemeConfig, "utf-8");
3509
4323
  writeFileSync9(globalsPath, newGlobals, "utf-8");
3510
- if (existsSync12(gitignorePath)) {
3511
- writeFileSync9(gitignorePath, newGitignore, "utf-8");
4324
+ for (const srcFile of stockFiles) {
4325
+ copyFileSync(srcFile, join15(docsPublicThemesDir, basename3(srcFile)));
3512
4326
  }
3513
- const allSourceFiles = [...stockFiles, ...customFiles];
3514
- for (const srcFile of allSourceFiles) {
3515
- const filename = basename2(srcFile);
3516
- 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));
3517
4330
  }
3518
4331
  for (const stale of stalePublicYamls) {
3519
- unlinkSync2(join14(docsPublicThemesDir, stale));
4332
+ unlinkSync2(join15(docsPublicThemesDir, stale));
3520
4333
  }
3521
4334
  } catch (err) {
3522
4335
  const msg = err instanceof Error ? err.message : "Write failed";
@@ -3529,6 +4342,7 @@ function themeSyncCommand(cwd, options) {
3529
4342
  return;
3530
4343
  }
3531
4344
  if (options.json) {
4345
+ const warnings = [...deprecationWarnings, ...discoveryWarnings];
3532
4346
  console.log(JSON.stringify({
3533
4347
  success: true,
3534
4348
  themes: manifest.length,
@@ -3536,7 +4350,8 @@ function themeSyncCommand(cwd, options) {
3536
4350
  custom: customManifest.length,
3537
4351
  staleCssDeleted: staleCssFiles.length,
3538
4352
  staleYamlsDeleted: stalePublicYamls.length,
3539
- slugs: allSlugs
4353
+ slugs: allSlugs,
4354
+ ...warnings.length > 0 ? { warnings } : {}
3540
4355
  }));
3541
4356
  } else {
3542
4357
  logger.success(`Theme sync complete \u2014 ${manifest.length} themes registered`);
@@ -3552,19 +4367,19 @@ function themeSyncCommand(cwd, options) {
3552
4367
 
3553
4368
  // src/commands/theme-batch-apply-flutter.ts
3554
4369
  import {
3555
- readFileSync as readFileSync15,
4370
+ readFileSync as readFileSync17,
3556
4371
  writeFileSync as writeFileSync10,
3557
4372
  mkdirSync as mkdirSync6,
3558
- existsSync as existsSync13,
3559
- readdirSync as readdirSync5,
4373
+ existsSync as existsSync14,
4374
+ readdirSync as readdirSync7,
3560
4375
  rmSync
3561
4376
  } from "fs";
3562
- 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";
3563
4378
  import { generateThemeData as generateThemeData5 } from "@loworbitstudio/visor-theme-engine";
3564
4379
  import { flutterAdapter as flutterAdapter2 } from "@loworbitstudio/visor-theme-engine/adapters";
3565
4380
  function scanThemeDir2(dir) {
3566
- if (!existsSync13(dir)) return [];
3567
- 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();
3568
4383
  }
3569
4384
  function slugToCamel(slug2) {
3570
4385
  return slug2.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -3627,7 +4442,7 @@ function slugToDartPrefix(slug2) {
3627
4442
  return slug2.replace(/-/g, "_") + "_t";
3628
4443
  }
3629
4444
  function emitMetaBarrel(slugs) {
3630
- const lines = [
4445
+ const lines2 = [
3631
4446
  `// GENERATED BY visor \u2014 DO NOT EDIT.`,
3632
4447
  `// Regenerate with \`npm run themes:apply-flutter\`.`,
3633
4448
  `//`,
@@ -3643,37 +4458,37 @@ function emitMetaBarrel(slugs) {
3643
4458
  ];
3644
4459
  for (const slug2 of slugs) {
3645
4460
  const prefix = slugToDartPrefix(slug2);
3646
- lines.push(`import 'src/${slug2}/theme/visor_theme.dart' as ${prefix};`);
3647
- }
3648
- lines.push(``);
3649
- lines.push(`/// A light/dark [ThemeData] pair for a single Visor theme.`);
3650
- lines.push(`class VisorThemePair {`);
3651
- lines.push(` final ThemeData light;`);
3652
- lines.push(` final ThemeData dark;`);
3653
- lines.push(` const VisorThemePair({required this.light, required this.dark});`);
3654
- lines.push(`}`);
3655
- lines.push(``);
3656
- lines.push(`/// Static access to all Visor-generated Flutter themes.`);
3657
- lines.push(`///`);
3658
- lines.push(`/// Usage:`);
3659
- lines.push(`/// \`\`\`dart`);
3660
- lines.push(`/// MaterialApp(`);
3661
- lines.push(`/// theme: VisorThemes.blackout.light,`);
3662
- lines.push(`/// darkTheme: VisorThemes.blackout.dark,`);
3663
- lines.push(`/// );`);
3664
- lines.push(`/// \`\`\``);
3665
- 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 {`);
3666
4481
  for (const slug2 of slugs) {
3667
4482
  const camel = slugToCamel(slug2);
3668
4483
  const prefix = slugToDartPrefix(slug2);
3669
- lines.push(` static VisorThemePair get ${camel} => VisorThemePair(`);
3670
- lines.push(` light: ${prefix}.VisorAppTheme.light,`);
3671
- lines.push(` dark: ${prefix}.VisorAppTheme.dark,`);
3672
- 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(` );`);
3673
4488
  }
3674
- lines.push(`}`);
3675
- lines.push(``);
3676
- return lines.join("\n");
4489
+ lines2.push(`}`);
4490
+ lines2.push(``);
4491
+ return lines2.join("\n");
3677
4492
  }
3678
4493
  function emitGitignore() {
3679
4494
  return [
@@ -3697,9 +4512,9 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3697
4512
  process.exit(1);
3698
4513
  return;
3699
4514
  }
3700
- const themesDir = join15(repoRoot, "themes");
3701
- const customThemesDir = join15(repoRoot, "custom-themes");
3702
- 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");
3703
4518
  const stockFiles = scanThemeDir2(themesDir);
3704
4519
  const customFiles = scanThemeDir2(customThemesDir);
3705
4520
  const allFiles = [...stockFiles, ...customFiles];
@@ -3720,7 +4535,7 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3720
4535
  for (const filePath of allFiles) {
3721
4536
  let yamlContent;
3722
4537
  try {
3723
- yamlContent = readFileSync15(filePath, "utf-8");
4538
+ yamlContent = readFileSync17(filePath, "utf-8");
3724
4539
  } catch {
3725
4540
  errors.push(`Could not read: ${filePath}`);
3726
4541
  continue;
@@ -3730,7 +4545,7 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3730
4545
  data = generateThemeData5(yamlContent);
3731
4546
  } catch (err) {
3732
4547
  errors.push(
3733
- `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"}`
3734
4549
  );
3735
4550
  continue;
3736
4551
  }
@@ -3799,8 +4614,8 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3799
4614
  return;
3800
4615
  }
3801
4616
  const slugs = processed.map((p) => p.slug);
3802
- const libSrcDir = join15(outputDir, "lib", "src");
3803
- if (existsSync13(libSrcDir)) {
4617
+ const libSrcDir = join16(outputDir, "lib", "src");
4618
+ if (existsSync14(libSrcDir)) {
3804
4619
  rmSync(libSrcDir, { recursive: true, force: true });
3805
4620
  }
3806
4621
  const packageFiles = {
@@ -3812,16 +4627,16 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3812
4627
  };
3813
4628
  let totalFiles = 0;
3814
4629
  for (const [relPath, content] of Object.entries(packageFiles)) {
3815
- const absPath = join15(outputDir, relPath);
3816
- mkdirSync6(dirname6(absPath), { recursive: true });
4630
+ const absPath = join16(outputDir, relPath);
4631
+ mkdirSync6(dirname7(absPath), { recursive: true });
3817
4632
  writeFileSync10(absPath, content, "utf-8");
3818
4633
  totalFiles++;
3819
4634
  }
3820
4635
  for (const { slug: slug2, tokenFiles } of processed) {
3821
- const themeBaseDir = join15(outputDir, "lib", "src", slug2);
4636
+ const themeBaseDir = join16(outputDir, "lib", "src", slug2);
3822
4637
  for (const [relPath, content] of Object.entries(tokenFiles)) {
3823
- const absPath = join15(themeBaseDir, relPath);
3824
- mkdirSync6(dirname6(absPath), { recursive: true });
4638
+ const absPath = join16(themeBaseDir, relPath);
4639
+ mkdirSync6(dirname7(absPath), { recursive: true });
3825
4640
  writeFileSync10(absPath, content, "utf-8");
3826
4641
  totalFiles++;
3827
4642
  }
@@ -3843,11 +4658,11 @@ function themeBatchApplyFlutterCommand(cwd, options) {
3843
4658
  }
3844
4659
 
3845
4660
  // src/commands/fonts-add.ts
3846
- import { existsSync as existsSync14, statSync as statSync4, readdirSync as readdirSync6, readFileSync as readFileSync16 } from "fs";
3847
- import { resolve as resolve9, 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";
3848
4663
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
3849
4664
  function deriveFamilySlug(filename) {
3850
- const name = basename4(filename, extname3(filename));
4665
+ const name = basename5(filename, extname4(filename));
3851
4666
  const WEIGHT_STYLE_SUFFIXES = /* @__PURE__ */ new Set([
3852
4667
  "thin",
3853
4668
  "hairline",
@@ -3888,28 +4703,28 @@ function deriveFamilySlug(filename) {
3888
4703
  return parts.join("-").toLowerCase();
3889
4704
  }
3890
4705
  function collectWoff2Files(inputPath) {
3891
- const resolved = resolve9(inputPath);
3892
- if (!existsSync14(resolved)) {
4706
+ const resolved = resolve12(inputPath);
4707
+ if (!existsSync15(resolved)) {
3893
4708
  throw new Error(`Path not found: ${resolved}`);
3894
4709
  }
3895
- const stat = statSync4(resolved);
4710
+ const stat = statSync7(resolved);
3896
4711
  if (stat.isFile()) {
3897
- if (extname3(resolved).toLowerCase() !== ".woff2") {
4712
+ if (extname4(resolved).toLowerCase() !== ".woff2") {
3898
4713
  throw new Error(
3899
- `Invalid file format: ${basename4(resolved)}. Only .woff2 files are accepted.`
4714
+ `Invalid file format: ${basename5(resolved)}. Only .woff2 files are accepted.`
3900
4715
  );
3901
4716
  }
3902
4717
  return [resolved];
3903
4718
  }
3904
4719
  if (stat.isDirectory()) {
3905
- const files = readdirSync6(resolved).filter((f) => extname3(f).toLowerCase() === ".woff2").map((f) => resolve9(resolved, f));
4720
+ const files = readdirSync8(resolved).filter((f) => extname4(f).toLowerCase() === ".woff2").map((f) => resolve12(resolved, f));
3906
4721
  if (files.length === 0) {
3907
4722
  throw new Error(
3908
4723
  `No .woff2 files found in directory: ${resolved}`
3909
4724
  );
3910
4725
  }
3911
- const nonWoff2Fonts = readdirSync6(resolved).filter((f) => {
3912
- const ext = extname3(f).toLowerCase();
4726
+ const nonWoff2Fonts = readdirSync8(resolved).filter((f) => {
4727
+ const ext = extname4(f).toLowerCase();
3913
4728
  return [".ttf", ".otf", ".woff", ".eot"].includes(ext);
3914
4729
  });
3915
4730
  return files.sort();
@@ -3917,12 +4732,12 @@ function collectWoff2Files(inputPath) {
3917
4732
  throw new Error(`Path is neither a file nor a directory: ${resolved}`);
3918
4733
  }
3919
4734
  function getNonWoff2Fonts(inputPath) {
3920
- const resolved = resolve9(inputPath);
3921
- if (!existsSync14(resolved) || !statSync4(resolved).isDirectory()) {
4735
+ const resolved = resolve12(inputPath);
4736
+ if (!existsSync15(resolved) || !statSync7(resolved).isDirectory()) {
3922
4737
  return [];
3923
4738
  }
3924
- return readdirSync6(resolved).filter((f) => {
3925
- const ext = extname3(f).toLowerCase();
4739
+ return readdirSync8(resolved).filter((f) => {
4740
+ const ext = extname4(f).toLowerCase();
3926
4741
  return [".ttf", ".otf", ".woff", ".eot"].includes(ext);
3927
4742
  });
3928
4743
  }
@@ -3955,7 +4770,7 @@ function createR2Client(config) {
3955
4770
  });
3956
4771
  }
3957
4772
  async function uploadFile(client, bucket, key, filePath) {
3958
- const body = readFileSync16(filePath);
4773
+ const body = readFileSync18(filePath);
3959
4774
  await client.send(
3960
4775
  new PutObjectCommand({
3961
4776
  Bucket: bucket,
@@ -3970,9 +4785,9 @@ async function fontsAddCommand(inputPath, options) {
3970
4785
  try {
3971
4786
  const r2Config = getR2Config();
3972
4787
  const files = collectWoff2Files(inputPath);
3973
- const familySlug = options.family ?? deriveFamilySlug(basename4(files[0]));
3974
- const resolved = resolve9(inputPath);
3975
- 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) : [];
3976
4791
  if (!json) {
3977
4792
  logger.heading("Visor Font Upload");
3978
4793
  logger.info(`Organization: ${org}`);
@@ -3990,13 +4805,13 @@ async function fontsAddCommand(inputPath, options) {
3990
4805
  const bucket = "visor-fonts";
3991
4806
  const results = [];
3992
4807
  for (const filePath of files) {
3993
- const filename = basename4(filePath);
4808
+ const filename = basename5(filePath);
3994
4809
  const key = buildS3Key(org, familySlug, filename);
3995
4810
  if (!json) {
3996
4811
  logger.info(`Uploading ${filename}...`);
3997
4812
  }
3998
4813
  await uploadFile(client, bucket, key, filePath);
3999
- const size = statSync4(filePath).size;
4814
+ const size = statSync7(filePath).size;
4000
4815
  results.push({ file: filename, key, size });
4001
4816
  if (!json) {
4002
4817
  logger.success(`Uploaded: ${key} (${formatBytes(size)})`);
@@ -4046,7 +4861,7 @@ function formatBytes(bytes) {
4046
4861
  // src/commands/doctor.ts
4047
4862
  import * as fs from "fs";
4048
4863
  import * as path from "path";
4049
- import { execFileSync as execFileSync3 } from "child_process";
4864
+ import { execFileSync as execFileSync4 } from "child_process";
4050
4865
  async function doctorCommand(cwd, options, cliVersion) {
4051
4866
  const checks = [];
4052
4867
  const visorJsonPath = path.join(cwd, "visor.json");
@@ -4181,9 +4996,9 @@ async function doctorCommand(cwd, options, cliVersion) {
4181
4996
  }
4182
4997
  if (process.platform !== "win32") {
4183
4998
  try {
4184
- const globalPath = execFileSync3("which", ["visor"], { encoding: "utf-8" }).trim();
4999
+ const globalPath = execFileSync4("which", ["visor"], { encoding: "utf-8" }).trim();
4185
5000
  if (globalPath) {
4186
- const globalVersionRaw = execFileSync3(globalPath, ["--version"], { encoding: "utf-8" }).trim();
5001
+ const globalVersionRaw = execFileSync4(globalPath, ["--version"], { encoding: "utf-8" }).trim();
4187
5002
  const globalVersion = globalVersionRaw.split(/\s+/).pop() ?? "";
4188
5003
  if (isOlder(globalVersion, cliVersion)) {
4189
5004
  checks.push({
@@ -4259,27 +5074,27 @@ function findCssFiles(dir, maxDepth = 3) {
4259
5074
  }
4260
5075
 
4261
5076
  // src/utils/patterns.ts
4262
- import { existsSync as existsSync16, readdirSync as readdirSync8, readFileSync as readFileSync18 } from "fs";
4263
- 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";
4264
5079
  import { parse as parseYAML } from "yaml";
4265
5080
  function loadPatternsFromYaml(repoRoot) {
4266
- const patternsDir = join17(repoRoot, "patterns");
4267
- if (!existsSync16(patternsDir)) return [];
4268
- const files = readdirSync8(patternsDir).filter(
5081
+ const patternsDir = join18(repoRoot, "patterns");
5082
+ if (!existsSync17(patternsDir)) return [];
5083
+ const files = readdirSync10(patternsDir).filter(
4269
5084
  (f) => f.endsWith(".visor-pattern.yaml")
4270
5085
  );
4271
5086
  return files.map((file) => {
4272
- const content = readFileSync18(join17(patternsDir, file), "utf-8");
5087
+ const content = readFileSync20(join18(patternsDir, file), "utf-8");
4273
5088
  return parseYAML(content);
4274
5089
  }).filter(Boolean);
4275
5090
  }
4276
5091
  function findRepoRoot2(startDir) {
4277
5092
  let current = startDir;
4278
5093
  while (true) {
4279
- if (existsSync16(join17(current, "patterns"))) {
5094
+ if (existsSync17(join18(current, "patterns"))) {
4280
5095
  return current;
4281
5096
  }
4282
- const parent = join17(current, "..");
5097
+ const parent = join18(current, "..");
4283
5098
  if (parent === current) return null;
4284
5099
  current = parent;
4285
5100
  }
@@ -4657,6 +5472,244 @@ Visor Tokens (${categoryLabel}) \u2014 ${tokens2.length} tokens
4657
5472
  );
4658
5473
  }
4659
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
+
4660
5713
  // src/index.ts
4661
5714
  var program = new Command2();
4662
5715
  program.name("visor").description("CLI for the Visor design system").version("0.3.0");
@@ -4793,4 +5846,16 @@ var tokens = program.command("tokens").description("Explore design tokens");
4793
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) => {
4794
5847
  await tokensListCommand(process.cwd(), options);
4795
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
+ );
4796
5861
  program.parse();