@c9up/nebula 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/atoms/Slider.d.ts +1 -3
- package/dist/atoms/Slider.js +12 -5
- package/dist/cli/add.js +12 -0
- package/dist/cli/diff.d.ts +47 -0
- package/dist/cli/diff.js +78 -0
- package/dist/cli/manifest.d.ts +37 -0
- package/dist/cli/manifest.js +71 -0
- package/dist/configure.js +62 -3
- package/dist/console/DiffCommand.d.ts +21 -0
- package/dist/console/DiffCommand.js +78 -0
- package/dist/molecules/InputGroup.d.ts +18 -3
- package/dist/molecules/InputGroup.js +22 -4
- package/dist/molecules/index.d.ts +1 -1
- package/dist/molecules/index.js +1 -1
- package/dist/organisms/Form.js +9 -1
- package/dist/primitives/floating.js +1 -1
- package/dist/primitives/focusTrap.js +35 -3
- package/dist/primitives/focusable.d.ts +12 -5
- package/dist/primitives/focusable.js +68 -9
- package/nebula.css +1 -1
- package/package.json +6 -2
- package/registry.json +3 -1
- package/src/atoms/Slider.ts +13 -8
- package/src/cli/add.ts +12 -0
- package/src/cli/diff.ts +114 -0
- package/src/cli/manifest.ts +80 -0
- package/src/configure.ts +73 -3
- package/src/console/DiffCommand.ts +103 -0
- package/src/molecules/InputGroup.ts +24 -4
- package/src/molecules/index.ts +1 -0
- package/src/organisms/Form.ts +9 -1
- package/src/primitives/floating.ts +1 -1
- package/src/primitives/focusTrap.ts +35 -3
- package/src/primitives/focusable.ts +75 -9
package/README.md
CHANGED
|
@@ -33,10 +33,13 @@ import { Button } from '#pages/atoms/Button.js'
|
|
|
33
33
|
|
|
34
34
|
`ream nebula:add` copies the component's source into your project and hands it over. No version, no upgrade path, no wrapper to fight when a design needs one class changed. That is shadcn's premise and nebula keeps it.
|
|
35
35
|
|
|
36
|
+
The bill for that premise is that a fix released here never reaches a project on its own, and nothing reports it — the files are local, no lockfile mentions them, `pnpm update` does not touch them. `ream nebula:diff` is where that becomes visible. It records the hash of what it copied, so it can separate a change you made from one you have not seen: `edited` is yours and needs nothing, `outdated` means the package moved while your copy stayed put and re-copying loses nothing, `conflict` means both moved. Components copied before the record existed report `unknown`, which is the honest answer — there is no way to tell which side moved.
|
|
37
|
+
|
|
36
38
|
```bash
|
|
37
39
|
ream nebula:list # everything in the registry
|
|
38
40
|
ream nebula:list --layer organisms
|
|
39
41
|
ream nebula:add dialog data-table # copies both, plus what they depend on
|
|
42
|
+
ream nebula:diff # which copies the package has since changed
|
|
40
43
|
ream nebula:add button --force # overwrite your edited copy
|
|
41
44
|
```
|
|
42
45
|
|
|
@@ -255,7 +258,7 @@ pnpm build
|
|
|
255
258
|
|
|
256
259
|
`registry.json` is derived from the imports rather than maintained by hand, and a test asserts that every file an item ships actually resolves — the failure it guards is otherwise silent, showing up in a user's build rather than here.
|
|
257
260
|
|
|
258
|
-
Coverage sits around 83% of statements. The shape matters more than the number: every component is mounted and unmounted by `render-smoke.test.ts`, the shared surfaces and the headless primitives are tested directly, and the overlays are opened rather than only rendered closed. What the suite cannot reach is pointer-drag —
|
|
261
|
+
Coverage sits around 83% of statements. The shape matters more than the number: every component is mounted and unmounted by `render-smoke.test.ts`, the shared surfaces and the headless primitives are tested directly, and the overlays are opened rather than only rendered closed. What the suite cannot reach is pointer-drag — jsdom has no `setPointerCapture`, so Drawer's swipe-to-dismiss and Resizable's drag are covered by their keyboard paths only.
|
|
259
262
|
|
|
260
263
|
## Licence
|
|
261
264
|
|
package/dist/atoms/Slider.d.ts
CHANGED
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
* free.
|
|
10
10
|
*
|
|
11
11
|
* The cost is the thumb, which can only be reached through the two vendor
|
|
12
|
-
* pseudo-elements. Both are written out
|
|
13
|
-
* selector, since a browser drops an entire rule containing a pseudo-element
|
|
14
|
-
* it does not recognise.
|
|
12
|
+
* pseudo-elements. Both are written out, as literals — see `sliderClasses`.
|
|
15
13
|
*/
|
|
16
14
|
import { type Reactive } from "../lib/props.js";
|
|
17
15
|
export declare const sliderClasses: string;
|
package/dist/atoms/Slider.js
CHANGED
|
@@ -9,15 +9,22 @@
|
|
|
9
9
|
* free.
|
|
10
10
|
*
|
|
11
11
|
* The cost is the thumb, which can only be reached through the two vendor
|
|
12
|
-
* pseudo-elements. Both are written out
|
|
13
|
-
* selector, since a browser drops an entire rule containing a pseudo-element
|
|
14
|
-
* it does not recognise.
|
|
12
|
+
* pseudo-elements. Both are written out, as literals — see `sliderClasses`.
|
|
15
13
|
*/
|
|
16
14
|
import { component, html } from "@c9up/aurora";
|
|
17
15
|
import { cn } from "../lib/cn.js";
|
|
18
16
|
import { accessor, read } from "../lib/props.js";
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
// Every thumb variant is written out, and the repetition is the point.
|
|
18
|
+
//
|
|
19
|
+
// Tailwind finds class names by SCANNING this file as text — it never runs it.
|
|
20
|
+
// A variant built at runtime, `[&::-webkit-slider-thumb]:${THUMB.split(...)}`,
|
|
21
|
+
// is never a literal here, so no rule is generated for it and the attribute
|
|
22
|
+
// ends up naming classes that do not exist. Nothing throws and the markup looks
|
|
23
|
+
// right; the thumb simply falls back to the browser's default.
|
|
24
|
+
//
|
|
25
|
+
// The two vendor pseudo-elements cannot be folded into one selector either: a
|
|
26
|
+
// browser drops an entire rule containing a pseudo-element it does not know.
|
|
27
|
+
export const sliderClasses = cn("h-1.5 w-full cursor-pointer appearance-none rounded-full bg-primary/20 outline-none disabled:pointer-events-none disabled:opacity-50", "[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-[color,box-shadow]", "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-moz-range-thumb]:transition-[color,box-shadow]", "focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-webkit-slider-thumb]:ring-[3px]", "focus-visible:[&::-moz-range-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:ring-[3px]");
|
|
21
28
|
function inputValue(event) {
|
|
22
29
|
const target = event.target;
|
|
23
30
|
return target instanceof HTMLInputElement ? Number(target.value) : 0;
|
package/dist/cli/add.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { defaultPaths } from "../config.js";
|
|
20
|
+
import { hashOf, writeManifest } from "./manifest.js";
|
|
20
21
|
import { loadRegistry, packageRoot, resolveItems } from "./registry.js";
|
|
21
22
|
/**
|
|
22
23
|
* Which language the project's component tree is written in.
|
|
@@ -69,6 +70,11 @@ export function add(options) {
|
|
|
69
70
|
}
|
|
70
71
|
const written = [];
|
|
71
72
|
const skipped = [];
|
|
73
|
+
// What each file looked like when it was copied. `nebula diff` reads this
|
|
74
|
+
// to tell a change you made from one released upstream that you have not
|
|
75
|
+
// seen — without it every edited component reads as "differs", which is
|
|
76
|
+
// true of all of them by design.
|
|
77
|
+
const copiedHashes = {};
|
|
72
78
|
// Shared files — `lib/cn.ts`, the primitives — belong to several items, so
|
|
73
79
|
// one run reaches the same path repeatedly. Without this, the second visit
|
|
74
80
|
// finds the file the first visit just wrote and reports it as pre-existing,
|
|
@@ -93,7 +99,13 @@ export function add(options) {
|
|
|
93
99
|
continue;
|
|
94
100
|
mkdirSync(dirname(to), { recursive: true });
|
|
95
101
|
copyFileSync(from, to);
|
|
102
|
+
const hash = hashOf(to);
|
|
103
|
+
if (hash !== undefined)
|
|
104
|
+
copiedHashes[relative] = hash;
|
|
96
105
|
}
|
|
97
106
|
}
|
|
107
|
+
if (options.dryRun !== true && Object.keys(copiedHashes).length > 0) {
|
|
108
|
+
writeManifest(target, copiedHashes);
|
|
109
|
+
}
|
|
98
110
|
return { written, skipped, language };
|
|
99
111
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula diff` — which copied components no longer match the package.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart to `add`. Copying is one-way by design, so a fix released
|
|
5
|
+
* upstream never reaches a project on its own and nothing anywhere says so:
|
|
6
|
+
* not the lockfile, not `pnpm update`, not the file itself. This is the report
|
|
7
|
+
* that makes that visible.
|
|
8
|
+
*
|
|
9
|
+
* It answers with the *reason* a file differs, not just that it does. Every
|
|
10
|
+
* copied component is expected to diverge from upstream eventually — that is
|
|
11
|
+
* what owning the source means — so "differs" alone would flag the whole tree
|
|
12
|
+
* and mean nothing. The hash recorded at copy time is what separates a change
|
|
13
|
+
* you made from one you have not seen yet.
|
|
14
|
+
*/
|
|
15
|
+
import { type Language, type NebulaPaths } from "../config.js";
|
|
16
|
+
export type DiffState =
|
|
17
|
+
/** Copy, record and package all agree. */
|
|
18
|
+
"same"
|
|
19
|
+
/** The package moved and the copy is untouched — a re-copy loses nothing. */
|
|
20
|
+
| "outdated"
|
|
21
|
+
/** The copy was edited; the package has not moved. */
|
|
22
|
+
| "edited"
|
|
23
|
+
/** Both moved. The only state that needs a decision. */
|
|
24
|
+
| "conflict"
|
|
25
|
+
/** Copied before anything was recorded, and it no longer matches. */
|
|
26
|
+
| "unknown";
|
|
27
|
+
export interface DiffEntry {
|
|
28
|
+
/** Path as the registry names it, relative to the components root. */
|
|
29
|
+
file: string;
|
|
30
|
+
state: DiffState;
|
|
31
|
+
}
|
|
32
|
+
export interface DiffOptions {
|
|
33
|
+
cwd: string;
|
|
34
|
+
paths?: Partial<NebulaPaths>;
|
|
35
|
+
language?: Language;
|
|
36
|
+
}
|
|
37
|
+
export interface DiffResult {
|
|
38
|
+
entries: DiffEntry[];
|
|
39
|
+
language: Language;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Compare every copied component against the installed package.
|
|
43
|
+
*
|
|
44
|
+
* Only files that are actually in the project are considered — the registry
|
|
45
|
+
* lists everything nebula ships, and a component nobody copied is not news.
|
|
46
|
+
*/
|
|
47
|
+
export declare function diff(options: DiffOptions): DiffResult;
|
package/dist/cli/diff.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula diff` — which copied components no longer match the package.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart to `add`. Copying is one-way by design, so a fix released
|
|
5
|
+
* upstream never reaches a project on its own and nothing anywhere says so:
|
|
6
|
+
* not the lockfile, not `pnpm update`, not the file itself. This is the report
|
|
7
|
+
* that makes that visible.
|
|
8
|
+
*
|
|
9
|
+
* It answers with the *reason* a file differs, not just that it does. Every
|
|
10
|
+
* copied component is expected to diverge from upstream eventually — that is
|
|
11
|
+
* what owning the source means — so "differs" alone would flag the whole tree
|
|
12
|
+
* and mean nothing. The hash recorded at copy time is what separates a change
|
|
13
|
+
* you made from one you have not seen yet.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { defaultPaths } from "../config.js";
|
|
18
|
+
import { detectLanguage } from "./add.js";
|
|
19
|
+
import { hashOf, readManifest } from "./manifest.js";
|
|
20
|
+
import { loadRegistry, packageRoot } from "./registry.js";
|
|
21
|
+
/**
|
|
22
|
+
* Compare every copied component against the installed package.
|
|
23
|
+
*
|
|
24
|
+
* Only files that are actually in the project are considered — the registry
|
|
25
|
+
* lists everything nebula ships, and a component nobody copied is not news.
|
|
26
|
+
*/
|
|
27
|
+
export function diff(options) {
|
|
28
|
+
const paths = { ...defaultPaths, ...options.paths };
|
|
29
|
+
const target = join(options.cwd, paths.components);
|
|
30
|
+
const language = options.language ?? detectLanguage(target) ?? "js";
|
|
31
|
+
const root = packageRoot(join(options.cwd, "package.json"));
|
|
32
|
+
const sourceRoot = join(root, language === "ts" ? "src" : "dist");
|
|
33
|
+
const registry = loadRegistry(join(root, "registry.json"));
|
|
34
|
+
const manifest = readManifest(target);
|
|
35
|
+
const entries = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const item of registry.items) {
|
|
38
|
+
for (const file of item.files) {
|
|
39
|
+
// The registry names `.ts` paths; the compiled tree mirrors it exactly.
|
|
40
|
+
const relative = language === "ts" ? file : file.replace(/\.ts$/, ".js");
|
|
41
|
+
if (seen.has(relative))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(relative);
|
|
44
|
+
const copied = join(target, relative);
|
|
45
|
+
if (!existsSync(copied))
|
|
46
|
+
continue;
|
|
47
|
+
const state = compare({
|
|
48
|
+
copy: hashOf(copied),
|
|
49
|
+
source: hashOf(join(sourceRoot, relative)),
|
|
50
|
+
recorded: manifest.files[relative],
|
|
51
|
+
});
|
|
52
|
+
if (state !== "same")
|
|
53
|
+
entries.push({ file: relative, state });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
entries.sort((a, b) => a.file.localeCompare(b.file));
|
|
57
|
+
return { entries, language };
|
|
58
|
+
}
|
|
59
|
+
function compare(hashes) {
|
|
60
|
+
const { copy, source, recorded } = hashes;
|
|
61
|
+
// Nothing to compare against: the package no longer ships this file, so
|
|
62
|
+
// whatever is in the project is now the only copy of it.
|
|
63
|
+
if (source === undefined)
|
|
64
|
+
return "same";
|
|
65
|
+
if (copy === source)
|
|
66
|
+
return "same";
|
|
67
|
+
// Copied before the manifest existed. The two differ, and there is no way
|
|
68
|
+
// to tell which side moved.
|
|
69
|
+
if (recorded === undefined)
|
|
70
|
+
return "unknown";
|
|
71
|
+
const copyTouched = copy !== recorded;
|
|
72
|
+
const sourceMoved = source !== recorded;
|
|
73
|
+
if (copyTouched && sourceMoved)
|
|
74
|
+
return "conflict";
|
|
75
|
+
if (sourceMoved)
|
|
76
|
+
return "outdated";
|
|
77
|
+
return "edited";
|
|
78
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a copied component looked like when it was copied.
|
|
3
|
+
*
|
|
4
|
+
* The registry model hands you the source and steps back: from then on the file
|
|
5
|
+
* is yours, and nothing upgrades it. That is the point, and it is also the one
|
|
6
|
+
* thing it cannot tell you — when a component is fixed upstream, the copy in
|
|
7
|
+
* your project stays as it was, and no install, lockfile or `pnpm update`
|
|
8
|
+
* mentions it. shadcn has the same gap.
|
|
9
|
+
*
|
|
10
|
+
* Recording the hash of what was copied is what closes it. With it, three
|
|
11
|
+
* different situations stop looking alike:
|
|
12
|
+
*
|
|
13
|
+
* - the file still matches what was copied → upstream moving is a safe re-copy
|
|
14
|
+
* - the file was edited and upstream has not moved → nothing to do
|
|
15
|
+
* - both moved → a decision, and the only case that needs a person
|
|
16
|
+
*
|
|
17
|
+
* Without it, every edited component reads as "differs from upstream", which is
|
|
18
|
+
* true of all of them by design and therefore says nothing.
|
|
19
|
+
*/
|
|
20
|
+
/** Lives beside the components it describes, so moving the tree keeps it. */
|
|
21
|
+
export declare const MANIFEST_FILE = ".nebula.json";
|
|
22
|
+
export interface Manifest {
|
|
23
|
+
/** Component path (as the registry names it) → hash of the copied bytes. */
|
|
24
|
+
files: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
/** Content hash of a file's bytes, or `undefined` when it is not there. */
|
|
27
|
+
export declare function hashOf(path: string): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Read the manifest for a component tree.
|
|
30
|
+
*
|
|
31
|
+
* A missing or unreadable one is an empty manifest, never an error: a project
|
|
32
|
+
* that copied components before this existed still has to work, and it simply
|
|
33
|
+
* has nothing recorded yet.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readManifest(componentsRoot: string): Manifest;
|
|
36
|
+
/** Merge new entries in and write the manifest back, sorted for a clean diff. */
|
|
37
|
+
export declare function writeManifest(componentsRoot: string, added: Record<string, string>): void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a copied component looked like when it was copied.
|
|
3
|
+
*
|
|
4
|
+
* The registry model hands you the source and steps back: from then on the file
|
|
5
|
+
* is yours, and nothing upgrades it. That is the point, and it is also the one
|
|
6
|
+
* thing it cannot tell you — when a component is fixed upstream, the copy in
|
|
7
|
+
* your project stays as it was, and no install, lockfile or `pnpm update`
|
|
8
|
+
* mentions it. shadcn has the same gap.
|
|
9
|
+
*
|
|
10
|
+
* Recording the hash of what was copied is what closes it. With it, three
|
|
11
|
+
* different situations stop looking alike:
|
|
12
|
+
*
|
|
13
|
+
* - the file still matches what was copied → upstream moving is a safe re-copy
|
|
14
|
+
* - the file was edited and upstream has not moved → nothing to do
|
|
15
|
+
* - both moved → a decision, and the only case that needs a person
|
|
16
|
+
*
|
|
17
|
+
* Without it, every edited component reads as "differs from upstream", which is
|
|
18
|
+
* true of all of them by design and therefore says nothing.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
/** Lives beside the components it describes, so moving the tree keeps it. */
|
|
24
|
+
export const MANIFEST_FILE = ".nebula.json";
|
|
25
|
+
/** Content hash of a file's bytes, or `undefined` when it is not there. */
|
|
26
|
+
export function hashOf(path) {
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return undefined;
|
|
29
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the manifest for a component tree.
|
|
33
|
+
*
|
|
34
|
+
* A missing or unreadable one is an empty manifest, never an error: a project
|
|
35
|
+
* that copied components before this existed still has to work, and it simply
|
|
36
|
+
* has nothing recorded yet.
|
|
37
|
+
*/
|
|
38
|
+
export function readManifest(componentsRoot) {
|
|
39
|
+
const path = join(componentsRoot, MANIFEST_FILE);
|
|
40
|
+
if (!existsSync(path))
|
|
41
|
+
return { files: {} };
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
45
|
+
return { files: {} };
|
|
46
|
+
const files = Reflect.get(parsed, "files");
|
|
47
|
+
if (typeof files !== "object" || files === null)
|
|
48
|
+
return { files: {} };
|
|
49
|
+
const entries = {};
|
|
50
|
+
for (const [key, value] of Object.entries(files)) {
|
|
51
|
+
if (typeof value === "string")
|
|
52
|
+
entries[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return { files: entries };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { files: {} };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Merge new entries in and write the manifest back, sorted for a clean diff. */
|
|
61
|
+
export function writeManifest(componentsRoot, added) {
|
|
62
|
+
const existing = readManifest(componentsRoot);
|
|
63
|
+
const merged = { ...existing.files, ...added };
|
|
64
|
+
const sorted = {};
|
|
65
|
+
for (const key of Object.keys(merged).sort()) {
|
|
66
|
+
const value = merged[key];
|
|
67
|
+
if (value !== undefined)
|
|
68
|
+
sorted[key] = value;
|
|
69
|
+
}
|
|
70
|
+
writeFileSync(join(componentsRoot, MANIFEST_FILE), `${JSON.stringify({ files: sorted }, null, 2)}\n`);
|
|
71
|
+
}
|
package/dist/configure.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* container, and nebula has none: its components are client-side templates and
|
|
16
16
|
* its adapters run at build time. An empty provider would be ceremony.
|
|
17
17
|
*/
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { resolve } from "node:path";
|
|
18
20
|
import { adapterFor, isAdapterName } from "./adapters/index.js";
|
|
19
21
|
import { resolveConfig } from "./config.js";
|
|
20
22
|
function configFile(adapter) {
|
|
@@ -65,8 +67,23 @@ export async function configure(codemods, flags = {}) {
|
|
|
65
67
|
// line of Rust and without waiting on a release of the binary.
|
|
66
68
|
await codemods.registerCommand("@c9up/nebula/commands/add");
|
|
67
69
|
await codemods.registerCommand("@c9up/nebula/commands/list");
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
await codemods.registerCommand("@c9up/nebula/commands/diff");
|
|
71
|
+
const generated = adapter.files(config);
|
|
72
|
+
for (const file of generated) {
|
|
73
|
+
// `skipIfExists` is the adapter saying the app owns this file. Pass it
|
|
74
|
+
// through rather than relying on the codemod's default, so the contract
|
|
75
|
+
// is the thing that decides.
|
|
76
|
+
await codemods.writeFile(file.path, file.contents, {
|
|
77
|
+
force: !file.skipIfExists,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const gaps = [];
|
|
81
|
+
for (const file of generated) {
|
|
82
|
+
if (!file.skipIfExists)
|
|
83
|
+
continue;
|
|
84
|
+
const missing = await missingFrom(file);
|
|
85
|
+
if (missing.length > 0)
|
|
86
|
+
gaps.push({ path: file.path, missing });
|
|
70
87
|
}
|
|
71
88
|
// On stderr, and never installed on the user's behalf. nebula declares no
|
|
72
89
|
// CSS dependency at all — the app owns its build tooling, and a UI library
|
|
@@ -83,6 +100,48 @@ export async function configure(codemods, flags = {}) {
|
|
|
83
100
|
else {
|
|
84
101
|
lines.push(" Register the build in config/assets.ts:", ` build: ${commands.build.command} ${commands.build.args.join(" ")}`, ` devServer: ${commands.dev.command} ${commands.dev.args.join(" ")}`);
|
|
85
102
|
}
|
|
86
|
-
lines.push(" Then add components with `ream nebula:add button card` — they are copied", " into your project and are yours to edit."
|
|
103
|
+
lines.push(" Then add components with `ream nebula:add button card` — they are copied", " into your project and are yours to edit.");
|
|
104
|
+
for (const gap of gaps) {
|
|
105
|
+
lines.push("", ` ${gap.path} already existed, so it was left alone — it is yours.`, " The components resolve against tokens it does not define:", ...gap.missing.map((token) => ` ${token}`), "", ' Import nebula\'s theme (`@import "@c9up/nebula/theme.css"` plus the', " `@theme inline` block), or declare them yourself. Without them the", " utilities still compile and every colour resolves to nothing.");
|
|
106
|
+
}
|
|
107
|
+
lines.push("");
|
|
87
108
|
process.stderr.write(lines.join("\n"));
|
|
88
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Which of the tokens the components resolve against are absent from the
|
|
112
|
+
* stylesheet that was left in place.
|
|
113
|
+
*
|
|
114
|
+
* `skipIfExists` means an app's own stylesheet is never overwritten, which is
|
|
115
|
+
* right — it is theirs once it exists. The cost is that a project that already
|
|
116
|
+
* had one gets the components copied in and nothing for them to resolve
|
|
117
|
+
* against. Nothing throws, every class name is present in the markup, and the
|
|
118
|
+
* page comes out grey.
|
|
119
|
+
*
|
|
120
|
+
* Tokens rather than directives, because there is more than one correct way to
|
|
121
|
+
* supply them: importing nebula's theme, or declaring `--color-*` directly in
|
|
122
|
+
* an `@theme` block. Only the second half of that is checkable by reading, so
|
|
123
|
+
* the import short-circuits the question.
|
|
124
|
+
*/
|
|
125
|
+
async function missingFrom(file) {
|
|
126
|
+
let existing;
|
|
127
|
+
try {
|
|
128
|
+
existing = await readFile(resolve(process.cwd(), file.path), "utf8");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// No file to read means nothing was skipped: it was written as generated.
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
if (existing.includes("@c9up/nebula/theme.css"))
|
|
135
|
+
return [];
|
|
136
|
+
return colorTokensOf(file.contents).filter((token) => !new RegExp(`${token}\\s*:`).test(existing));
|
|
137
|
+
}
|
|
138
|
+
/** The `--color-*` names a generated stylesheet makes available. */
|
|
139
|
+
function colorTokensOf(css) {
|
|
140
|
+
const found = new Set();
|
|
141
|
+
for (const match of css.matchAll(/(--color-[a-z-]+)\s*:/g)) {
|
|
142
|
+
const name = match[1];
|
|
143
|
+
if (name !== undefined)
|
|
144
|
+
found.add(name);
|
|
145
|
+
}
|
|
146
|
+
return [...found];
|
|
147
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ream nebula:diff` — which copied components no longer match the package.
|
|
3
|
+
*
|
|
4
|
+
* `nebula:add` hands you the source and steps back, so a fix released upstream
|
|
5
|
+
* never reaches a project on its own. Nothing else reports that: the files are
|
|
6
|
+
* local, no lockfile mentions them and `pnpm update` does not touch them. This
|
|
7
|
+
* is the one place it becomes visible.
|
|
8
|
+
*/
|
|
9
|
+
export default class NebulaDiffCommand {
|
|
10
|
+
static commandName: string;
|
|
11
|
+
static description: string;
|
|
12
|
+
static options: {
|
|
13
|
+
startApp: boolean;
|
|
14
|
+
};
|
|
15
|
+
static args: never[];
|
|
16
|
+
static flags: import("./contract.js").FlagMetaData[];
|
|
17
|
+
static help: string[];
|
|
18
|
+
ts: boolean;
|
|
19
|
+
js: boolean;
|
|
20
|
+
run(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ream nebula:diff` — which copied components no longer match the package.
|
|
3
|
+
*
|
|
4
|
+
* `nebula:add` hands you the source and steps back, so a fix released upstream
|
|
5
|
+
* never reaches a project on its own. Nothing else reports that: the files are
|
|
6
|
+
* local, no lockfile mentions them and `pnpm update` does not touch them. This
|
|
7
|
+
* is the one place it becomes visible.
|
|
8
|
+
*/
|
|
9
|
+
import { diff } from "../cli/diff.js";
|
|
10
|
+
import { flag } from "./contract.js";
|
|
11
|
+
/** What each state means, and what to do about it. */
|
|
12
|
+
const EXPLAIN = {
|
|
13
|
+
same: "",
|
|
14
|
+
outdated: "the package moved, your copy is untouched — re-copy loses nothing",
|
|
15
|
+
edited: "you changed it; the package has not moved",
|
|
16
|
+
conflict: "you changed it AND the package moved — read both before copying",
|
|
17
|
+
unknown: "copied before nebula recorded hashes; cannot tell which side moved",
|
|
18
|
+
};
|
|
19
|
+
export default class NebulaDiffCommand {
|
|
20
|
+
static commandName = "nebula:diff";
|
|
21
|
+
static description = "Report copied components that no longer match the installed package";
|
|
22
|
+
// Files only; nothing here needs the container or a booted app.
|
|
23
|
+
static options = { startApp: false };
|
|
24
|
+
static args = [];
|
|
25
|
+
static flags = [
|
|
26
|
+
flag("ts", "boolean", {
|
|
27
|
+
description: "Compare against the TypeScript sources",
|
|
28
|
+
}),
|
|
29
|
+
flag("js", "boolean", {
|
|
30
|
+
description: "Compare against the compiled JavaScript",
|
|
31
|
+
}),
|
|
32
|
+
];
|
|
33
|
+
static help = [
|
|
34
|
+
" ream nebula:diff",
|
|
35
|
+
"",
|
|
36
|
+
"Components are copied, so nothing upgrades them. This says which ones the",
|
|
37
|
+
"package has since changed, and separates that from the edits you made —",
|
|
38
|
+
"every component is expected to diverge eventually, so 'differs' alone",
|
|
39
|
+
"would flag the whole tree and say nothing.",
|
|
40
|
+
"",
|
|
41
|
+
"Update one with `ream nebula:add <name> --force`, after reading the change",
|
|
42
|
+
"if you had edited it.",
|
|
43
|
+
];
|
|
44
|
+
ts = false;
|
|
45
|
+
js = false;
|
|
46
|
+
async run() {
|
|
47
|
+
const result = diff({
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
language: this.ts ? "ts" : this.js ? "js" : undefined,
|
|
50
|
+
});
|
|
51
|
+
if (result.entries.length === 0) {
|
|
52
|
+
process.stdout.write("\n Every copied component matches the installed package.\n\n");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const width = Math.max(...result.entries.map((e) => e.file.length));
|
|
56
|
+
const out = [""];
|
|
57
|
+
for (const entry of result.entries) {
|
|
58
|
+
out.push(` ${entry.state.padEnd(9)} ${entry.file.padEnd(width)} ${EXPLAIN[entry.state]}`);
|
|
59
|
+
}
|
|
60
|
+
const outdated = result.entries.filter((entry) => entry.state === "outdated");
|
|
61
|
+
if (outdated.length > 0) {
|
|
62
|
+
out.push("", ` ${outdated.length} can be updated with no loss:`, ` ream nebula:add --force ${outdated.map(componentOf).join(" ")}`);
|
|
63
|
+
}
|
|
64
|
+
out.push("");
|
|
65
|
+
process.stdout.write(out.join("\n"));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** `molecules/InputGroup.js` → `input-group`, as the registry names it. */
|
|
69
|
+
function componentOf(entry) {
|
|
70
|
+
const base = entry.file.split("/").pop() ?? entry.file;
|
|
71
|
+
return base
|
|
72
|
+
.replace(/\.(ts|js)$/, "")
|
|
73
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
74
|
+
.toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
// Fails the build if the class drifts from what the kernel dispatches against.
|
|
77
|
+
const _contract = NebulaDiffCommand;
|
|
78
|
+
void _contract;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* own `:focus-visible` would draw a ring around the middle third of a control
|
|
10
10
|
* that visually reads as one box.
|
|
11
11
|
*/
|
|
12
|
+
import { type InputProps } from "../atoms/Input.js";
|
|
12
13
|
import { type Slot } from "../lib/children.js";
|
|
13
14
|
import { type Reactive } from "../lib/props.js";
|
|
14
15
|
export interface InputGroupProps {
|
|
@@ -24,10 +25,24 @@ export interface InputGroupProps {
|
|
|
24
25
|
/**
|
|
25
26
|
* Strip the inner input of its own chrome.
|
|
26
27
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Put these ON the input. The group cannot do it from the outside: a descendant
|
|
29
|
+
* variant like `[&_input]:border-0` has to exist as a literal for Tailwind to
|
|
30
|
+
* generate a rule for it, and building one by joining this string at runtime
|
|
31
|
+
* produces class names no stylesheet ever defines — the input keeps its border
|
|
32
|
+
* and draws a second one inside the group's, with nothing raised to say so.
|
|
33
|
+
*
|
|
34
|
+
* {@link InputGroupInput} applies them for you. This export is for a caller
|
|
35
|
+
* passing a plain `<input>` instead.
|
|
30
36
|
*/
|
|
31
37
|
export declare const inputGroupControlClasses = "flex-1 border-0 bg-transparent px-0 shadow-none outline-none focus-visible:border-0 focus-visible:ring-0 disabled:opacity-100";
|
|
32
38
|
export declare const InputGroup: (props?: InputGroupProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
39
|
+
/**
|
|
40
|
+
* The input that belongs inside an {@link InputGroup}.
|
|
41
|
+
*
|
|
42
|
+
* Same component as {@link Input}, with the group's stripping applied first so
|
|
43
|
+
* a caller's own `class` still wins. This is where the classes have to live:
|
|
44
|
+
* the group carries the border and the ring, and the input has to bring none
|
|
45
|
+
* of its own.
|
|
46
|
+
*/
|
|
47
|
+
export declare const InputGroupInput: (props?: InputProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
33
48
|
export declare const InputGroupAddon: (props?: import("./Card.js").CardProps) => import("@c9up/aurora").TemplateResult;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* that visually reads as one box.
|
|
11
11
|
*/
|
|
12
12
|
import { component, html } from "@c9up/aurora";
|
|
13
|
+
import { Input } from "../atoms/Input.js";
|
|
13
14
|
import { slot } from "../lib/children.js";
|
|
14
15
|
import { cn } from "../lib/cn.js";
|
|
15
16
|
import { read } from "../lib/props.js";
|
|
@@ -17,9 +18,14 @@ import { styledDiv } from "../lib/styled.js";
|
|
|
17
18
|
/**
|
|
18
19
|
* Strip the inner input of its own chrome.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Put these ON the input. The group cannot do it from the outside: a descendant
|
|
22
|
+
* variant like `[&_input]:border-0` has to exist as a literal for Tailwind to
|
|
23
|
+
* generate a rule for it, and building one by joining this string at runtime
|
|
24
|
+
* produces class names no stylesheet ever defines — the input keeps its border
|
|
25
|
+
* and draws a second one inside the group's, with nothing raised to say so.
|
|
26
|
+
*
|
|
27
|
+
* {@link InputGroupInput} applies them for you. This export is for a caller
|
|
28
|
+
* passing a plain `<input>` instead.
|
|
23
29
|
*/
|
|
24
30
|
export const inputGroupControlClasses = "flex-1 border-0 bg-transparent px-0 shadow-none outline-none focus-visible:border-0 focus-visible:ring-0 disabled:opacity-100";
|
|
25
31
|
export const InputGroup = component((props) => {
|
|
@@ -27,9 +33,21 @@ export const InputGroup = component((props) => {
|
|
|
27
33
|
data-slot="input-group"
|
|
28
34
|
data-disabled="${() => (read(props.disabled) === true ? "" : undefined)}"
|
|
29
35
|
aria-invalid="${() => (read(props.invalid) === true ? "true" : undefined)}"
|
|
30
|
-
class="${() => cn("border-input dark:bg-input/30 flex h-9 w-full min-w-0 items-center gap-2 rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow]", "focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]", "aria-invalid:border-destructive aria-invalid:ring-destructive/20", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
36
|
+
class="${() => cn("border-input dark:bg-input/30 flex h-9 w-full min-w-0 items-center gap-2 rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow]", "focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]", "aria-invalid:border-destructive aria-invalid:ring-destructive/20", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", read(props.class))}"
|
|
31
37
|
>
|
|
32
38
|
${slot(props.leading)}${slot(props.children)}${slot(props.trailing)}
|
|
33
39
|
</div>`;
|
|
34
40
|
});
|
|
41
|
+
/**
|
|
42
|
+
* The input that belongs inside an {@link InputGroup}.
|
|
43
|
+
*
|
|
44
|
+
* Same component as {@link Input}, with the group's stripping applied first so
|
|
45
|
+
* a caller's own `class` still wins. This is where the classes have to live:
|
|
46
|
+
* the group carries the border and the ring, and the input has to bring none
|
|
47
|
+
* of its own.
|
|
48
|
+
*/
|
|
49
|
+
export const InputGroupInput = component((props) => Input({
|
|
50
|
+
...props,
|
|
51
|
+
class: () => cn(inputGroupControlClasses, read(props.class)),
|
|
52
|
+
}));
|
|
35
53
|
export const InputGroupAddon = styledDiv("input-group-addon", "text-muted-foreground flex shrink-0 items-center gap-2 [&_svg:not([class*='size-'])]:size-4");
|
|
@@ -24,7 +24,7 @@ export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader,
|
|
|
24
24
|
export { Collapsible, type CollapsibleProps } from "./Collapsible.js";
|
|
25
25
|
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, type EmptyProps, EmptyTitle, } from "./Empty.js";
|
|
26
26
|
export { Field, FieldGroup, type FieldIds, type FieldProps, FieldSeparator, fieldIds, } from "./Field.js";
|
|
27
|
-
export { InputGroup, InputGroupAddon, type InputGroupProps, inputGroupControlClasses, } from "./InputGroup.js";
|
|
27
|
+
export { InputGroup, InputGroupAddon, InputGroupInput, type InputGroupProps, inputGroupControlClasses, } from "./InputGroup.js";
|
|
28
28
|
export { InputOTP, type InputOTPProps } from "./InputOTP.js";
|
|
29
29
|
export { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, type ItemProps, ItemSeparator, ItemTitle, type ItemVariants, itemVariants, } from "./Item.js";
|
|
30
30
|
export { Message, type MessageProps } from "./Message.js";
|
package/dist/molecules/index.js
CHANGED
|
@@ -24,7 +24,7 @@ export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader,
|
|
|
24
24
|
export { Collapsible } from "./Collapsible.js";
|
|
25
25
|
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "./Empty.js";
|
|
26
26
|
export { Field, FieldGroup, FieldSeparator, fieldIds, } from "./Field.js";
|
|
27
|
-
export { InputGroup, InputGroupAddon, inputGroupControlClasses, } from "./InputGroup.js";
|
|
27
|
+
export { InputGroup, InputGroupAddon, InputGroupInput, inputGroupControlClasses, } from "./InputGroup.js";
|
|
28
28
|
export { InputOTP } from "./InputOTP.js";
|
|
29
29
|
export { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemSeparator, ItemTitle, itemVariants, } from "./Item.js";
|
|
30
30
|
export { Message } from "./Message.js";
|
package/dist/organisms/Form.js
CHANGED
|
@@ -59,7 +59,15 @@ export function Form(props) {
|
|
|
59
59
|
novalidate
|
|
60
60
|
class="${() => cn("flex flex-col gap-6", read(props.class))}"
|
|
61
61
|
@submit="${(event) => {
|
|
62
|
-
|
|
62
|
+
// A DOM listener returns nothing, so this promise is nobody's to
|
|
63
|
+
// await. `command.run` always resolves — failures route to
|
|
64
|
+
// `onFail` — but `validate()` runs first, and a validator that
|
|
65
|
+
// throws rejects here: the form would silently do nothing and leave
|
|
66
|
+
// a bare `Uncaught (in promise)` behind. Reported, so the thing
|
|
67
|
+
// that broke is the thing the console names.
|
|
68
|
+
void props.form.handleSubmit(event).catch((error) => {
|
|
69
|
+
console.error("[nebula] Form submit failed:", error);
|
|
70
|
+
});
|
|
63
71
|
}}"
|
|
64
72
|
>${slot(props.children)}</form>`;
|
|
65
73
|
}
|