@astrale-os/cli 1.0.0-beta.20 → 1.0.0-beta.21

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/astrale.js CHANGED
@@ -2578,7 +2578,7 @@ var package_default;
2578
2578
  var init_package = __esm(() => {
2579
2579
  package_default = {
2580
2580
  name: "@astrale-os/cli",
2581
- version: "1.0.0-beta.20",
2581
+ version: "1.0.0-beta.21",
2582
2582
  description: "Astrale CLI — connect to existing Astrale kernels",
2583
2583
  keywords: [
2584
2584
  "astrale",
@@ -84421,7 +84421,7 @@ function parseUiLock(value3) {
84421
84421
  throw new UiError("UI_LOCK_INVALID", UI_LOCK_FILE + " is structurally invalid.");
84422
84422
  }
84423
84423
  for (const [address, item] of Object.entries(lock.items)) {
84424
- if (!/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(address) || !isRecord8(item) || item.address !== address || !isDigest(item.sourceDigest) || !isRecord8(item.files)) {
84424
+ if (!/^(?:(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+|theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/u.test(address) || !isRecord8(item) || item.address !== address || !isDigest(item.sourceDigest) || !isRecord8(item.files)) {
84425
84425
  throw new UiError("UI_LOCK_INVALID", UI_LOCK_FILE + " contains an invalid item record.");
84426
84426
  }
84427
84427
  for (const [file2, expected] of Object.entries(item.files)) {
@@ -84776,7 +84776,10 @@ function isInstallableItem(item) {
84776
84776
  if (!item || typeof item !== "object")
84777
84777
  return false;
84778
84778
  const candidate2 = item;
84779
- return typeof candidate2.name === "string" && /^(?:pattern|block)-[a-z0-9-]+$/u.test(candidate2.name) && candidate2.type === "registry:block" && (candidate2.dependencies === undefined || Array.isArray(candidate2.dependencies) && candidate2.dependencies.every((dependency2) => typeof dependency2 === "string" && /^(?:@[a-z0-9-]+\/)?[a-z0-9-]+@[~^<>=0-9A-Za-z.* -]+$/u.test(dependency2))) && Array.isArray(candidate2.files) && candidate2.files.length > 0 && candidate2.files.every((file2) => file2 && typeof file2.path === "string" && isSafeRelative2(file2.path) && typeof file2.type === "string" && typeof file2.target === "string" && file2.target.startsWith("components/astrale/") && isSafeRelative2(file2.target)) && typeof candidate2.meta?.canonicalAddress === "string" && /^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(candidate2.meta.canonicalAddress);
84779
+ const address = candidate2.meta?.canonicalAddress;
84780
+ const isTheme = candidate2.type === "registry:theme" && typeof candidate2.name === "string" && /^theme-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(candidate2.name) && typeof address === "string" && /^theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(address);
84781
+ const isComposition = candidate2.type === "registry:block" && typeof candidate2.name === "string" && /^(?:pattern|block)-[a-z0-9-]+$/u.test(candidate2.name) && typeof address === "string" && /^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(address);
84782
+ return (isTheme || isComposition) && (candidate2.dependencies === undefined || Array.isArray(candidate2.dependencies) && candidate2.dependencies.every((dependency2) => typeof dependency2 === "string" && /^(?:@[a-z0-9-]+\/)?[a-z0-9-]+@[~^<>=0-9A-Za-z.* -]+$/u.test(dependency2))) && Array.isArray(candidate2.files) && candidate2.files.length > 0 && candidate2.files.every((file2) => file2 && typeof file2.path === "string" && isSafeRelative2(file2.path) && typeof file2.type === "string" && typeof file2.target === "string" && file2.target.startsWith("components/astrale/") && isSafeRelative2(file2.target) && (!isTheme || candidate2.files?.length === 1 && file2.type === "registry:file" && file2.target === "components/astrale/theme/" + address.slice("theme/".length) + ".css")) && typeof address === "string";
84780
84783
  }
84781
84784
  async function readUiRegistryItem(release, expected, fetcher = fetch) {
84782
84785
  const item = await json4(fetcher, registryItemUrl(release, expected.name), "UI registry item " + expected.name);
@@ -84829,6 +84832,32 @@ async function readOptional(target2) {
84829
84832
  return;
84830
84833
  });
84831
84834
  }
84835
+ function isLocalThemeAddress(address) {
84836
+ return (address.startsWith("./") || address.startsWith("../")) && address.endsWith(".css");
84837
+ }
84838
+ function admitLocalThemeCss(source2, slug) {
84839
+ if (new TextEncoder().encode(source2).byteLength > LOCAL_THEME_MAX_BYTES) {
84840
+ throw new UiError("UI_ITEM_CONFLICT", "Local theme CSS exceeds 128 KiB.");
84841
+ }
84842
+ if (!source2.includes("Consumer-owned after installation.") || !source2.includes("[data-ui-theme='" + slug + "']") || !source2.includes("[data-ui-theme='" + slug + "'].dark") || /@import\b|url\s*\(/iu.test(source2) || REQUIRED_THEME_TOKENS.some((token) => source2.match(new RegExp(`--ui-${token}:`, "gu"))?.length !== 2)) {
84843
+ throw new UiError("UI_ITEM_CONFLICT", "Local theme CSS is not an admitted Astrale playground export.", "Export the CSS from the Astrale UI playground, keep its filename aligned with the theme name, and try again.");
84844
+ }
84845
+ }
84846
+ function themeImport(project2, target2) {
84847
+ let relative = path6.relative(path6.dirname(project2.cssPath), target2).split(path6.sep).join("/");
84848
+ if (!relative.startsWith("."))
84849
+ relative = "./" + relative;
84850
+ return "@import '" + relative + "';";
84851
+ }
84852
+ function activateTheme(current, statement) {
84853
+ const lines = current.split(/\r?\n/u).filter((line) => !/^@import\s+['"][^'"]*components\/astrale\/theme\/[a-z][a-z0-9-]*\.css['"];?\s*$/u.test(line));
84854
+ const packageImport = lines.reduce((last, line, index3) => /^@import\s+['"]@astrale-os\/ui\/(?:theme\.css|presets\/[a-z-]+\.css)['"];?\s*$/u.test(line) ? index3 : last, -1);
84855
+ lines.splice(packageImport + 1, 0, statement);
84856
+ return lines.join(`
84857
+ `).replace(/\n{3,}/gu, `
84858
+
84859
+ `).replace(/^\n/u, "");
84860
+ }
84832
84861
  function manifestDependencies(manifest) {
84833
84862
  return { ...manifest.dependencies };
84834
84863
  }
@@ -84936,6 +84965,7 @@ async function writeDomainRegistryWorkspace(project2, manifest) {
84936
84965
  }
84937
84966
  async function initUi(options, dependencies = {}) {
84938
84967
  const project2 = await discoverUiProject(options.path);
84968
+ await assertSafePlannedTarget(project2, UI_LOCK_FILE);
84939
84969
  assertSupportedUiProject(project2);
84940
84970
  const preset = options.preset ?? "astrale";
84941
84971
  if (!UI_PRESETS.includes(preset)) {
@@ -85113,17 +85143,94 @@ async function lockedRelease(project2, fetcher) {
85113
85143
  }, fetcher);
85114
85144
  return { lock, release };
85115
85145
  }
85146
+ async function addLocalTheme(address, project2, options) {
85147
+ const sourcePath = path6.resolve(project2.root, address);
85148
+ const sourceInfo = await lstat2(sourcePath).catch(() => {
85149
+ return;
85150
+ });
85151
+ if (!sourceInfo?.isFile() || sourceInfo.isSymbolicLink()) {
85152
+ throw new UiError("UI_ITEM_NOT_FOUND", "Local theme is not a regular file: " + address);
85153
+ }
85154
+ const slug = path6.basename(sourcePath, ".css");
85155
+ if (!THEME_SLUG.test(slug)) {
85156
+ throw new UiError("UI_ITEM_CONFLICT", "Local theme filename must be a kebab-case theme name.", "Rename it to a name such as observatory.css.");
85157
+ }
85158
+ const source2 = await readFile18(sourcePath, "utf8");
85159
+ admitLocalThemeCss(source2, slug);
85160
+ const lock = await readUiLock(project2.uiLockPath);
85161
+ const canonicalAddress = "theme/" + slug;
85162
+ const relativeTarget = "components/astrale/theme/" + slug + ".css";
85163
+ const target2 = await assertSafePlannedTarget(project2, relativeTarget);
85164
+ const existing = await readOptional(target2);
85165
+ const installed = lock.items[canonicalAddress];
85166
+ if (existing !== undefined && !installed && !options.overwrite) {
85167
+ throw new UiError("UI_LOCAL_CHANGES", "Theme target already exists outside the Astrale UI lock: " + relativeTarget, "Review the file, then repeat with explicit --overwrite --yes.");
85168
+ }
85169
+ if (installed && !options.overwrite) {
85170
+ const expected = installed.files[relativeTarget];
85171
+ if (!expected || digest5(existing ?? "") !== expected) {
85172
+ throw new UiError("UI_LOCAL_CHANGES", "Installed UI file has local changes: " + relativeTarget, "Review the file, then repeat add with explicit --overwrite --yes.");
85173
+ }
85174
+ }
85175
+ const statement = themeImport(project2, target2);
85176
+ const result = {
85177
+ status: options.dryRun ? "planned" : "installed",
85178
+ items: [canonicalAddress],
85179
+ files: { [canonicalAddress]: { [relativeTarget]: digest5(source2) } },
85180
+ activation: { file: projectRelative(project2, project2.cssPath), import: statement },
85181
+ source: address
85182
+ };
85183
+ if (options.dryRun)
85184
+ return result;
85185
+ const snapshots = new Map;
85186
+ for (const mutation of [target2, project2.cssPath, project2.uiLockPath]) {
85187
+ snapshots.set(mutation, await readOptional(mutation));
85188
+ }
85189
+ try {
85190
+ await mkdir10(path6.dirname(target2), { recursive: true });
85191
+ await writeFile6(target2, source2, "utf8");
85192
+ const css = await readOptional(project2.cssPath) ?? "";
85193
+ await writeFile6(project2.cssPath, activateTheme(css, statement), "utf8");
85194
+ lock.items[canonicalAddress] = {
85195
+ address: canonicalAddress,
85196
+ sourceDigest: digest5(source2),
85197
+ files: { [relativeTarget]: digest5(source2) }
85198
+ };
85199
+ await writeJson(project2.uiLockPath, lock);
85200
+ return result;
85201
+ } catch (error52) {
85202
+ for (const [mutation, previous] of snapshots) {
85203
+ if (previous === undefined)
85204
+ await rm3(mutation, { force: true });
85205
+ else
85206
+ await writeFile6(mutation, previous, "utf8");
85207
+ }
85208
+ throw error52;
85209
+ }
85210
+ }
85116
85211
  async function addUi(addresses, options, dependencies = {}) {
85117
85212
  if (addresses.length === 0) {
85118
85213
  throw new UiError("UI_ITEM_NOT_FOUND", "No UI item was provided.");
85119
85214
  }
85120
85215
  const project2 = await discoverUiProject(options.project);
85121
- const { lock, release } = await lockedRelease(project2, dependencies.fetcher);
85122
- const items = addresses.map((address) => findItem(release, address));
85123
- const itemDocuments = await Promise.all(items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)));
85216
+ await assertSafePlannedTarget(project2, UI_LOCK_FILE);
85124
85217
  if (options.overwrite && !options.yes) {
85125
85218
  throw new UiError("UI_LOCAL_CHANGES", "Replacing installed UI source requires explicit confirmation.", "Review the locally edited files, then repeat with both --overwrite and --yes.");
85126
85219
  }
85220
+ const localThemes = addresses.filter(isLocalThemeAddress);
85221
+ if (localThemes.length > 0) {
85222
+ if (addresses.length !== 1) {
85223
+ throw new UiError("UI_ITEM_CONFLICT", "Install one local theme at a time and do not mix local files with registry addresses.");
85224
+ }
85225
+ return addLocalTheme(localThemes[0], project2, options);
85226
+ }
85227
+ const { lock, release } = await lockedRelease(project2, dependencies.fetcher);
85228
+ const items = addresses.map((address) => findItem(release, address));
85229
+ const themes = items.filter((item) => item.type === "registry:theme");
85230
+ if (themes.length > 1) {
85231
+ throw new UiError("UI_ITEM_CONFLICT", "Only one theme can be activated per add operation.");
85232
+ }
85233
+ const itemDocuments = await Promise.all(items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)));
85127
85234
  await rejectLocalChanges(project2, lock, items, options.overwrite === true);
85128
85235
  const targets = (await Promise.all(items.flatMap((item) => item.files.filter((file2) => file2.target).map((file2) => assertSafePlannedTarget(project2, file2.target))))).filter((target2, index3, all) => all.indexOf(target2) === index3);
85129
85236
  const invocation3 = shadcnInvocation(project2.manager, release.compatibility.shadcn, [
@@ -85137,7 +85244,13 @@ async function addUi(addresses, options, dependencies = {}) {
85137
85244
  ]);
85138
85245
  const snapshots = new Map;
85139
85246
  if (!options.dryRun) {
85140
- for (const target2 of [project2.packageJsonPath, project2.lockPath, ...targets]) {
85247
+ for (const target2 of [
85248
+ project2.packageJsonPath,
85249
+ project2.lockPath,
85250
+ project2.cssPath,
85251
+ project2.uiLockPath,
85252
+ ...targets
85253
+ ]) {
85141
85254
  snapshots.set(target2, await readOptional(target2));
85142
85255
  }
85143
85256
  }
@@ -85154,6 +85267,13 @@ async function addUi(addresses, options, dependencies = {}) {
85154
85267
  }
85155
85268
  }
85156
85269
  await pinUiDependency(project2, lock.package.version, dependencies.runner ?? defaultUiRunner);
85270
+ const theme = themes[0];
85271
+ const themeTarget = theme?.files[0]?.target;
85272
+ if (themeTarget) {
85273
+ const target2 = await safeTarget(project2, themeTarget);
85274
+ const css = await readOptional(project2.cssPath) ?? "";
85275
+ await writeFile6(project2.cssPath, activateTheme(css, themeImport(project2, target2)), "utf8");
85276
+ }
85157
85277
  }
85158
85278
  } catch (error52) {
85159
85279
  for (const [target2, previous] of snapshots) {
@@ -85174,7 +85294,11 @@ async function addUi(addresses, options, dependencies = {}) {
85174
85294
  files: item.files.map((file2) => file2.target).filter(Boolean)
85175
85295
  })),
85176
85296
  command: [invocation3.file, ...invocation3.args],
85177
- output: result.stdout
85297
+ output: result.stdout,
85298
+ activation: themes[0] ? {
85299
+ file: projectRelative(project2, project2.cssPath),
85300
+ import: themeImport(project2, targets.find((target2) => target2.endsWith(".css")))
85301
+ } : undefined
85178
85302
  };
85179
85303
  }
85180
85304
  try {
@@ -85246,6 +85370,11 @@ async function doctorUi(input) {
85246
85370
  checks3.push({ check: "item:" + item.address + ":" + file2, ok: actual === expected });
85247
85371
  }
85248
85372
  }
85373
+ const themeFiles = Object.values(lock.items).filter((item) => item.address.startsWith("theme/")).flatMap((item) => Object.keys(item.files).filter((file2) => file2.endsWith(".css")));
85374
+ if (themeFiles.length > 0) {
85375
+ const active = themeFiles.some((file2) => css.includes(themeImport(project2, path6.join(project2.root, file2))));
85376
+ checks3.push({ check: "theme-active", ok: active });
85377
+ }
85249
85378
  }
85250
85379
  return { healthy: checks3.every((check3) => check3.ok), checks: checks3 };
85251
85380
  }
@@ -85254,6 +85383,7 @@ async function applyPreset(preset, options) {
85254
85383
  throw new UiError("UI_ITEM_NOT_FOUND", "Unknown preset: " + preset);
85255
85384
  }
85256
85385
  const project2 = await discoverUiProject(options.project);
85386
+ await assertSafePlannedTarget(project2, UI_LOCK_FILE);
85257
85387
  const lock = await readUiLock(project2.uiLockPath);
85258
85388
  const css = await readOptional(project2.cssPath) ?? "";
85259
85389
  const next = css.replace(/@astrale-os\/ui\/presets\/[a-z-]+\.css/gu, "@astrale-os/ui/presets/" + preset + ".css");
@@ -85336,6 +85466,7 @@ async function writeJson(target2, value3) {
85336
85466
  await writeFile6(target2, JSON.stringify(value3, null, 2) + `
85337
85467
  `, "utf8");
85338
85468
  }
85469
+ var LOCAL_THEME_MAX_BYTES = 131072, THEME_SLUG, REQUIRED_THEME_TOKENS;
85339
85470
  var init_operations3 = __esm(() => {
85340
85471
  init_dist();
85341
85472
  init_lock();
@@ -85343,6 +85474,44 @@ var init_operations3 = __esm(() => {
85343
85474
  init_project3();
85344
85475
  init_release();
85345
85476
  init_runner();
85477
+ THEME_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
85478
+ REQUIRED_THEME_TOKENS = [
85479
+ "background",
85480
+ "foreground",
85481
+ "card",
85482
+ "card-foreground",
85483
+ "popover",
85484
+ "popover-foreground",
85485
+ "primary",
85486
+ "primary-foreground",
85487
+ "secondary",
85488
+ "secondary-foreground",
85489
+ "muted",
85490
+ "muted-foreground",
85491
+ "accent",
85492
+ "accent-foreground",
85493
+ "destructive",
85494
+ "destructive-foreground",
85495
+ "border",
85496
+ "input",
85497
+ "ring",
85498
+ "chart-1",
85499
+ "chart-2",
85500
+ "chart-3",
85501
+ "chart-4",
85502
+ "chart-5",
85503
+ "font-body",
85504
+ "font-heading",
85505
+ "radius",
85506
+ "radius-panel",
85507
+ "control-height",
85508
+ "control-height-sm",
85509
+ "control-height-lg",
85510
+ "shadow-control",
85511
+ "shadow-panel",
85512
+ "motion-fast",
85513
+ "motion-standard"
85514
+ ];
85346
85515
  });
85347
85516
 
85348
85517
  // src/ui/index.ts
@@ -85425,13 +85594,13 @@ var init_list = __esm(() => {
85425
85594
  init_shared();
85426
85595
  list_default = {
85427
85596
  name: "list",
85428
- description: "List or search Astrale patterns and blocks",
85597
+ description: "List or search Astrale patterns, blocks, and themes",
85429
85598
  arguments: [{ name: "query", description: "Optional search text", required: false }],
85430
85599
  options: [
85431
85600
  {
85432
85601
  flags: "--type <type>",
85433
- description: "Restrict to pattern or block",
85434
- choices: ["pattern", "block"]
85602
+ description: "Restrict to pattern, block, or theme",
85603
+ choices: ["pattern", "block", "theme"]
85435
85604
  },
85436
85605
  { flags: "--limit <n>", description: "Maximum results", default: "100" },
85437
85606
  { flags: "--version <version>", description: "Exact release to inspect" },
@@ -85453,11 +85622,11 @@ var init_add = __esm(() => {
85453
85622
  init_shared();
85454
85623
  add_default = {
85455
85624
  name: "add",
85456
- description: "Install consumer-owned Astrale pattern or block source",
85625
+ description: "Install consumer-owned Astrale pattern, block, or theme source",
85457
85626
  arguments: [
85458
85627
  {
85459
85628
  name: "items",
85460
- description: "Canonical pattern or block addresses",
85629
+ description: "Canonical registry addresses or a local exported theme CSS file",
85461
85630
  required: false,
85462
85631
  variadic: true
85463
85632
  }
@@ -85470,8 +85639,8 @@ var init_add = __esm(() => {
85470
85639
  UI_JSON_OPTION
85471
85640
  ],
85472
85641
  afterHelpText: `
85473
- Installed source belongs to the application. Ordinary add never overwrites local edits.
85474
- Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
85642
+ Installed source belongs to the application. Use theme/<name> for a released theme or ./theme.css for a playground export.
85643
+ Ordinary add never overwrites local edits. Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
85475
85644
  `,
85476
85645
  action: async (items, options) => runUiCommand(options, async () => {
85477
85646
  let selected = items;
@@ -85506,8 +85675,13 @@ var init_doctor = __esm(() => {
85506
85675
  name: "doctor",
85507
85676
  description: "Verify Astrale UI package, CSS, config, lock, and installed source integrity",
85508
85677
  arguments: [{ name: "path", description: "Application directory", required: false }],
85509
- options: [UI_JSON_OPTION],
85510
- action: async (path7, options) => runUiCommand(options, () => doctorUi(path7))
85678
+ options: [UI_PROJECT_OPTION, UI_JSON_OPTION],
85679
+ action: async (path7, options) => runUiCommand(options, () => {
85680
+ if (path7 && options.project) {
85681
+ throw new UiError("UI_ITEM_CONFLICT", "Choose either the positional application path or --project, not both.");
85682
+ }
85683
+ return doctorUi(options.project ?? path7);
85684
+ })
85511
85685
  };
85512
85686
  });
85513
85687
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.20",
3
+ "version": "1.0.0-beta.21",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -8,11 +8,11 @@ type Options = UiCommandOptions & { dryRun?: boolean; overwrite?: boolean; yes?:
8
8
 
9
9
  export default {
10
10
  name: 'add',
11
- description: 'Install consumer-owned Astrale pattern or block source',
11
+ description: 'Install consumer-owned Astrale pattern, block, or theme source',
12
12
  arguments: [
13
13
  {
14
14
  name: 'items',
15
- description: 'Canonical pattern or block addresses',
15
+ description: 'Canonical registry addresses or a local exported theme CSS file',
16
16
  required: false,
17
17
  variadic: true,
18
18
  },
@@ -25,7 +25,7 @@ export default {
25
25
  UI_JSON_OPTION,
26
26
  ],
27
27
  afterHelpText:
28
- '\nInstalled source belongs to the application. Ordinary add never overwrites local edits.\nRun astrale ui doctor before intentionally replacing local source with --overwrite --yes.\n',
28
+ '\nInstalled source belongs to the application. Use theme/<name> for a released theme or ./theme.css for a playground export.\nOrdinary add never overwrites local edits. Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.\n',
29
29
  action: async (items: string[], options: Options) =>
30
30
  runUiCommand(options, async () => {
31
31
  let selected = items
@@ -1,13 +1,21 @@
1
1
  import type { CommandDefinition } from '../../program'
2
2
 
3
- import { doctorUi } from '../../ui'
4
- import { UI_JSON_OPTION, runUiCommand, type UiCommandOptions } from './shared'
3
+ import { doctorUi, UiError } from '../../ui'
4
+ import { UI_JSON_OPTION, UI_PROJECT_OPTION, runUiCommand, type UiCommandOptions } from './shared'
5
5
 
6
6
  export default {
7
7
  name: 'doctor',
8
8
  description: 'Verify Astrale UI package, CSS, config, lock, and installed source integrity',
9
9
  arguments: [{ name: 'path', description: 'Application directory', required: false }],
10
- options: [UI_JSON_OPTION],
10
+ options: [UI_PROJECT_OPTION, UI_JSON_OPTION],
11
11
  action: async (path: string | undefined, options: UiCommandOptions) =>
12
- runUiCommand(options, () => doctorUi(path)),
12
+ runUiCommand(options, () => {
13
+ if (path && options.project) {
14
+ throw new UiError(
15
+ 'UI_ITEM_CONFLICT',
16
+ 'Choose either the positional application path or --project, not both.',
17
+ )
18
+ }
19
+ return doctorUi(options.project ?? path)
20
+ }),
13
21
  } satisfies CommandDefinition
@@ -7,13 +7,13 @@ type Options = UiCommandOptions & { type?: string; limit?: string; version?: str
7
7
 
8
8
  export default {
9
9
  name: 'list',
10
- description: 'List or search Astrale patterns and blocks',
10
+ description: 'List or search Astrale patterns, blocks, and themes',
11
11
  arguments: [{ name: 'query', description: 'Optional search text', required: false }],
12
12
  options: [
13
13
  {
14
14
  flags: '--type <type>',
15
- description: 'Restrict to pattern or block',
16
- choices: ['pattern', 'block'],
15
+ description: 'Restrict to pattern, block, or theme',
16
+ choices: ['pattern', 'block', 'theme'],
17
17
  },
18
18
  { flags: '--limit <n>', description: 'Maximum results', default: '100' },
19
19
  { flags: '--version <version>', description: 'Exact release to inspect' },
@@ -26,6 +26,17 @@ afterEach(async () => {
26
26
  })
27
27
 
28
28
  describe('viewer asset resolution', () => {
29
+ test('keeps long placement labels from widening the mounted View viewport', async () => {
30
+ const source = await readFile(
31
+ join(import.meta.dirname, '..', '..', '..', 'viewer', 'index.html'),
32
+ 'utf8',
33
+ )
34
+
35
+ expect(source).toContain('#view-label,')
36
+ expect(source).toContain('text-overflow: ellipsis;')
37
+ expect(source).toContain('#frame {\n flex: 1;\n min-width: 0;')
38
+ })
39
+
29
40
  test('skips partial candidates and prefers complete package-owned assets', async () => {
30
41
  const root = await mkdtemp(join(tmpdir(), 'astrale-view-candidates-'))
31
42
  temporaryDirectories.push(root)
@@ -195,7 +195,7 @@ describe('program composition', () => {
195
195
  'whoami',
196
196
  ])
197
197
  expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
198
- '51546dd7ea5cbf25efbd41f0cd09ceb480abad5be489fc16743500a18e3a02c6',
198
+ '913d37eab2f603cc745bd9a1c10763a57bc9c2425d0181958d458b80e4a451d7',
199
199
  )
200
200
  })
201
201
 
@@ -4,7 +4,9 @@ This private CLI owner manages an existing local React project. It does not conn
4
4
  does not add React, Base UI, Tailwind, shadcn, or the UI runtime to the installed CLI dependency
5
5
  graph.
6
6
 
7
- One operation resolves one immutable UI commit. Package compatibility and registry reads use that
8
- commit, and the detected package manager invokes the release-qualified shadcn CLI on demand.
9
- Application writes complete before the lock advances. Dry-run, list, view, diff, and doctor do not
7
+ One registry operation resolves one immutable UI commit. Package compatibility and registry reads
8
+ use that commit, and the detected package manager invokes the release-qualified shadcn CLI on
9
+ demand. A local playground-exported theme is instead admitted and copied without network or
10
+ shadcn. Both paths install consumer-owned source, activate themes through one project-relative CSS
11
+ import, and complete application writes before the lock advances. Dry-run, list, and doctor do not
10
12
  claim a file mutation.
@@ -28,6 +28,13 @@ export const CLI_UI_EXACT_ITEM_SOURCE = defineLaw({
28
28
  tests: [{ file: '../__tests__/ui.test.ts', id: 'TEST-CLI-UI-EXACT-ITEM-SOURCE' }],
29
29
  })
30
30
 
31
+ export const CLI_UI_THEME_OWNERSHIP = defineLaw({
32
+ id: 'CLI-UI-THEME-OWNERSHIP',
33
+ statement:
34
+ 'A released or locally exported theme becomes one project-contained consumer-owned CSS file, is activated by one relative host stylesheet import, and rolls back file, import, and lock together on failure.',
35
+ tests: [{ file: '../__tests__/ui.test.ts', id: 'TEST-CLI-UI-THEME-OWNERSHIP' }],
36
+ })
37
+
31
38
  export const CLI_UI_SEMANTIC_DIFF = defineLaw({
32
39
  id: 'CLI-UI-SEMANTIC-DIFF',
33
40
  statement:
@@ -1,11 +1,11 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
2
+ import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import path from 'node:path'
5
5
 
6
6
  import { digest, parseUiLock } from '../lock'
7
7
  import { UiError, type UiLock, type UiRegistry } from '../model'
8
- import { addUi, applyPreset, doctorUi, initUi } from '../operations'
8
+ import { addUi, applyPreset, doctorUi, initUi, listUi } from '../operations'
9
9
  import { resolveUiRelease } from '../release'
10
10
  import { shadcnInvocation } from '../runner'
11
11
 
@@ -28,6 +28,102 @@ const registry: UiRegistry = {
28
28
  },
29
29
  ],
30
30
  }
31
+ const themeCss = `/* Generated from observatory.astrale-theme.json. Consumer-owned after installation. */
32
+ :root,
33
+ [data-ui-theme='observatory'] {
34
+ --ui-background: #ffffff;
35
+ --ui-foreground: #111111;
36
+ --ui-card: #ffffff;
37
+ --ui-card-foreground: #111111;
38
+ --ui-popover: #ffffff;
39
+ --ui-popover-foreground: #111111;
40
+ --ui-primary: #2244aa;
41
+ --ui-primary-foreground: #ffffff;
42
+ --ui-secondary: #eeeeee;
43
+ --ui-secondary-foreground: #111111;
44
+ --ui-muted: #eeeeee;
45
+ --ui-muted-foreground: #555555;
46
+ --ui-accent: #ddeeff;
47
+ --ui-accent-foreground: #111111;
48
+ --ui-destructive: #aa2222;
49
+ --ui-destructive-foreground: #ffffff;
50
+ --ui-border: #dddddd;
51
+ --ui-input: #dddddd;
52
+ --ui-ring: #2244aa;
53
+ --ui-chart-1: #2244aa;
54
+ --ui-chart-2: #228844;
55
+ --ui-chart-3: #aa7722;
56
+ --ui-chart-4: #aa2222;
57
+ --ui-chart-5: #7722aa;
58
+ --ui-font-body: ui-sans-serif;
59
+ --ui-font-heading: ui-serif;
60
+ --ui-radius: 0.5rem;
61
+ --ui-radius-panel: 0.75rem;
62
+ --ui-control-height: 2.25rem;
63
+ --ui-control-height-sm: 2rem;
64
+ --ui-control-height-lg: 2.5rem;
65
+ --ui-shadow-control: none;
66
+ --ui-shadow-panel: none;
67
+ --ui-motion-fast: 120ms;
68
+ --ui-motion-standard: 180ms;
69
+ }
70
+ [data-ui-theme='observatory'].dark {
71
+ --ui-background: #111111;
72
+ --ui-foreground: #ffffff;
73
+ --ui-card: #191919;
74
+ --ui-card-foreground: #ffffff;
75
+ --ui-popover: #191919;
76
+ --ui-popover-foreground: #ffffff;
77
+ --ui-primary: #88aaff;
78
+ --ui-primary-foreground: #111111;
79
+ --ui-secondary: #292929;
80
+ --ui-secondary-foreground: #ffffff;
81
+ --ui-muted: #292929;
82
+ --ui-muted-foreground: #bbbbbb;
83
+ --ui-accent: #334455;
84
+ --ui-accent-foreground: #ffffff;
85
+ --ui-destructive: #ff7777;
86
+ --ui-destructive-foreground: #111111;
87
+ --ui-border: #333333;
88
+ --ui-input: #333333;
89
+ --ui-ring: #88aaff;
90
+ --ui-chart-1: #88aaff;
91
+ --ui-chart-2: #77dd99;
92
+ --ui-chart-3: #ffcc77;
93
+ --ui-chart-4: #ff7777;
94
+ --ui-chart-5: #cc88ff;
95
+ --ui-font-body: ui-sans-serif;
96
+ --ui-font-heading: ui-serif;
97
+ --ui-radius: 0.5rem;
98
+ --ui-radius-panel: 0.75rem;
99
+ --ui-control-height: 2.25rem;
100
+ --ui-control-height-sm: 2rem;
101
+ --ui-control-height-lg: 2.5rem;
102
+ --ui-shadow-control: none;
103
+ --ui-shadow-panel: none;
104
+ --ui-motion-fast: 120ms;
105
+ --ui-motion-standard: 180ms;
106
+ }
107
+ `
108
+ const themeRegistry: UiRegistry = {
109
+ name: 'astrale-ui',
110
+ items: [
111
+ {
112
+ name: 'theme-observatory',
113
+ type: 'registry:theme',
114
+ description: 'An owned theme.',
115
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0'],
116
+ files: [
117
+ {
118
+ path: 'observatory.css',
119
+ type: 'registry:file',
120
+ target: 'components/astrale/theme/observatory.css',
121
+ },
122
+ ],
123
+ meta: { canonicalAddress: 'theme/observatory', ownership: 'consumer-source' },
124
+ },
125
+ ],
126
+ }
31
127
  const compatibility = {
32
128
  version: 1,
33
129
  shadcn: '4.18.0',
@@ -74,6 +170,34 @@ function mockFetch(seen: string[] = [], suppliedRegistry: UiRegistry = registry)
74
170
  }) as typeof fetch
75
171
  }
76
172
 
173
+ function themeFetch(seen: string[] = []): typeof fetch {
174
+ return (async (input: string | URL | Request) => {
175
+ const url = String(input)
176
+ seen.push(url)
177
+ if (url.includes('/git/ref/tags/')) {
178
+ return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
179
+ }
180
+ if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
181
+ if (url.endsWith('/' + commit + '/registry.json')) {
182
+ return Response.json({ name: 'astrale-ui', include: ['registry/themes/registry.json'] })
183
+ }
184
+ if (url.endsWith('/registry/themes/registry.json')) return Response.json(themeRegistry)
185
+ if (url.endsWith('/registry/public/r/theme-observatory.json')) {
186
+ return Response.json({
187
+ ...themeRegistry.items[0],
188
+ files: [
189
+ {
190
+ ...themeRegistry.items[0]!.files[0],
191
+ path: 'registry/themes/observatory.css',
192
+ content: themeCss,
193
+ },
194
+ ],
195
+ })
196
+ }
197
+ return new Response('not found', { status: 404 })
198
+ }) as typeof fetch
199
+ }
200
+
77
201
  function builtItem(item: UiRegistry['items'][number]) {
78
202
  return {
79
203
  ...item,
@@ -176,6 +300,33 @@ describe('UI release and runner contracts', () => {
176
300
  )
177
301
  })
178
302
 
303
+ test('admits a release theme as one canonical consumer-owned CSS target', async () => {
304
+ const release = await resolveUiRelease('0.3.0-beta.0', themeFetch())
305
+ expect(release.registry.items).toEqual([
306
+ expect.objectContaining({
307
+ name: 'theme-observatory',
308
+ type: 'registry:theme',
309
+ meta: expect.objectContaining({ canonicalAddress: 'theme/observatory' }),
310
+ files: [
311
+ expect.objectContaining({
312
+ type: 'registry:file',
313
+ path: 'registry/themes/observatory.css',
314
+ target: 'components/astrale/theme/observatory.css',
315
+ }),
316
+ ],
317
+ }),
318
+ ])
319
+ })
320
+
321
+ test('lists release themes through the public type filter', async () => {
322
+ const themes = await listUi(
323
+ undefined,
324
+ { type: 'theme', version: '0.3.0-beta.0' },
325
+ { fetcher: themeFetch() },
326
+ )
327
+ expect(themes.map((item) => item.meta.canonicalAddress)).toEqual(['theme/observatory'])
328
+ })
329
+
179
330
  test('constructs exact on-demand commands for every supported package manager', () => {
180
331
  expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
181
332
  file: 'pnpm',
@@ -724,6 +875,166 @@ describe('UI source operations', () => {
724
875
  expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
725
876
  })
726
877
 
878
+ /** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
879
+ test('installs and activates a released theme while recording owned source', async () => {
880
+ const root = await lockedFixture()
881
+ await writeFile(
882
+ path.join(root, 'components.json'),
883
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
884
+ )
885
+ await writeFile(
886
+ path.join(root, 'src/index.css'),
887
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
888
+ )
889
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
890
+ const result = await addUi(
891
+ ['theme/observatory'],
892
+ { project: root, yes: true },
893
+ {
894
+ fetcher: themeFetch(),
895
+ runner: async () => {
896
+ await mkdir(path.dirname(target), { recursive: true })
897
+ await writeFile(target, themeCss)
898
+ return { code: 0, stdout: '', stderr: '' }
899
+ },
900
+ },
901
+ )
902
+
903
+ expect(result).toMatchObject({ status: 'installed', items: ['theme/observatory'] })
904
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
905
+ "@import '../components/astrale/theme/observatory.css';",
906
+ )
907
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
908
+ expect(written.items['theme/observatory']).toMatchObject({
909
+ address: 'theme/observatory',
910
+ files: { 'components/astrale/theme/observatory.css': digest(themeCss) },
911
+ })
912
+ expect((await doctorUi(root)).healthy).toBe(true)
913
+ })
914
+
915
+ /** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
916
+ test('installs a playground-exported theme without registry or shadcn and preserves edits', async () => {
917
+ const root = await lockedFixture()
918
+ const source = path.join(root, 'observatory.css')
919
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
920
+ await writeFile(source, themeCss)
921
+ let invoked = false
922
+
923
+ const planned = await addUi(
924
+ ['./observatory.css'],
925
+ { project: root, dryRun: true },
926
+ {
927
+ fetcher: (async () => {
928
+ invoked = true
929
+ throw new Error('must not fetch')
930
+ }) as unknown as typeof fetch,
931
+ runner: async () => {
932
+ invoked = true
933
+ return { code: 1, stdout: '', stderr: '' }
934
+ },
935
+ },
936
+ )
937
+ expect(planned).toMatchObject({
938
+ status: 'planned',
939
+ items: ['theme/observatory'],
940
+ activation: {
941
+ file: 'src/index.css',
942
+ import: "@import '../components/astrale/theme/observatory.css';",
943
+ },
944
+ })
945
+ expect(await Bun.file(target).exists()).toBe(false)
946
+
947
+ await addUi(
948
+ ['./observatory.css'],
949
+ { project: root },
950
+ {
951
+ fetcher: (async () => {
952
+ invoked = true
953
+ throw new Error('must not fetch')
954
+ }) as unknown as typeof fetch,
955
+ runner: async () => {
956
+ invoked = true
957
+ return { code: 1, stdout: '', stderr: '' }
958
+ },
959
+ },
960
+ )
961
+ expect(invoked).toBe(false)
962
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
963
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
964
+ "@import '../components/astrale/theme/observatory.css';",
965
+ )
966
+
967
+ await writeFile(target, 'consumer edit\n')
968
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
969
+ code: 'UI_LOCAL_CHANGES',
970
+ })
971
+ await addUi(['./observatory.css'], { project: root, overwrite: true, yes: true })
972
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
973
+ })
974
+
975
+ test('rejects malformed and symlinked local themes before project mutation', async () => {
976
+ const root = await lockedFixture()
977
+ const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
978
+ await writeFile(path.join(root, 'unsafe.css'), "@import 'https://example.invalid/theme.css';\n")
979
+ await expect(addUi(['./unsafe.css'], { project: root })).rejects.toMatchObject({
980
+ code: 'UI_ITEM_CONFLICT',
981
+ })
982
+
983
+ const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-theme-'))
984
+ temporary.push(outside)
985
+ await writeFile(path.join(outside, 'observatory.css'), themeCss)
986
+ await symlink(path.join(outside, 'observatory.css'), path.join(root, 'linked.css'))
987
+ await expect(addUi(['./linked.css'], { project: root })).rejects.toMatchObject({
988
+ code: 'UI_ITEM_NOT_FOUND',
989
+ })
990
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
991
+ })
992
+
993
+ test('rejects a symlinked UI lock before a theme can mutate outside the project', async () => {
994
+ const root = await lockedFixture()
995
+ const source = path.join(root, 'observatory.css')
996
+ await writeFile(source, themeCss)
997
+ const lockPath = path.join(root, 'astrale-ui.lock.json')
998
+ const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-lock-'))
999
+ temporary.push(outside)
1000
+ const outsideLock = path.join(outside, 'astrale-ui.lock.json')
1001
+ const lockSource = await readFile(lockPath, 'utf8')
1002
+ await writeFile(outsideLock, lockSource)
1003
+ await rm(lockPath)
1004
+ await symlink(outsideLock, lockPath)
1005
+ const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
1006
+
1007
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
1008
+ code: 'UI_LOCK_INVALID',
1009
+ })
1010
+ expect(await readFile(outsideLock, 'utf8')).toBe(lockSource)
1011
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
1012
+ expect(
1013
+ await Bun.file(path.join(root, 'components/astrale/theme/observatory.css')).exists(),
1014
+ ).toBe(false)
1015
+ })
1016
+
1017
+ test('rolls back a newly copied local theme and activation when the lock commit fails', async () => {
1018
+ const root = await lockedFixture()
1019
+ await writeFile(path.join(root, 'observatory.css'), themeCss)
1020
+ const lockPath = path.join(root, 'astrale-ui.lock.json')
1021
+ const cssPath = path.join(root, 'src/index.css')
1022
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
1023
+ const lockBefore = await readFile(lockPath, 'utf8')
1024
+ const cssBefore = await readFile(cssPath, 'utf8')
1025
+ await chmod(lockPath, 0o444)
1026
+
1027
+ try {
1028
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toBeInstanceOf(Error)
1029
+ } finally {
1030
+ await chmod(lockPath, 0o644)
1031
+ }
1032
+
1033
+ expect(await Bun.file(target).exists()).toBe(false)
1034
+ expect(await readFile(cssPath, 'utf8')).toBe(cssBefore)
1035
+ expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
1036
+ })
1037
+
727
1038
  test('successful add records installed file digests and doctor detects later edits', async () => {
728
1039
  const root = await lockedFixture()
729
1040
  await writeFile(
package/src/ui/lock.ts CHANGED
@@ -32,7 +32,9 @@ export function parseUiLock(value: unknown): UiLock {
32
32
  }
33
33
  for (const [address, item] of Object.entries(lock.items)) {
34
34
  if (
35
- !/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(address) ||
35
+ !/^(?:(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+|theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/u.test(
36
+ address,
37
+ ) ||
36
38
  !isRecord(item) ||
37
39
  item.address !== address ||
38
40
  !isDigest(item.sourceDigest) ||
@@ -6,6 +6,7 @@ import { digest, parseUiLock, readUiLock } from './lock'
6
6
  import {
7
7
  UI_PACKAGE,
8
8
  UI_PRESETS,
9
+ UI_LOCK_FILE,
9
10
  UiError,
10
11
  type UiLock,
11
12
  type UiPreset,
@@ -28,6 +29,46 @@ import { defaultUiRunner, shadcnInvocation, type UiRunner } from './runner'
28
29
 
29
30
  type Dependencies = { fetcher?: typeof fetch; runner?: UiRunner }
30
31
 
32
+ const LOCAL_THEME_MAX_BYTES = 131_072
33
+ const THEME_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u
34
+ const REQUIRED_THEME_TOKENS = [
35
+ 'background',
36
+ 'foreground',
37
+ 'card',
38
+ 'card-foreground',
39
+ 'popover',
40
+ 'popover-foreground',
41
+ 'primary',
42
+ 'primary-foreground',
43
+ 'secondary',
44
+ 'secondary-foreground',
45
+ 'muted',
46
+ 'muted-foreground',
47
+ 'accent',
48
+ 'accent-foreground',
49
+ 'destructive',
50
+ 'destructive-foreground',
51
+ 'border',
52
+ 'input',
53
+ 'ring',
54
+ 'chart-1',
55
+ 'chart-2',
56
+ 'chart-3',
57
+ 'chart-4',
58
+ 'chart-5',
59
+ 'font-body',
60
+ 'font-heading',
61
+ 'radius',
62
+ 'radius-panel',
63
+ 'control-height',
64
+ 'control-height-sm',
65
+ 'control-height-lg',
66
+ 'shadow-control',
67
+ 'shadow-panel',
68
+ 'motion-fast',
69
+ 'motion-standard',
70
+ ] as const
71
+
31
72
  async function exists(target: string): Promise<boolean> {
32
73
  return access(target).then(
33
74
  () => true,
@@ -39,6 +80,60 @@ async function readOptional(target: string): Promise<string | undefined> {
39
80
  return readFile(target, 'utf8').catch(() => undefined)
40
81
  }
41
82
 
83
+ function isLocalThemeAddress(address: string): boolean {
84
+ return (address.startsWith('./') || address.startsWith('../')) && address.endsWith('.css')
85
+ }
86
+
87
+ function admitLocalThemeCss(source: string, slug: string): void {
88
+ if (new TextEncoder().encode(source).byteLength > LOCAL_THEME_MAX_BYTES) {
89
+ throw new UiError('UI_ITEM_CONFLICT', 'Local theme CSS exceeds 128 KiB.')
90
+ }
91
+ if (
92
+ !source.includes('Consumer-owned after installation.') ||
93
+ !source.includes("[data-ui-theme='" + slug + "']") ||
94
+ !source.includes("[data-ui-theme='" + slug + "'].dark") ||
95
+ /@import\b|url\s*\(/iu.test(source) ||
96
+ REQUIRED_THEME_TOKENS.some(
97
+ (token) => source.match(new RegExp(`--ui-${token}:`, 'gu'))?.length !== 2,
98
+ )
99
+ ) {
100
+ throw new UiError(
101
+ 'UI_ITEM_CONFLICT',
102
+ 'Local theme CSS is not an admitted Astrale playground export.',
103
+ 'Export the CSS from the Astrale UI playground, keep its filename aligned with the theme name, and try again.',
104
+ )
105
+ }
106
+ }
107
+
108
+ function themeImport(project: UiProject, target: string): string {
109
+ let relative = path.relative(path.dirname(project.cssPath), target).split(path.sep).join('/')
110
+ if (!relative.startsWith('.')) relative = './' + relative
111
+ return "@import '" + relative + "';"
112
+ }
113
+
114
+ function activateTheme(current: string, statement: string): string {
115
+ const lines = current
116
+ .split(/\r?\n/u)
117
+ .filter(
118
+ (line) =>
119
+ !/^@import\s+['"][^'"]*components\/astrale\/theme\/[a-z][a-z0-9-]*\.css['"];?\s*$/u.test(
120
+ line,
121
+ ),
122
+ )
123
+ const packageImport = lines.reduce(
124
+ (last, line, index) =>
125
+ /^@import\s+['"]@astrale-os\/ui\/(?:theme\.css|presets\/[a-z-]+\.css)['"];?\s*$/u.test(line)
126
+ ? index
127
+ : last,
128
+ -1,
129
+ )
130
+ lines.splice(packageImport + 1, 0, statement)
131
+ return lines
132
+ .join('\n')
133
+ .replace(/\n{3,}/gu, '\n\n')
134
+ .replace(/^\n/u, '')
135
+ }
136
+
42
137
  function manifestDependencies(manifest: Record<string, unknown>): Record<string, string> {
43
138
  return { ...(manifest.dependencies as Record<string, string> | undefined) }
44
139
  }
@@ -191,6 +286,7 @@ export async function initUi(
191
286
  dependencies: Dependencies = {},
192
287
  ): Promise<Record<string, unknown>> {
193
288
  const project = await discoverUiProject(options.path)
289
+ await assertSafePlannedTarget(project, UI_LOCK_FILE)
194
290
  assertSupportedUiProject(project)
195
291
  const preset = options.preset ?? 'astrale'
196
292
  if (!UI_PRESETS.includes(preset)) {
@@ -427,6 +523,85 @@ async function lockedRelease(
427
523
  return { lock, release }
428
524
  }
429
525
 
526
+ async function addLocalTheme(
527
+ address: string,
528
+ project: UiProject,
529
+ options: { dryRun?: boolean; overwrite?: boolean },
530
+ ): Promise<Record<string, unknown>> {
531
+ const sourcePath = path.resolve(project.root, address)
532
+ const sourceInfo = await lstat(sourcePath).catch(() => undefined)
533
+ if (!sourceInfo?.isFile() || sourceInfo.isSymbolicLink()) {
534
+ throw new UiError('UI_ITEM_NOT_FOUND', 'Local theme is not a regular file: ' + address)
535
+ }
536
+ const slug = path.basename(sourcePath, '.css')
537
+ if (!THEME_SLUG.test(slug)) {
538
+ throw new UiError(
539
+ 'UI_ITEM_CONFLICT',
540
+ 'Local theme filename must be a kebab-case theme name.',
541
+ 'Rename it to a name such as observatory.css.',
542
+ )
543
+ }
544
+ const source = await readFile(sourcePath, 'utf8')
545
+ admitLocalThemeCss(source, slug)
546
+
547
+ const lock = await readUiLock(project.uiLockPath)
548
+ const canonicalAddress = 'theme/' + slug
549
+ const relativeTarget = 'components/astrale/theme/' + slug + '.css'
550
+ const target = await assertSafePlannedTarget(project, relativeTarget)
551
+ const existing = await readOptional(target)
552
+ const installed = lock.items[canonicalAddress]
553
+ if (existing !== undefined && !installed && !options.overwrite) {
554
+ throw new UiError(
555
+ 'UI_LOCAL_CHANGES',
556
+ 'Theme target already exists outside the Astrale UI lock: ' + relativeTarget,
557
+ 'Review the file, then repeat with explicit --overwrite --yes.',
558
+ )
559
+ }
560
+ if (installed && !options.overwrite) {
561
+ const expected = installed.files[relativeTarget]
562
+ if (!expected || digest(existing ?? '') !== expected) {
563
+ throw new UiError(
564
+ 'UI_LOCAL_CHANGES',
565
+ 'Installed UI file has local changes: ' + relativeTarget,
566
+ 'Review the file, then repeat add with explicit --overwrite --yes.',
567
+ )
568
+ }
569
+ }
570
+ const statement = themeImport(project, target)
571
+ const result = {
572
+ status: options.dryRun ? 'planned' : 'installed',
573
+ items: [canonicalAddress],
574
+ files: { [canonicalAddress]: { [relativeTarget]: digest(source) } },
575
+ activation: { file: projectRelative(project, project.cssPath), import: statement },
576
+ source: address,
577
+ }
578
+ if (options.dryRun) return result
579
+
580
+ const snapshots = new Map<string, string | undefined>()
581
+ for (const mutation of [target, project.cssPath, project.uiLockPath]) {
582
+ snapshots.set(mutation, await readOptional(mutation))
583
+ }
584
+ try {
585
+ await mkdir(path.dirname(target), { recursive: true })
586
+ await writeFile(target, source, 'utf8')
587
+ const css = (await readOptional(project.cssPath)) ?? ''
588
+ await writeFile(project.cssPath, activateTheme(css, statement), 'utf8')
589
+ lock.items[canonicalAddress] = {
590
+ address: canonicalAddress,
591
+ sourceDigest: digest(source),
592
+ files: { [relativeTarget]: digest(source) },
593
+ }
594
+ await writeJson(project.uiLockPath, lock)
595
+ return result
596
+ } catch (error) {
597
+ for (const [mutation, previous] of snapshots) {
598
+ if (previous === undefined) await rm(mutation, { force: true })
599
+ else await writeFile(mutation, previous, 'utf8')
600
+ }
601
+ throw error
602
+ }
603
+ }
604
+
430
605
  export async function addUi(
431
606
  addresses: string[],
432
607
  options: { project?: string; dryRun?: boolean; overwrite?: boolean; yes?: boolean },
@@ -436,11 +611,7 @@ export async function addUi(
436
611
  throw new UiError('UI_ITEM_NOT_FOUND', 'No UI item was provided.')
437
612
  }
438
613
  const project = await discoverUiProject(options.project)
439
- const { lock, release } = await lockedRelease(project, dependencies.fetcher)
440
- const items = addresses.map((address) => findItem(release, address))
441
- const itemDocuments = await Promise.all(
442
- items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)),
443
- )
614
+ await assertSafePlannedTarget(project, UI_LOCK_FILE)
444
615
  if (options.overwrite && !options.yes) {
445
616
  throw new UiError(
446
617
  'UI_LOCAL_CHANGES',
@@ -448,6 +619,25 @@ export async function addUi(
448
619
  'Review the locally edited files, then repeat with both --overwrite and --yes.',
449
620
  )
450
621
  }
622
+ const localThemes = addresses.filter(isLocalThemeAddress)
623
+ if (localThemes.length > 0) {
624
+ if (addresses.length !== 1) {
625
+ throw new UiError(
626
+ 'UI_ITEM_CONFLICT',
627
+ 'Install one local theme at a time and do not mix local files with registry addresses.',
628
+ )
629
+ }
630
+ return addLocalTheme(localThemes[0]!, project, options)
631
+ }
632
+ const { lock, release } = await lockedRelease(project, dependencies.fetcher)
633
+ const items = addresses.map((address) => findItem(release, address))
634
+ const themes = items.filter((item) => item.type === 'registry:theme')
635
+ if (themes.length > 1) {
636
+ throw new UiError('UI_ITEM_CONFLICT', 'Only one theme can be activated per add operation.')
637
+ }
638
+ const itemDocuments = await Promise.all(
639
+ items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)),
640
+ )
451
641
  await rejectLocalChanges(project, lock, items, options.overwrite === true)
452
642
  const targets = (
453
643
  await Promise.all(
@@ -469,7 +659,13 @@ export async function addUi(
469
659
  ])
470
660
  const snapshots = new Map<string, string | undefined>()
471
661
  if (!options.dryRun) {
472
- for (const target of [project.packageJsonPath, project.lockPath!, ...targets]) {
662
+ for (const target of [
663
+ project.packageJsonPath,
664
+ project.lockPath!,
665
+ project.cssPath,
666
+ project.uiLockPath,
667
+ ...targets,
668
+ ]) {
473
669
  snapshots.set(target, await readOptional(target))
474
670
  }
475
671
  }
@@ -494,6 +690,13 @@ export async function addUi(
494
690
  }
495
691
  }
496
692
  await pinUiDependency(project, lock.package.version, dependencies.runner ?? defaultUiRunner)
693
+ const theme = themes[0]
694
+ const themeTarget = theme?.files[0]?.target
695
+ if (themeTarget) {
696
+ const target = await safeTarget(project, themeTarget)
697
+ const css = (await readOptional(project.cssPath)) ?? ''
698
+ await writeFile(project.cssPath, activateTheme(css, themeImport(project, target)), 'utf8')
699
+ }
497
700
  }
498
701
  } catch (error) {
499
702
  for (const [target, previous] of snapshots) {
@@ -513,6 +716,15 @@ export async function addUi(
513
716
  })),
514
717
  command: [invocation.file, ...invocation.args],
515
718
  output: result.stdout,
719
+ activation: themes[0]
720
+ ? {
721
+ file: projectRelative(project, project.cssPath),
722
+ import: themeImport(
723
+ project,
724
+ targets.find((target) => target.endsWith('.css'))!,
725
+ ),
726
+ }
727
+ : undefined,
516
728
  }
517
729
  }
518
730
 
@@ -589,6 +801,15 @@ export async function doctorUi(
589
801
  checks.push({ check: 'item:' + item.address + ':' + file, ok: actual === expected })
590
802
  }
591
803
  }
804
+ const themeFiles = Object.values(lock.items)
805
+ .filter((item) => item.address.startsWith('theme/'))
806
+ .flatMap((item) => Object.keys(item.files).filter((file) => file.endsWith('.css')))
807
+ if (themeFiles.length > 0) {
808
+ const active = themeFiles.some((file) =>
809
+ css.includes(themeImport(project, path.join(project.root, file))),
810
+ )
811
+ checks.push({ check: 'theme-active', ok: active })
812
+ }
592
813
  }
593
814
  return { healthy: checks.every((check) => check.ok), checks }
594
815
  }
@@ -601,6 +822,7 @@ export async function applyPreset(
601
822
  throw new UiError('UI_ITEM_NOT_FOUND', 'Unknown preset: ' + preset)
602
823
  }
603
824
  const project = await discoverUiProject(options.project)
825
+ await assertSafePlannedTarget(project, UI_LOCK_FILE)
604
826
  const lock = await readUiLock(project.uiLockPath)
605
827
  const css = (await readOptional(project.cssPath)) ?? ''
606
828
  const next = css.replace(
package/src/ui/release.ts CHANGED
@@ -237,10 +237,21 @@ function qualifyRegistryItemPaths(item: unknown, sourceDirectory: string): unkno
237
237
  function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
238
238
  if (!item || typeof item !== 'object') return false
239
239
  const candidate = item as Partial<UiRegistry['items'][number]>
240
- return (
240
+ const address = candidate.meta?.canonicalAddress
241
+ const isTheme =
242
+ candidate.type === 'registry:theme' &&
241
243
  typeof candidate.name === 'string' &&
242
- /^(?:pattern|block)-[a-z0-9-]+$/u.test(candidate.name) &&
244
+ /^theme-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(candidate.name) &&
245
+ typeof address === 'string' &&
246
+ /^theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(address)
247
+ const isComposition =
243
248
  candidate.type === 'registry:block' &&
249
+ typeof candidate.name === 'string' &&
250
+ /^(?:pattern|block)-[a-z0-9-]+$/u.test(candidate.name) &&
251
+ typeof address === 'string' &&
252
+ /^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(address)
253
+ return (
254
+ (isTheme || isComposition) &&
244
255
  (candidate.dependencies === undefined ||
245
256
  (Array.isArray(candidate.dependencies) &&
246
257
  candidate.dependencies.every(
@@ -258,10 +269,14 @@ function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
258
269
  typeof file.type === 'string' &&
259
270
  typeof file.target === 'string' &&
260
271
  file.target.startsWith('components/astrale/') &&
261
- isSafeRelative(file.target),
272
+ isSafeRelative(file.target) &&
273
+ (!isTheme ||
274
+ (candidate.files?.length === 1 &&
275
+ file.type === 'registry:file' &&
276
+ file.target ===
277
+ 'components/astrale/theme/' + address!.slice('theme/'.length) + '.css')),
262
278
  ) &&
263
- typeof candidate.meta?.canonicalAddress === 'string' &&
264
- /^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(candidate.meta.canonicalAddress)
279
+ typeof address === 'string'
265
280
  )
266
281
  }
267
282
 
@@ -32,6 +32,8 @@
32
32
  display: flex;
33
33
  gap: 12px;
34
34
  align-items: center;
35
+ min-width: 0;
36
+ overflow: hidden;
35
37
  padding: 6px 10px;
36
38
  border-bottom: 1px solid #26262e;
37
39
  flex: none;
@@ -56,6 +58,23 @@
56
58
  font-weight: 600;
57
59
  color: #e8e8f0;
58
60
  }
61
+ #view-label,
62
+ #target-label,
63
+ #identity-label {
64
+ min-width: 0;
65
+ overflow: hidden;
66
+ text-overflow: ellipsis;
67
+ white-space: nowrap;
68
+ }
69
+ #view-label {
70
+ flex-shrink: 1;
71
+ }
72
+ #target-label {
73
+ flex-shrink: 2;
74
+ }
75
+ #identity-label {
76
+ flex-shrink: 3;
77
+ }
59
78
  .dim {
60
79
  color: #7a7a86;
61
80
  }
@@ -68,6 +87,8 @@
68
87
  }
69
88
  #frame {
70
89
  flex: 1;
90
+ min-width: 0;
91
+ min-height: 0;
71
92
  position: relative;
72
93
  }
73
94
  #frame iframe {