synthesisui 0.16.56 → 0.16.58
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/commands/import.js +195 -3
- package/dist/doctor/color-distance.js +54 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -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";
|
|
@@ -407,7 +407,12 @@ function summarize(c) {
|
|
|
407
407
|
// started using their own token table: not "you have drift" but "you have
|
|
408
408
|
// drift your own system already solved".
|
|
409
409
|
if (c.declaredAlt && Object.keys(c.declaredAlt).length > 0) {
|
|
410
|
-
console.log(body(
|
|
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`));
|
|
411
416
|
}
|
|
412
417
|
// The inventory, phase one: what this project composes. Printed, never
|
|
413
418
|
// mapped - deciding that their `Pill` is our `ds-badge` is a judgement a
|
|
@@ -602,7 +607,8 @@ function printAgentContract() {
|
|
|
602
607
|
console.log(body(paint.faint(' "reading": {')));
|
|
603
608
|
console.log(body(paint.faint(' "roles": { "canvas": "#…", "foreground": "#…", "primary": "#…" },')));
|
|
604
609
|
console.log(body(paint.faint(' "fonts": { "display": "…", "body": "…" },')));
|
|
605
|
-
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"] }')));
|
|
606
612
|
console.log(body(paint.faint(" }")));
|
|
607
613
|
console.log("");
|
|
608
614
|
console.log(body("Every hex in `roles` must be one the census already observed - a value"));
|
|
@@ -668,6 +674,33 @@ const GENERIC = new Set([
|
|
|
668
674
|
"shared",
|
|
669
675
|
"common",
|
|
670
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
|
+
}
|
|
671
704
|
export function deriveName(pkgName, dirName) {
|
|
672
705
|
const raw = (pkgName ?? "").trim();
|
|
673
706
|
const scoped = /^@([^/]+)\/(.+)$/.exec(raw);
|
|
@@ -707,6 +740,127 @@ async function askName(suggested) {
|
|
|
707
740
|
rl.close();
|
|
708
741
|
}
|
|
709
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
|
+
}
|
|
710
864
|
export async function runImport(opts) {
|
|
711
865
|
const root = opts.root ?? process.cwd();
|
|
712
866
|
// A census handed to us (an agent annotated it) is sent as-is; the numbers
|
|
@@ -747,6 +901,7 @@ export async function runImport(opts) {
|
|
|
747
901
|
console.log(body(`Written to ${paint.strong(relative(root, out))}`));
|
|
748
902
|
if (opts.dry) {
|
|
749
903
|
console.log(body("Nothing was sent. Read the file, then run it without --dry."));
|
|
904
|
+
sayReach(census);
|
|
750
905
|
printComponents(census);
|
|
751
906
|
printAgentContract();
|
|
752
907
|
console.log("");
|
|
@@ -754,6 +909,31 @@ export async function runImport(opts) {
|
|
|
754
909
|
}
|
|
755
910
|
const suggested = deriveName(census.project.name, basename(root) || "system");
|
|
756
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");
|
|
757
937
|
const token = await readToken();
|
|
758
938
|
if (!token) {
|
|
759
939
|
console.log("");
|
|
@@ -780,6 +960,18 @@ export async function runImport(opts) {
|
|
|
780
960
|
.catch(() => null)
|
|
781
961
|
: null;
|
|
782
962
|
console.log("");
|
|
963
|
+
// A 401 is not a refusal of the census, it is an expired session - and
|
|
964
|
+
// saying "HTTP 401" at the end of a long read leaves the person guessing at
|
|
965
|
+
// which of the two it was (dono, 31/07). A stale token gets past the
|
|
966
|
+
// `readToken` guard above and only fails here.
|
|
967
|
+
if (res?.status === 401) {
|
|
968
|
+
console.log(body("Your session has expired. The census is on disk."));
|
|
969
|
+
console.log("");
|
|
970
|
+
console.log(body(` ${paint.strong("synthesisui login")}`));
|
|
971
|
+
console.log(body(` synthesisui import --census ${relative(root, out)}${chosen ? ` --name "${chosen}"` : ""}`));
|
|
972
|
+
console.log("");
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
783
975
|
console.log(body(res
|
|
784
976
|
? `The registry refused it (HTTP ${res.status})${detail ? `: ${detail}` : "."}`
|
|
785
977
|
: "Could not reach the registry. The census is on disk - try again later."));
|
|
@@ -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