@dreamtree-org/twreact-ui 1.3.2 → 1.3.3

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.
Files changed (37) hide show
  1. package/DESIGN.md +2 -2
  2. package/README.md +11 -5
  3. package/ai-skills/dreamtree-ui.md +3 -1
  4. package/bin/cli.mjs +72 -4
  5. package/dist/ai-schema.json +1 -1
  6. package/dist/charts/index.js +75 -47
  7. package/dist/components/charts/CartesianChart.d.ts.map +1 -1
  8. package/dist/components/charts/DonutChart.d.ts.map +1 -1
  9. package/dist/components/charts/Sparkline.d.ts.map +1 -1
  10. package/dist/components/charts/chartTheme.d.ts +13 -3
  11. package/dist/components/charts/chartTheme.d.ts.map +1 -1
  12. package/dist/components/core/Table/MobileCard.d.ts.map +1 -1
  13. package/dist/components/core/Table/MobileFilters.d.ts.map +1 -1
  14. package/dist/components/core/Table/Table.d.ts.map +1 -1
  15. package/dist/components/utility/Stepper.d.ts.map +1 -1
  16. package/dist/esm/components/blocks/StatCard.js +1 -1
  17. package/dist/esm/components/charts/CartesianChart.js +7 -2
  18. package/dist/esm/components/charts/DonutChart.js +4 -1
  19. package/dist/esm/components/charts/Sparkline.js +4 -1
  20. package/dist/esm/components/charts/chartTheme.js +65 -48
  21. package/dist/esm/components/core/Select.js +2 -2
  22. package/dist/esm/components/core/Table/MobileCard.js +5 -2
  23. package/dist/esm/components/core/Table/MobileFilters.js +10 -3
  24. package/dist/esm/components/core/Table/Table.js +10 -7
  25. package/dist/esm/components/utility/Stepper.js +6 -4
  26. package/dist/index.css +1 -1
  27. package/dist/index.esm.css +1 -1
  28. package/dist/index.esm.js +33 -18
  29. package/dist/index.js +33 -18
  30. package/dist/tokens/brands/practicepro.json +1 -0
  31. package/dist/tokens/brands/studio.json +1 -0
  32. package/mcp/catalog.snapshot.json +13 -13
  33. package/package.json +2 -1
  34. package/tokens/brand.schema.json +612 -0
  35. package/tokens/brands/practicepro.json +1 -0
  36. package/tokens/brands/studio.json +1 -0
  37. package/tokens/lib.cjs +131 -7
package/tokens/lib.cjs CHANGED
@@ -17,12 +17,76 @@ function listBrands() {
17
17
  return fs.readdirSync(BRANDS_DIR).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).sort();
18
18
  }
19
19
 
