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

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.22",
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,16 +85143,111 @@ 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
+ return installThemeCss(slug, source2, digest5(source2), address, project2, options);
85161
+ }
85162
+ async function installThemeCss(slug, source2, sourceDigest, sourceLabel, project2, options) {
85163
+ const lock = await readUiLock(project2.uiLockPath);
85164
+ const canonicalAddress = "theme/" + slug;
85165
+ const relativeTarget = "components/astrale/theme/" + slug + ".css";
85166
+ const target2 = await assertSafePlannedTarget(project2, relativeTarget);
85167
+ const existing = await readOptional(target2);
85168
+ const installed = lock.items[canonicalAddress];
85169
+ if (existing !== undefined && !installed && !options.overwrite) {
85170
+ 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.");
85171
+ }
85172
+ if (installed && !options.overwrite) {
85173
+ const expected = installed.files[relativeTarget];
85174
+ if (!expected || digest5(existing ?? "") !== expected) {
85175
+ throw new UiError("UI_LOCAL_CHANGES", "Installed UI file has local changes: " + relativeTarget, "Review the file, then repeat add with explicit --overwrite --yes.");
85176
+ }
85177
+ }
85178
+ const statement = themeImport(project2, target2);
85179
+ const result = {
85180
+ status: options.dryRun ? "planned" : "installed",
85181
+ items: [canonicalAddress],
85182
+ files: { [canonicalAddress]: { [relativeTarget]: digest5(source2) } },
85183
+ activation: { file: projectRelative(project2, project2.cssPath), import: statement },
85184
+ source: sourceLabel
85185
+ };
85186
+ if (options.dryRun)
85187
+ return result;
85188
+ const snapshots = new Map;
85189
+ for (const mutation of [target2, project2.cssPath, project2.uiLockPath]) {
85190
+ snapshots.set(mutation, await readOptional(mutation));
85191
+ }
85192
+ try {
85193
+ await mkdir10(path6.dirname(target2), { recursive: true });
85194
+ await writeFile6(target2, source2, "utf8");
85195
+ const css = await readOptional(project2.cssPath) ?? "";
85196
+ await writeFile6(project2.cssPath, activateTheme(css, statement), "utf8");
85197
+ lock.items[canonicalAddress] = {
85198
+ address: canonicalAddress,
85199
+ sourceDigest,
85200
+ files: { [relativeTarget]: digest5(source2) }
85201
+ };
85202
+ await writeJson(project2.uiLockPath, lock);
85203
+ return result;
85204
+ } catch (error52) {
85205
+ for (const [mutation, previous] of snapshots) {
85206
+ if (previous === undefined)
85207
+ await rm3(mutation, { force: true });
85208
+ else
85209
+ await writeFile6(mutation, previous, "utf8");
85210
+ }
85211
+ throw error52;
85212
+ }
85213
+ }
85214
+ async function addReleasedTheme(item, document, project2, options) {
85215
+ const slug = item.meta.canonicalAddress.slice("theme/".length);
85216
+ const source2 = document.files[0]?.content;
85217
+ if (typeof source2 !== "string") {
85218
+ throw new UiError("UI_REGISTRY_UNAVAILABLE", "The released theme has no admitted CSS source.");
85219
+ }
85220
+ admitLocalThemeCss(source2, slug);
85221
+ return installThemeCss(slug, source2, digest5(JSON.stringify(document)), item.meta.canonicalAddress, project2, options);
85222
+ }
85116
85223
  async function addUi(addresses, options, dependencies = {}) {
85117
85224
  if (addresses.length === 0) {
85118
85225
  throw new UiError("UI_ITEM_NOT_FOUND", "No UI item was provided.");
85119
85226
  }
85120
85227
  const project2 = await discoverUiProject(options.project);
85228
+ await assertSafePlannedTarget(project2, UI_LOCK_FILE);
85229
+ if (options.overwrite && !options.yes) {
85230
+ 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.");
85231
+ }
85232
+ const localThemes = addresses.filter(isLocalThemeAddress);
85233
+ if (localThemes.length > 0) {
85234
+ if (addresses.length !== 1) {
85235
+ throw new UiError("UI_ITEM_CONFLICT", "Install one local theme at a time and do not mix local files with registry addresses.");
85236
+ }
85237
+ return addLocalTheme(localThemes[0], project2, options);
85238
+ }
85121
85239
  const { lock, release } = await lockedRelease(project2, dependencies.fetcher);
85122
85240
  const items = addresses.map((address) => findItem(release, address));
85241
+ const themes = items.filter((item) => item.type === "registry:theme");
85242
+ if (themes.length > 1) {
85243
+ throw new UiError("UI_ITEM_CONFLICT", "Only one theme can be activated per add operation.");
85244
+ }
85123
85245
  const itemDocuments = await Promise.all(items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)));
