@c9up/nebula 0.1.3 → 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 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 — happy-dom has no `setPointerCapture`, so Drawer's swipe-to-dismiss and Resizable's drag are covered by their keyboard paths only.
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
 
@@ -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; they cannot be combined into one
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;
@@ -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; they cannot be combined into one
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
- const THUMB = "size-4 appearance-none rounded-full border border-primary bg-background shadow-sm transition-[color,box-shadow]";
20
- 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]:${THUMB.split(" ").join(" [&::-webkit-slider-thumb]:")}`, `[&::-moz-range-thumb]:${THUMB.split(" ").join(" [&::-moz-range-thumb]:")}`, "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]");
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;
@@ -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
- for (const file of adapter.files(config)) {
69
- await codemods.writeFile(file.path, file.contents);
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
- * Exported so a caller passing a plain `<input>` rather than nebula's `Input`
28
- * can apply it too without this the input draws a second border inside the
29
- * group's, which is the one way this component visibly goes wrong.
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
- * Exported so a caller passing a plain `<input>` rather than nebula's `Input`
21
- * can apply it too without this the input draws a second border inside the
22
- * group's, which is the one way this component visibly goes wrong.
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", `[&_input]:${inputGroupControlClasses.split(" ").join(" [&_input]:")}`, read(props.class))}"
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";
@@ -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";
@@ -275,7 +275,7 @@ function isRightToLeft(element) {
275
275
  /**
276
276
  * Watch elements for size changes, where the platform supports it.
277
277
  *
278
- * happy-dom (nebula's unit-test environment) has no `ResizeObserver`, and the
278
+ * jsdom (nebula's unit-test environment) has no `ResizeObserver`, and the
279
279
  * scroll and resize listeners already cover the common cases, so its absence
280
280
  * degrades rather than throws.
281
281
  */