@rojaostudio/ds-core 1.1.0-next.7 → 1.1.0-next.9

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/LICENSE CHANGED
@@ -19,3 +19,14 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---
24
+
25
+ Arquivos gerados
26
+
27
+ Os arquivos que esta ferramenta escreve no seu projeto (o tema CSS e o bloco
28
+ entre os marcadores <!-- rojao-ds:start --> e <!-- rojao-ds:end --> em
29
+ CLAUDE.md, AGENTS.md ou .cursorrules) são de quem usa a ferramenta. Você pode
30
+ usá-los, alterá-los e distribuí-los como quiser, sem obrigação de manter aviso
31
+ de licença ou atribuição. A licença MIT acima vale para o código da ferramenta,
32
+ não para o que ela gera.
@@ -675,12 +675,114 @@ function resolveTheme(def, opts) {
675
675
  return { tokens: isDark ? __spreadValues(__spreadValues({}, light2), dark) : light2, isDark };
676
676
  }
677
677
 
678
+ // generate/validate.ts
679
+ var HEX = /^#[0-9a-f]{3,8}$/i;
680
+ var CH = String.raw`(?:[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?(?:%|deg)?|none)`;
681
+ var COLOR_FN = new RegExp(String.raw`^(?:rgba?|oklch)\(\s*${CH}(?:\s*[,/]?\s*${CH}){2,3}\s*\)$`, "i");
682
+ var VAR_NAME = /^[a-z0-9]+(?:[/-][a-z0-9]+)*$/;
683
+ var FONT_BAD = /["';{}\\<>\u0000-\u001f\u007f]/;
684
+ var isSafeColor = (v) => typeof v === "string" && (HEX.test(v) || COLOR_FN.test(v));
685
+ var isSafeVarName = (v) => typeof v === "string" && VAR_NAME.test(v);
686
+ var isSafeFont = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 100 && !FONT_BAD.test(v);
687
+ var isSafeCustomProperty = (k) => k.startsWith("--") && isSafeVarName(k.slice(2));
688
+ var FONT_ROLE_VAR = "--type-font-mono";
689
+ function preview(v) {
690
+ var _a;
691
+ const s = typeof v === "string" ? v : (_a = JSON.stringify(v)) != null ? _a : String(v);
692
+ const one = s.replace(/[\r\n\t]+/g, " ");
693
+ return JSON.stringify(one.length > 60 ? `${one.slice(0, 60)}\u2026` : one);
694
+ }
695
+ var RdsValidationError = class extends Error {
696
+ constructor(where, problems) {
697
+ super(`${where}: invalid input
698
+ ${problems.join("\n ")}`);
699
+ this.problems = problems;
700
+ this.name = "RdsValidationError";
701
+ }
702
+ };
703
+ function collector(where) {
704
+ const problems = [];
705
+ return {
706
+ problems,
707
+ color(field, v) {
708
+ if (!isSafeColor(v)) problems.push(`${field}: ${preview(v)} is not a colour (hex, rgb(), rgba() or oklch())`);
709
+ },
710
+ name(field, v) {
711
+ if (!isSafeVarName(v)) problems.push(`${field}: ${preview(v)} is not a valid name (lowercase letters, digits, "-" and "/")`);
712
+ },
713
+ font(field, v) {
714
+ if (!isSafeFont(v)) problems.push(`${field}: ${preview(v)} is not a valid font family (no quotes, ";", braces, "\\" or line breaks)`);
715
+ },
716
+ done() {
717
+ if (problems.length) throw new RdsValidationError(where, problems);
718
+ }
719
+ };
720
+ }
721
+ function assertSafeTheme(theme, where) {
722
+ const c = collector(where);
723
+ if (!theme || typeof theme !== "object") {
724
+ c.problems.push("the theme is not an object");
725
+ c.done();
726
+ }
727
+ const check = (label, map, fontRole) => {
728
+ if (!map || typeof map !== "object" || Array.isArray(map)) {
729
+ c.problems.push(`${label}: not an object`);
730
+ return;
731
+ }
732
+ for (const [k, v] of Object.entries(map)) {
733
+ if (!isSafeCustomProperty(k)) {
734
+ c.problems.push(`${label}: ${preview(k)} is not a valid CSS variable name`);
735
+ continue;
736
+ }
737
+ if (fontRole && k === FONT_ROLE_VAR) c.font(`${label}.${k}`, v);
738
+ else c.color(`${label}.${k}`, v);
739
+ }
740
+ };
741
+ for (const mode of ["light", "dark", "brand"]) check(mode, theme[mode], true);
742
+ if (theme.vars !== void 0) check("vars", theme.vars, false);
743
+ c.done();
744
+ }
745
+ var isSafeSelectorList = (v) => (
746
+ // eslint-disable-next-line no-control-regex -- control characters are exactly what is refused
747
+ typeof v === "string" && v.trim().length > 0 && !/[{};\\<@\u0000-\u001f\u007f]|\/\*|\*\//.test(v)
748
+ );
749
+ function oneLine(v, max = 200) {
750
+ if (typeof v !== "string") return "";
751
+ let s = v.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").replace(/[<>`]/g, "").replace(/rojao-ds:(start|end)/gi, "").replace(/\s+/g, " ").trim();
752
+ if (s.length > max) s = `${s.slice(0, max - 1).trimEnd()}\u2026`;
753
+ return s;
754
+ }
755
+
678
756
  // generate/rdsTheme.ts
679
757
  var palettes = primitives.color;
680
758
  var WHITE = "#ffffff";
681
759
  var BLACK = "#000000";
682
760
  var black = (pct) => BLACK + Math.round(pct / 100 * 255).toString(16).padStart(2, "0");
683
761
  var white = (pct) => WHITE + Math.round(pct / 100 * 255).toString(16).padStart(2, "0");
762
+ var PALETTE_STEPS2 = /* @__PURE__ */ new Set([...SCALE_STEPS.map(String), "950"]);
763
+ function checkPalettes(custom, where) {
764
+ if (custom === void 0) return;
765
+ const c = collector(where);
766
+ if (!custom || typeof custom !== "object" || Array.isArray(custom)) {
767
+ c.problems.push("palettes: not an object");
768
+ c.done();
769
+ }
770
+ for (const [name, value] of Object.entries(custom)) {
771
+ if (!/^[a-z][a-z0-9]*$/i.test(name)) {
772
+ c.problems.push(`palettes: ${preview(name)} is not a valid palette name (letters and digits)`);
773
+ continue;
774
+ }
775
+ if (typeof value === "string") {
776
+ if (!isHex(value)) c.problems.push(`palettes.${name}: ${preview(value)} is not a hex colour`);
777
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
778
+ for (const [step2, v] of Object.entries(value)) {
779
+ if (!PALETTE_STEPS2.has(step2)) c.problems.push(`palettes.${name}: ${preview(step2)} is not a scale step (50\u2013900)`);
780
+ else c.color(`palettes.${name}.${step2}`, v);
781
+ }
782
+ } else c.problems.push(`palettes.${name}: expected a hex or a scale of steps 50\u2013900`);
783
+ }
784
+ c.done();
785
+ }
684
786
  function palette(name, custom) {
685
787
  const own = custom == null ? void 0 : custom[name];
686
788
  if (typeof own === "string") {
@@ -706,18 +808,19 @@ function ramp(c, custom) {
706
808
  }
707
809
  var on = (bg) => onColor(bg);
708
810
  var AA = 4.5;
709
- var readsOn = (fg, bgs) => bgs.every((bg) => contrastRatio(fg, bg) >= AA);
710
- function readableFrom(base, scale, bgs, toward, fallback) {
811
+ var NON_TEXT = 3;
812
+ var readsOn = (fg, bgs, min = AA) => bgs.every((bg) => contrastRatio(fg, bg) >= min);
813
+ function readableFrom(base, scale, bgs, toward, fallback, min = AA) {
711
814
  var _a;
712
- if (readsOn(base, bgs)) return base;
815
+ if (readsOn(base, bgs, min)) return base;
713
816
  const lb = relativeLuminance(base);
714
817
  const steps = SCALE_STEPS.map((s2) => scale[s2]).filter((c) => !!c && isHex(c)).filter((c) => toward === "darker" ? relativeLuminance(c) < lb : relativeLuminance(c) > lb).sort((a, b) => Math.abs(relativeLuminance(a) - lb) - Math.abs(relativeLuminance(b) - lb));
715
- const step2 = steps.find((c) => readsOn(c, bgs));
818
+ const step2 = steps.find((c) => readsOn(c, bgs, min));
716
819
  if (step2) return step2;
717
820
  const [h, s, l0] = hexToHsl((_a = steps[steps.length - 1]) != null ? _a : base);
718
821
  for (let l = l0; toward === "darker" ? l >= 0 : l <= 100; l += toward === "darker" ? -2 : 2) {
719
822
  const c = hslToHex(h, s, l);
720
- if (readsOn(c, bgs)) return c;
823
+ if (readsOn(c, bgs, min)) return c;
721
824
  }
722
825
  return fallback;
723
826
  }
@@ -828,14 +931,19 @@ var ROLES = [
828
931
  ["colors/state/info-strong", "d", "d"],
829
932
  ["colors/state/neutral-strong", "d", "d"],
830
933
  ["text/on/primary-subtle", "d", "b"],
831
- ["surface/tint/subtle", "d", "b"]
934
+ ["surface/tint/subtle", "d", "b"],
935
+ ["border/error", "d", "d"]
832
936
  ];
937
+ var awayFrom = (bg) => contrastRatio(bg, BLACK) >= contrastRatio(bg, WHITE) ? "darker" : "lighter";
833
938
  var TINT_DEFAULT = 200;
834
939
  var lighterStep = (s) => s - 100 ;
835
940
  var roleVar = (role) => `--${role.replaceAll("/", "-")}`;
836
941
  function generateRdsTheme(def, opts = {}) {
837
942
  var _a, _b;
838
943
  const warn = (_a = opts.warn) != null ? _a : defaultWarn;
944
+ const where = `generateRdsTheme(${preview(def == null ? void 0 : def.name)})`;
945
+ if (!(def == null ? void 0 : def.brand) || typeof def.brand !== "object") throw new Error(`${where}: brand is missing`);
946
+ checkPalettes(def.palettes, where);
839
947
  const b = def.brand;
840
948
  const own = def.palettes;
841
949
  const P = ramp(b.primary, own);
@@ -930,8 +1038,7 @@ function generateRdsTheme(def, opts = {}) {
930
1038
  "text/warning": orange[700],
931
1039
  "surface/neutral": N[100],
932
1040
  "text/neutral": N[800],
933
- "chart/series/1": P.scale[600],
934
- "chart/series/2": orange[500],
1041
+ "chart/series/2": orange[600],
935
1042
  "chart/series/3": teal[500],
936
1043
  "chart/series/4": purple[500],
937
1044
  "chart/series/5": green[500],
@@ -959,6 +1066,8 @@ function generateRdsTheme(def, opts = {}) {
959
1066
  ["colors/primary/dark", "text/on/primary-strong"],
960
1067
  ["surface/tint/default", "text/on/tint"]
961
1068
  ]) l[text] = on(l[fill]);
1069
+ l["border/error"] = readableFrom(l["colors/state/error"], red, [l["surface/card"]], awayFrom(l["surface/card"]), BLACK, NON_TEXT);
1070
+ l["chart/series/1"] = readableFrom(P.scale[600], P.scale, [l["surface/card"]], awayFrom(l["surface/card"]), BLACK, NON_TEXT);
962
1071
  l["logo/primary"] = l["colors/primary/default"];
963
1072
  l["logo/signature"] = l["colors/primary/default"];
964
1073
  l["logo/accent"] = l["colors/accent/logo"];
@@ -1023,7 +1132,6 @@ function generateRdsTheme(def, opts = {}) {
1023
1132
  "text/warning": orange[300],
1024
1133
  "surface/neutral": N[800],
1025
1134
  "text/neutral": N[200],
1026
- "chart/series/1": P.scale[400],
1027
1135
  "chart/series/2": orange[400],
1028
1136
  "chart/series/3": teal[400],
1029
1137
  "chart/series/4": purple[400],
@@ -1046,12 +1154,15 @@ function generateRdsTheme(def, opts = {}) {
1046
1154
  ["surface/tint/default", "text/on/tint"],
1047
1155
  ["surface/lift/action", "text/on/lift-action"]
1048
1156
  ]) d[text] = on(d[fill]);
1157
+ d["border/error"] = readableFrom(d["colors/state/error-strong"], red, [d["surface/card"]], awayFrom(d["surface/card"]), WHITE, NON_TEXT);
1158
+ d["chart/series/1"] = readableFrom(P.scale[400], P.scale, [d["surface/card"]], awayFrom(d["surface/card"]), WHITE, NON_TEXT);
1049
1159
  const plate = l["colors/primary/default"];
1050
1160
  const ink = on(plate);
1051
1161
  const inkA = ink === WHITE ? white : black;
1052
1162
  const antiInk = ink === WHITE ? black : white;
1053
1163
  const lp = relativeLuminance(plate);
1054
- const plateCard = (_b = SCALE_STEPS.map((st) => P.scale[st]).filter((c) => !!c && isHex(c) && c.toLowerCase() !== plate.toLowerCase()).sort((a, b2) => Math.abs(relativeLuminance(a) - lp) - Math.abs(relativeLuminance(b2) - lp)).find((c) => contrastRatio(ink, c) >= AA)) != null ? _b : plate;
1164
+ const isPlate = (c) => c.toLowerCase() === plate.toLowerCase();
1165
+ const plateCard = P.scale[800] && !isPlate(P.scale[800]) && contrastRatio(ink, P.scale[800]) >= AA ? P.scale[800] : (_b = SCALE_STEPS.map((st) => P.scale[st]).filter((c) => !!c && isHex(c) && !isPlate(c)).sort((a, b2) => Math.abs(relativeLuminance(a) - lp) - Math.abs(relativeLuminance(b2) - lp)).find((c) => contrastRatio(ink, c) >= AA)) != null ? _b : plate;
1055
1166
  const onPrimaryStrong = contrastRatio(plate, P.scale[100]) >= AA ? plate : on(P.scale[100]);
1056
1167
  const br = {
1057
1168
  "colors/primary/light": P.scale[300],
@@ -1096,6 +1207,7 @@ function generateRdsTheme(def, opts = {}) {
1096
1207
  "border/strong-hover": inkA(80),
1097
1208
  "text/on/primary-subtle": inkA(30)
1098
1209
  };
1210
+ const brandError = readableFrom(red[300], red, [plateCard], awayFrom(plateCard), ink, NON_TEXT);
1099
1211
  const out = { light: {}, dark: {}, brand: {} };
1100
1212
  const pick = (src, role) => {
1101
1213
  var _a2;
@@ -1109,6 +1221,8 @@ function generateRdsTheme(def, opts = {}) {
1109
1221
  out.dark[roleVar(role)] = pick(darkSrc, role);
1110
1222
  out.brand[roleVar(role)] = pick(brandSrc, role);
1111
1223
  }
1224
+ out.brand[roleVar("border/error")] = brandError;
1225
+ assertSafeTheme(out, where);
1112
1226
  return out;
1113
1227
  }
1114
1228
  var RDS_SCOPE_SELECTORS = [
@@ -1134,6 +1248,12 @@ var covered = (sel) => {
1134
1248
  };
1135
1249
  function emitRdsCss(theme, opts = {}) {
1136
1250
  var _a, _b, _c;
1251
+ assertSafeTheme(theme, "emitRdsCss");
1252
+ const badSel = ["scope", "dark", "plate"].filter((k) => opts[k] !== void 0 && !isSafeSelectorList(opts[k]));
1253
+ if (badSel.length)
1254
+ throw new Error(
1255
+ `emitRdsCss: invalid selector in ${badSel.map((k) => `${k} ${preview(opts[k])}`).join(", ")} (no braces, ";", comments, backslash, "@", "<" or line breaks).`
1256
+ );
1137
1257
  const scopes = split((_a = opts.scope) != null ? _a : ":root, .ds-scope, [data-rds-scope]");
1138
1258
  const darks = split((_b = opts.dark) != null ? _b : '.dark, [data-rds-mode="dark"]');
1139
1259
  const plates = split((_c = opts.plate) != null ? _c : ".ds-plate, [data-rds-plate]");
@@ -1200,10 +1320,34 @@ function rdsContrastReport(theme) {
1200
1320
  return out;
1201
1321
  }
1202
1322
  var isColourRef = (v) => /^[a-z][a-z0-9-]*\/[a-z]?\d+$/i.test(v);
1323
+ var ADDED_ROLES = {
1324
+ // Figma: light → base colors/state/error, dark → base dark/colors/state/error-strong, brand → base dark/text/error.
1325
+ "border/error": { light: "colors/state/error", dark: "colors/state/error-strong", brand: "text/error" }
1326
+ };
1203
1327
  function rdsThemeFromTable(table, opts = {}) {
1204
- var _a, _b;
1328
+ var _a, _b, _c;
1329
+ const where = `rdsThemeFromTable(${preview(table == null ? void 0 : table.name)})`;
1330
+ if (!table || typeof table !== "object") throw new Error(`${where}: the table is not an object`);
1331
+ const check = collector(where);
1332
+ if (!table.primitives || typeof table.primitives !== "object" || Array.isArray(table.primitives))
1333
+ check.problems.push("primitives: not an object");
1334
+ else
1335
+ for (const [name, value] of Object.entries(table.primitives)) {
1336
+ check.name(`primitives ${preview(name)}`, name);
1337
+ check.color(`primitives.${name}`, value);
1338
+ }
1339
+ if (table.vars !== void 0) {
1340
+ if (!table.vars || typeof table.vars !== "object" || Array.isArray(table.vars)) check.problems.push("vars: not an object");
1341
+ else
1342
+ for (const [name, value] of Object.entries(table.vars)) {
1343
+ check.name(`vars ${preview(name)}`, name);
1344
+ if (typeof value !== "string") check.problems.push(`vars.${name}: not a string`);
1345
+ }
1346
+ }
1347
+ check.done();
1205
1348
  const out = { light: {}, dark: {}, brand: {} };
1206
1349
  const problems = [];
1350
+ const derived = /* @__PURE__ */ new Set();
1207
1351
  for (const mode of ["light", "dark", "brand"]) {
1208
1352
  const roles = (_a = table.modes) == null ? void 0 : _a[mode];
1209
1353
  if (!roles) {
@@ -1211,7 +1355,12 @@ function rdsThemeFromTable(table, opts = {}) {
1211
1355
  continue;
1212
1356
  }
1213
1357
  for (const [role] of ROLES) {
1214
- const ref2 = roles[role];
1358
+ let ref2 = roles[role];
1359
+ const added = (_b = ADDED_ROLES[role]) == null ? void 0 : _b[mode];
1360
+ if (ref2 === void 0 && added && roles[added] !== void 0) {
1361
+ ref2 = roles[added];
1362
+ derived.add(role);
1363
+ }
1215
1364
  if (ref2 === void 0) problems.push(`${mode}: role "${role}" is missing`);
1216
1365
  else if (isColourRef(ref2)) {
1217
1366
  const value = table.primitives[ref2];
@@ -1220,10 +1369,10 @@ function rdsThemeFromTable(table, opts = {}) {
1220
1369
  } else out[mode][roleVar(role)] = ref2;
1221
1370
  }
1222
1371
  }
1223
- const valueOf = (where, ref2) => {
1372
+ const valueOf = (field, ref2) => {
1224
1373
  if (!isColourRef(ref2)) return ref2;
1225
1374
  const value = table.primitives[ref2];
1226
- if (value === void 0) problems.push(`${where} points to unknown primitive "${ref2}"`);
1375
+ if (value === void 0) problems.push(`${field} points to unknown primitive "${ref2}"`);
1227
1376
  return value == null ? void 0 : value.toLowerCase();
1228
1377
  };
1229
1378
  if (table.vars) {
@@ -1233,12 +1382,18 @@ function rdsThemeFromTable(table, opts = {}) {
1233
1382
  if (value !== void 0) out.vars[roleVar(name)] = value;
1234
1383
  }
1235
1384
  }
1236
- if (problems.length) throw new Error(`rdsThemeFromTable(${table.name}):
1385
+ if (problems.length) throw new Error(`${where}:
1237
1386
  ${problems.join("\n ")}`);
1387
+ assertSafeTheme(out, where);
1388
+ const warn = (_c = opts.warn) != null ? _c : defaultWarn;
1389
+ if (derived.size)
1390
+ warn(
1391
+ `${where}: the table was exported before ${[...derived].map((r) => `"${r}"`).join(", ")} existed in the [RDS] theme. Taken from the tokens Figma aliases it to (` + [...derived].map((r) => Object.entries(ADDED_ROLES[r]).map(([m, src]) => `${m}: ${src}`).join(", ")).join("; ") + `). Export the table again with figma/export-brand.js.`
1392
+ );
1238
1393
  const fails = rdsContrastReport(out);
1239
1394
  if (fails.length)
1240
- ((_b = opts.warn) != null ? _b : defaultWarn)(
1241
- `rdsThemeFromTable(${table.name}): ${fails.length} text pair(s) below 4.5:1
1395
+ warn(
1396
+ `${where}: ${fails.length} text pair(s) below 4.5:1
1242
1397
  ` + fails.map((f) => `${f.mode}: ${f.fg} on ${f.bg} ${f.ratio}:1`).join("\n ")
1243
1398
  );
1244
1399
  return out;
@@ -1315,6 +1470,11 @@ ${lightBlock}${darkBlock}
1315
1470
  }
1316
1471
 
1317
1472
  // generate/emitClaudeMd.ts
1473
+ var MARKERS = /rojao-ds:(start|end)/i;
1474
+ var NAME = /^[a-z0-9][a-z0-9 _-]{0,63}$/i;
1475
+ var CSS_FILE = /^[a-z0-9_@./-]+\.css$/i;
1476
+ var CSS_URL = /^https?:\/\/[^\s"'`<>()\\]+$/i;
1477
+ var INSTALL = /^(?:pnpm add|yarn add|bun add|npm install|npm i) @rojaostudio\/ds(?:@[a-z0-9][a-z0-9.+-]*)?$/;
1318
1478
  var ROLE_DOCS = [
1319
1479
  { role: "colors/primary/default", use: "primary action, brand fills" },
1320
1480
  { role: "text/on/primary", use: "text/icon on the primary fill" },
@@ -1374,8 +1534,9 @@ var CLAUDE_MD_FOUNDATION = {
1374
1534
  function isTable(x) {
1375
1535
  return typeof x === "object" && x !== null && "modes" in x && "primitives" in x;
1376
1536
  }
1537
+ var TARGET_FILES = { claude: "CLAUDE.md", cursor: ".cursorrules", agents: "AGENTS.md" };
1377
1538
  function fileLabel(target) {
1378
- return target === "cursor" ? ".cursorrules" : target === "agents" ? "AGENTS.md" : "CLAUDE.md";
1539
+ return TARGET_FILES[target];
1379
1540
  }
1380
1541
  function roleTable(theme) {
1381
1542
  const rows = ROLE_DOCS.filter((r) => roleVar(r.role) in theme.light).map((r) => {
@@ -1386,19 +1547,29 @@ function roleTable(theme) {
1386
1547
  return ["| Token | Use for | Light | Dark |", "|---|---|---|---|", ...rows].join("\n");
1387
1548
  }
1388
1549
  function emitClaudeMd(source, opts = {}) {
1389
- var _a, _b, _c;
1550
+ var _a, _b, _c, _d;
1390
1551
  const theme = (_a = opts.theme) != null ? _a : isTable(source) ? rdsThemeFromTable(source) : generateRdsTheme(source);
1552
+ assertSafeTheme(theme, "emitClaudeMd");
1391
1553
  const name = source.name;
1554
+ if (name !== void 0 && name !== "" && !(typeof name === "string" && NAME.test(name)))
1555
+ throw new Error(`emitClaudeMd: name ${preview(name)} is not valid (letters, digits, space, "-" and "_", up to 64)`);
1392
1556
  const Name = name ? name[0].toUpperCase() + name.slice(1) : "Brand";
1393
- const description = !isTable(source) && source.description ? ` \u2014 ${source.description}` : "";
1557
+ const desc = !isTable(source) ? oneLine(source.description) : "";
1558
+ const description = desc ? ` \u2014 ${desc}` : "";
1394
1559
  const cssFile = (_b = opts.cssFile) != null ? _b : "rds-theme.css";
1395
- const target = (_c = opts.target) != null ? _c : "claude";
1560
+ if (!CSS_FILE.test(cssFile)) throw new Error(`emitClaudeMd: cssFile ${preview(cssFile)} is not a valid .css path`);
1561
+ if (opts.cssUrl !== void 0 && !CSS_URL.test(opts.cssUrl))
1562
+ throw new Error(`emitClaudeMd: cssUrl ${preview(opts.cssUrl)} is not a valid http(s) URL`);
1563
+ const install = (_c = opts.install) != null ? _c : "pnpm add @rojaostudio/ds";
1564
+ if (!INSTALL.test(install)) throw new Error(`emitClaudeMd: install ${preview(install)} is not an install command of @rojaostudio/ds`);
1565
+ const target = (_d = opts.target) != null ? _d : "claude";
1566
+ if (!Object.prototype.hasOwnProperty.call(TARGET_FILES, target)) throw new Error(`emitClaudeMd: unknown target ${preview(target)}`);
1396
1567
  const f = CLAUDE_MD_FOUNDATION;
1397
1568
  const own = theme.vars ? Object.keys(theme.vars) : [];
1398
1569
  const themeImport = opts.cssUrl ? `@import "@rojaostudio/ds/styles/rds.css";
1399
1570
  @import url("${opts.cssUrl}"); /* the brand theme, AFTER rds.css */` : `@import "@rojaostudio/ds/styles/rds.css";
1400
1571
  @import "./${cssFile}"; /* the brand theme, AFTER rds.css */`;
1401
- return `# Design System \u2014 ${Name}
1572
+ const text = `# Design System \u2014 ${Name}
1402
1573
 
1403
1574
  > This project uses **Roj\xE3o DS 2.0** with the **${Name}** brand${description}. When you build any
1404
1575
  > UI \u2014 components, pages, screens \u2014 follow the rules below so every screen looks like the same
@@ -1406,7 +1577,7 @@ function emitClaudeMd(source, opts = {}) {
1406
1577
 
1407
1578
  ## Setup (once per app)
1408
1579
  \`\`\`bash
1409
- pnpm add @rojaostudio/ds
1580
+ ${install}
1410
1581
  \`\`\`
1411
1582
  \`\`\`css
1412
1583
  /* root stylesheet */
@@ -1466,6 +1637,8 @@ Brand's own variables (same value in every mode): ${own.map((v) => `\`${v}\``).j
1466
1637
 
1467
1638
  <!-- Generated by Roj\xE3o DS (npx rojao-ds init) \u2014 file: ${fileLabel(target)}. Claude Code, Cursor and other agents read it from the project root. -->
1468
1639
  `;
1640
+ if (MARKERS.test(text)) throw new Error("emitClaudeMd: the generated text contains a rojao-ds marker");
1641
+ return text;
1469
1642
  }
1470
1643
 
1471
- export { RDS_CONTRAST_PAIRS, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, ROLES, SCALE_STEPS, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, generateTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, resolveTheme, roleVar };
1644
+ export { RDS_CONTRAST_PAIRS, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, ROLES, RdsValidationError, SCALE_STEPS, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, generateTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, isSafeColor, isSafeFont, isSafeVarName, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, resolveTheme, roleVar };
@@ -84,12 +84,33 @@ type RdsBrandTable = {
84
84
  vars?: Record<string, string>;
85
85
  };
86
86
  /**
87
- * The theme of a brand table. Fails on a missing role or an unknown primitive, listing them all. The contrast of
88
- * the main text pairs (rdsContrastReport) is only reported, through `opts.warn`: the table is the brand as drawn
89
- * in Figma, kept one to one even where a pair fails.
87
+ * The theme of a brand table. Fails on a missing role or an unknown primitive, listing them all. A table exported
88
+ * before a role of ADDED_ROLES existed is the exception: the role is taken from the token Figma aliases it to, and
89
+ * `opts.warn` asks to export the table again. The contrast of the main text pairs (rdsContrastReport) is only
90
+ * reported, through `opts.warn`: the table is the brand as drawn in Figma, kept one to one even where a pair fails.
90
91
  */
91
92
  declare function rdsThemeFromTable(table: RdsBrandTable, opts?: RdsThemeOptions): RdsTheme;
92
93
 
94
+ /**
95
+ * validate.ts — what the emitters accept from the outside (a recipe, a brand table, a theme object).
96
+ *
97
+ * ds-core is a public API, and its output lands in a stylesheet (`emitRdsCss`) and in the rules file an AI agent
98
+ * reads (`emitClaudeMd`). A value that is not a colour could close the CSS rule and open another one, pull an
99
+ * `url()`, or carry an instruction into CLAUDE.md. So every value is checked against an allow list before it is
100
+ * written, and the check runs again in each emitter (defence in depth: a theme object can be built by hand).
101
+ *
102
+ * Allow lists, never deny lists: a colour is a hex or a closed `rgb()/rgba()/oklch()`; a variable name is lowercase
103
+ * words joined by `-` or `/`; a font family has no quote, semicolon, brace, backslash or line break.
104
+ */
105
+ declare const isSafeColor: (v: unknown) => v is string;
106
+ declare const isSafeVarName: (v: unknown) => v is string;
107
+ declare const isSafeFont: (v: unknown) => v is string;
108
+ /** Thrown when an input fails the allow list. The message lists every field that failed. */
109
+ declare class RdsValidationError extends Error {
110
+ readonly problems: string[];
111
+ constructor(where: string, problems: string[]);
112
+ }
113
+
93
114
  /**
94
115
  * emitCss.ts — formata o output do generateTheme num theme.css legível.
95
116
  * Header + bloco light (.theme-<name>) + bloco dark (seletor duplo), agrupado
@@ -123,6 +144,11 @@ interface ClaudeMdOptions {
123
144
  theme?: RdsTheme;
124
145
  /** File the text is written to (only the footer changes). Default `claude`. */
125
146
  target?: ClaudeMdTarget;
147
+ /**
148
+ * The install command shown in the setup, as the consumer's package manager runs it
149
+ * (`npm install @rojaostudio/ds@next`). Default `pnpm add @rojaostudio/ds`.
150
+ */
151
+ install?: string;
126
152
  }
127
153
  declare function emitClaudeMd(source: BrandDef | RdsBrandTable, opts?: ClaudeMdOptions): string;
128
154
 
@@ -192,4 +218,4 @@ declare function isNeutralBrand(def: {
192
218
  palettes?: PaletteDefs;
193
219
  }): boolean;
194
220
 
195
- export { BrandDef, type ClaudeMdOptions, type ClaudeMdTarget, RDS_CONTRAST_PAIRS, ROLES as RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, type RdsBrandTable, type RdsContrastFailure, type RdsCssOptions, type RdsMode, type RdsTheme, type RdsThemeOptions, SCALE_STEPS, type Scale, type ScaleStep, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, roleVar };
221
+ export { BrandDef, type ClaudeMdOptions, type ClaudeMdTarget, RDS_CONTRAST_PAIRS, ROLES as RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, type RdsBrandTable, type RdsContrastFailure, type RdsCssOptions, type RdsMode, type RdsTheme, type RdsThemeOptions, RdsValidationError, SCALE_STEPS, type Scale, type ScaleStep, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, isSafeColor, isSafeFont, isSafeVarName, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, roleVar };
package/dist/generate.js CHANGED
@@ -1,3 +1,3 @@
1
- export { RDS_CONTRAST_PAIRS, ROLES as RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, SCALE_STEPS, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, generateTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, resolveTheme, roleVar } from './chunk-Y25PXY26.js';
1
+ export { RDS_CONTRAST_PAIRS, ROLES as RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, RdsValidationError, SCALE_STEPS, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, generateTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, isSafeColor, isSafeFont, isSafeVarName, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, resolveTheme, roleVar } from './chunk-N3HPD3U5.js';
2
2
  import './chunk-FRWRLCYM.js';
3
3
  import './chunk-ORMEWXMH.js';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { G as GenResult, R as ResolvedTheme, T as TokenMap, g as generateTheme, r as resolveTheme } from './generateTheme-CUfNpsKs.js';
2
- export { ClaudeMdOptions, ClaudeMdTarget, RDS_CONTRAST_PAIRS, RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, RdsBrandTable, RdsContrastFailure, RdsCssOptions, RdsMode, RdsTheme, RdsThemeOptions, SCALE_STEPS, Scale, ScaleStep, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, roleVar } from './generate.js';
2
+ export { ClaudeMdOptions, ClaudeMdTarget, RDS_CONTRAST_PAIRS, RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, RdsBrandTable, RdsContrastFailure, RdsCssOptions, RdsMode, RdsTheme, RdsThemeOptions, RdsValidationError, SCALE_STEPS, Scale, ScaleStep, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, isSafeColor, isSafeFont, isSafeVarName, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, roleVar } from './generate.js';
3
3
  export { A as Archetype, B as BrandColors, a as BrandDef, C as ColorRef, D as DarkStrategy, F as FontDef, P as PaletteRef, S as ScaleToken, T as TextStyle, b as TextStyleName, c as TypeScale } from './recipe.schema-Cq7e82nx.js';
4
4
  export { primitives, tokens } from './tokens.js';
5
5
  export { RecipeName, recipes } from './recipes.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { recipes } from './chunk-PV6GFEAM.js';
2
- export { RDS_CONTRAST_PAIRS, ROLES as RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, SCALE_STEPS, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, generateTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, resolveTheme, roleVar } from './chunk-Y25PXY26.js';
2
+ export { RDS_CONTRAST_PAIRS, ROLES as RDS_ROLES, RDS_SCOPE_SELECTORS, RDS_TOKEN_SCOPE, RdsValidationError, SCALE_STEPS, brandTones, buildScale, contrastRatio, emitClaudeMd, emitCss, emitRdsCss, generateRdsTheme, generateTheme, hexToHsl, hslToHex, isHex, isNeutralBrand, isSafeColor, isSafeFont, isSafeVarName, onColor, rdsContrast, rdsContrastReport, rdsThemeFromTable, refToHex, relativeLuminance, resolveTheme, roleVar } from './chunk-N3HPD3U5.js';
3
3
  export { primitives, tokens } from './chunk-FRWRLCYM.js';
4
4
  import './chunk-ORMEWXMH.js';
package/dist/themes.js CHANGED
@@ -1,4 +1,4 @@
1
- import { generateTheme } from './chunk-Y25PXY26.js';
1
+ import { generateTheme } from './chunk-N3HPD3U5.js';
2
2
  import './chunk-FRWRLCYM.js';
3
3
  import { __spreadValues, __objRest } from './chunk-ORMEWXMH.js';
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rojaostudio/ds-core",
3
- "version": "1.1.0-next.7",
3
+ "version": "1.1.0-next.9",
4
4
  "description": "O motor do Rojão DS — tokens, derivação de tema e emissores. Sem React, sem Tailwind, sem CSS.",
5
5
  "license": "MIT",
6
6
  "author": "Rojão Studio (https://rojao.studio)",