20
- function loadBrand(name) {
21
- const file = path.join(BRANDS_DIR, `${name}.json`);
20
+ const isBrandPath = (s) => /[\\/]/.test(s) || s.endsWith('.json');
21
+
22
+ /**
23
+ * Load a brand. A product keeps its brand in its own repo as dreamtree.brand.json and passes the path;
24
+ * a bare name reads the brands shipped here (only "default" is meant to stay).
25
+ */
26
+ function loadBrand(nameOrPath) {
27
+ const file = isBrandPath(nameOrPath) ? path.resolve(nameOrPath) : path.join(BRANDS_DIR, `${nameOrPath}.json`);
22
28
  if (!fs.existsSync(file)) {
23
- throw new Error(`Unknown brand "${name}". Available brands: ${listBrands().join(', ') || '(none)'}. Add tokens/brands/${name}.json to create one.`);
29
+ if (isBrandPath(nameOrPath)) throw new Error(`No brand file at ${file}. Create one with: npx @dreamtree-org/twreact-ui brand init`);
30
+ throw new Error(`Unknown brand "${nameOrPath}". A product's brand lives in its repo: pass the path to its dreamtree.brand.json. Shipped brands: ${listBrands().join(', ') || '(none)'}.`);
31
+ }
32
+ let brand;
33
+ try {
34
+ brand = JSON.parse(fs.readFileSync(file, 'utf8'));
35
+ } catch (err) {
36
+ throw new Error(`${file} is not valid JSON (${err.message}).`);
24
37
  }
25
- return JSON.parse(fs.readFileSync(file, 'utf8'));
38
+ const errors = validateBrand(brand);
39
+ if (errors.length) throw new Error(`${file} is not a valid brand file:\n- ${errors.join('\n- ')}`);
40
+ return brand;
41
+ }
42
+
43
+ /** The brand's id (new files: "id"; the shipped files use "name"). */
44
+ const brandId = (brand) => brand.id || brand.name;
45
+ /** The brand's display name (new files: "name"; the shipped files use "label"). */
46
+ const brandLabel = (brand) => (brand.id ? brand.name : brand.label) || brandId(brand);
47
+
48
+ const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
49
+ const BRAND_KEYS = ['$schema', '$description', '$deprecated', 'id', 'name', 'label', 'logo', 'color', 'role', 'font', 'marketing'];
50
+
51
+ /** Every problem with a brand file, each phrased as the fix. Empty when the file is valid. */
52
+ function validateBrand(brand, tokens = loadTokens()) {
53
+ const errors = [];
54
+ if (!brand || typeof brand !== 'object' || Array.isArray(brand)) return ['The brand file must be a JSON object.'];
55
+ const id = brandId(brand);
56
+ if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(id)) errors.push('Set "id" to a lowercase slug, e.g. "practicepro".');
57
+ for (const key of Object.keys(brand)) if (!BRAND_KEYS.includes(key)) errors.push(`Remove "${key}": allowed keys are ${BRAND_KEYS.join(', ')}.`);
58
+ const knownScales = Object.keys(scales(tokens));
59
+ for (const [scale, steps] of Object.entries(brand.color || {})) {
60
+ if (!knownScales.includes(scale)) {
61
+ errors.push(`color.${scale} is not a scale. Use one of: ${knownScales.join(', ')}.`);
62
+ continue;
63
+ }
64
+ for (const [step, hex] of Object.entries(steps || {})) {
65
+ if (!SCALE_STEPS.includes(step)) errors.push(`color.${scale}.${step} is not a step. Steps are ${SCALE_STEPS.join(', ')}.`);
66
+ else if (typeof hex !== 'string' || !HEX_RE.test(hex)) errors.push(`color.${scale}.${step} must be a hex colour like "#0d7377", not ${JSON.stringify(hex)}.`);
67
+ }
68
+ }
69
+ const knownRoles = Object.keys(roles(tokens));
70
+ for (const [name, value] of Object.entries(brand.role || {})) {
71
+ if (!knownRoles.includes(name)) {
72
+ errors.push(`role.${name} is not a role. Use one of: ${knownRoles.join(', ')}.`);
73
+ continue;
74
+ }
75
+ const values = typeof value === 'string' ? [value] : value && typeof value === 'object' ? [value.light, value.dark].filter((v) => v !== undefined) : [value];
76
+ for (const v of values) if (typeof v !== 'string' || !HEX_RE.test(v)) errors.push(`role.${name} must be a hex colour or { "light": hex, "dark": hex }, not ${JSON.stringify(value)}.`);
77
+ }
78
+ if (brand.font !== undefined) {
79
+ const sans = brand.font && brand.font.sans;
80
+ if (!Array.isArray(sans) || !sans.length || !sans.every((f) => typeof f === 'string')) errors.push('font.sans must be a list of font families, e.g. ["Inter", "system-ui", "sans-serif"].');
81
+ }
82
+ for (const [key, v] of Object.entries(brand.marketing || {})) {
83
+ if (!['primary', 'accent', 'panel'].includes(key)) errors.push(`marketing.${key} is not used. Allowed: primary, accent, panel.`);
84
+ else if (typeof v !== 'string' || !HEX_RE.test(v)) errors.push(`marketing.${key} must be a hex colour.`);
85
+ }
86
+ for (const [key, v] of Object.entries(brand.logo || {})) {
87
+ if (typeof v !== 'string') errors.push(`logo.${key} must be a path or URL string.`);
88
+ }
89
+ return errors;
26
90
  }
27
91
 