85124
- if (options.overwrite && !options.yes) {
85125
- 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.");
85246
+ if (themes.length === 1) {
85247
+ if (items.length !== 1) {
85248
+ throw new UiError("UI_ITEM_CONFLICT", "Install a released theme separately from patterns and blocks.");
85249
+ }
85250
+ return addReleasedTheme(themes[0], itemDocuments[0], project2, options);
85126
85251
  }
85127
85252
  await rejectLocalChanges(project2, lock, items, options.overwrite === true);
85128
85253
  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);
@@ -85137,7 +85262,13 @@ async function addUi(addresses, options, dependencies = {}) {
85137
85262
  ]);
85138
85263
  const snapshots = new Map;
85139
85264
  if (!options.dryRun) {
85140
- for (const target2 of [project2.packageJsonPath, project2.lockPath, ...targets]) {
85265
+ for (const target2 of [
85266
+ project2.packageJsonPath,
85267
+ project2.lockPath,
85268
+ project2.cssPath,
85269
+ project2.uiLockPath,
85270
+ ...targets
85271
+ ]) {
85141
85272
  snapshots.set(target2, await readOptional(target2));
85142
85273
  }
85143
85274
  }
@@ -85154,6 +85285,13 @@ async function addUi(addresses, options, dependencies = {}) {
85154
85285
  }
85155
85286
  }
85156
85287
  await pinUiDependency(project2, lock.package.version, dependencies.runner ?? defaultUiRunner);
85288
+ const theme = themes[0];
85289
+ const themeTarget = theme?.files[0]?.target;
85290
+ if (themeTarget) {
85291
+ const target2 = await safeTarget(project2, themeTarget);
85292
+ const css = await readOptional(project2.cssPath) ?? "";
85293
+ await writeFile6(project2.cssPath, activateTheme(css, themeImport(project2, target2)), "utf8");
85294
+ }
85157
85295
  }
85158
85296
  } catch (error52) {
85159
85297
  for (const [target2, previous] of snapshots) {
@@ -85174,7 +85312,11 @@ async function addUi(addresses, options, dependencies = {}) {
85174
85312
  files: item.files.map((file2) => file2.target).filter(Boolean)
85175
85313
  })),
85176
85314
  command: [invocation3.file, ...invocation3.args],
85177
- output: result.stdout
85315
+ output: result.stdout,
85316
+ activation: themes[0] ? {
85317
+ file: projectRelative(project2, project2.cssPath),
85318
+ import: themeImport(project2, targets.find((target2) => target2.endsWith(".css")))
85319
+ } : undefined
85178
85320
  };
85179
85321
  }
85180
85322
  try {
@@ -85246,6 +85388,11 @@ async function doctorUi(input) {
85246
85388
  checks3.push({ check: "item:" + item.address + ":" + file2, ok: actual === expected });
85247
85389
  }
85248
85390
  }
85391
+ const themeFiles = Object.values(lock.items).filter((item) => item.address.startsWith("theme/")).flatMap((item) => Object.keys(item.files).filter((file2) => file2.endsWith(".css")));
85392
+ if (themeFiles.length > 0) {
85393
+ const active = themeFiles.some((file2) => css.includes(themeImport(project2, path6.join(project2.root, file2))));
85394
+ checks3.push({ check: "theme-active", ok: active });
85395
+ }
85249
85396
  }
