elcrm 1.1.27 → 1.1.34

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/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // @bun
3
3
 
4
4
  // src/index.ts
5
- import { readFileSync as readFileSync28 } from "fs";
6
- import { dirname as dirname4, join as join25 } from "path";
5
+ import { readFileSync as readFileSync29 } from "fs";
6
+ import { dirname as dirname4, join as join26 } from "path";
7
7
  import { fileURLToPath as fileURLToPath2 } from "url";
8
8
 
9
9
  // src/parseArgs.ts
@@ -37,7 +37,10 @@ function parseArgs(argv = process.argv.slice(2)) {
37
37
  "folder",
38
38
  "out",
39
39
  "api",
40
- "web"
40
+ "web",
41
+ "access",
42
+ "tag",
43
+ "otp"
41
44
  ]);
42
45
  if (valueOpts.has(body)) {
43
46
  options[body] = next;
@@ -913,6 +916,70 @@ function rewriteImports(source) {
913
916
  hits++;
914
917
  return `import { Stack } from "@elcrm/components/Stack"`;
915
918
  });
919
+ next = next.replace(/\bPricingGroupItem\b/g, () => {
920
+ hits++;
921
+ return "PricingPlan";
922
+ });
923
+ next = next.replace(/import\s*\{([^}]+)\}\s*from\s*["']@elcrm\/components(?:\/Toolbar)?["']/g, (full, list) => {
924
+ if (!/\bapplyToolbarSize\b/.test(list))
925
+ return full;
926
+ hits++;
927
+ const parts = list.split(",").map((p) => p.trim()).filter((p) => p && !/\bapplyToolbarSize\b/.test(p));
928
+ if (parts.length === 0)
929
+ return "";
930
+ const from = /from\s*(["'][^"']+["'])/.exec(full)?.[1] ?? `"@elcrm/components"`;
931
+ return `import { ${parts.join(", ")} } from ${from}`;
932
+ });
933
+ next = next.replace(/\bapplyToolbarSize\s*\(\s*([^,]+)\s*,\s*[^)]+\)/g, (_m, node) => {
934
+ hits++;
935
+ return node.trim();
936
+ });
937
+ let needChatFrame = false;
938
+ let needChatFrameProps = false;
939
+ next = next.replace(/import\s*(type\s+)?\{([^}]+)\}\s*from\s*["']@elcrm\/components["']/g, (full, typeKw, list) => {
940
+ if (!/\bChatFrame(?:Props)?\b/.test(list))
941
+ return full;
942
+ hits++;
943
+ const parts = list.split(",").map((p) => p.trim()).filter(Boolean);
944
+ const kept = [];
945
+ for (const p of parts) {
946
+ const isType = Boolean(typeKw) || /^type\s+/.test(p);
947
+ const bare = p.replace(/^type\s+/, "").replace(/\s+as\s+\w+$/, "").trim();
948
+ if (bare === "ChatFrame") {
949
+ needChatFrame = true;
950
+ continue;
951
+ }
952
+ if (bare === "ChatFrameProps") {
953
+ needChatFrameProps = true;
954
+ continue;
955
+ }
956
+ kept.push(p);
957
+ }
958
+ if (kept.length === 0)
959
+ return "/* elcrm: ChatFrame \u2192 deep */";
960
+ const head = typeKw ? `import type { ` : `import { `;
961
+ return `${head}${kept.join(", ")} } from "@elcrm/components"`;
962
+ });
963
+ if (needChatFrame || needChatFrameProps) {
964
+ const inject = [];
965
+ if (needChatFrame) {
966
+ inject.push(`import { ChatFrame } from "@elcrm/components/ChatFrame";`);
967
+ }
968
+ if (needChatFrameProps) {
969
+ inject.push(`import type { ChatFrameProps } from "@elcrm/components/ChatFrame";`);
970
+ }
971
+ const block = inject.join(`
972
+ `);
973
+ if (/from\s*["']@elcrm\/components/.test(next)) {
974
+ next = next.replace(/(from\s*["']@elcrm\/components(?:\/[^"']*)?["'];?)/, `$1
975
+ ${block}`);
976
+ } else {
977
+ next = `${block}
978
+ ${next}`;
979
+ }
980
+ next = next.replace(/\n?\/\* elcrm: ChatFrame \u2192 deep \*\/\n?/g, `
981
+ `);
982
+ }
916
983
  return { next, hits };
917
984
  }
918
985
  function rewriteJsx(source) {
@@ -967,6 +1034,34 @@ function rewriteSectionOnChange(source) {
967
1034
  });
968
1035
  return { next, hits };
969
1036
  }