28
92
  function hexToChannels(hex) {
@@ -63,14 +127,14 @@ function applyBrand(tokens, brand) {
63
127
  if (!brand) return tokens;
64
128
  const next = JSON.parse(JSON.stringify(tokens));
65
129
  for (const [scale, steps] of Object.entries(brand.color || {})) {
66
- if (!next.color[scale]) throw new Error(`Brand "${brand.name}" sets unknown colour scale "${scale}". Known: ${Object.keys(scales(tokens)).join(', ')}.`);
130
+ if (!next.color[scale]) throw new Error(`Brand "${brandId(brand)}" sets unknown colour scale "${scale}". Known: ${Object.keys(scales(tokens)).join(', ')}.`);
67
131
  for (const [step, hex] of Object.entries(steps)) {
68
- if (!SCALE_STEPS.includes(step)) throw new Error(`Brand "${brand.name}" sets ${scale}.${step}; steps are ${SCALE_STEPS.join(', ')}.`);
132
+ if (!SCALE_STEPS.includes(step)) throw new Error(`Brand "${brandId(brand)}" sets ${scale}.${step}; steps are ${SCALE_STEPS.join(', ')}.`);
69
133
  next.color[scale][step] = { $value: hex };
70
134
  }
71
135
  }
72
136
  for (const [name, value] of Object.entries(brand.role || {})) {
73
- if (!next.role[name]) throw new Error(`Brand "${brand.name}" sets unknown role "${name}". Known: ${Object.keys(roles(tokens)).join(', ')}.`);
137
+ if (!next.role[name]) throw new Error(`Brand "${brandId(brand)}" sets unknown role "${name}". Known: ${Object.keys(roles(tokens)).join(', ')}.`);
74
138
  // A plain string overrides light only; dark keeps the shared value unless {light, dark} is given.
75
139
  const current = next.role[name];
76
140
  const light = typeof value === 'string' ? value : value.light || current.$value;
@@ -81,6 +145,61 @@ function applyBrand(tokens, brand) {
81
145
  return next;
82
146
  }
83
147
 
148
+ /** JSON Schema for dreamtree.brand.json, derived from the tokens (shipped as tokens/brand.schema.json). */
149
+ function brandSchema(tokens = loadTokens()) {
150
+ const hex = { type: 'string', pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$' };
151
+ const scale = { type: 'object', additionalProperties: false, properties: Object.fromEntries(SCALE_STEPS.map((s) => [s, hex])) };
152
+ const roleValue = { oneOf: [hex, { type: 'object', additionalProperties: false, properties: { light: hex, dark: hex } }] };
153
+ return {
154
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
155
+ $id: 'https://unpkg.com/@dreamtree-org/twreact-ui/tokens/brand.schema.json',
156
+ title: 'Dreamtree brand (dreamtree.brand.json)',
157
+ description: "A product's look, kept in the product's own repo. Build its CSS with `npx @dreamtree-org/twreact-ui brand build`.",
158
+ type: 'object',
159
+ additionalProperties: false,
160
+ required: ['id'],
161
+ properties: {
162
+ $schema: { type: 'string' },
163
+ $description: { type: 'string' },
164
+ $deprecated: { type: 'string' },
165
+ id: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$', description: 'Lowercase slug, e.g. "practicepro".' },
166
+ name: { type: 'string', description: 'Display name, e.g. "PracticePro".' },
167
+ label: { type: 'string', description: 'Legacy display name (brands shipped in twreact-ui).' },
168
+ logo: { type: 'object', additionalProperties: { type: 'string' }, description: 'Paths or URLs, e.g. { "mark": "public/brand/mark.svg" }.' },
169
+ color: { type: 'object', additionalProperties: false, properties: Object.fromEntries(Object.keys(scales(tokens)).map((s) => [s, scale])) },
170
+ role: { type: 'object', additionalProperties: false, properties: Object.fromEntries(Object.keys(roles(tokens)).map((r) => [r, roleValue])) },
171
+ font: { type: 'object', additionalProperties: false, properties: { sans: { type: 'array', items: { type: 'string' }, minItems: 1 } } },
172
+ marketing: {
173
+ type: 'object',
174
+ additionalProperties: false,
175
+ description: 'Landing and sign-in pages (landing-kit). Defaults: primary = color.primary.600, accent = color.primary.300, panel = role.shell.',
176
+ properties: { primary: hex, accent: hex, panel: hex },
177
+ },
178
+ },
179
+ };
180
+ }
181
+
182
+ const block = (selector, decls) =>
183
+ `${selector} {\n${Object.entries(decls).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}\n`;
184
+
185
+ /**
186
+ * CSS for only the variables a brand overrides, light and dark. Load it after twreact-ui's CSS.
187
+ * `scope` limits it to one part of the page (e.g. ".studio-ui") instead of :root.
188
+ */
189
+ function brandCss(tokens, brand, { scope } = {}) {
190
+ const base = cssVariables(tokens);
191
+ const branded = cssVariables(applyBrand(tokens, brand));
192
+ const pick = (a, b) => Object.fromEntries(Object.entries(b).filter(([k, v]) => a[k] !== v));
193
+ const light = pick(base.light, branded.light);
194
+ const dark = pick(base.dark, branded.dark);
195
+ const lightSel = scope || ':root';
196
+ const darkSel = scope ? DARK_SELECTOR.split(',').map((s) => `${s.trim()} ${scope}`).join(', ') : DARK_SELECTOR;
197
+ let css = `/* Brand "${brandId(brand)}": only the variables it overrides. Load after twreact-ui's CSS. */\n`;
198
+ if (Object.keys(light).length) css += block(lightSel, light);
199
+ if (Object.keys(dark).length) css += '\n' + block(darkSel, dark);
200
+ return css;
201
+ }
202
+
84
203
  const varName = (name, step) => (step ? `--dt-${name}-${step}` : `--dt-${name}`);
85
204
  const colorRef = (name, step) => `rgb(var(${varName(name, step)}) / <alpha-value>)`;
86
205
 
@@ -118,6 +237,11 @@ module.exports = {
118
237
  loadTokens,
119
238
  loadBrand,
120
239
  listBrands,
240
+ validateBrand,
241
+ brandSchema,
242
+ brandId,
243
+ brandLabel,
244
+ brandCss,
121
245
  applyBrand,
122
246
  hexToChannels,
123
247
  scales,