85250
85397
  return { healthy: checks3.every((check3) => check3.ok), checks: checks3 };
85251
85398
  }
@@ -85254,6 +85401,7 @@ async function applyPreset(preset, options) {
85254
85401
  throw new UiError("UI_ITEM_NOT_FOUND", "Unknown preset: " + preset);
85255
85402
  }
85256
85403
  const project2 = await discoverUiProject(options.project);
85404
+ await assertSafePlannedTarget(project2, UI_LOCK_FILE);
85257
85405
  const lock = await readUiLock(project2.uiLockPath);
85258
85406
  const css = await readOptional(project2.cssPath) ?? "";
85259
85407
  const next = css.replace(/@astrale-os\/ui\/presets\/[a-z-]+\.css/gu, "@astrale-os/ui/presets/" + preset + ".css");
@@ -85336,6 +85484,7 @@ async function writeJson(target2, value3) {
85336
85484
  await writeFile6(target2, JSON.stringify(value3, null, 2) + `
85337
85485
  `, "utf8");
85338
85486
  }
85487
+ var LOCAL_THEME_MAX_BYTES = 131072, THEME_SLUG, REQUIRED_THEME_TOKENS;
85339
85488
  var init_operations3 = __esm(() => {
85340
85489
  init_dist();
85341
85490
  init_lock();
@@ -85343,6 +85492,44 @@ var init_operations3 = __esm(() => {
85343
85492
  init_project3();
85344
85493
  init_release();
85345
85494
  init_runner();
85495
+ THEME_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
85496
+ REQUIRED_THEME_TOKENS = [
85497
+ "background",
85498
+ "foreground",
85499
+ "card",
85500
+ "card-foreground",
85501
+ "popover",
85502
+ "popover-foreground",
85503
+ "primary",
85504
+ "primary-foreground",
85505
+ "secondary",
85506
+ "secondary-foreground",
85507
+ "muted",
85508
+ "muted-foreground",
85509
+ "accent",
85510
+ "accent-foreground",
85511
+ "destructive",
85512
+ "destructive-foreground",
85513
+ "border",
85514
+ "input",
85515
+ "ring",
85516
+ "chart-1",
85517
+ "chart-2",
85518
+ "chart-3",
85519
+ "chart-4",
85520
+ "chart-5",
85521
+ "font-body",
85522
+ "font-heading",
85523
+ "radius",
85524
+ "radius-panel",
85525
+ "control-height",
85526
+ "control-height-sm",
85527
+ "control-height-lg",
85528
+ "shadow-control",
85529
+ "shadow-panel",
85530
+ "motion-fast",
85531
+ "motion-standard"
85532
+ ];
85346
85533
  });
85347
85534
 
85348
85535
  // src/ui/index.ts
@@ -85425,13 +85612,13 @@ var init_list = __esm(() => {
85425
85612
  init_shared();
85426
85613
  list_default = {
85427
85614
  name: "list",
85428
- description: "List or search Astrale patterns and blocks",
85615
+ description: "List or search Astrale patterns, blocks, and themes",
85429
85616
  arguments: [{ name: "query", description: "Optional search text", required: false }],
85430
85617
  options: [
85431
85618
  {
85432
85619
  flags: "--type <type>",
85433
- description: "Restrict to pattern or block",
85434
- choices: ["pattern", "block"]
85620
+ description: "Restrict to pattern, block, or theme",
85621
+ choices: ["pattern", "block", "theme"]
85435
85622
  },
85436
85623
  { flags: "--limit <n>", description: "Maximum results", default: "100" },
85437
85624
  { flags: "--version <version>", description: "Exact release to inspect" },
@@ -85453,11 +85640,11 @@ var init_add = __esm(() => {
85453
85640
  init_shared();
85454
85641
  add_default = {
85455
85642
  name: "add",
85456
- description: "Install consumer-owned Astrale pattern or block source",
85643
+ description: "Install consumer-owned Astrale pattern, block, or theme source",
85457
85644
  arguments: [
85458
85645
  {
85459
85646
  name: "items",
85460
- description: "Canonical pattern or block addresses",
85647
+ description: "Canonical registry addresses or a local exported theme CSS file",
85461
85648
  required: false,
85462
85649
  variadic: true
85463
85650
  }
@@ -85470,8 +85657,8 @@ var init_add = __esm(() => {
85470
85657
  UI_JSON_OPTION
85471
85658
  ],
85472
85659
  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.
85660
+ Installed source belongs to the application. Use theme/<name> for a released theme or ./theme.css for a playground export.
85661
+ Ordinary add never overwrites local edits. Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
85475
85662
  `,
85476
85663
  action: async (items, options) => runUiCommand(options, async () => {
85477
85664
  let selected = items;
@@ -85506,8 +85693,13 @@ var init_doctor = __esm(() => {
85506
85693
  name: "doctor",
85507
85694
  description: "Verify Astrale UI package, CSS, config, lock, and installed source integrity",
85508
85695
  arguments: [{ name: "path", description: "Application directory", required: false }],
85509
- options: [UI_JSON_OPTION],
85510
- action: async (path7, options) => runUiCommand(options, () => doctorUi(path7))
85696
+ options: [UI_PROJECT_OPTION, UI_JSON_OPTION],
85697
+ action: async (path7, options) => runUiCommand(options, () => {
85698
+ if (path7 && options.project) {
85699
+ throw new UiError("UI_ITEM_CONFLICT", "Choose either the positional application path or --project, not both.");
85700
+ }
85701
+ return doctorUi(options.project ?? path7);
85702
+ })
85511
85703
  };
85512
85704
  });
85513
85705
 
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.22",
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,10 @@ 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. Patterns and blocks invoke the release-qualified shadcn CLI on demand through the
9
+ detected package manager. Released themes copy the exact admitted embedded CSS directly, while a
10
+ local playground export is admitted and copied without registry or shadcn. Both theme paths install
11
+ consumer-owned source and activate it through one project-relative CSS
12
+ import, and complete application writes before the lock advances. Dry-run, list, and doctor do not
10
13
  claim a file mutation.
@@ -24,10 +24,17 @@ export const CLI_UI_BOUNDED_REMOTE_DOCUMENTS = defineLaw({
24
24
  export const CLI_UI_EXACT_ITEM_SOURCE = defineLaw({
25
25
  id: 'CLI-UI-EXACT-ITEM-SOURCE',
26
26
  statement:
27
- 'An add operation admits the exact built item document against the release index and records a content-bearing source digest before invoking project tooling.',
27
+ 'An add operation admits the exact built item document against the release index and records its content-bearing source digest before any consumer mutation; only patterns and blocks invoke project tooling.',
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,38 @@ 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(builtThemeItem())
187
+ }
188
+ return new Response('not found', { status: 404 })
189
+ }) as typeof fetch
190
+ }
191
+
192
+ function builtThemeItem() {
193
+ return {
194
+ ...themeRegistry.items[0],
195
+ files: [
196
+ {
197
+ ...themeRegistry.items[0]!.files[0],
198
+ path: 'registry/themes/observatory.css',
199
+ content: themeCss,
200
+ },
201
+ ],
202
+ }
203
+ }
204
+
77
205
  function builtItem(item: UiRegistry['items'][number]) {
78
206
  return {
79
207
  ...item,
@@ -176,6 +304,33 @@ describe('UI release and runner contracts', () => {
176
304
  )
177
305
  })
178
306
 
307
+ test('admits a release theme as one canonical consumer-owned CSS target', async () => {
308
+ const release = await resolveUiRelease('0.3.0-beta.0', themeFetch())
309
+ expect(release.registry.items).toEqual([
310
+ expect.objectContaining({
311
+ name: 'theme-observatory',
312
+ type: 'registry:theme',
313
+ meta: expect.objectContaining({ canonicalAddress: 'theme/observatory' }),
314
+ files: [
315
+ expect.objectContaining({
316
+ type: 'registry:file',
317
+ path: 'registry/themes/observatory.css',
318
+ target: 'components/astrale/theme/observatory.css',
319
+ }),
320
+ ],
321
+ }),
322
+ ])
323
+ })
324
+
325
+ test('lists release themes through the public type filter', async () => {
326
+ const themes = await listUi(
327
+ undefined,
328
+ { type: 'theme', version: '0.3.0-beta.0' },
329
+ { fetcher: themeFetch() },
330
+ )
331
+ expect(themes.map((item) => item.meta.canonicalAddress)).toEqual(['theme/observatory'])
332
+ })
333
+
179
334
  test('constructs exact on-demand commands for every supported package manager', () => {
180
335
  expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
181
336
  file: 'pnpm',
@@ -724,6 +879,169 @@ describe('UI source operations', () => {
724
879
  expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
725
880
  })
726
881
 
882
+ /** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
883
+ test('installs and activates a released theme while recording owned source', async () => {
884
+ const root = await lockedFixture()
885
+ await writeFile(
886
+ path.join(root, 'components.json'),
887
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
888
+ )
889
+ await writeFile(
890
+ path.join(root, 'src/index.css'),
891
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
892
+ )
893
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
894
+ let invoked = false
895
+ const result = await addUi(
896
+ ['theme/observatory'],
897
+ { project: root, yes: true },
898
+ {
899
+ fetcher: themeFetch(),
900
+ runner: async () => {
901
+ invoked = true
902
+ return { code: 0, stdout: '', stderr: '' }
903
+ },
904
+ },
905
+ )
906
+
907
+ expect(invoked).toBe(false)
908
+ expect(result).toMatchObject({ status: 'installed', items: ['theme/observatory'] })
909
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
910
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
911
+ "@import '../components/astrale/theme/observatory.css';",
912
+ )
913
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
914
+ expect(written.items['theme/observatory']).toMatchObject({
915
+ address: 'theme/observatory',
916
+ sourceDigest: digest(JSON.stringify(builtThemeItem())),
917
+ files: { 'components/astrale/theme/observatory.css': digest(themeCss) },
918
+ })
919
+ expect((await doctorUi(root)).healthy).toBe(true)
920
+ })
921
+
922
+ /** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
923
+ test('installs a playground-exported theme without registry or shadcn and preserves edits', async () => {
924
+ const root = await lockedFixture()
925
+ const source = path.join(root, 'observatory.css')
926
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
927
+ await writeFile(source, themeCss)
928
+ let invoked = false
929
+
930
+ const planned = await addUi(
931
+ ['./observatory.css'],
932
+ { project: root, dryRun: true },
933
+ {
934
+ fetcher: (async () => {
935
+ invoked = true
936
+ throw new Error('must not fetch')
937
+ }) as unknown as typeof fetch,
938
+ runner: async () => {
939
+ invoked = true
940
+ return { code: 1, stdout: '', stderr: '' }
941
+ },
942
+ },
943
+ )
944
+ expect(planned).toMatchObject({
945
+ status: 'planned',
946
+ items: ['theme/observatory'],
947
+ activation: {
948
+ file: 'src/index.css',
949
+ import: "@import '../components/astrale/theme/observatory.css';",
950
+ },
951
+ })
952
+ expect(await Bun.file(target).exists()).toBe(false)
953
+
954
+ await addUi(
955
+ ['./observatory.css'],
956
+ { project: root },
957
+ {
958
+ fetcher: (async () => {
959
+ invoked = true
960
+ throw new Error('must not fetch')
961
+ }) as unknown as typeof fetch,
962
+ runner: async () => {
963
+ invoked = true
964
+ return { code: 1, stdout: '', stderr: '' }
965
+ },
966
+ },
967
+ )
968
+ expect(invoked).toBe(false)
969
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
970
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
971
+ "@import '../components/astrale/theme/observatory.css';",
972
+ )
973
+
974
+ await writeFile(target, 'consumer edit\n')
975
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
976
+ code: 'UI_LOCAL_CHANGES',
977
+ })
978
+ await addUi(['./observatory.css'], { project: root, overwrite: true, yes: true })
979
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
980
+ })
981
+
982
+ test('rejects malformed and symlinked local themes before project mutation', async () => {
983
+ const root = await lockedFixture()
984
+ const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
985
+ await writeFile(path.join(root, 'unsafe.css'), "@import 'https://example.invalid/theme.css';\n")
986
+ await expect(addUi(['./unsafe.css'], { project: root })).rejects.toMatchObject({
987
+ code: 'UI_ITEM_CONFLICT',
988
+ })
989
+
990
+ const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-theme-'))
991
+ temporary.push(outside)
992
+ await writeFile(path.join(outside, 'observatory.css'), themeCss)
993
+ await symlink(path.join(outside, 'observatory.css'), path.join(root, 'linked.css'))
994
+ await expect(addUi(['./linked.css'], { project: root })).rejects.toMatchObject({
995
+ code: 'UI_ITEM_NOT_FOUND',
996
+ })
997
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
998
+ })
999
+
1000
+ test('rejects a symlinked UI lock before a theme can mutate outside the project', async () => {
1001
+ const root = await lockedFixture()
1002
+ const source = path.join(root, 'observatory.css')
1003
+ await writeFile(source, themeCss)
1004
+ const lockPath = path.join(root, 'astrale-ui.lock.json')
1005
+ const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-lock-'))
1006
+ temporary.push(outside)
1007
+ const outsideLock = path.join(outside, 'astrale-ui.lock.json')
1008
+ const lockSource = await readFile(lockPath, 'utf8')
1009
+ await writeFile(outsideLock, lockSource)
1010
+ await rm(lockPath)
1011
+ await symlink(outsideLock, lockPath)
1012
+ const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
1013
+
1014
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
1015
+ code: 'UI_LOCK_INVALID',
1016
+ })
1017
+ expect(await readFile(outsideLock, 'utf8')).toBe(lockSource)
1018
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
1019
+ expect(
1020
+ await Bun.file(path.join(root, 'components/astrale/theme/observatory.css')).exists(),
1021
+ ).toBe(false)
1022
+ })
1023
+
1024
+ test('rolls back a newly copied local theme and activation when the lock commit fails', async () => {
1025
+ const root = await lockedFixture()
1026
+ await writeFile(path.join(root, 'observatory.css'), themeCss)
1027
+ const lockPath = path.join(root, 'astrale-ui.lock.json')
1028
+ const cssPath = path.join(root, 'src/index.css')
1029
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
1030
+ const lockBefore = await readFile(lockPath, 'utf8')
1031
+ const cssBefore = await readFile(cssPath, 'utf8')
1032
+ await chmod(lockPath, 0o444)
1033
+
1034
+ try {
1035
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toBeInstanceOf(Error)
1036
+ } finally {
1037
+ await chmod(lockPath, 0o644)
1038
+ }
1039
+
1040
+ expect(await Bun.file(target).exists()).toBe(false)
1041
+ expect(await readFile(cssPath, 'utf8')).toBe(cssBefore)
1042
+ expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
1043
+ })
1044
+
727
1045
  test('successful add records installed file digests and doctor detects later edits', async () => {
728
1046
  const root = await lockedFixture()
729
1047
  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,118 @@ 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
+ return installThemeCss(slug, source, digest(source), address, project, options)
548
+ }
549
+
550
+ async function installThemeCss(
551
+ slug: string,
552
+ source: string,
553
+ sourceDigest: string,
554
+ sourceLabel: string,
555
+ project: UiProject,
556
+ options: { dryRun?: boolean; overwrite?: boolean },
557
+ ): Promise<Record<string, unknown>> {
558
+ const lock = await readUiLock(project.uiLockPath)
559
+ const canonicalAddress = 'theme/' + slug
560
+ const relativeTarget = 'components/astrale/theme/' + slug + '.css'
561
+ const target = await assertSafePlannedTarget(project, relativeTarget)
562
+ const existing = await readOptional(target)
563
+ const installed = lock.items[canonicalAddress]
564
+ if (existing !== undefined && !installed && !options.overwrite) {
565
+ throw new UiError(
566
+ 'UI_LOCAL_CHANGES',
567
+ 'Theme target already exists outside the Astrale UI lock: ' + relativeTarget,
568
+ 'Review the file, then repeat with explicit --overwrite --yes.',
569
+ )
570
+ }
571
+ if (installed && !options.overwrite) {
572
+ const expected = installed.files[relativeTarget]
573
+ if (!expected || digest(existing ?? '') !== expected) {
574
+ throw new UiError(
575
+ 'UI_LOCAL_CHANGES',
576
+ 'Installed UI file has local changes: ' + relativeTarget,
577
+ 'Review the file, then repeat add with explicit --overwrite --yes.',
578
+ )
579
+ }
580
+ }
581
+ const statement = themeImport(project, target)
582
+ const result = {
583
+ status: options.dryRun ? 'planned' : 'installed',
584
+ items: [canonicalAddress],
585
+ files: { [canonicalAddress]: { [relativeTarget]: digest(source) } },
586
+ activation: { file: projectRelative(project, project.cssPath), import: statement },
587
+ source: sourceLabel,
588
+ }
589
+ if (options.dryRun) return result
590
+
591
+ const snapshots = new Map<string, string | undefined>()
592
+ for (const mutation of [target, project.cssPath, project.uiLockPath]) {
593
+ snapshots.set(mutation, await readOptional(mutation))
594
+ }
595
+ try {
596
+ await mkdir(path.dirname(target), { recursive: true })
597
+ await writeFile(target, source, 'utf8')
598
+ const css = (await readOptional(project.cssPath)) ?? ''
599
+ await writeFile(project.cssPath, activateTheme(css, statement), 'utf8')
600
+ lock.items[canonicalAddress] = {
601
+ address: canonicalAddress,
602
+ sourceDigest,
603
+ files: { [relativeTarget]: digest(source) },
604
+ }
605
+ await writeJson(project.uiLockPath, lock)
606
+ return result
607
+ } catch (error) {
608
+ for (const [mutation, previous] of snapshots) {
609
+ if (previous === undefined) await rm(mutation, { force: true })
610
+ else await writeFile(mutation, previous, 'utf8')
611
+ }
612
+ throw error
613
+ }
614
+ }
615
+
616
+ async function addReleasedTheme(
617
+ item: UiRegistryItem,
618
+ document: UiRegistryItem,
619
+ project: UiProject,
620
+ options: { dryRun?: boolean; overwrite?: boolean },
621
+ ): Promise<Record<string, unknown>> {
622
+ const slug = item.meta.canonicalAddress.slice('theme/'.length)
623
+ const source = document.files[0]?.content
624
+ if (typeof source !== 'string') {
625
+ throw new UiError('UI_REGISTRY_UNAVAILABLE', 'The released theme has no admitted CSS source.')
626
+ }
627
+ admitLocalThemeCss(source, slug)
628
+ return installThemeCss(
629
+ slug,
630
+ source,
631
+ digest(JSON.stringify(document)),
632
+ item.meta.canonicalAddress,
633
+ project,
634
+ options,
635
+ )
636
+ }
637
+
430
638
  export async function addUi(
431
639
  addresses: string[],
432
640
  options: { project?: string; dryRun?: boolean; overwrite?: boolean; yes?: boolean },
@@ -436,11 +644,7 @@ export async function addUi(
436
644
  throw new UiError('UI_ITEM_NOT_FOUND', 'No UI item was provided.')
437
645
  }
438
646
  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
- )
647
+ await assertSafePlannedTarget(project, UI_LOCK_FILE)
444
648
  if (options.overwrite && !options.yes) {
445
649
  throw new UiError(
446
650
  'UI_LOCAL_CHANGES',
@@ -448,6 +652,34 @@ export async function addUi(
448
652
  'Review the locally edited files, then repeat with both --overwrite and --yes.',
449
653
  )
450
654
  }
655
+ const localThemes = addresses.filter(isLocalThemeAddress)
656
+ if (localThemes.length > 0) {
657
+ if (addresses.length !== 1) {
658
+ throw new UiError(
659
+ 'UI_ITEM_CONFLICT',
660
+ 'Install one local theme at a time and do not mix local files with registry addresses.',
661
+ )
662
+ }
663
+ return addLocalTheme(localThemes[0]!, project, options)
664
+ }
665
+ const { lock, release } = await lockedRelease(project, dependencies.fetcher)
666
+ const items = addresses.map((address) => findItem(release, address))
667
+ const themes = items.filter((item) => item.type === 'registry:theme')
668
+ if (themes.length > 1) {
669
+ throw new UiError('UI_ITEM_CONFLICT', 'Only one theme can be activated per add operation.')
670
+ }
671
+ const itemDocuments = await Promise.all(
672
+ items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)),
673
+ )
674
+ if (themes.length === 1) {
675
+ if (items.length !== 1) {
676
+ throw new UiError(
677
+ 'UI_ITEM_CONFLICT',
678
+ 'Install a released theme separately from patterns and blocks.',
679
+ )
680
+ }
681
+ return addReleasedTheme(themes[0]!, itemDocuments[0]!, project, options)
682
+ }
451
683
  await rejectLocalChanges(project, lock, items, options.overwrite === true)
452
684
  const targets = (
453
685
  await Promise.all(
@@ -469,7 +701,13 @@ export async function addUi(
469
701
  ])
470
702
  const snapshots = new Map<string, string | undefined>()
471
703
  if (!options.dryRun) {
472
- for (const target of [project.packageJsonPath, project.lockPath!, ...targets]) {
704
+ for (const target of [
705
+ project.packageJsonPath,
706
+ project.lockPath!,
707
+ project.cssPath,
708
+ project.uiLockPath,
709
+ ...targets,
710
+ ]) {
473
711
  snapshots.set(target, await readOptional(target))
474
712
  }
475
713
  }
@@ -494,6 +732,13 @@ export async function addUi(
494
732
  }
495
733
  }
496
734
  await pinUiDependency(project, lock.package.version, dependencies.runner ?? defaultUiRunner)
735
+ const theme = themes[0]
736
+ const themeTarget = theme?.files[0]?.target
737
+ if (themeTarget) {
738
+ const target = await safeTarget(project, themeTarget)
739
+ const css = (await readOptional(project.cssPath)) ?? ''
740
+ await writeFile(project.cssPath, activateTheme(css, themeImport(project, target)), 'utf8')
741
+ }
497
742
  }
498
743
  } catch (error) {
499
744
  for (const [target, previous] of snapshots) {
@@ -513,6 +758,15 @@ export async function addUi(
513
758
  })),
514
759
  command: [invocation.file, ...invocation.args],
515
760
  output: result.stdout,
761
+ activation: themes[0]
762
+ ? {
763
+ file: projectRelative(project, project.cssPath),
764
+ import: themeImport(
765
+ project,
766
+ targets.find((target) => target.endsWith('.css'))!,
767
+ ),
768
+ }
769
+ : undefined,
516
770
  }
517
771
  }
518
772
 
@@ -589,6 +843,15 @@ export async function doctorUi(
589
843
  checks.push({ check: 'item:' + item.address + ':' + file, ok: actual === expected })
590
844
  }
591
845
  }
846
+ const themeFiles = Object.values(lock.items)
847
+ .filter((item) => item.address.startsWith('theme/'))
848
+ .flatMap((item) => Object.keys(item.files).filter((file) => file.endsWith('.css')))
849
+ if (themeFiles.length > 0) {
850
+ const active = themeFiles.some((file) =>
851
+ css.includes(themeImport(project, path.join(project.root, file))),
852
+ )
853
+ checks.push({ check: 'theme-active', ok: active })
854
+ }
592
855
  }
593
856
  return { healthy: checks.every((check) => check.ok), checks }
594
857
  }
@@ -601,6 +864,7 @@ export async function applyPreset(
601
864
  throw new UiError('UI_ITEM_NOT_FOUND', 'Unknown preset: ' + preset)
602
865
  }
603
866
  const project = await discoverUiProject(options.project)
867
+ await assertSafePlannedTarget(project, UI_LOCK_FILE)
604
868
  const lock = await readUiLock(project.uiLockPath)
605
869
  const css = (await readOptional(project.cssPath)) ?? ''
606
870
  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 {