synthesisui 0.16.55 → 0.16.57

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.
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, join, relative } from "node:path";
3
3
  import { readToken, resolveRegistry } from "../config.js";
4
- import { isNearDuplicate } from "../doctor/color-distance.js";
4
+ import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
5
5
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
6
6
  import { crosswalk, observedRules } from "../doctor/crosswalk.js";
7
7
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
@@ -327,13 +327,19 @@ export async function takeCensus(root) {
327
327
  * by the two-option rule, while its type declares four sizes and three
328
328
  * variants (dono, 31/07). The component would have reached the platform with
329
329
  * no contract at all, which is the exact thing reading definitions was for.
330
+ *
331
+ * Carried BESIDE the usage rather than written into it. The first attempt
332
+ * merged the declaration into `props` and broke the two readings that need
333
+ * `props` to mean "what the code passed": the composition list started
334
+ * showing declared options as composed, and the dead-option check went quiet
335
+ * because every declared option now looked used.
330
336
  */
331
337
  const declaredBy = new Map(defined.map((d) => [d.name, d]));
332
338
  const enriched = inventory.map((c) => {
333
339
  const d = declaredBy.get(c.name);
334
340
  if (!d || Object.keys(d.axes).length === 0)
335
341
  return c;
336
- return { ...c, props: { ...c.props, ...d.axes } };
342
+ return { ...c, declaredAxes: d.axes };
337
343
  });
338
344
  const merged = [...enriched, ...fromTypes];
339
345
  // The verdict travels WITH the payload: the platform reads one reading rather
@@ -401,7 +407,12 @@ function summarize(c) {
401
407
  // started using their own token table: not "you have drift" but "you have
402
408
  // drift your own system already solved".
403
409
  if (c.declaredAlt && Object.keys(c.declaredAlt).length > 0) {
404
- console.log(body(`${Object.keys(c.declaredAlt).length} of them are declared again for dark - both schemes travel`));
410
+ console.log(body(
411
+ // No promise here. Whether a second scheme actually travels depends on
412
+ // those declarations naming ROLES, which the platform decides - and a
413
+ // project with two aliased dark tokens got told "both schemes travel"
414
+ // and then received one (dono, 31/07).
415
+ `${Object.keys(c.declaredAlt).length} of them are declared again in a dark scope`));
405
416
  }
406
417
  // The inventory, phase one: what this project composes. Printed, never
407
418
  // mapped - deciding that their `Pill` is our `ds-badge` is a judgement a
@@ -596,7 +607,8 @@ function printAgentContract() {
596
607
  console.log(body(paint.faint(' "reading": {')));
597
608
  console.log(body(paint.faint(' "roles": { "canvas": "#…", "foreground": "#…", "primary": "#…" },')));
598
609
  console.log(body(paint.faint(' "fonts": { "display": "…", "body": "…" },')));
599
- console.log(body(paint.faint(' "concept": "one paragraph on what this product is"')));
610
+ console.log(body(paint.faint(' "concept": "one paragraph on what this product is",')));
611
+ console.log(body(paint.faint(' "themes": { "default": "dark", "has": ["dark"] }')));
600
612
  console.log(body(paint.faint(" }")));
601
613
  console.log("");
602
614
  console.log(body("Every hex in `roles` must be one the census already observed - a value"));
@@ -662,6 +674,33 @@ const GENERIC = new Set([
662
674
  "shared",
663
675
  "common",
664
676
  ]);
677
+ /**
678
+ * Which ends of the neutral ladder their own tokens reach.
679
+ *
680
+ * The bands match the ones the platform maps roles with: a surface needs to be
681
+ * a surface, and a near-black is the only thing that reads as a dark canvas.
682
+ * Both true means BOTH schemes can be built out of values they already
683
+ * declared - no colour borrowed, their token names intact - and the only open
684
+ * question is which one is the default. Exactly one true means the other mode
685
+ * would need colours they do not have, which is worth saying out loud rather
686
+ * than inventing.
687
+ */
688
+ export function ladderReach(declared) {
689
+ let light = false;
690
+ let dark = false;
691
+ for (const value of Object.values(declared)) {
692
+ if (typeof value !== "string")
693
+ continue;
694
+ const L = lightness(value);
695
+ if (L == null || !isNeutral(value))
696
+ continue;
697
+ if (L > 0.93)
698
+ light = true;
699
+ if (L < 0.05)
700
+ dark = true;
701
+ }
702
+ return { light, dark };
703
+ }
665
704
  export function deriveName(pkgName, dirName) {
666
705
  const raw = (pkgName ?? "").trim();
667
706
  const scoped = /^@([^/]+)\/(.+)$/.exec(raw);
@@ -701,6 +740,127 @@ async function askName(suggested) {
701
740
  rl.close();
702
741
  }
703
742
  }
743
+ /**
744
+ * Pick the default surface, by SELECTION rather than by typing (dono, 31/07).
745
+ *
746
+ * A one-bit answer typed as a word invites a typo that inverts a whole system,
747
+ * and a free-text prompt reads as "compose an answer" when the answer is a
748
+ * choice between two things we can already name. Arrows move, Enter takes what
749
+ * is highlighted; `1`/`2` and `d`/`l` work too, because muscle memory differs
750
+ * and none of them are ambiguous here.
751
+ *
752
+ * Never blocks without a person: no TTY means the caller's `--scheme` or the
753
+ * measured default stands.
754
+ */
755
+ async function askScheme(suggested) {
756
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
757
+ return suggested;
758
+ const options = ["dark", "light"];
759
+ let at = options.indexOf(suggested);
760
+ if (at < 0)
761
+ at = 0;
762
+ console.log("");
763
+ console.log(section("Your ladder reaches both ends"));
764
+ console.log(body(paint.faint("Both schemes get built from tokens you already declare. Pick the default.")));
765
+ console.log("");
766
+ const render = (first) => {
767
+ if (!first)
768
+ process.stdout.write(`\u001b[${options.length}A`);
769
+ for (const [i, opt] of options.entries()) {
770
+ const label = opt === "dark" ? "dark" : "light";
771
+ const line = i === at
772
+ ? ` ${paint.strong(`> ${label}`)}`
773
+ : ` ${paint.faint(` ${label}`)}`;
774
+ process.stdout.write(`\u001b[2K${line}\n`);
775
+ }
776
+ };
777
+ render(true);
778
+ return await new Promise((resolve) => {
779
+ const stdin = process.stdin;
780
+ const done = (value) => {
781
+ stdin.off("data", onData);
782
+ if (stdin.isTTY)
783
+ stdin.setRawMode(false);
784
+ stdin.pause();
785
+ // Leave the choice on screen rather than a cursor parked mid-list.
786
+ at = options.indexOf(value);
787
+ render(false);
788
+ console.log("");
789
+ resolve(value);
790
+ };
791
+ const onData = (buf) => {
792
+ const key = buf.toString();
793
+ if (key === "\u0003") {
794
+ // Ctrl-C during a question is a person leaving, not a value to invent.
795
+ if (stdin.isTTY)
796
+ stdin.setRawMode(false);
797
+ process.exit(130);
798
+ }
799
+ if (key === "\u001b[A" || key === "k") {
800
+ at = (at + options.length - 1) % options.length;
801
+ render(false);
802
+ return;
803
+ }
804
+ if (key === "\u001b[B" || key === "j" || key === "\t") {
805
+ at = (at + 1) % options.length;
806
+ render(false);
807
+ return;
808
+ }
809
+ if (key === "1")
810
+ return done("dark");
811
+ if (key === "2")
812
+ return done("light");
813
+ if (key === "d" || key === "D")
814
+ return done("dark");
815
+ if (key === "l" || key === "L")
816
+ return done("light");
817
+ if (key === "\r" || key === "\n")
818
+ return done(options[at]);
819
+ };
820
+ stdin.setRawMode(true);
821
+ stdin.resume();
822
+ stdin.on("data", onData);
823
+ });
824
+ }
825
+ /**
826
+ * Which end to offer first when both are available.
827
+ *
828
+ * The ladder itself votes: a product built dark tends to declare more rungs at
829
+ * the dark end, because that is where its surfaces live and where it needs the
830
+ * separation. The real one (dono, 31/07) declares nine near-blacks and five
831
+ * light greys, and its dashboard is near-black - so the count agrees with the
832
+ * screen. It is only a default; the person confirms it either way.
833
+ */
834
+ export function defaultScheme(declared) {
835
+ let dark = 0;
836
+ let light = 0;
837
+ for (const value of Object.values(declared)) {
838
+ if (typeof value !== "string" || !isNeutral(value))
839
+ continue;
840
+ const L = lightness(value);
841
+ if (L == null)
842
+ continue;
843
+ if (L < 0.5)
844
+ dark++;
845
+ else
846
+ light++;
847
+ }
848
+ return dark > light ? "dark" : "light";
849
+ }
850
+ /** On a dry run there is nobody to ask, so say what was measured. */
851
+ function sayReach(c) {
852
+ const reach = ladderReach(c.declared);
853
+ console.log("");
854
+ if (reach.light && reach.dark) {
855
+ console.log(body(`Your neutrals reach both ends, so light and dark could both be built from your own tokens - but only your project knows which it actually ships. Answer ${paint.strong("themes")} below, or let the real run ask; the ladder alone would guess ${paint.strong(defaultScheme(c.declared))}.`));
856
+ return;
857
+ }
858
+ if (reach.dark || reach.light) {
859
+ const has = reach.dark ? "dark" : "light";
860
+ const missing = reach.dark ? "light" : "dark";
861
+ console.log(body(`Your neutrals only reach the ${has} end, so this system gets one scheme. A ${missing} mode would need surfaces you do not declare.`));
862
+ }
863
+ }
704
864
  export async function runImport(opts) {
705
865
  const root = opts.root ?? process.cwd();
706
866
  // A census handed to us (an agent annotated it) is sent as-is; the numbers
@@ -741,6 +901,7 @@ export async function runImport(opts) {
741
901
  console.log(body(`Written to ${paint.strong(relative(root, out))}`));
742
902
  if (opts.dry) {
743
903
  console.log(body("Nothing was sent. Read the file, then run it without --dry."));
904
+ sayReach(census);
744
905
  printComponents(census);
745
906
  printAgentContract();
746
907
  console.log("");
@@ -748,6 +909,31 @@ export async function runImport(opts) {
748
909
  }
749
910
  const suggested = deriveName(census.project.name, basename(root) || "system");
750
911
  const chosen = opts.name?.trim() || (await askName(suggested));
912
+ const reach = ladderReach(census.declared);
913
+ // An explicit flag is a decision already made. Otherwise the person answers,
914
+ // starting from the agent's reading if there is one and from the ladder's own
915
+ // vote if there is not - and with nobody at a terminal, that same order
916
+ // stands unattended.
917
+ const read = census.reading?.themes;
918
+ const scheme = opts.scheme ??
919
+ read?.default ??
920
+ // Only ask when nobody has looked. A reading that names one theme has
921
+ // already answered this, and a second question would just be a chance to
922
+ // contradict it.
923
+ (read?.has?.length === 1
924
+ ? read.has[0]
925
+ : reach.light && reach.dark
926
+ ? await askScheme(defaultScheme(census.declared))
927
+ : reach.dark
928
+ ? "dark"
929
+ : reach.light
930
+ ? "light"
931
+ : undefined);
932
+ if (scheme)
933
+ census.scheme = scheme;
934
+ // The file on disk has to say what we sent, or the next person to read it is
935
+ // reading a different import than the one that happened.
936
+ await writeFile(out, `${JSON.stringify(census, null, 2)}\n`, "utf8");
751
937
  const token = await readToken();
752
938
  if (!token) {
753
939
  console.log("");
@@ -68,3 +68,57 @@ export function isNearDuplicate(a, b, threshold = JND) {
68
68
  const d = deltaE(a, b);
69
69
  return d != null && d <= threshold;
70
70
  }
71
+ /**
72
+ * LIGHTNESS and NEUTRAL - carried here for the same reason the ΔE maths is:
73
+ * the CLI is a standalone package and cannot import from the app. These are the
74
+ * platform's `lightness` and its neutral test (`apps/web/src/lib/ds/census-import.ts`)
75
+ * to the digit, and the spec asserts the same landmarks on both sides, because
76
+ * the two halves deciding differently what counts as a dark canvas is worse
77
+ * than either of them being wrong.
78
+ */
79
+ export function lightness(hex) {
80
+ const h = hex.trim().replace("#", "");
81
+ const full = h.length === 3 || h.length === 4
82
+ ? h
83
+ .slice(0, 3)
84
+ .split("")
85
+ .map((c) => c + c)
86
+ .join("")
87
+ : h.slice(0, 6);
88
+ if (full.length !== 6)
89
+ return null;
90
+ const n = Number.parseInt(full, 16);
91
+ if (Number.isNaN(n))
92
+ return null;
93
+ return ((0.2126 * ((n >> 16) & 255) +
94
+ 0.7152 * ((n >> 8) & 255) +
95
+ 0.0722 * (n & 255)) /
96
+ 255);
97
+ }
98
+ /** Channel spread, not HSL saturation: `#f9fbfd` is 0.50 saturated and 0.016
99
+ * chromatic, and treating it as coloured throws every off-white surface out of
100
+ * the neutral ladder. */
101
+ export function chroma(hex) {
102
+ const h = hex.trim().replace("#", "");
103
+ const full = h.length === 3 || h.length === 4
104
+ ? h
105
+ .slice(0, 3)
106
+ .split("")
107
+ .map((c) => c + c)
108
+ .join("")
109
+ : h.slice(0, 6);
110
+ if (full.length !== 6)
111
+ return null;
112
+ const n = Number.parseInt(full, 16);
113
+ if (Number.isNaN(n))
114
+ return null;
115
+ const r = (n >> 16) & 255;
116
+ const g = (n >> 8) & 255;
117
+ const b = n & 255;
118
+ return (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
119
+ }
120
+ export const NEUTRAL_CHROMA = 0.1;
121
+ export function isNeutral(hex) {
122
+ const c = chroma(hex);
123
+ return c != null && c < NEUTRAL_CHROMA;
124
+ }
package/dist/index.js CHANGED
@@ -66,6 +66,7 @@ Options:
66
66
  --version <n> install a specific version (default: latest)
67
67
  --ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
68
68
  --name <name> preferred component name for generate
69
+ --scheme <s> dark|light - which end of your ladder is the default
69
70
  --target <t> template/init target: next | general (default: next)
70
71
  --pages-dir <dir> init: folder for generated pages (default: app)
71
72
  --components-dir <dir> init: folder where components live (default: components)
@@ -159,6 +160,11 @@ async function main() {
159
160
  dry: flags.dry === true,
160
161
  census: typeof flags.census === "string" ? flags.census : undefined,
161
162
  name: typeof flags.name === "string" ? flags.name : undefined,
163
+ // Only these two words. Anything else is a typo that would silently
164
+ // invert a system, so it falls through to being measured and asked.
165
+ scheme: flags.scheme === "dark" || flags.scheme === "light"
166
+ ? flags.scheme
167
+ : undefined,
162
168
  registry,
163
169
  });
164
170
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.55",
3
+ "version": "0.16.57",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {