@nebutra/theme 0.1.1 → 0.2.1

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { applyLanguage, clearLanguage, getActiveLanguageId } from "../apply-language";
6
+ import { getBuiltInBrandPackage, listBuiltInBrandIds } from "../built-in-packages";
7
+
8
+ afterEach(() => {
9
+ clearLanguage();
10
+ document.getElementById("nebutra-brand-skin")?.remove();
11
+ delete document.documentElement.dataset.brand;
12
+ });
13
+
14
+ describe("getBuiltInBrandPackage", () => {
15
+ it("loads all stress-test fixtures", () => {
16
+ const ids = listBuiltInBrandIds();
17
+ expect(ids).toEqual(
18
+ expect.arrayContaining(["linear", "gsap", "raycast", "vercel", "vanta", "stripe", "notion"]),
19
+ );
20
+ for (const id of ids) {
21
+ const pkg = getBuiltInBrandPackage(id);
22
+ expect(pkg?.id).toBe(id);
23
+ expect(pkg?.semantic.primary).toBeTruthy();
24
+ expect(pkg?.recipe.buttonDefault).toBeTruthy();
25
+ }
26
+ });
27
+ });
28
+
29
+ describe("applyLanguage", () => {
30
+ it("applies built-in language without options.package", () => {
31
+ const entry = applyLanguage("vanta");
32
+ expect(entry?.id).toBe("vanta");
33
+ expect(getActiveLanguageId()).toBe("vanta");
34
+ expect(document.documentElement.dataset.brand).toBe("vanta");
35
+ const style = document.getElementById("nebutra-brand-skin");
36
+ expect(style?.textContent).toMatch(/data-brand="vanta"/);
37
+ // light pack: global inject must not force .dark selector
38
+ expect(style?.textContent).not.toMatch(/:root,\n\.dark,/);
39
+ });
40
+
41
+ it.each([
42
+ "linear",
43
+ "vercel",
44
+ ] as const)("applies dual-mode language %s with separate light/dark CSS blocks", (id) => {
45
+ applyLanguage(id);
46
+ const style = document.getElementById("nebutra-brand-skin");
47
+ const css = style?.textContent ?? "";
48
+ expect(css).toMatch(/dualMode=true/);
49
+ // dual-mode: light under :root / html[data-brand]; dark under .dark / html.dark[data-brand]
50
+ expect(css).toMatch(new RegExp(`:root,\\nhtml\\[data-brand="${id}"\\]`));
51
+ expect(css).toMatch(new RegExp(`\\.dark,\\nhtml\\.dark\\[data-brand="${id}"\\]`));
52
+ // must not glue light palette onto bare .dark (single-mode darkDefault hack)
53
+ expect(css).not.toMatch(new RegExp(`:root,\\n\\.dark,\\nhtml\\[data-brand="${id}"\\]`));
54
+ });
55
+
56
+ it("clears to factory", () => {
57
+ applyLanguage("stripe");
58
+ clearLanguage();
59
+ expect(getActiveLanguageId()).toBe("factory");
60
+ expect(document.documentElement.dataset.brand).toBeUndefined();
61
+ expect(document.getElementById("nebutra-brand-skin")).toBeNull();
62
+ });
63
+
64
+ it("applyLanguage('factory') clears brand", () => {
65
+ applyLanguage("gsap");
66
+ applyLanguage("factory");
67
+ expect(getActiveLanguageId()).toBe("factory");
68
+ expect(document.getElementById("nebutra-brand-skin")).toBeNull();
69
+ });
70
+ });
@@ -0,0 +1,100 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { listBuiltInBrandIds } from "../built-in-packages";
6
+ import {
7
+ DEFAULT_LANGUAGE,
8
+ getLanguageById,
9
+ isLanguageId,
10
+ LANGUAGE_IDS,
11
+ LANGUAGE_REGISTRY,
12
+ listSkinLanguages,
13
+ } from "../languages";
14
+
15
+ const tokensRoot = resolve(fileURLToPath(new URL("../../../tokens", import.meta.url)));
16
+ const brandsRoot = resolve(tokensRoot, "brands");
17
+
18
+ describe("@nebutra/theme design-language catalog", () => {
19
+ it("lists factory + stress-test fixtures as languages", () => {
20
+ expect(DEFAULT_LANGUAGE).toBe("factory");
21
+ expect(LANGUAGE_IDS).toEqual(
22
+ expect.arrayContaining([
23
+ "factory",
24
+ "linear",
25
+ "gsap",
26
+ "raycast",
27
+ "vercel",
28
+ "vanta",
29
+ "stripe",
30
+ "notion",
31
+ ]),
32
+ );
33
+ expect(LANGUAGE_IDS[0]).toBe("factory");
34
+ });
35
+
36
+ it("documents contract proofs per skin language", () => {
37
+ for (const lang of listSkinLanguages()) {
38
+ expect(lang.proves.length).toBeGreaterThan(0);
39
+ expect(lang.brandPath).toMatch(/^brands\/.+\/brand\.json$/);
40
+ expect(lang.skinPath).toMatch(/^skins\/.+\.css$/);
41
+ expect(lang.compatibility.brandPackage).toBe(true);
42
+ expect(lang.compatibility.dataBrand).toBe(true);
43
+ }
44
+ });
45
+
46
+ it("keeps brand.json + published skins/<id>.css for each non-factory language", () => {
47
+ for (const lang of listSkinLanguages()) {
48
+ expect(lang.brandPath).toBeTruthy();
49
+ expect(lang.skinPath).toBeTruthy();
50
+ if (!lang.brandPath || !lang.skinPath) continue;
51
+ expect(existsSync(resolve(tokensRoot, lang.brandPath))).toBe(true);
52
+ expect(existsSync(resolve(tokensRoot, lang.skinPath))).toBe(true);
53
+ // No dual-write brands/<id>/skin.css
54
+ expect(existsSync(resolve(tokensRoot, "brands", lang.id, "skin.css"))).toBe(false);
55
+ }
56
+ });
57
+
58
+ it("supports lookup without hardcoding fixture lists in apps", () => {
59
+ expect(getLanguageById("vanta")?.proves.some((p) => /brand-mark|action/i.test(p))).toBe(true);
60
+ expect(getLanguageById("missing")).toBeUndefined();
61
+ expect(isLanguageId("linear")).toBe(true);
62
+ expect(isLanguageId("nebutra")).toBe(false);
63
+ });
64
+
65
+ it("describes the package as design-language swap not mood tint", () => {
66
+ expect(LANGUAGE_REGISTRY.description).toMatch(/Brand Package/i);
67
+ expect(LANGUAGE_REGISTRY.description).toMatch(/roles|recipe/i);
68
+ });
69
+
70
+ it("keeps languages.json, brands/ dirs, and built-in packages in sync", () => {
71
+ const diskBrandIds = readdirSync(brandsRoot, { withFileTypes: true })
72
+ .filter((d) => d.isDirectory())
73
+ .map((d) => d.name)
74
+ .sort();
75
+ const catalogSkinIds = listSkinLanguages()
76
+ .map((l) => l.id)
77
+ .sort();
78
+ const builtInIds = listBuiltInBrandIds().sort();
79
+
80
+ expect(catalogSkinIds).toEqual(diskBrandIds);
81
+ expect(builtInIds).toEqual(diskBrandIds);
82
+
83
+ for (const id of diskBrandIds) {
84
+ const lang = getLanguageById(id);
85
+ expect(lang?.brandPath).toBe(`brands/${id}/brand.json`);
86
+ expect(lang?.skinPath).toBe(`skins/${id}.css`);
87
+ expect(existsSync(resolve(brandsRoot, id, "brand.json"))).toBe(true);
88
+ }
89
+ });
90
+
91
+ it("derives catalog names/darkDefault from brand.json (sync-languages SSOT)", () => {
92
+ for (const lang of listSkinLanguages()) {
93
+ const brand = JSON.parse(
94
+ readFileSync(resolve(tokensRoot, lang.brandPath as string), "utf8"),
95
+ ) as { name: string; darkDefault: boolean };
96
+ expect(lang.name).toBe(brand.name);
97
+ expect(lang.darkDefault).toBe(brand.darkDefault);
98
+ }
99
+ });
100
+ });
@@ -1,49 +1,27 @@
1
- import { readFile } from "node:fs/promises";
1
+ /**
2
+ * Mood catalog is deleted — only design languages remain.
3
+ * Empty registry shim was removed; assert LANGUAGE_REGISTRY is the sole catalog.
4
+ */
2
5
  import { describe, expect, it } from "vitest";