1037
+ function rewriteGroupCallbacks(source) {
1038
+ if (!/@elcrm\/components/.test(source) && !/\b(Menu|Sidebar|Dropdown|Breadcrumb|AvatarGroup|ActionGroup)\b/.test(source)) {
1039
+ return { next: source, hits: 0 };
1040
+ }
1041
+ let hits = 0;
1042
+ let next = source;
1043
+ next = next.replace(/\bonFooterSelect=/g, () => {
1044
+ hits++;
1045
+ return "onFooterValueChange=";
1046
+ });
1047
+ next = next.replace(/\bonItemClick=/g, () => {
1048
+ hits++;
1049
+ return "onValueChange=";
1050
+ });
1051
+ next = next.replace(/\bonSelect=/g, () => {
1052
+ hits++;
1053
+ return "onValueChange=";
1054
+ });
1055
+ next = next.replace(/(<ActionGroup\b[^>]*?)\sitems=/g, (_m, pre) => {
1056
+ hits++;
1057
+ return `${pre} actions=`;
1058
+ });
1059
+ next = next.replace(/(<Sidebar\b[^>]*?)\sitems=/g, (_m, pre) => {
1060
+ hits++;
1061
+ return `${pre} groups=`;
1062
+ });
1063
+ return { next, hits };
1064
+ }
970
1065
  function rewriteItemKeys(source) {
971
1066
  if (!/\b(Menu|NavSections|TabSections|MenuItem|NavSectionsItem|TabSectionsItem)\b/.test(source)) {
972
1067
  return { next: source, hits: 0 };
@@ -1001,6 +1096,9 @@ function rewriteComponentsTsx(source) {
1001
1096
  const oc = rewriteSectionOnChange(next);
1002
1097
  next = oc.next;
1003
1098
  hits += oc.hits;
1099
+ const cb = rewriteGroupCallbacks(next);
1100
+ next = cb.next;
1101
+ hits += cb.hits;
1004
1102
  const keys = rewriteItemKeys(next);
1005
1103
  next = keys.next;
1006
1104
  hits += keys.hits;
@@ -1430,6 +1528,7 @@ function syncElcrmCss(options) {
1430
1528
  skippedPkgs.push(name);
1431
1529
  allDefs.push(...defs);
1432
1530
  }
1531
+ const pkgsWithTokens = new Set(cssExports.filter((e) => e.tokens).map((e) => e.name));
1433
1532
  const added = {
1434
1533
  "theme.css": [],
1435
1534
  "theme-light.css": [],
@@ -1445,7 +1544,7 @@ function syncElcrmCss(options) {
1445
1544
  continue;
1446
1545
  let css = readFileSync7(file, "utf8");
1447
1546
  const have = existingTokenNames(css);
1448
- const missing = allDefs.filter((d) => d.bucket === bucket && !have.has(d.name) && !d.name.startsWith("--search-") && !TOKEN_DEPRECATED.has(d.name) && !isButtonShadowNoise(d.name, d.value));
1547
+ const missing = allDefs.filter((d) => d.bucket === bucket && !have.has(d.name) && !d.name.startsWith("--search-") && !TOKEN_DEPRECATED.has(d.name) && !isButtonShadowNoise(d.name, d.value) && !(bucket === "geometry" && pkgsWithTokens.has(d.pkg)));
1449
1548
  const uniq = [];
1450
1549
  const seen = new Set;
1451
1550
  for (const d of missing) {
@@ -5071,15 +5170,158 @@ async function cmdKit(parsed) {
5071
5170
  process.exit(1);
5072
5171
  }
5073
5172
 
5173
+ // src/commands/publish.ts
5174
+ import { copyFileSync, existsSync as existsSync20, readFileSync as readFileSync28, unlinkSync, writeFileSync as writeFileSync12 } from "fs";
5175
+ import { join as join25, resolve as resolve13 } from "path";
5176
+ import { spawnSync as spawnSync3 } from "child_process";
5177
+ var PUBLIC_NAME = "package.public.json";
5178
+ var PKG_NAME = "package.json";
5179
+ var BACKUP_NAME = ".package.json.elcrm-publish.bak";
5180
+ async function cmdPublish(parsed) {
5181
+ const cwd = resolve13(process.cwd(), parsed.options.dir || ".");
5182
+ const dry = hasFlag(parsed, "dry");
5183
+ const noCheck = hasFlag(parsed, "no-check", "noCheck");
5184
+ const pkgPath = join25(cwd, PKG_NAME);
5185
+ const publicPath = join25(cwd, PUBLIC_NAME);
5186
+ const backupPath = join25(cwd, BACKUP_NAME);
5187
+ if (!existsSync20(pkgPath)) {
5188
+ throw new Error(`\u041D\u0435\u0442 ${PKG_NAME} \u0432 ${cwd}`);
5189
+ }
5190
+ if (!existsSync20(publicPath)) {
5191
+ throw new Error(`\u041D\u0435\u0442 ${PUBLIC_NAME} \u0432 ${cwd}
5192
+ ` + `\u0421\u043E\u0437\u0434\u0430\u0439\u0442\u0435 \u043C\u0430\u043D\u0438\u0444\u0435\u0441\u0442 \u0431\u0435\u0437 scripts/devDependencies \u0434\u043B\u044F npm.`);
5193
+ }
5194
+ const local = readPackageJson(cwd);
5195
+ if (!local?.name || !local.version) {
5196
+ throw new Error(`${PKG_NAME}: \u043D\u0443\u0436\u043D\u044B \u043F\u043E\u043B\u044F name \u0438 version`);
5197
+ }
5198
+ let pub;
5199
+ try {
5200
+ pub = JSON.parse(readFileSync28(publicPath, "utf8"));
5201
+ } catch {
5202
+ throw new Error(`${PUBLIC_NAME}: \u043D\u0435\u0432\u0430\u043B\u0438\u0434\u043D\u044B\u0439 JSON`);
5203
+ }
5204
+ pub.version = local.version;
5205
+ if (typeof pub.name !== "string")
5206
+ pub.name = local.name;
5207
+ const access = parsed.options.access || (typeof pub.publishConfig?.access === "string" ? pub.publishConfig.access : "public");
5208
+ console.log(`[publish] ${local.name}@${local.version}`);
5209
+ console.log(`[publish] \u043C\u0430\u043D\u0438\u0444\u0435\u0441\u0442: ${PUBLIC_NAME} \u2192 ${PKG_NAME}`);
5210
+ if (!noCheck && !dry) {
5211
+ await runPreChecks(cwd, local.scripts);
5212
+ } else if (noCheck) {
5213
+ console.log("[publish] \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u044B (--no-check)");
5214
+ }
5215
+ const npmArgs = buildNpmArgs(parsed, access);
5216
+ console.log(`[publish] npm ${npmArgs.join(" ")}`);
5217
+ if (dry) {
5218
+ console.log("[publish] --dry: \u043F\u043E\u0434\u043C\u0435\u043D\u0430 \u0438 publish \u043D\u0435 \u0432\u044B\u043F\u043E\u043B\u043D\u044F\u043B\u0438\u0441\u044C");
5219
+ return;
5220
+ }
5221
+ const originalBytes = readFileSync28(pkgPath);
5222
+ copyFileSync(pkgPath, backupPath);
5223
+ const indent = detectIndent2(readFileSync28(publicPath, "utf8"));
5224
+ writeFileSync12(pkgPath, JSON.stringify(pub, null, indent) + `
5225
+ `, "utf8");
5226
+ let publishStatus = 1;
5227
+ try {
5228
+ const r = spawnSync3("npm", npmArgs, {
5229
+ cwd,
5230
+ stdio: "inherit",
5231
+ env: process.env
5232
+ });
5233
+ publishStatus = r.status ?? 1;
5234
+ if (r.error) {
5235
+ console.error(r.error.message);
5236
+ }
5237
+ } finally {
5238
+ writeFileSync12(pkgPath, originalBytes);
5239
+ if (existsSync20(backupPath)) {
5240
+ try {
5241
+ unlinkSync(backupPath);
5242
+ } catch {}
5243
+ }
5244
+ console.log(`[publish] \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D ${PKG_NAME}`);
5245
+ }
5246
+ if (publishStatus !== 0) {
5247
+ process.exit(publishStatus);
5248
+ }
5249
+ console.log(`[publish] \u0433\u043E\u0442\u043E\u0432\u043E: ${local.name}@${local.version}`);
5250
+ }
5251
+ async function runPreChecks(cwd, scripts) {
5252
+ const list = scripts ?? {};
5253
+ const chain = [];
5254
+ if (list.prepublishOnly)
5255
+ chain.push("prepublishOnly");
5256
+ else {
5257
+ if (list["check:css"])
5258
+ chain.push("check:css");
5259
+ if (list.test)
5260
+ chain.push("test");
5261
+ if (list.build)
5262
+ chain.push("build");
5263
+ }
5264
+ if (chain.length === 0) {
5265
+ console.log("[publish] scripts \u0434\u043B\u044F \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043D\u0435\u0442 \u2014 \u0441\u0440\u0430\u0437\u0443 publish");
5266
+ return;
5267
+ }
5268
+ for (const script of chain) {
5269
+ console.log(`[publish] bun run ${script}`);
5270
+ const r = spawnSync3("bun", ["run", script], {
5271
+ cwd,
5272
+ stdio: "inherit",
5273
+ env: process.env
5274
+ });
5275
+ if ((r.status ?? 1) !== 0) {
5276
+ throw new Error(`\u0421\u043A\u0440\u0438\u043F\u0442 "${script}" \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043B\u0441\u044F \u0441 \u043A\u043E\u0434\u043E\u043C ${r.status}`);
5277
+ }
5278
+ }
5279
+ }
5280
+ function buildNpmArgs(parsed, access) {
5281
+ const args = ["publish", `--access=${access}`, "--ignore-scripts"];
5282
+ if (parsed.options.tag)
5283
+ args.push(`--tag=${parsed.options.tag}`);
5284
+ if (parsed.options.otp)
5285
+ args.push(`--otp=${parsed.options.otp}`);
5286
+ const reserved = new Set([
5287
+ "dir",
5288
+ "access",
5289
+ "tag",
5290
+ "otp",
5291
+ "dry",
5292
+ "no-check",
5293
+ "noCheck"
5294
+ ]);
5295
+ for (const [key, value] of Object.entries(parsed.options)) {
5296
+ if (reserved.has(key))
5297
+ continue;
5298
+ args.push(`--${key}=${value}`);
5299
+ }
5300
+ for (const flag of parsed.flags) {
5301
+ if (reserved.has(flag) || flag === "dry")
5302
+ continue;
5303
+ if (flag === "no-check" || flag === "noCheck")
5304
+ continue;
5305
+ args.push(`--${flag}`);
5306
+ }
5307
+ return args;
5308
+ }
5309
+ function detectIndent2(jsonText) {
5310
+ const m = jsonText.match(/\n([ \t]+)"/);
5311
+ if (!m?.[1])
5312
+ return 4;
5313
+ return m[1].includes("\t") ? 4 : m[1].length;
5314
+ }
5315
+
5074
5316
  // src/index.ts
5075
5317
  function cliVersion() {
5076
5318
  const here = dirname4(fileURLToPath2(import.meta.url));
5077
5319
  for (const p of [
5078
- join25(here, "..", "package.json"),
5079
- join25(here, "package.json")
5320
+ join26(here, "..", "package.json"),
5321
+ join26(here, "package.json")
5080
5322
  ]) {
5081
5323
  try {
5082
- const v = JSON.parse(readFileSync28(p, "utf8")).version;
5324
+ const v = JSON.parse(readFileSync29(p, "utf8")).version;
5083
5325
  if (typeof v === "string" && v)
5084
5326
  return v;
5085
5327
  } catch {}
@@ -5143,6 +5385,8 @@ function showHelp() {
5143
5385
  init [--template app|lib|panel|orbit] [--force] [--no-install]
5144
5386
  build [--client] [--url=\u2026] [--name=\u2026] [--dir=\u2026] [--folder=\u2026]
5145
5387
  postbuild [--dir=./dist]
5388
+ publish [--dry] [--no-check] [--dir=\u2026] [--access=\u2026] [--tag=\u2026] [--otp=\u2026]
5389
+ package.public.json \u2192 package.json \u2192 npm publish \u2192 \u043E\u0442\u043A\u0430\u0442
5146
5390
  uuid
5147
5391
 
5148
5392
  \u0424\u043B\u0430\u0433\u0438: -v/--version, -h/--help
@@ -5150,6 +5394,8 @@ function showHelp() {
5150
5394
  \u041F\u0440\u0438\u043C\u0435\u0440\u044B:
5151
5395
  elcrm doctor
5152
5396
  elcrm doctor --docs --test
5397
+ elcrm publish
5398
+ elcrm publish --dry
5153
5399
  elcrm update --fix --test && elcrm css && elcrm docs && elcrm cursor
5154
5400
  elcrm css # ENOENT @elcrm/\u2026/light.css \u2192 \u0441\u043D\u0438\u043C\u0435\u0442 \u0431\u0438\u0442\u044B\u0439 import
5155
5401
  elcrm audit
@@ -5209,6 +5455,9 @@ async function main() {
5209
5455
  case "kit":
5210
5456
  await cmdKit(parsed);
5211
5457
  break;
5458
+ case "publish":
5459
+ await cmdPublish(parsed);
5460
+ break;
5212
5461
  case "uuid":
5213
5462
  await cmdUuid();
5214
5463
  break;
@@ -1,6 +1,12 @@
1
+ import type { PrefixGroups } from "./plugin-css-scoped";
2
+
1
3
  export type DiscoverLibOptions = {
2
4
  libDir: string;
3
5
  letterOverrides?: Record<string, string>;
6
+ /** Дефолтная семья; @default "c" */
7
+ prefix?: string;
8
+ prefixGroups?: PrefixGroups;
9
+ prefixOverrides?: Record<string, string>;
4
10
  };
5
11
 
6
12
  export type DiscoverLibResult = {
@@ -1,42 +1,90 @@
1
1
  // src/vite/discover-lib.ts
2
2
  import { existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+
5
+ // src/vite/css-prefix.ts
6
+ function assertPrefixLetter(value, label) {
7
+ if (!/^[a-z]$/.test(value)) {
8
+ throw new Error(`[css-scoped] ${label} должен быть одной буквой a-z, получено: "${value}"`);
9
+ }
10
+ }
11
+ function buildPrefixMap(options) {
12
+ const map = {};
13
+ const groups = options.prefixGroups ?? {};
14
+ for (const [pfx, names] of Object.entries(groups)) {
15
+ assertPrefixLetter(pfx, `prefixGroups["${pfx}"]`);
16
+ if (!Array.isArray(names)) {
17
+ throw new Error(`[css-scoped] prefixGroups["${pfx}"] должен быть массивом имён`);
18
+ }
19
+ for (const name of names) {
20
+ if (map[name] != null && map[name] !== pfx) {
21
+ throw new Error(`[css-scoped] "${name}" в двух семьях: ${map[name]} и ${pfx}`);
22
+ }
23
+ map[name] = pfx;
24
+ }
25
+ }
26
+ for (const [name, pfx] of Object.entries(options.prefixOverrides ?? {})) {
27
+ assertPrefixLetter(pfx, `prefixOverrides["${name}"]`);
28
+ map[name] = pfx;
29
+ }
30
+ return map;
31
+ }
32
+
33
+ // src/vite/discover-lib.ts
34
+ var LETTER_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
4
35
  function listComponentDirs(libDir) {
5
36
  return readdirSync(libDir).filter((name) => {
6
37
  const dir = join(libDir, name);
7
38
  return statSync(dir).isDirectory() && existsSync(join(dir, "index.ts"));
8
39
  }).sort();
9
40
  }
10
- function assignLetters(names, overrides) {
11
- const used = new Set;
41
+ function slotFor(used, prefix) {
42
+ let set = used.get(prefix);
43
+ if (!set) {
44
+ set = new Set;
45
+ used.set(prefix, set);
46
+ }
47
+ return set;
48
+ }
49
+ function assignLetters(names, overrides, prefixOf) {
50
+ const used = new Map;
12
51
  const letters = {};
13
52
  for (const [name, letter] of Object.entries(overrides)) {
14
- if (!/^[a-z0-9A-Z]$/.test(letter)) {
15
- throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна a-z0-9A-Z)`);
53
+ if (!/^[a-z]$/.test(letter)) {
54
+ throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна одна буква a-z)`);
16
55
  }
17
- if (used.has(letter)) {
18
- throw new Error(`[discover-lib] дубль буквы "${letter}" в overrides`);
56
+ const pfx = prefixOf(name);
57
+ const slot = slotFor(used, pfx);
58
+ if (slot.has(letter)) {
59
+ throw new Error(`[discover-lib] дубль буквы "${letter}" в семье "${pfx}" (overrides)`);
19
60
  }
20
- used.add(letter);
61
+ slot.add(letter);
21
62
  if (names.includes(name))
22
63
  letters[name] = letter;
23
64
  }
24
65
  for (const name of names) {
25
66
  if (letters[name])
26
67
  continue;
68
+ const slot = slotFor(used, prefixOf(name));
27
69
  const base = name.replace(/[^a-zA-Z]/g, "").toLowerCase();
28
- let letter = [...base].find((c) => /[a-z]/.test(c) && !used.has(c)) ?? [..."abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"].find((c) => !used.has(c));
70
+ const letter = [...base].find((c) => /[a-z]/.test(c) && !slot.has(c)) ?? [...LETTER_ALPHABET].find((c) => !slot.has(c));
29
71
  if (!letter) {
30
- throw new Error(`[discover-lib] нет свободной буквы/цифры для "${name}"`);
72
+ throw new Error(`[discover-lib] нет свободной буквы a-z для "${name}" в семье "${prefixOf(name)}" — заведи новую семью в prefixGroups`);
31
73
  }
32
- used.add(letter);
74
+ slot.add(letter);
33
75
  letters[name] = letter;
34
76
  }
35
77
  return letters;
36
78
  }
37
79
  function discoverLibComponents(options) {
38
80
  const names = listComponentDirs(options.libDir);
39
- const letters = assignLetters(names, options.letterOverrides ?? {});
81
+ const defaultPrefix = options.prefix ?? "c";
82
+ const prefixMap = buildPrefixMap({
83
+ prefixGroups: options.prefixGroups,
84
+ prefixOverrides: options.prefixOverrides
85
+ });
86
+ const prefixOf = (name) => prefixMap[name] ?? defaultPrefix;
87
+ const letters = assignLetters(names, options.letterOverrides ?? {}, prefixOf);
40
88
  const entries = {
41
89
  index: join(options.libDir, "index.ts")
42
90
  };
@@ -269,13 +269,13 @@ function minifyCssVars(options) {
269
269
  };
270
270
  }
271
271
  export {
272
- toIndex,
273
- replaceCssVarToken,
274
- namespaceSize,
275
- minifyCssVars,
276
- isVendorJsChunk,
277
- encodeName,
278
- collectCssVarNames,
272
+ allocateUnique,
279
273
  applyVarDictionary,
280
- allocateUnique
274
+ collectCssVarNames,
275
+ encodeName,
276
+ isVendorJsChunk,
277
+ minifyCssVars,
278
+ namespaceSize,
279
+ replaceCssVarToken,
280
+ toIndex
281
281
  };
@@ -1,14 +1,28 @@
1
1
  /**
2
2
  * Vite helper: короткие unique className для CSS Modules elCRM.
3
- * Формат: 3 символа `{prefix}{буква}{класс}`.
3
+ * Формат: 3 буквы `{prefix}{буква}{первая буква локального}`.
4
+ * В исходнике `.root` / `.icon`; в DOM `slr` / `llr`.
5
+ * Семьи: `prefixGroups` (буква уникальна внутри prefix).
4
6
  * @see createCssScoped
5
7
  */
8
+ export type PrefixGroups = Record<string, string[]>;
9
+
10
+ export declare function buildPrefixMap(options: {
11
+ prefixGroups?: PrefixGroups;
12
+ prefixOverrides?: Record<string, string>;
13
+ }): Record<string, string>;
14
+
15
+ /** 3-й знак scoped: первая буква локального имени (`.root` → `r`). */
16
+ export declare function localChar(name: string): string;
17
+
6
18
  export type CssScopedOptions = {
7
- /** Первая буква пакета (c = components, b = button, …) */
19
+ /** Дефолтная семья (c = chrome/components, b = button, …) */
8
20
  prefix: string;
9
- /** Свой prefix у компонента, если слотов пакета не хватает */
21
+ /** Семьи: prefix папки компонентов */
22
+ prefixGroups?: PrefixGroups;
23
+ /** Свой prefix у компонента (перебивает группу) */
10
24
  prefixOverrides?: Record<string, string>;
11
- /** Папка компонента → одна буква */
25
+ /** Папка компонента → одна буква a–z */
12
26
  components: Record<string, string>;
13
27
  /** Каталог с папками компонентов (абсолютный путь) */
14
28
  libDir: string;
@@ -1,6 +1,44 @@
1
1
  // src/vite/plugin-css-scoped.ts
2
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+
5
+ // src/vite/css-prefix.ts
6
+ function assertPrefixLetter(value, label) {
7
+ if (!/^[a-z]$/.test(value)) {
8
+ throw new Error(`[css-scoped] ${label} должен быть одной буквой a-z, получено: "${value}"`);
9
+ }
10
+ }
11
+ function buildPrefixMap(options) {
12
+ const map = {};
13
+ const groups = options.prefixGroups ?? {};
14
+ for (const [pfx, names] of Object.entries(groups)) {
15
+ assertPrefixLetter(pfx, `prefixGroups["${pfx}"]`);
16
+ if (!Array.isArray(names)) {
17
+ throw new Error(`[css-scoped] prefixGroups["${pfx}"] должен быть массивом имён`);
18
+ }
19
+ for (const name of names) {
20
+ if (map[name] != null && map[name] !== pfx) {
21
+ throw new Error(`[css-scoped] "${name}" в двух семьях: ${map[name]} и ${pfx}`);
22
+ }
23
+ map[name] = pfx;
24
+ }
25
+ }
26
+ for (const [name, pfx] of Object.entries(options.prefixOverrides ?? {})) {
27
+ assertPrefixLetter(pfx, `prefixOverrides["${name}"]`);
28
+ map[name] = pfx;
29
+ }
30
+ return map;
31
+ }
32
+
33
+ // src/vite/css-local.ts
34
+ function localChar(name) {
35
+ if (!/^[a-z][a-z0-9]*$/.test(name)) {
36
+ throw new Error(`[css-scoped] локальный класс — [a-z][a-z0-9]*: ".${name}"`);
37
+ }
38
+ return name[0];
39
+ }
40
+
41
+ // src/vite/plugin-css-scoped.ts
4
42
  var DEFAULT_MODULE_RE = /\/(?:src\/)?lib\/([^/]+)\/[^/]+\.module\.css$/;
5
43
  function listModuleCss(dir) {
6
44
  const out = [];
@@ -14,24 +52,20 @@ function listModuleCss(dir) {
14
52
  return out;
15
53
  }
16
54
  function localClasses(css) {
55
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, " ");
17
56
  const set = new Set;
18
- for (const m of css.matchAll(/(?:^|[,{\s>+~])\.([a-z0-9])\b/gi)) {
57
+ for (const m of stripped.matchAll(/(?:^|[,{\s>+~])\.([a-z][a-z0-9]*)\b/gi)) {
19
58
  set.add(m[1].toLowerCase());
20
59
  }
21
60
  return [...set];
22
61
  }
23
- function assertPrefix(value, label) {
24
- if (!/^[a-z]$/.test(value)) {
25
- throw new Error(`[css-scoped] ${label} должен быть одной буквой a-z, получено: "${value}"`);
26
- }
27
- }
28
62
  function createCssScoped(options) {
29
63
  const prefix = options.prefix;
30
- assertPrefix(prefix, "prefix");
31
- const prefixOverrides = options.prefixOverrides ?? {};
32
- for (const [name, pfx] of Object.entries(prefixOverrides)) {
33
- assertPrefix(pfx, `prefixOverrides["${name}"]`);
34
- }
64
+ assertPrefixLetter(prefix, "prefix");
65
+ const prefixMap = buildPrefixMap({
66
+ prefixGroups: options.prefixGroups,
67
+ prefixOverrides: options.prefixOverrides
68
+ });
35
69
  const components = options.components;
36
70
  const libDir = options.libDir;
37
71
  const modulePathRe = options.modulePathRe ?? DEFAULT_MODULE_RE;
@@ -45,23 +79,21 @@ function createCssScoped(options) {
45
79
  return m[1];
46
80
  }
47
81
  function prefixFor(component) {
48
- return prefixOverrides[component] ?? prefix;
82
+ return prefixMap[component] ?? prefix;
49
83
  }
50
84
  function generateScopedName(name, filename) {
51
- if (!/^[a-z0-9]$/.test(name)) {
52
- throw new Error(`[css-scoped] локальный класс — 1 знак [a-z0-9] (scoped = 3 символа): ".${name}" (${filename})`);
53
- }
85
+ const key = localChar(name);
54
86
  const component = componentFromFilename(filename);
55
87
  const letter = components[component];
56
88
  if (!letter) {
57
89
  throw new Error(`[css-scoped] нет буквы для компонента "${component}". Добавьте в components.`);
58
90
  }
59
- if (!/^[a-z0-9A-Z]$/.test(letter)) {
60
- throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-9A-Z: "${letter}"`);
91
+ if (!/^[a-z]$/.test(letter)) {
92
+ throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z: "${letter}"`);
61
93
  }
62
- const scoped = `${prefixFor(component)}${letter}${name}`;
63
- if (scoped.length !== 3) {
64
- throw new Error(`[css-scoped] "${scoped}" ≠ 3 символа (${component}.${name})`);
94
+ const scoped = `${prefixFor(component)}${letter}${key}`;
95
+ if (!/^[a-z]{3}$/.test(scoped)) {
96
+ throw new Error(`[css-scoped] "${scoped}" ≠ 3 буквы a-z (${component}.${name})`);
65
97
  }
66
98
  const owner = `${component}.${name}`;
67
99
  const prev = registry.get(scoped);
@@ -77,6 +109,11 @@ function createCssScoped(options) {
77
109
  function snapshot() {
78
110
  return new Map(registry);
79
111
  }
112
+ function groupedNames() {
113
+ const fromGroups = Object.values(options.prefixGroups ?? {}).flat();
114
+ const fromOverrides = Object.keys(options.prefixOverrides ?? {});
115
+ return [...new Set([...fromGroups, ...fromOverrides])];
116
+ }
80
117
  function check() {
81
118
  reset();
82
119
  const folders = readdirSync(libDir).filter((n) => statSync(join(libDir, n)).isDirectory());
@@ -85,16 +122,9 @@ function createCssScoped(options) {
85
122
  if (missingMap.length) {
86
123
  throw new Error(`[css-scoped] нет буквы в components: ${missingMap.join(", ")}`);
87
124
  }
88
- const unknownOverride = Object.keys(prefixOverrides).filter((n) => !mapped.has(n));
89
- if (unknownOverride.length) {
90
- throw new Error(`[css-scoped] prefixOverrides без компонента: ${unknownOverride.join(", ")}`);
91
- }
92
- const letters = Object.values(components);
93
- const dupLetter = [
94
- ...new Set(letters.filter((l, i) => letters.indexOf(l) !== i))
95
- ];
96
- if (dupLetter.length) {
97
- throw new Error(`[css-scoped] дубли букв компонентов: ${dupLetter.join(", ")}`);
125
+ const unknownFamily = groupedNames().filter((n) => !mapped.has(n));
126
+ if (unknownFamily.length) {
127
+ throw new Error(`[css-scoped] prefixGroups/prefixOverrides без компонента: ${unknownFamily.join(", ")}`);
98
128
  }
99
129
  const namespaces = Object.entries(components).map(([name, letter]) => `${prefixFor(name)}${letter}`);
100
130
  const dupNs = [
@@ -106,20 +136,27 @@ function createCssScoped(options) {
106
136
  const files = listModuleCss(libDir);
107
137
  for (const file of files) {
108
138
  const css = readFileSync(file, "utf8");
109
- const long = [
110
- ...css.matchAll(/(?:^|[,{\s>+~])\.([a-z][a-z0-9]+)\b/gi)
111
- ].map((m) => m[1]);
112
- if (long.length) {
113
- const component = componentFromFilename(file);
114
- throw new Error(`[css-scoped] ${component}: класс длиннее 1 знака (${[...new Set(long)].join(", ")}). Сократи или вынеси в prefixOverrides + 1 знак.`);
115
- }
116
- for (const local of localClasses(css)) {
139
+ const locals = localClasses(css);
140
+ const seen = new Map;
141
+ for (const local of locals) {
142
+ const ch = localChar(local);
143
+ const prev = seen.get(ch);
144
+ if (prev && prev !== local) {
145
+ const component = componentFromFilename(file);
146
+ throw new Error(`[css-scoped] ${component}: ".${prev}" и ".${local}" дают один 3-й знак "${ch}"`);
147
+ }
148
+ seen.set(ch, local);
117
149
  generateScopedName(local, file);
118
150
  }
119
151
  }
120
152
  const snap = snapshot();
121
- const extra = Object.entries(prefixOverrides).map(([n, p]) => `${n}=${p}`).join(", ");
122
- console.log(`[css-scoped] OK: ${files.length} module.css, ${snap.size} классов (prefix=${prefix}${extra ? `; ${extra}` : ""})`);
153
+ const familyCounts = new Map;
154
+ for (const name of Object.keys(components)) {
155
+ const p = prefixFor(name);
156
+ familyCounts.set(p, (familyCounts.get(p) ?? 0) + 1);
157
+ }
158
+ const families = [...familyCounts.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([p, n]) => `${p}:${n}`).join(", ");
159
+ console.log(`[css-scoped] OK: ${files.length} module.css, ${snap.size} классов (${families})`);
123
160
  return snap;
124
161
  }
125
162
  function plugin() {
@@ -148,5 +185,7 @@ function createCssScoped(options) {
148
185
  };
149
186
  }
150
187
  export {
151
- createCssScoped
188
+ buildPrefixMap,
189
+ createCssScoped,
190
+ localChar
152
191
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "elcrm",
3
- "version": "1.1.27",
4
- "description": "CLI @elcrm/*: doctor --fix (порядок проекта), update, css, docs, cursor, kit, migrate",
3
+ "version": "1.1.34",
4
+ "description": "CLI @elcrm/*: doctor --fix (порядок проекта), update, css, docs, cursor, kit, migrate, publish",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "MaSkal <dev@elcrm.online>",
@@ -18,7 +18,7 @@ elcrm cursor
18
18
  | # | Команда | Что делает |
19
19
  | --- | --- | --- |
20
20
  | 1 | `elcrm update --fix --test` | Обновляет глобальный `elcrm` + все `@elcrm/*` в `package.json` → install → **migrate front** (API/токены/css-sanitize) → внутри `--fix` ещё раз **sync CSS** → `elcrm test` |
21
- | 2 | `elcrm css` | Недостающие CSS-токены → `theme.css` / `theme-light` / `theme-dark`. **Не дописывает** `--button-*-shadow: none` и снимает такие строки из `theme.css`. В `elcrm.css`: **добавляет** рабочие `@import`, **удаляет битые** (нет export/файла в установленном пакете). Затем css-sanitize |
21
+ | 2 | `elcrm css` | Недостающие **цвета** → `theme-light` / `theme-dark`. Геометрию из пакетов с `tokens.css` **не** дублирует в `theme.css` (дефолты через `@import …/tokens.css`). **Не дописывает** `--button-*-shadow: none` и снимает такие строки. В `elcrm.css`: **добавляет** рабочие `@import`, **удаляет битые**. Затем css-sanitize |
22
22
  | 3 | `elcrm docs` | Перезаписывает `docs/*.elCRM.md` (шпаргалки пакетов) |
23
23
  | 4 | `elcrm cursor` | `.cursor/rules` + `.cursor/docs` + `AGENTS.md` для агента |
24
24
 
@@ -70,7 +70,7 @@ elcrm migrate front # пакет (то же, что doctor --fix без
70
70
  elcrm migrate form-native # снять prop native у @elcrm/form (не трогает pages/Auth.tsx)
71
71
  elcrm migrate native-html # <input|textarea> → StringField/CheckField/…
72
72
  elcrm migrate css-sanitize # осиротевшие in srgb / transparent);
73
- elcrm migrate components # Block/Row → Stack, --page-* / --item-* → --shell-*
73
+ elcrm migrate components # Block/Row→Stack; onSelectonValueChange; ActionGroup actions; --page-*/--item-*→--shell-*
74
74
  elcrm migrate form-tokens
75
75
  elcrm migrate form-aliases
76
76
  elcrm migrate size-sml # size sm|md → s|m|l
@@ -111,7 +111,7 @@ minifyCssVars({ var: 2 })
111
111
 
112
112
  Короткие имена **уникальны и подряд**: `--aa`, `--ab`, `--ac`… Занятые слоты пропускаются; `--field` не затирает `--field-border`. Чанки JSZip не минифицируются (декремент `--n`). Если слотов нет — сборка падает.
113
113
 
114
- Также: `elcrm/vite/plugin-css-scoped` (`prefix` + `prefixOverrides`, scoped = 3 знака), `discover-lib`, `concat-css`, `check-tokens`, `postbuild`.
114
+ Также: `elcrm/vite/plugin-css-scoped` (`prefix` + `prefixGroups`, scoped = 3 буквы a–z: `.root` → 3-й знак `r`; буква компонента уникальна внутри семьи), `discover-lib`, `concat-css`, `check-tokens`, `postbuild`.
115
115
 
116
116
  ### Прочее
117
117
 
@@ -127,6 +127,7 @@ elcrm init --template panel
127
127
  elcrm init --template orbit --force --no-install
128
128
  elcrm build --client [--url=…] [--name=…] [--dir=…] [--folder=…]
129
129
  elcrm postbuild [--dir=./dist] # lib: склейка CSS после vite build
130
+ elcrm publish [--dry] [--no-check] # lib: package.public.json → npm publish → откат
130
131
  elcrm uuid
131
132
  elcrm -v / --help
132
133
  ```
@@ -3,12 +3,14 @@
3
3
  # `@elcrm/components`
4
4
 
5
5
  Shell UI: оболочка, шапка, секции, списки, Dropdown / RadioGroup / Pricing* / Avatar* / Breadcrumb / Stack.
6
- **Цвета и размеры задаёт приложение** (`theme.css` + `theme-light` / `theme-dark`). Fallback в пакете нет.
6
+ **Цвета и размеры задаёт приложение** (`theme.css` + `theme-light` / `theme-dark`). Fallback в пакете нет.
7
+ **Локаль:** дефолтные aria/подписи — русский; переопределяй `navLabel` / `label` / `copyLabel` / `aria-label` (без i18n-runtime в пакете).
7
8
 
8
9
  Токены как у form: сначала общие **`--shell-*`** (+ `--control-*`, `--popup-shadow`), потом узкие (`--header-height`, `--section-padding`, `--avatar-size-*`, …).
9
10
  Дефолты подтягивает `elcrm css` из `@elcrm/components/tokens.css` + `light.css` / `dark.css`.
10
11
 
11
- Группы — через `items` / `groups`, идентификатор пункта — **`value`**.
12
+ Группы — через `items` / `groups`, идентификатор пункта — **`value`**, выбор — **`onValueChange`** (+ `defaultValue` где нужно).
13
+ `ActionGroup` — слоты `actions` / `children`, не декларативные `items`. Sidebar — только `groups` (не alias `items`).
12
14
 
13
15
  ```ts
14
16
  import {
@@ -17,51 +19,85 @@ import {
17
19
  Card, Stat, ActionGroup, Toolbar, TextGroup, ItemColumn, List, Item, PageHead, PageShell, ChatList, ChatMessages, ChatSplit, Sidebar, Popover,
18
20
  Healthmap, PanelInfo,
19
21
  Loading, EmptyState, RadioGroup, PricingGroup, PricingTable,
20
- Table, CodeBlock, SplitPane, Banner, IconButton,
22
+ Table, CodeBlock, SplitPane, Banner, IconButton, Text,
21
23
  createLazyResolver,
24
+ acquireOverlayZIndex,
22
25
  } from "@elcrm/components";
23
- import type { PricingPlan, HealthmapData } from "@elcrm/components";
26
+ import type { PricingPlan, HealthmapData, NavTreeItem } from "@elcrm/components";
24
27
  ```
25
28
 
29
+ Версия пакета: **≥ 0.5.x** (`onValueChange`, без Block/Row/Column).
30
+ ## Роли (кто когда)
31
+
32
+ Не сливаем компоненты — выбирай по слою:
33
+
34
+ | Слой | Компоненты | Не путать с |
35
+ | --- | --- | --- |
36
+ | Оболочка app | `Layout` (+ Header / Footer / Sidebar) | PageShell |
37
+ | Каркас модуля | `PageShell` | Section |
38
+ | Шапка страницы | `PageHead` | Section title |
39
+ | Блок контента | `Section` (`direction` / `gap` / `columns` как у Stack) | PageHead |
40
+ | Нав app | `Sidebar` | NavSections (страница + lazy-панель) |
41
+ | Вкладки | `Menu variant="line"` = только табы; `TabSections` = табы + lazy-панель | |
42
+ | Две колонки | `SplitPane` = resize; `ChatSplit` = чат без resize | |
43
+ | Чат | `ChatList` / `ChatMessages` / `ChatSplit`; `ChatFrame` — каркас колонки | |
44
+ | Планы | `PricingGroup` / `PricingTable`; `RadioGroup variant="split"` — radio-UI | |
45
+ | Toolbar | `Toolbar` = фильтры; `ActionGroup` = иконки в карточке | |
46
+ | Текст | `Text` / `TextGroup` / `AvatarName` (+аватар) | |
47
+ | Статус | `Badge` метка; `Banner` полоса; `EmptyState` пустой экран | |
48
+ | Таблица | `Table` = `<table>`; `PricingTable` = матрица тарифов | |
49
+ | Раскладка | `Stack` (или `Section` с `direction`) | Block / Row / Column |
50
+
26
51
  ## Роли layout
27
52
 
28
53
  - **PageShell** — каркас модуля (`scroll` в body + footer). `scroll={false}` — без внутреннего скролла.
29
54
  - **ChatList** — колонка диалогов: слоты `header` / `footer`, `children` = `List` + `Item`. Токены `--chat-list-width`, `--chat-*` (алиасы `--shell-*`).
30
55
  - **ChatMessages** — колонка переписки. `bodyRef` / `onBodyScroll`. Паддинги `--chat-messages-*-padding`.
31
- - **ChatSplit** — ряд `list` + `messages` (`--chat-split-gap`).
56
+ - **ChatSplit** — ряд `list` + `messages` (`--chat-split-gap`). Не `SplitPane` (нет resize).
32
57
  - **PageHead** — title + actions. `sup` — текст или Badge сразу после title (надстрочно; `0` скрыт). `sticky` — шапка прилипает к верху скролла (фон `--shell-bg`). `children` — фильтры/поиск под заголовком, отступ `--shell-gap`.
33
- - **Section** — блок контента / auth.
58
+ - **Section** — блок контента / auth / подраздел. Корень по умолчанию `<section>` (не `<main>` — его даёт Layout). Тело: `direction` / `gap` / `columns` как у Stack. Ряд кнопок в `actions` — `<Stack direction="row">`.
34
59
  - **Layout / Header** — оболочка приложения. `Layout sidebar={<Sidebar />}` — ряд: меню слева + контент. `fixed={["header", "footer", "sidebar"]}` (по умолчанию) — эти слоты не скроллятся, крутится только `children`. `fixed={[]}` — поток страницы. Высота каркаса: `--layout-height` (`100dvh`).
35
- - **Sidebar** — левое меню. `variant="cards"` (карточки) или `"plain"` (плоский backoffice). `groups` + `value`. Подменю: у пункта `items` + `defaultOpen` (открыто/скрыто при старте). Контроль: `open` / `onOpenChange` (массив `value` родителей). Клик по родителю только раскрывает; `onSelect` — у листьев. Если выбран потомок — ветка открывается. Слоты: `brand`, `extra`, `footer`. Меню аккаунта: `footerItems` + `onFooterSelect`. Отступ вложенных: `--sidebar-indent`.
36
- - **NavSections** — сайдбар страницы + lazy-панель. Подменю как у Sidebar: `items` + `defaultOpen`, `open` / `onOpenChange`. Панель грузится по `value` листа (`resolveSection`). Клик по родителю только раскрывает.
37
- - **Stack** — ряд/колонка/сетка (`direction="row"|"column"|"grid"`). Не `Block` / `Row` / `Column`. Сетка: `auto-fit` + `--stack-grid-min`, либо `columns={4}` / `columns={["1fr", "100px", "auto"]}`. С `columns` ряды `stretch` (карточки не наезжают); в одну линию — `style={{ alignItems: "center" }}`. Не `height: 100%` у детей сетки.
60
+ - **Sidebar** — левое меню приложения. `variant="cards"` (карточки) или `"plain"` (плоский backoffice). Только `groups` + `value` / `defaultValue` + `onValueChange`. Подменю: у пункта `items` + `defaultOpen`. Контроль раскрытия: `open` / `onOpenChange`. Клик по родителю только раскрывает; выбор — у листьев. Слоты: `brand`, `extra`, `footer`. Меню аккаунта: `footer` + `footerItems` + `onFooterValueChange` — chip + панель в цветах темы (`Dropdown tone="account"`, токены `--sidebar-account-*`). `version` под chip. Отступ вложенных: `--sidebar-indent`.
61
+ - **NavSections** — сайдбар **страницы** + панель (не замена Sidebar в Layout). У группы обязателен `value`. Подменю как у Sidebar. Панель: `resolveSection` (lazy) **или** готовые `children`. Клик по родителю только раскрывает.
62
+ - **TabSections** — горизонтальные вкладки + панель (`resolveSection` или `children`). Только табы без панели — `Menu variant="line"`.
63
+ - **Stack** — ряд/колонка/сетка (`direction="row"|"column"|"grid"`). `Block` / `Row` / `Column` **удалены** из пакета → `elcrm migrate components`. Сетка: `auto-fit` + `--stack-grid-min`, либо `columns={4}` / `columns={["1fr", "100px", "auto"]}`. С `columns` ряды `stretch`; в одну линию — `style={{ alignItems: "center" }}`.
38
64
  - **Card** — курсор: `--card-pointer` (`auto` / `pointer`). Сетка Stack: `--stack-grid-min`.
39
65
  - **Stat** — метрика: `label` + `value`, `tone` как у Badge, `labelMuted`. Не собирать из `span` в приложении.
40
- - **Healthmap** — теплокарта дней (`data.days`). Данные грузит приложение; слоты `brandName` / `brandSub` / `unit` / `hint`. Токены `--healthmap-cell`, `--healthmap-gap`, `--healthmap-l0`…`l4`.
41
- - **PanelInfo** — выезд справа (шапка / body / footer). На узком экранеоверлей. Слоты: `children`, `footer`, `headerExtra`, `close`. Токены `--panel-info-width`, `--panel-info-width-min`, `--panel-info-overlay-max`, `--panel-info-backdrop`.
42
- - **ActionGroup** — ряд иконок в карточке / строке: `label` + `items` и/или `children`. Кнопки — `@elcrm/button`.
43
- - **Toolbar** — фильтры над списком. `size` (`s`/`m`/`l`, по умолчанию `s`) наследуют вложенные `*Field`, `Search`, `Button`, `Dropdown` (свой `size` важнее). `align`: `start` / `center` / `end` / `between`. `width`: `l` (100%, по умолчанию) или `s` (по контенту, не растягивать). Токены `--toolbar-gap`. Не `ActionGroup`.
66
+ - **Healthmap** — теплокарта (`data.days`). Слоты `brandName` / `brandSub` / `unit` / `hint`; locale: `months` / `weekdays` / `locale` / `loadingLabel` / `lastYearLabel`. Токены `--healthmap-cell`, `--healthmap-gap`, `--healthmap-l0`…`l4`.
67
+ - **PanelInfo** — выезд справа. Desktop — `aside`; узкий (`max-width: 56rem`) `dialog` + `aria-modal`, focus trap, Escape не перебивает вложенный Popover. `open` / `defaultOpen` + `onOpenChange` (`onClose` alias). Слоты: `children`, `footer`, `headerExtra`, `close`. Токены `--panel-info-*`.
68
+ - **ActionGroup** — ряд иконок в карточке / строке: `label` + `actions` и/или `children` (не group-`items`). Кнопки — `@elcrm/button`.
69
+ - **Toolbar** — фильтры над списком. `size` токены `data-size` + `useToolbar()` (не cloneElement). Поля `@elcrm/form` 0.1.20 и `Dropdown` читают контекст. `align` / `width`. Токены `--toolbar-gap`, `--select-height` на `data-size`. Не `ActionGroup`.
70
+ - **Popover** — portal у якоря. Канон: `trigger={(api) => <button {...api.anchorProps}>…</button>}` (без cloneElement). Узел-trigger — клик/ARIA на обёртке.
44
71
  - **TextGroup** — две строки: `title` + `description` (без аватара; с аватаром — AvatarName).
45
- - **Item** — строка списка. `as` button/a/li, `active`. `variant="card"` — рамка и muted-фон (идеи / тикеты / inbox). Не дублировать `.desk-row` в приложении.
72
+ - **Text** — короткий текст. `tone="muted"` `--shell-color-muted`. `as` — тег (`p`/`span`/…). Не `className="muted"` в приложении.
73
+ - **Loading** — индикатор ожидания. Мин. высота: `--loading-min-height` (`min(50vh, 24rem)`).
74
+ - **EmptyState** — заглушка / ошибка страницы. `--empty-max-width`, `--empty-padding`.
75
+ - **Item** — строка списка. По умолчанию `<li>`; кликабельный — `as="button"` / `as="a"`. `active`. `variant="card"` — рамка и muted-фон. Не дублировать `.desk-row` в приложении.
76
+ - **Brand** — логотип в шапке. По умолчанию `<strong>`; кликабельный — `as="button"` / `as="a"`.
46
77
  - **ItemColumn** — внутри Item: `icon` слева, столбик `title` / `description` / `footer` или `meta`, `extra` — бейджи справа сверху, `aside` — колонка справа. `unread` — жирный title. Title: `--item-column-title-size` / `--item-column-title-line` (в console = `--button-size-s` / `--button-line-height`).
47
- - **Table** — `<table>` в обёртке со скроллом. `layout="fixed"` для фиксированной сетки. Токены `--table-*`.
78
+ - **Table** — `<table>` в обёртке со скроллом. `stickyHeader` — липкий thead. `layout="fixed"`. Подпись `<caption>`. Токены `--table-*`.
79
+ - **ChatFrame** — внутренний каркас ChatList/Messages; **не** в корневом экспорте → `@elcrm/components/ChatFrame` (`elcrm migrate components`).
48
80
  - **CodeBlock** — bar + `<pre>`, `onCopy`, `children` для подсветки. Токены `--code-block-*`.
49
- - **SplitPane** — две панели + drag handle. `direction`, `ratio`, `onRatioChange`, `minFirst` / `minSecond`. Не `ChatSplit` (только чат). Токены `--split-handle-*`.
81
+ - **SplitPane** — две панели + drag handle. `direction`, `ratio` / `defaultRatio`, `onRatioChange`, `minFirst` / `minSecond`. Не `ChatSplit` (только чат). Токены `--split-handle-*`.
50
82
  - **Banner** — статусная полоса (`tone`: info / warning / danger / success). Не toast — не `Notice`.
51
83
  - **IconButton** — `name` (`Icons.Line`) + обязательный `label` + `Tooltip` по умолчанию. `tooltip={false}` без подсказки. Низкий уровень — `@elcrm/button/IconButton` (`icon` + `label`).
52
84
 
53
85
  ## Меню
54
86
 
55
- - **Menu** — inline-навигация в шапке. `items`: `value` + `label` + `badge` (`0` не показываем, иначе `Badge` size s). `variant="line"` — вкладки с чертой снизу (не ломать pill селекторами страницы).
56
- - **Dropdown** — выпадающее меню по `items`/`groups` (portal). `placement="auto"|"top"|"bottom"` — куда открывать панель (футер Sidebar `top`). `size`высота триггера; в `Toolbar` берётся сам.
57
- - **Popover** — свой попап в portal (якорь + `children`). z-index: `acquireOverlayZIndex` (тот же стек, что у form). Не `@elcrm/overlay` `Menu`. `placement` как у Dropdown: позиция по **реальной** высоте панели, не по `maxHeight` 420.
87
+ - **Menu** — inline-навигация в шапке. `items` + `value` / `defaultValue` + `onValueChange`. `badge` (`0` скрыт). `variant="line"` — вкладки с чертой снизу.
88
+ - **Dropdown** — выпадающее меню по `items`/`groups` (portal) + `onValueChange`. `placement="auto"|"top"|"bottom"`. `size`высота триггера; в `Toolbar` берётся сам. `item.submenu` — только шеврон (намёк); вложенное меню — снаружи.
89
+ - **Popover** — свой попап в portal (якорь + `children`). По умолчанию **без** `role="menu"`; меню пунктов `Dropdown` (`role="menu"`). ARIA на триггере. z-index: `acquireOverlayZIndex`. Канон `trigger={(api) => …}`.
90
+ - **Breadcrumb** — `items` + `separator` / `collapseAfter`. `item.dropdown` — только ↕-намёк; меню — обернуть в `Dropdown`.
91
+ - Native `<button>` внутри chrome пакета — **внутренний primitive**; в приложении — `@elcrm/button`.
58
92
 
59
93
  ## Avatar
60
94
 
61
95
  - **Avatar** — фото / инициалы (`name` только для инициалов). Скругление: `radius={10}` или `--avatar-radius` (дефолт `999px` — круг).
62
96
  - **Badge** — метка: `tone`, `size` (`s`/`m`/`l`), `dot` (точка слева). Не Button.
63
97
  - **AvatarName** — `title` + `description` (+ `name` для инициалов). Тот же `radius`. Цвета: `--avatar-name-title-color`, `--avatar-name-description-color`.
64
- - **AvatarGroup** — `items` с `value` / `title` / `name` / `src`.
98
+ - **AvatarGroup** — `items` с обязательным `value` + `title` / `name` / `src`; клик — `onValueChange(value)`.
99
+ - **Breadcrumb** — `items` + `onValueChange(value)`; `separator`, `collapseAfter`.
100
+ - **createLazyResolver** — кэш lazy-чанков; при ошибке загрузки слот сбрасывается; `.clear(segment?)` для retry.
65
101
 
66
102
  ## Pricing
67
103
 
@@ -79,12 +115,18 @@ import type { PricingPlan, HealthmapData } from "@elcrm/components";
79
115
  - `--popup-shadow` общий с `@elcrm/form`.
80
116
  - `elcrm css` — недостающие `--shell-*`.
81
117
  - Импорт `@elcrm/components/style.css` — опционально.
118
+ - CSS Modules: в DOM всегда **3 буквы** `{семья}{компонент}{первая буква локального}` (короткий код, все a–z): List `.root` → `llr`, Layout `.root` → `slr`. В исходнике — слова. Семьи: `l` lists, `n` nav, `s` shell, `k` kit, `t` type, `a` avatar, `o` overlay, `g` pricing, `h` healthmap, `d` data. Не опирайся на эти className в приложении.
82
119
 
83
120
  ## Нельзя
84
121
 
85
122
  - `import "@elcrm/…/themes.css"`.
86
- - `Block` / `Row` / `Column` — `Stack` (`elcrm migrate components`).
123
+ - `Block` / `Row` / `Column` — удалены; только `Stack` (или `Section` с `direction`). `elcrm migrate components`.
124
+ - `applyToolbarSize` — удалён; размер — `useToolbar` / `data-size`.
125
+ - `PricingGroupItem` — удалён; тип `PricingPlan`.
87
126
  - `key` / `activeKey` / `defaultActiveKey` в группах — `value` / `defaultValue` (`elcrm migrate components`).
127
+ - `onSelect` / `onItemClick` / `onFooterSelect` у Menu / Sidebar / Dropdown / Breadcrumb / AvatarGroup — `onValueChange` / `onFooterValueChange` (`elcrm migrate components`).
128
+ - `ActionGroup items=` — `actions=`; `Sidebar items=` (группы) — `groups=`.
129
+ - `ChatFrame` из `@elcrm/components` — deep `@elcrm/components/ChatFrame` (`elcrm migrate components`).
88
130
  - Нативные `<button>` / инпуты вместо `@elcrm/button` / `@elcrm/form` в приложении.
89
131
  - `size="sm"|"md"` → `"s"|"m"`.
90
132
  - Хардкод цветов в модулях приложения, если есть токен.
@@ -32,7 +32,7 @@ const form = useForm({ login: "", password: "" });
32
32
  Канонические поля: `StringField`, `PasswordField`, `TextareaField`, `NumberField`, `PercentField`, `MoneyField`, `MaskField`, `PhoneField`, `EmailField`, `UrlField`, `DateField`, `TimeField`, `SelectField`, `OptionsField`, `ModalField`, `RangeField`, `CheckField`, `RadioField`, `TagsField`, `RatingField`, `CodeField`, `FileField`, `DragDropField`, `RichTextField`, `ColorField`, `HiddenField`, `DisplayField`, `SearchField`, `TabsField`.
33
33
 
34
34
  `disabled`, `size` (`"s"` | `"m"` | `"l"`) — у всех видимых `*Field` (кроме `HiddenField`).
35
- В ряду фильтров — `<Toolbar size="s">` из `@elcrm/components`: вложенные поля/Search/Button берут этот `size`.
35
+ В ряду фильтров — `<Toolbar size="s">` из `@elcrm/components` (дефолт тулбара уже `"s"`): без своего `size` поля берут размер через `useToolbar` (`@elcrm/form` ≥ 0.1.20). Явный `size` у поля важнее. `--select-height` = `var(--field-height)` — селект той же высоты, что капсула.
36
36
 
37
37
  `PasswordField`: `generate` — кнопка случайного пароля. `true` → `Az09#` (A–Z, a–z, 0–9, символы). `generate="A"` только заглавные, `"59"` цифры 5–9, `"#"` только символы. Длина — `maxLength` или 12.
38
38
 
@@ -139,6 +139,17 @@ body[data-theme="dark"] {
139
139
  --sidebar-item-active: var(--shell-color);
140
140
  --sidebar-item-active-bg: var(--shell-background-selected);
141
141
  --sidebar-icon-active: var(--shell-color-accent);
142
+ --sidebar-account-menu-bg: var(--shell-background);
143
+ --sidebar-account-menu-color: var(--shell-color);
144
+ --sidebar-account-menu-border: var(--shell-border);
145
+ --sidebar-account-menu-shadow: var(--popup-shadow, var(--shell-shadow));
146
+ --sidebar-account-item-hover: var(--shell-background-hover);
147
+ --sidebar-account-item-icon: var(--shell-color-muted);
148
+ --sidebar-account-danger-hover: color-mix(
149
+ in srgb,
150
+ var(--shell-color-danger) 14%,
151
+ transparent
152
+ );
142
153
  --shell-background-hover: color-mix(in srgb, var(--text) 8%, transparent);
143
154
  --shell-background-selected: color-mix(in srgb, var(--accent) 14%, transparent);
144
155
  --shell-shadow: var(--shadow);
@@ -139,6 +139,17 @@ body[data-theme="light"] {
139
139
  --sidebar-item-active: var(--shell-color);
140
140
  --sidebar-item-active-bg: var(--shell-background-selected);
141
141
  --sidebar-icon-active: var(--shell-color-accent);
142
+ --sidebar-account-menu-bg: var(--shell-background);
143
+ --sidebar-account-menu-color: var(--shell-color);
144
+ --sidebar-account-menu-border: var(--shell-border);
145
+ --sidebar-account-menu-shadow: var(--popup-shadow, var(--shell-shadow));
146
+ --sidebar-account-item-hover: var(--shell-background-hover);
147
+ --sidebar-account-item-icon: var(--shell-color-muted);
148
+ --sidebar-account-danger-hover: color-mix(
149
+ in srgb,
150
+ var(--shell-color-danger) 12%,
151
+ transparent
152
+ );
142
153
  --shell-background-hover: color-mix(in srgb, var(--text) 6%, transparent);
143
154
  --shell-background-selected: color-mix(in srgb, var(--accent) 10%, transparent);
144
155
  --shell-shadow: var(--shadow);
@@ -64,6 +64,11 @@
64
64
  /* Layout: внутренний отступ и внешний margin каркаса */
65
65
  --layout-padding: 0;
66
66
  --layout-margin: 0;
67
+ --layout-gap: 0;
68
+ --layout-main-radius: 0;
69
+ --layout-main-background: transparent;
70
+ --layout-main-shadow: none;
71
+ --layout-main-border: none;
67
72
  /* none — на всю ширину; иначе например 1440px */
68
73
  --layout-max-width: none;
69
74
  --elcrm-z-popover: 1000;
@@ -114,6 +119,7 @@
114
119
  --item-column-icon-radius: var(--avatar-radius);
115
120
  --item-column-title-size: var(--shell-font-size);
116
121
  --item-column-title-line: 1.3;
122
+ --item-column-meta-size: var(--shell-font-size-sm);
117
123
  --stat-value-size: 1.75rem;
118
124
  --action-group-gap: var(--shell-gap-sm);
119
125
  --toolbar-gap: var(--shell-gap-sm);
@@ -126,6 +132,14 @@
126
132
  --sidebar-icon-size: 16px;
127
133
  --sidebar-group-font-size: 11px;
128
134
  --sidebar-indent: 14px;
135
+ --sidebar-account-chip-radius: 14px;
136
+ --sidebar-account-chip-padding: 8px;
137
+ --sidebar-account-chip-gap: 10px;
138
+ --sidebar-account-menu-radius: 18px;
139
+ --sidebar-account-menu-padding: 6px;
140
+ --sidebar-account-item-radius: 12px;
141
+ --sidebar-account-item-padding: 10px 12px;
142
+ --sidebar-account-item-gap: 12px;
129
143
  --pricing-card-min-width: 16rem;
130
144
  --pricing-table-label-width: 10rem;
131
145
  --chat-list-width: 340px;
@@ -152,6 +166,8 @@
152
166
  --table-cell-padding: 0.55rem 0.7rem;
153
167
  --table-head-font-size: 0.75rem;
154
168
  --table-code-font-size: 0.8rem;
169
+ --table-head-background: var(--shell-background);
170
+ --table-max-height: none;
155
171
  --code-block-bg: var(--shell-background);
156
172
  --code-block-bar-padding: 0.4rem 0.75rem;
157
173
  --code-block-padding: 0.85rem 1rem;
@@ -168,6 +184,9 @@
168
184
  --banner-padding: 0.55rem 0.85rem;
169
185
  --banner-bg: color-mix(in srgb, var(--shell-color-accent) 8%, var(--shell-bg));
170
186
  --banner-border: color-mix(in srgb, var(--shell-color-accent) 35%, var(--shell-border));
187
+ --loading-min-height: min(50vh, 24rem);
188
+ --empty-max-width: 36rem;
189
+ --empty-padding: 2rem;
171
190
  }
172
191
 
173
192
  /* —— база приложения (без захардкоженных цветов) —— */
@@ -144,6 +144,17 @@ body[data-theme="dark"] {
144
144
  --sidebar-item-active: var(--shell-color);
145
145
  --sidebar-item-active-bg: var(--shell-background-selected);
146
146
  --sidebar-icon-active: var(--shell-color-accent);
147
+ --sidebar-account-menu-bg: var(--shell-background);
148
+ --sidebar-account-menu-color: var(--shell-color);
149
+ --sidebar-account-menu-border: var(--shell-border);
150
+ --sidebar-account-menu-shadow: var(--popup-shadow, var(--shell-shadow));
151
+ --sidebar-account-item-hover: var(--shell-background-hover);
152
+ --sidebar-account-item-icon: var(--shell-color-muted);
153
+ --sidebar-account-danger-hover: color-mix(
154
+ in srgb,
155
+ var(--shell-color-danger) 14%,
156
+ transparent
157
+ );
147
158
  --shell-background-hover: color-mix(in srgb, var(--text) 8%, transparent);
148
159
  --shell-background-selected: color-mix(in srgb, var(--accent) 14%, transparent);
149
160
  --shell-shadow: var(--shadow);
@@ -144,6 +144,17 @@ body[data-theme="light"] {
144
144
  --sidebar-item-active: var(--shell-color);
145
145
  --sidebar-item-active-bg: var(--shell-background-selected);
146
146
  --sidebar-icon-active: var(--shell-color-accent);
147
+ --sidebar-account-menu-bg: var(--shell-background);
148
+ --sidebar-account-menu-color: var(--shell-color);
149
+ --sidebar-account-menu-border: var(--shell-border);
150
+ --sidebar-account-menu-shadow: var(--popup-shadow, var(--shell-shadow));
151
+ --sidebar-account-item-hover: var(--shell-background-hover);
152
+ --sidebar-account-item-icon: var(--shell-color-muted);
153
+ --sidebar-account-danger-hover: color-mix(
154
+ in srgb,
155
+ var(--shell-color-danger) 12%,
156
+ transparent
157
+ );
147
158
  --shell-background-hover: color-mix(in srgb, var(--text) 6%, transparent);
148
159
  --shell-background-selected: color-mix(in srgb, var(--accent) 10%, transparent);
149
160
  --shell-shadow: var(--shadow);
@@ -64,6 +64,11 @@
64
64
  /* Layout: внутренний отступ и внешний margin каркаса */
65
65
  --layout-padding: 0;
66
66
  --layout-margin: 0;
67
+ --layout-gap: 0;
68
+ --layout-main-radius: 0;
69
+ --layout-main-background: transparent;
70
+ --layout-main-shadow: none;
71
+ --layout-main-border: none;
67
72
  /* none — на всю ширину; иначе например 1440px */
68
73
  --layout-max-width: none;
69
74
  --elcrm-z-popover: 1000;
@@ -125,6 +130,7 @@
125
130
  --item-column-icon-radius: var(--avatar-radius);
126
131
  --item-column-title-size: var(--shell-font-size);
127
132
  --item-column-title-line: 1.3;
133
+ --item-column-meta-size: var(--shell-font-size-sm);
128
134
  --stat-value-size: 1.75rem;
129
135
  --action-group-gap: var(--shell-gap-sm);
130
136
  --toolbar-gap: var(--shell-gap-sm);
@@ -137,6 +143,14 @@
137
143
  --sidebar-icon-size: 16px;
138
144
  --sidebar-group-font-size: 11px;
139
145
  --sidebar-indent: 14px;
146
+ --sidebar-account-chip-radius: 14px;
147
+ --sidebar-account-chip-padding: 8px;
148
+ --sidebar-account-chip-gap: 10px;
149
+ --sidebar-account-menu-radius: 18px;
150
+ --sidebar-account-menu-padding: 6px;
151
+ --sidebar-account-item-radius: 12px;
152
+ --sidebar-account-item-padding: 10px 12px;
153
+ --sidebar-account-item-gap: 12px;
140
154
  --pricing-card-min-width: 16rem;
141
155
  --pricing-table-label-width: 10rem;
142
156
  --chat-list-width: 340px;
@@ -163,6 +177,8 @@
163
177
  --table-cell-padding: 0.55rem 0.7rem;
164
178
  --table-head-font-size: 0.75rem;
165
179
  --table-code-font-size: 0.8rem;
180
+ --table-head-background: var(--shell-background);
181
+ --table-max-height: none;
166
182
  --code-block-bg: var(--shell-background);
167
183
  --code-block-bar-padding: 0.4rem 0.75rem;
168
184
  --code-block-padding: 0.85rem 1rem;
@@ -179,6 +195,9 @@
179
195
  --banner-padding: 0.55rem 0.85rem;
180
196
  --banner-bg: color-mix(in srgb, var(--shell-color-accent) 8%, var(--shell-bg));
181
197
  --banner-border: color-mix(in srgb, var(--shell-color-accent) 35%, var(--shell-border));
198
+ --loading-min-height: min(50vh, 24rem);
199
+ --empty-max-width: 36rem;
200
+ --empty-padding: 2rem;
182
201
  }
183
202
 
184
203
  /* —— база приложения (без захардкоженных цветов) —— */