3
- import { getThemeById, THEME_IDS, THEME_REGISTRY } from "../index";
6
+ import { DEFAULT_LANGUAGE, isLanguageId, LANGUAGE_IDS, LANGUAGE_REGISTRY } from "../languages";
4
7
 
5
- function getTokenAtPath(tokens: Record<string, unknown>, tokenPath: string): unknown {
6
- return tokenPath.split(".").reduce<unknown>((node, key) => {
7
- if (!node || typeof node !== "object") return undefined;
8
- return (node as Record<string, unknown>)[key];
9
- }, tokens);
10
- }
11
-
12
- describe("@nebutra/theme registry contract", () => {
13
- it("exports deterministic theme ids from the registry", () => {
14
- expect(THEME_IDS).toEqual(["neon", "gradient", "dark-dense", "minimal", "vibrant", "ocean"]);
8
+ describe("@nebutra/theme — no mood registry", () => {
9
+ it("ships design languages only (factory + skins)", () => {
10
+ expect(DEFAULT_LANGUAGE).toBe("factory");
11
+ expect(LANGUAGE_IDS).toContain("factory");
12
+ expect(LANGUAGE_IDS.length).toBeGreaterThan(1);
13
+ expect(LANGUAGE_REGISTRY.languages.length).toBe(LANGUAGE_IDS.length);
15
14
  });
16
15
 
17
- it("exposes install and governance metadata for each built-in theme", () => {
18
- for (const theme of THEME_REGISTRY.themes) {
19
- expect(theme.id).toMatch(/^[a-z0-9-]+$/u);
20
- expect(theme.tokenPath).toBe(`tokens/themes/${theme.id}.json`);
21
- expect(theme.install.command).toContain(`nebutra theme add ${theme.id}`);
22
- expect(theme.governance.requiredTokens.length).toBeGreaterThan(0);
23
- expect(theme.governance.wcag).toBe("AA");
24
- expect(theme.compatibility.tailwind).toBe("4");
25
- expect(theme.compatibility.figmaVariables).toBe(true);
26
- }
16
+ it("rejects legacy oklch mood ids", () => {
17
+ expect(isLanguageId("crimson-light-vivid")).toBe(false);
18
+ expect(isLanguageId("vibrant")).toBe(false);
19
+ expect(isLanguageId("azure-dark-muted")).toBe(false);
27
20
  });
28
21
 
29
- it("keeps registry token paths and required tokens backed by DTCG files", async () => {
30
- for (const theme of THEME_REGISTRY.themes) {
31
- const tokenFile = new URL(`../../../design-tokens/${theme.tokenPath}`, import.meta.url);
32
- const tokens = JSON.parse(await readFile(tokenFile, "utf8")) as Record<string, unknown>;
33
-
34
- expect(tokens.theme).toBe(theme.id);
35
-
36
- for (const requiredToken of theme.governance.requiredTokens) {
37
- expect(getTokenAtPath(tokens, requiredToken)).toMatchObject({
38
- $type: expect.any(String),
39
- $value: expect.any(String),
40
- });
41
- }
22
+ it("accepts design language ids", () => {
23
+ for (const id of LANGUAGE_IDS) {
24
+ expect(isLanguageId(id)).toBe(true);
42
25
  }
43
26
  });
44
-
45
- it("supports lookup by id without callers hardcoding theme details", () => {
46
- expect(getThemeById("neon")?.mood).toContain("AI");
47
- expect(getThemeById("missing")).toBeUndefined();
48
- });
49
27
  });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Runtime design-language switch — Create Center / SaaS global theme swap.
3
+ *
4
+ * Light/dark remains class="dark" on @nebutra/tokens ThemeProvider.
5
+ * Design language is html[data-brand] + Brand Package CSS inject.
6
+ *
7
+ * Built-in languages load Brand Packages automatically; pass `package` only for
8
+ * custom / compile-time packages (Create Center preview).
9
+ */
10
+
11
+ import {
12
+ type ApplyBrandOptions,
13
+ applyBrandPackage,
14
+ type BrandPackage,
15
+ clearBrand,
16
+ getActiveBrandId,
17
+ normalizeBrandPackage,
18
+ validateBrandPackage,
19
+ } from "@nebutra/tokens/brand-package";
20
+ import { getBuiltInBrandPackage } from "./built-in-packages";
21
+ import { type DesignLanguageEntry, getLanguageById } from "./languages";
22
+
23
+ export type { ApplyBrandOptions };
24
+
25
+ export interface ApplyLanguageOptions extends ApplyBrandOptions {
26
+ /**
27
+ * Preloaded Brand Package (Create Center compile result or override).
28
+ * When omitted, built-in languages resolve from brands/<id>/brand.json.
29
+ */
30
+ package?: BrandPackage;
31
+ }
32
+
33
+ /**
34
+ * Apply a design language by id.
35
+ * - `factory` / unknown without package → clearBrand (tokens SSOT)
36
+ * - built-in id without package → load shipped Brand Package
37
+ * - with `package` → applyBrandPackage (full carrier swap)
38
+ */
39
+ export function applyLanguage(
40
+ languageId: string,
41
+ options: ApplyLanguageOptions = {},
42
+ ): DesignLanguageEntry | null {
43
+ const entry = getLanguageById(languageId);
44
+
45
+ if (!entry || entry.id === "factory" || entry.brandPath == null) {
46
+ if (options.package) {
47
+ const v = validateBrandPackage(options.package);
48
+ if (!v.ok) {
49
+ throw new Error(`Invalid Brand Package: ${v.errors.join("; ")}`);
50
+ }
51
+ applyBrandPackage(normalizeBrandPackage(options.package), options);
52
+ return entry ?? null;
53
+ }
54
+ clearBrand(options);
55
+ return entry ?? getLanguageById("factory") ?? null;
56
+ }
57
+
58
+ const pkg = options.package ?? getBuiltInBrandPackage(languageId);
59
+ if (!pkg) {
60
+ throw new Error(
61
+ `Language "${languageId}" has no built-in Brand Package and no options.package. ` +
62
+ `Load @nebutra/tokens/${entry.brandPath} or compileReferoTokens(), then ` +
63
+ `applyLanguage("${languageId}", { package }). ` +
64
+ `Or import the single skin CSS: ${entry.install.cssImport ?? "(none)"}.`,
65
+ );
66
+ }
67
+
68
+ const v = validateBrandPackage(pkg);
69
+ if (!v.ok) {
70
+ throw new Error(`Invalid Brand Package for ${languageId}: ${v.errors.join("; ")}`);
71
+ }
72
+ const normalized = normalizeBrandPackage({
73
+ ...pkg,
74
+ id: languageId,
75
+ });
76
+ applyBrandPackage(normalized, options);
77
+ return entry;
78
+ }
79
+
80
+ /** Restore factory product chrome (clear data-brand + injected skin). */
81
+ export function clearLanguage(options: ApplyBrandOptions = {}): void {
82
+ clearBrand(options);
83
+ }
84
+
85
+ export function getActiveLanguageId(doc?: Document): string {
86
+ return getActiveBrandId(doc) ?? "factory";
87
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tooling / Create Center surface — compile + iframe preview + emit.
3
+ * Prefer `@nebutra/tokens/brand-package` in new code; this re-export keeps
4
+ * historical `@nebutra/theme` compile entry points without bloating the root.
5
+ */
6
+
7
+ export {
8
+ applyBrandCss,
9
+ applyBrandPackage,
10
+ applyBrandToIframe,
11
+ BRAND_STORAGE_KEY,
12
+ type BrandPackage,
13
+ type BrandPackageInput,
14
+ type BrandRecipe,
15
+ type BrandRecipeInput,
16
+ type CompileResult,
17
+ clearBrand,
18
+ compileReferoTokens,
19
+ type EmitBrandCssMode,
20
+ type EmitBrandCssOptions,
21
+ emitBrandCss,
22
+ emitGlobalSkinSelector,
23
+ getActiveBrandId,
24
+ normalizeBrandPackage,
25
+ restorePersistedBrand,
26
+ useBrand,
27
+ useBrandIframePreview,
28
+ type ValidationResult,
29
+ validateBrandPackage,
30
+ } from "@nebutra/tokens/brand-package";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * AUTO-GENERATED by scripts/sync-languages.mjs — DO NOT EDIT.
3
+ * Built-in Brand Packages for applyLanguage(id) without manual JSON import.
4
+ * Source: packages/design/tokens/brands/<id>/brand.json
5
+ */
6
+
7
+ import { type BrandPackage, normalizeBrandPackage } from "@nebutra/tokens/brand-package";
8
+ import gsap from "@nebutra/tokens/brands/gsap/brand.json" with { type: "json" };
9
+ import linear from "@nebutra/tokens/brands/linear/brand.json" with { type: "json" };
10
+ import notion from "@nebutra/tokens/brands/notion/brand.json" with { type: "json" };
11
+ import raycast from "@nebutra/tokens/brands/raycast/brand.json" with { type: "json" };
12
+ import stripe from "@nebutra/tokens/brands/stripe/brand.json" with { type: "json" };
13
+ import vanta from "@nebutra/tokens/brands/vanta/brand.json" with { type: "json" };
14
+ import vercel from "@nebutra/tokens/brands/vercel/brand.json" with { type: "json" };
15
+
16
+ const RAW_BUILT_IN: Record<string, BrandPackage> = {
17
+ gsap: gsap as BrandPackage,
18
+ linear: linear as BrandPackage,
19
+ notion: notion as BrandPackage,
20
+ raycast: raycast as BrandPackage,
21
+ stripe: stripe as BrandPackage,
22
+ vanta: vanta as BrandPackage,
23
+ vercel: vercel as BrandPackage,
24
+ };
25
+
26
+ /** Normalized package for a shipped design-language id, or undefined. */
27
+ export function getBuiltInBrandPackage(id: string): BrandPackage | undefined {
28
+ const raw = RAW_BUILT_IN[id];
29
+ if (!raw) return undefined;
30
+ return normalizeBrandPackage({ ...raw, id });
31
+ }
32
+
33
+ export function listBuiltInBrandIds(): string[] {
34
+ return Object.keys(RAW_BUILT_IN);
35
+ }
36
+
37
+ export function hasBuiltInBrandPackage(id: string): boolean {
38
+ return id in RAW_BUILT_IN;
39
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Built-in Brand Packages for design-language ids.
3
+ *
4
+ * Implementation is AUTO-GENERATED — see built-in-packages.generated.ts
5
+ * (pnpm --filter @nebutra/theme sync:languages).
6
+ *
7
+ * SSOT remains packages/design/tokens/brands/<id>/brand.json.
8
+ */
9
+
10
+ export {
11
+ getBuiltInBrandPackage,
12
+ hasBuiltInBrandPackage,
13
+ listBuiltInBrandIds,
14
+ } from "./built-in-packages.generated";
package/src/client.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Explicit client entry — same lean surface as package root.
3
+ * Use: `import { applyLanguage } from "@nebutra/theme/client"`
4
+ *
5
+ * Light/dark: `import { ThemeProvider, useTheme } from "@nebutra/tokens"`
6
+ */
7
+
8
+ export {
9
+ applyBrandPackage,
10
+ type BrandPackage,
11
+ clearBrand,
12
+ normalizeBrandPackage,
13
+ } from "@nebutra/tokens/brand-package";
14
+ export {
15
+ type ApplyLanguageOptions,
16
+ applyLanguage,
17
+ clearLanguage,
18
+ getActiveLanguageId,
19
+ } from "./apply-language";
20
+ export {
21
+ getBuiltInBrandPackage,
22
+ hasBuiltInBrandPackage,
23
+ listBuiltInBrandIds,
24
+ } from "./built-in-packages";
25
+ export {
26
+ BUILT_IN_LANGUAGE_IDS,
27
+ DEFAULT_LANGUAGE,
28
+ type DesignLanguageEntry,
29
+ type DesignLanguageRegistry,
30
+ getLanguageById,
31
+ isLanguageId,
32
+ LANGUAGE_IDS,
33
+ LANGUAGE_REGISTRY,
34
+ listSkinLanguages,
35
+ } from "./languages";
package/src/index.ts CHANGED
@@ -1,20 +1,53 @@
1
- // packages/design/theme/src/index.ts
2
- // Re-export next-themes for convenience — all apps import from @nebutra/theme.
3
- // Theme catalog metadata lives in ./registry so CLI/preset/docs/Figma sync all
4
- // discover the same built-in theme contract.
5
-
6
- export type { ThemeProviderProps } from "next-themes";
7
- export { ThemeProvider, useTheme } from "next-themes";
1
+ /**
2
+ * @nebutra/theme — Design-language switch surface (client-safe root)
3
+ *
4
+ * Root exports are intentionally lean for app bundles:
5
+ * applyLanguage / LANGUAGE_REGISTRY / built-in packages
6
+ *
7
+ * Light/dark ThemeProvider: import from `@nebutra/tokens` only (not here).
8
+ *
9
+ * Compile & Create Center tooling:
10
+ * @nebutra/tokens/brand-package (preferred)
11
+ * @nebutra/theme/brand-package (re-export alias)
12
+ *
13
+ * Layers:
14
+ * @nebutra/tokens product chrome SSOT (styles.css + recipe.css + ThemeProvider)
15
+ * @nebutra/theme LANGUAGE_REGISTRY + applyLanguage + skins.css
16
+ */
8
17
 
18
+ // Re-export apply helpers used with applyLanguage({ package })
19
+ export {
20
+ type ApplyBrandOptions,
21
+ applyBrandPackage,
22
+ type BrandPackage,
23
+ clearBrand,
24
+ getActiveBrandId,
25
+ normalizeBrandPackage,
26
+ type ValidationResult,
27
+ validateBrandPackage,
28
+ } from "@nebutra/tokens/brand-package";
29
+ export {
30
+ type ApplyLanguageOptions,
31
+ applyLanguage,
32
+ clearLanguage,
33
+ getActiveLanguageId,
34
+ } from "./apply-language";
35
+ export {
36
+ getBuiltInBrandPackage,
37
+ hasBuiltInBrandPackage,
38
+ listBuiltInBrandIds,
39
+ } from "./built-in-packages";
9
40
  export {
10
- BUILT_IN_THEME_IDS,
11
- DEFAULT_THEME,
12
- getThemeById,
13
- isBuiltInThemeId,
14
- isThemeId,
15
- THEME_IDS,
16
- THEME_REGISTRY,
17
- type ThemeId,
18
- type ThemeRegistry,
19
- type ThemeRegistryEntry,
20
- } from "./registry";
41
+ BUILT_IN_LANGUAGE_IDS,
42
+ DEFAULT_LANGUAGE,
43
+ type DesignLanguageCompatibility,
44
+ type DesignLanguageEntry,
45
+ type DesignLanguageInstall,
46
+ type DesignLanguageRegistry,
47
+ getLanguageById,
48
+ isLanguageId,
49
+ LANGUAGE_IDS,
50
+ LANGUAGE_REGISTRY,
51
+ type LanguageId,
52
+ listSkinLanguages,
53
+ } from "./languages";