@starci/hfs 4.7.35 → 4.7.37
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/CHANGELOG.md +10 -0
- package/codemod/button-intent.mjs +356 -0
- package/package.json +2 -1
- package/runtime/knowledge/grammars/index.yaml +9 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +46 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/fe-stack.yaml +5 -4
- package/runtime/knowledge/hfs/rules.yaml +30 -0
- package/runtime/knowledge/ui/presentation/padding.yaml +5 -4
- package/runtime/modules/kernel/failure-codes.yaml +62 -0
- package/runtime/modules/kernel/roles.yaml +1 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +113 -3
- package/runtime/scripts/guards/release-definition.mjs +22 -3
- package/runtime/scripts/lib/css-rules.mjs +18 -0
- package/runtime/scripts/lib/{grammar-vendor-source.mjs → grammar-vendor-parity-jsx.mjs} +34 -19
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +94 -61
- package/runtime/scripts/lib/release-version.mjs +119 -0
- package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
- package/src/main.mjs +4 -2
- package/sync/hygiene.mjs +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +2 -2
- package/templates/fe/common/site-shell.tsx.partial +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.37 - 2026-10-26
|
|
4
|
+
- Fixed: the scaffold SiteShell declares viewport=flow so Grammar lint reads it as site chrome, the hygiene sync follows the scaffold end-to-end fixes, and the scaffold json-logger spec expects the logger field order.
|
|
5
|
+
|
|
6
|
+
## 4.7.36 - 2026-10-26
|
|
7
|
+
- Changed: the bundled canon pins select @starci/eslint-canon-be 3.4.45, @starci/eslint-canon-fe 9.1.0, the Grammar lint families 0.12.4 and @starci/grammar 0.12.4.
|
|
8
|
+
- Added: the five R319 button-intent finding codes are catalogued and proven, and the `app codemod button-intent` migration is bundled.
|
|
9
|
+
- Changed: the Grammar vendor-parity proof is strict. It reads the DNA vendor, parity and arrangement declarations, judges undeclared wraps and stale overrides, and exports repaint findings with `starci work export-repaints`; `wrappers.yaml` carries the owner-ruled overrides and one pin.
|
|
10
|
+
- Changed: releases follow the alpha.N.M counter (owner ruling `release-waves`): the next tag is derived from the highest released one.
|
|
11
|
+
|
|
3
12
|
## 4.7.35 - 2026-10-26
|
|
4
13
|
- Changed: the bundled canon pins select @starci/stylelint-canon 2.0.12, @starci/eslint-canon-be 3.4.44, @starci/eslint-canon-fe 9.0.0, the Grammar lint families 0.12.3 and @starci/grammar 0.12.3.
|
|
5
14
|
- Added: `app lint` composes the selected Grammar family lint with the universal front-end canon and refuses canon-fe 9 without it; the Grammar vendor-parity proof reads the generated HeroUI reference; the UI proof checks component ownership and surface layers.
|
|
@@ -7,6 +16,7 @@
|
|
|
7
16
|
- Changed: the runtime centralizes its parsing and gate summaries.
|
|
8
17
|
|
|
9
18
|
## 4.7.34 - 2026-10-26
|
|
19
|
+
- Added: `starci app codemod button-intent` previews legacy Grammar button migrations, applies safe edits with `--write`, and reports ambiguous consequences, competing commits and repeated item commits in JSON or Markdown.
|
|
10
20
|
- Fixed: `starci app db restamp` considers only active migrations when validating names and choosing unoccupied versions. Legacy archive names no longer block preview or apply, and archived files stay untouched.
|
|
11
21
|
- Changed: the loosening check reads the tree as it stands and never its history. The bundled `scripts/lib/loosening-state.mjs` (with `js-lex.mjs` and `loosening-config.mjs`) replaces the diff detector; `HFS_BYPASS_MARKER` (R303) reads it: a suppression word opens a comment, a test marker stands in code, a swallowed failure stands in a shell line or in the string a script hands a shell, a package.json is judged by its scripts, and the same words quoted as data are no bypass. It also refuses a coverage target or threshold below the canon, a relaxing switch, a disabled ESLint rule, a tsconfig strictness option turned off and an exception file that `knowledge/hfs/design-truths.yaml` does not list. `--passWithNoTests`, `--coverageThreshold`, `--no-error-on-unmatched-pattern` and a `--max-warnings` budget are bypasses in a script, a hook or a workflow.
|
|
12
22
|
- Added: `starci app emit` carries class-validator `MaxLength`, `ArrayMaxSize`, `Min` and `Max` of GraphQL input types and REST operation DTOs into the contract (a GraphQL `@inputBounds` directive and an OpenAPI constraint), and the `@starci/hfs/form-bounds` codegen plugin turns them into typed `INPUT_BOUNDS` and `inputBoundsIssues` for the form canon. The app codegen template refuses legacy or unsupported bounds metadata.
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
5
|
+
import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const GRAMMAR = /^@starci\/grammar(?:\/|$)/;
|
|
9
|
+
const VARIANTS = Object.freeze({ primary: 'commit', tertiary: 'dismiss', danger: 'destructive', 'danger-soft': 'destructive', outline: 'neutral', ghost: 'neutral' });
|
|
10
|
+
const WEAK = new Set(['outline', 'ghost', 'secondary']);
|
|
11
|
+
const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
|
|
12
|
+
|
|
13
|
+
function attributes(ts, opening) {
|
|
14
|
+
return new Map(opening.attributes.properties.filter(ts.isJsxAttribute).map(prop => [prop.name.getText(), prop]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function literal(ts, attribute) {
|
|
18
|
+
const value = attribute?.initializer;
|
|
19
|
+
if (value && ts.isStringLiteralLike(value)) return value.text;
|
|
20
|
+
return value && ts.isJsxExpression(value) && value.expression && ts.isStringLiteralLike(value.expression) ? value.expression.text : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buttonImports(ts, source) {
|
|
24
|
+
const names = new Set();
|
|
25
|
+
const namespaces = new Set();
|
|
26
|
+
for (const node of source.statements) {
|
|
27
|
+
if (!ts.isImportDeclaration(node) || !GRAMMAR.test(node.moduleSpecifier.text)) continue;
|
|
28
|
+
const bindings = node.importClause?.namedBindings;
|
|
29
|
+
if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
|
|
30
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
31
|
+
for (const binding of bindings.elements) {
|
|
32
|
+
if (['Button', 'IconButton'].includes(binding.propertyName?.text ?? binding.name.text)) names.add(binding.name.text);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { names, namespaces };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isButton(ts, opening, imports) {
|
|
40
|
+
const tag = opening.tagName;
|
|
41
|
+
if (ts.isIdentifier(tag)) return imports.names.has(tag.text);
|
|
42
|
+
return ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression)
|
|
43
|
+
&& imports.namespaces.has(tag.expression.text) && ['Button', 'IconButton'].includes(tag.name.text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function bindingNames(ts, binding) {
|
|
47
|
+
if (ts.isIdentifier(binding)) return [binding.text];
|
|
48
|
+
if (ts.isObjectBindingPattern(binding) || ts.isArrayBindingPattern(binding)) return binding.elements.flatMap(element => ts.isBindingElement(element) ? bindingNames(ts, element.name) : []);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shadowedBinding(ts, opening) {
|
|
53
|
+
const tag = opening.tagName;
|
|
54
|
+
const name = ts.isIdentifier(tag) ? tag.text : tag.expression.text;
|
|
55
|
+
for (let at = opening.parent; at && !ts.isSourceFile(at); at = at.parent) {
|
|
56
|
+
if (ts.isFunctionLike(at) && (at.parameters.some(parameter => bindingNames(ts, parameter.name).includes(name))
|
|
57
|
+
|| (ts.isFunctionExpression(at) && at.name?.text === name))) return true;
|
|
58
|
+
if (ts.isCatchClause(at) && at.variableDeclaration && bindingNames(ts, at.variableDeclaration.name).includes(name)) return true;
|
|
59
|
+
const statements = ts.isBlock(at) ? at.statements : [];
|
|
60
|
+
for (const statement of statements) {
|
|
61
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name) return true;
|
|
62
|
+
if (ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name))) return true;
|
|
63
|
+
}
|
|
64
|
+
if ((ts.isForStatement(at) || ts.isForOfStatement(at) || ts.isForInStatement(at)) && at.initializer && ts.isVariableDeclarationList(at.initializer)
|
|
65
|
+
&& at.initializer.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name))) return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isMap(ts, node) {
|
|
71
|
+
if (!ts.isCallExpression(node)) return false;
|
|
72
|
+
return ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text === 'map'
|
|
73
|
+
: ts.isElementAccessExpression(node.expression) && ts.isStringLiteralLike(node.expression.argumentExpression) && node.expression.argumentExpression.text === 'map';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function callbackArgument(ts, node) {
|
|
77
|
+
let expression = node;
|
|
78
|
+
while (expression.parent && ts.isParenthesizedExpression(expression.parent)) expression = expression.parent;
|
|
79
|
+
return isMap(ts, expression.parent) && expression.parent.arguments[0] === expression;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inlineRenderCallback(ts, node) {
|
|
83
|
+
let expression = node;
|
|
84
|
+
while (expression.parent && ts.isParenthesizedExpression(expression.parent)) expression = expression.parent;
|
|
85
|
+
if (!expression.parent || !ts.isJsxExpression(expression.parent)) return false;
|
|
86
|
+
const container = expression.parent.parent;
|
|
87
|
+
if (ts.isJsxElement(container) || ts.isJsxFragment(container)) return true;
|
|
88
|
+
return ts.isJsxAttribute(container) && /^(?:children|render[A-Z].*|.*Renderer)$/.test(container.name.getText());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolve same-file callback aliases before walking buttons: ancestry alone misses rows.map(renderRow). */
|
|
92
|
+
function collectionCallbacks(ts, source) {
|
|
93
|
+
const definitions = new Map();
|
|
94
|
+
const callbacks = [];
|
|
95
|
+
const collect = node => {
|
|
96
|
+
if (isMap(ts, node) && node.arguments[0]) callbacks.push(node.arguments[0]);
|
|
97
|
+
const name = (ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name;
|
|
98
|
+
if (name && ts.isIdentifier(name)) definitions.set(name.text, [...(definitions.get(name.text) ?? []), ts.isVariableDeclaration(node) ? node.initializer : node]);
|
|
99
|
+
ts.forEachChild(node, collect);
|
|
100
|
+
};
|
|
101
|
+
collect(source);
|
|
102
|
+
const owners = new Set();
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const resolve = node => {
|
|
105
|
+
if (!node || seen.has(node)) return;
|
|
106
|
+
seen.add(node);
|
|
107
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) owners.add(node);
|
|
108
|
+
else if (ts.isIdentifier(node)) for (const definition of definitions.get(node.text) ?? []) resolve(definition);
|
|
109
|
+
else if (ts.isParenthesizedExpression(node)) resolve(node.expression);
|
|
110
|
+
else if (ts.isPropertyAccessExpression(node)) for (const definition of definitions.get(node.name.text) ?? []) resolve(definition);
|
|
111
|
+
else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'bind') resolve(node.expression.expression);
|
|
112
|
+
};
|
|
113
|
+
for (const callback of callbacks) resolve(callback);
|
|
114
|
+
return owners;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderOwner(ts, node, source) {
|
|
118
|
+
for (let at = node.parent; at; at = at.parent) {
|
|
119
|
+
if (ts.isArrowFunction(at) || ts.isFunctionExpression(at) || ts.isFunctionDeclaration(at) || ts.isMethodDeclaration(at)) {
|
|
120
|
+
// Collection and JSX render callbacks belong to their enclosing component's render, including nested SlotView children.
|
|
121
|
+
if (callbackArgument(ts, at) || inlineRenderCallback(ts, at)) continue;
|
|
122
|
+
return at;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return source;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function inMap(ts, node, owner) {
|
|
129
|
+
for (let at = node.parent; at && at !== owner; at = at.parent) {
|
|
130
|
+
if (isMap(ts, at)) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function siblingGroup(ts, node, owner) {
|
|
136
|
+
for (let at = node.parent; at && at !== owner; at = at.parent) {
|
|
137
|
+
if (ts.isJsxElement(at) || ts.isJsxFragment(at)) return at;
|
|
138
|
+
}
|
|
139
|
+
return owner;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function stableName(ts, node) {
|
|
143
|
+
if (ts.isIdentifier(node)) return node.text;
|
|
144
|
+
if (ts.isPropertyAccessExpression(node) && stableName(ts, node.expression)) return `${stableName(ts, node.expression)}.${node.name.text}`;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function conditionFact(ts, expression, choice) {
|
|
149
|
+
if (ts.isParenthesizedExpression(expression)) return conditionFact(ts, expression.expression, choice);
|
|
150
|
+
if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.ExclamationToken) return conditionFact(ts, expression.operand, !choice);
|
|
151
|
+
if (ts.isBinaryExpression(expression)) {
|
|
152
|
+
const operator = expression.operatorToken.kind;
|
|
153
|
+
const equality = operator === ts.SyntaxKind.EqualsEqualsEqualsToken;
|
|
154
|
+
const inequality = operator === ts.SyntaxKind.ExclamationEqualsEqualsToken;
|
|
155
|
+
if (!equality && !inequality) return null;
|
|
156
|
+
const name = stableName(ts, expression.left);
|
|
157
|
+
const value = expression.right;
|
|
158
|
+
if (name && (ts.isStringLiteralLike(value) || ts.isNumericLiteral(value) || [ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(value.kind))) {
|
|
159
|
+
const canonical = ts.isStringLiteralLike(value) ? `string:${value.text}` : ts.isNumericLiteral(value) ? `number:${Number(value.text)}` : value.getText();
|
|
160
|
+
return { name, value: canonical, equal: equality === choice };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const name = stableName(ts, expression);
|
|
164
|
+
return name ? { name, value: 'truthy', equal: choice } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function branchChoices(ts, node, owner) {
|
|
168
|
+
const choices = new Map();
|
|
169
|
+
const facts = [];
|
|
170
|
+
const record = (branch, expression, choice) => {
|
|
171
|
+
choices.set(branch, choice);
|
|
172
|
+
const fact = conditionFact(ts, expression, choice);
|
|
173
|
+
if (fact) facts.push(fact);
|
|
174
|
+
};
|
|
175
|
+
for (let at = node; at.parent && at.parent !== owner; at = at.parent) {
|
|
176
|
+
const parent = at.parent;
|
|
177
|
+
if (ts.isConditionalExpression(parent)) {
|
|
178
|
+
if (at === parent.whenTrue) record(parent, parent.condition, true);
|
|
179
|
+
if (at === parent.whenFalse) record(parent, parent.condition, false);
|
|
180
|
+
}
|
|
181
|
+
if (ts.isIfStatement(parent)) {
|
|
182
|
+
if (at === parent.thenStatement) record(parent, parent.expression, true);
|
|
183
|
+
if (at === parent.elseStatement) record(parent, parent.expression, false);
|
|
184
|
+
}
|
|
185
|
+
if (ts.isBinaryExpression(parent) && at === parent.right) {
|
|
186
|
+
if (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) record(parent, parent.left, true);
|
|
187
|
+
if (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken) record(parent, parent.left, false);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { choices, facts };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function canCoexist(first, second) {
|
|
194
|
+
for (const [branch, choice] of first.branches) if (second.branches.has(branch) && second.branches.get(branch) !== choice) return false;
|
|
195
|
+
for (const left of first.facts) for (const right of second.facts) {
|
|
196
|
+
if (left.name !== right.name) continue;
|
|
197
|
+
if (left.value === right.value && left.equal !== right.equal) return false;
|
|
198
|
+
if (left.equal && right.equal && left.value !== right.value && left.value !== 'truthy' && right.value !== 'truthy') return false;
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function destructiveSignal(text) {
|
|
204
|
+
const words = text.normalize('NFD').replace(/\p{M}/gu, '').replaceAll('\u0111', 'd').replaceAll('\u0110', 'D')
|
|
205
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase();
|
|
206
|
+
return /\b(delete|remove|revoke|discard|destroy|purge|trash|terminate|clear|erase|reset|reject|decline|xoa)\b|\bthu[\s_-]*hoi\b|\b(?:cancel|huy)[\s_-]+(?:offer|booking|appointment|order|subscription|lich|don|de\s+nghi)\b/.test(words);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function describe(ts, source, opening, imports, callbacks) {
|
|
210
|
+
const props = attributes(ts, opening);
|
|
211
|
+
const element = ts.isJsxOpeningElement(opening) ? opening.parent : opening;
|
|
212
|
+
const owner = renderOwner(ts, element, source);
|
|
213
|
+
const variant = literal(ts, props.get('variant'));
|
|
214
|
+
const intent = literal(ts, props.get('intent'));
|
|
215
|
+
const branch = branchChoices(ts, element, owner);
|
|
216
|
+
return { opening, element, owner, props, variant, intent, branches: branch.choices, facts: branch.facts,
|
|
217
|
+
group: siblingGroup(ts, element, owner), mapped: variant === 'secondary' ? null : VARIANTS[variant],
|
|
218
|
+
repeated: callbacks.has(owner) || inMap(ts, element, owner), shadowed: shadowedBinding(ts, opening), name: opening.tagName.getText(source), imports };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Migrate imported Grammar buttons using source edits; ambiguous buttons retain their original props and gain a report finding. */
|
|
222
|
+
export function transformButtonIntent({ ts, text, file = 'component.tsx' }) {
|
|
223
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
|
|
224
|
+
const imports = buttonImports(ts, source);
|
|
225
|
+
const callbacks = collectionCallbacks(ts, source);
|
|
226
|
+
const buttons = [];
|
|
227
|
+
const visit = node => {
|
|
228
|
+
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && isButton(ts, node, imports)) buttons.push(describe(ts, source, node, imports, callbacks));
|
|
229
|
+
ts.forEachChild(node, visit);
|
|
230
|
+
};
|
|
231
|
+
visit(source);
|
|
232
|
+
const findings = [];
|
|
233
|
+
const changes = [];
|
|
234
|
+
const edits = [];
|
|
235
|
+
const flag = (button, code, reason) => findings.push({ file, ...sourceLocation(source, button.opening), code, reason,
|
|
236
|
+
component: button.name, variant: button.variant, source: button.element.getText(source) });
|
|
237
|
+
if (source.parseDiagnostics.length) {
|
|
238
|
+
return { text, changes, findings: [{ file, line: 1, column: 1, code: 'parse-error', reason: ts.flattenDiagnosticMessageText(source.parseDiagnostics[0].messageText, '\n') }] };
|
|
239
|
+
}
|
|
240
|
+
const commits = buttons.filter(button => button.variant === 'primary' || button.intent === 'commit');
|
|
241
|
+
for (const button of buttons) {
|
|
242
|
+
if (button.shadowed) {
|
|
243
|
+
flag(button, 'shadowed-button-binding', 'A local binding shadows the Grammar import; inspect its actual component before migrating.');
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const pending = [];
|
|
247
|
+
if (button.intent === 'commit' || button.variant === 'primary') {
|
|
248
|
+
if (button.repeated) pending.push(['commit-in-collection', 'A repeated item needs a human choice of alternative or neutral.']);
|
|
249
|
+
if (commits.some(other => other !== button && other.owner === button.owner && canCoexist(button, other))) pending.push(['multiple-commits', 'More than one commit can render in this component; choose the decision owner and next action.']);
|
|
250
|
+
}
|
|
251
|
+
if (button.props.has('intent') && !button.props.has('variant')) {
|
|
252
|
+
for (const [code, reason] of pending) flag(button, code, reason);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (button.props.has('intent')) pending.push(['intent-and-variant', 'Existing intent and variant need a human reconciliation.']);
|
|
256
|
+
if (!button.props.has('variant')) pending.push(['missing-variant', 'No old variant declares the command role; choose its consequence and rank.']);
|
|
257
|
+
else if (button.variant === null) pending.push(['dynamic-variant', 'A dynamic variant needs a human intent for every branch.']);
|
|
258
|
+
else if (button.variant !== 'secondary' && !button.mapped) pending.push(['unknown-variant', 'This variant has no published mechanical intent mapping.']);
|
|
259
|
+
if (button.opening.attributes.properties.some(ts.isJsxSpreadAttribute)) pending.push(['spread-props', 'Spread props can supply or override intent, step or variant; inspect the source.']);
|
|
260
|
+
if (WEAK.has(button.variant) && destructiveSignal(button.element.getText(source))) pending.push(['destructive-verb', 'A destructive or negative command on a weak variant needs its actual consequence and confirmation step reviewed.']);
|
|
261
|
+
if (button.props.has('step')) pending.push(['existing-step', 'An existing destructive step needs reconciliation with the migrated intent.']);
|
|
262
|
+
if (pending.length) {
|
|
263
|
+
for (const [code, reason] of pending) flag(button, code, reason);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const intent = button.variant === 'secondary'
|
|
267
|
+
? commits.some(other => other.group === button.group && canCoexist(button, other)) ? 'alternative' : 'neutral'
|
|
268
|
+
: button.mapped;
|
|
269
|
+
const step = button.variant === 'danger' ? 'confirm' : button.variant === 'danger-soft' ? 'entry' : null;
|
|
270
|
+
const attribute = button.props.get('variant');
|
|
271
|
+
const replacement = `intent="${intent}"${step ? ` step="${step}"` : ''}`;
|
|
272
|
+
edits.push({ start: attribute.getStart(source), end: attribute.end, replacement });
|
|
273
|
+
changes.push({ file, ...sourceLocation(source, button.opening), component: button.name, from: button.variant, intent, ...(step ? { step } : {}) });
|
|
274
|
+
}
|
|
275
|
+
let output = text;
|
|
276
|
+
for (const edit of edits.sort((first, second) => second.start - first.start)) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
|
|
277
|
+
return { text: output, changes, findings };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sourceFiles(directory) {
|
|
281
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
282
|
+
const target = path.join(directory, entry.name);
|
|
283
|
+
if (entry.isDirectory()) return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
284
|
+
return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
|
|
285
|
+
}).sort();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Preview or apply safe migrations in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
289
|
+
export function migrateButtonIntent({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
290
|
+
if (!ts) throw new Error('button-intent needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
291
|
+
const frontend = path.join(root, 'fe');
|
|
292
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory()) throw new Error('button-intent runs at an app root with a fe/ directory');
|
|
293
|
+
const report = { schema: 'starci/button-intent-codemod@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
294
|
+
for (const target of sourceFiles(frontend)) {
|
|
295
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
296
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
297
|
+
const result = transformButtonIntent({ ts, text: original, file });
|
|
298
|
+
report.filesScanned += 1;
|
|
299
|
+
report.changes.push(...result.changes);
|
|
300
|
+
report.findings.push(...result.findings);
|
|
301
|
+
if (result.text !== original) {
|
|
302
|
+
report.filesChanged.push(file);
|
|
303
|
+
if (write) fs.writeFileSync(target, result.text);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return report;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const cell = value => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
310
|
+
|
|
311
|
+
/** Render the migration and human decision list as Markdown; JSON consumers use the same unabridged report object. */
|
|
312
|
+
export function buttonIntentMarkdown(report) {
|
|
313
|
+
const lines = ['# Button intent migration', '', `${report.write ? 'Applied' : 'Dry run'}: ${report.changes.length} safe changes in ${report.filesChanged.length} files; ${report.findings.length} unresolved findings.`, '',
|
|
314
|
+
'## Safe changes', '', '| Location | Before | Intent | Step |', '| --- | --- | --- | --- |',
|
|
315
|
+
...report.changes.map(change => `| ${cell(`${change.file}:${change.line}:${change.column}`)} | ${change.from} | ${change.intent} | ${change.step ?? ''} |`), '',
|
|
316
|
+
'## Human decisions', '', '| Location | Code | Reason |', '| --- | --- | --- |',
|
|
317
|
+
...report.findings.map(finding => `| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`), ''];
|
|
318
|
+
return lines.join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function parse(argv) {
|
|
322
|
+
const [name, ...rest] = argv;
|
|
323
|
+
if (name !== 'button-intent') throw new Error('starci app codemod takes button-intent');
|
|
324
|
+
const options = { write: false, format: 'markdown' };
|
|
325
|
+
const args = rest.values();
|
|
326
|
+
for (const arg of args) {
|
|
327
|
+
const [flag, inline] = arg.split(/=(.*)/s);
|
|
328
|
+
if (flag === '--write' || flag === '--json') {
|
|
329
|
+
if (inline !== undefined && !['true', 'false'].includes(inline)) throw new Error(`${flag} expects true or false`);
|
|
330
|
+
const value = inline === undefined || inline === 'true';
|
|
331
|
+
if (flag === '--write') options.write = value;
|
|
332
|
+
else if (value) options.format = 'json';
|
|
333
|
+
} else if (flag === '--format' || flag === '--report') {
|
|
334
|
+
const value = inline ?? args.next().value;
|
|
335
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} needs a value`);
|
|
336
|
+
options[flag.slice(2)] = value;
|
|
337
|
+
} else throw new Error(`unknown codemod option ${arg}`);
|
|
338
|
+
}
|
|
339
|
+
if (!['json', 'markdown'].includes(options.format)) throw new Error('--format accepts json or markdown');
|
|
340
|
+
return options;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** CLI boundary: dry-run by default, --write mutates only safe FE source; findings return exit 1 with a complete report. */
|
|
344
|
+
export function codemodMain(argv, { cwd, stdout }) {
|
|
345
|
+
const options = parse(argv);
|
|
346
|
+
if (options.report && !/\.(?:json|md)$/i.test(options.report)) throw new Error('--report needs a .json or .md report file');
|
|
347
|
+
const report = migrateButtonIntent({ root: path.resolve(cwd), write: options.write });
|
|
348
|
+
const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : buttonIntentMarkdown(report);
|
|
349
|
+
if (options.report) {
|
|
350
|
+
const target = path.resolve(cwd, options.report);
|
|
351
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
352
|
+
fs.writeFileSync(target, output);
|
|
353
|
+
}
|
|
354
|
+
stdout(output);
|
|
355
|
+
return report.findings.length ? 1 : 0;
|
|
356
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.37",
|
|
4
4
|
"description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"db/**",
|
|
40
40
|
"ci/**",
|
|
41
41
|
"coverage/**",
|
|
42
|
+
"codemod/**",
|
|
42
43
|
"lint/**",
|
|
43
44
|
"scaffold/**",
|
|
44
45
|
"report/**",
|
|
@@ -61,7 +61,16 @@ topics:
|
|
|
61
61
|
shadow, one pink decision accent with a text-safe accent for text — with DNA, idioms, a
|
|
62
62
|
mobile-first playbook for playful learning or consumer apps, and family boundaries including two
|
|
63
63
|
decisions flagged for the family owner.
|
|
64
|
+
- id: heritage
|
|
65
|
+
path: heritage/DNA.yaml
|
|
66
|
+
title: Heritage Grammar
|
|
67
|
+
summary: |
|
|
68
|
+
Generated snapshot of the Heritage family's parity declaration, arrangement, scoped tokens and Common renderers.
|
|
64
69
|
families:
|
|
70
|
+
- id: heritage
|
|
71
|
+
title: heritage
|
|
72
|
+
contractRef: common
|
|
73
|
+
packageSnapshot: heritage/DNA.yaml
|
|
65
74
|
- id: common
|
|
66
75
|
title: common
|
|
67
76
|
componentContract:
|
|
@@ -132,7 +132,10 @@ shapes:
|
|
|
132
132
|
`cancelLabel` in the product's words; `onConfirm` may return a promise, which keeps the confirm
|
|
133
133
|
pending and the dialog open until it settles
|
|
134
134
|
- renderer: Button
|
|
135
|
-
role:
|
|
135
|
+
role: |
|
|
136
|
+
The trigger, inside the Core root: `intent="destructive"` at the entry step, which resolves to
|
|
137
|
+
`danger-soft` (CTA-6); the confirm inside the AlertDialog is the solid `danger` confirm, dismiss
|
|
138
|
+
comes first and takes initial focus
|
|
136
139
|
notes: |
|
|
137
140
|
Initial focus lands on the cancel action and an outside press never dismisses. A reversible
|
|
138
141
|
action is not a confirmation: act, then offer undo through a Toast `action`.
|
|
@@ -189,7 +192,8 @@ shapes:
|
|
|
189
192
|
implements: [FE-APP-SHELL-2]
|
|
190
193
|
compose: |
|
|
191
194
|
IdentityLockup sits inside PlainPressable size="control". Its square logo mark has a
|
|
192
|
-
transparent background, no radius and object-fit contain
|
|
195
|
+
transparent background, no radius and object-fit contain; without an image it uses Avatar's
|
|
196
|
+
bundled DiceBear initials. The title
|
|
193
197
|
has a muted role subtitle and no caret. PlainPressable has no button shape or hover effect;
|
|
194
198
|
press uses scale(0.96), focus remains visible and reduced motion removes the zoom.
|
|
195
199
|
- id: navbar-right-cluster
|
|
@@ -199,7 +203,9 @@ shapes:
|
|
|
199
203
|
single-select DropdownMenu, Switch size="control" with sun/moon size-5 thumb icons and
|
|
200
204
|
isLabelHidden, a bell IconButton with count badge opening Popover, then a circle-only
|
|
201
205
|
AvatarButton size="control" opening the account DropdownMenu. AvatarButton uses uploaded src
|
|
202
|
-
first and otherwise
|
|
206
|
+
first and otherwise the bundled DiceBear initials style: the first and last words of name
|
|
207
|
+
supply the letters, and userId passed as seed determines colour, falling back to name when
|
|
208
|
+
seed is absent (owner:avatar-default-dicebear-initials). DropdownMenu preserves HeroUI's
|
|
203
209
|
anatomy and default selection indicator. The supported language labels use the "Vietnamese"
|
|
204
210
|
and "English" entries in modules/i18n/messages/app-shell.yaml; each language keeps its autonym.
|
|
205
211
|
- id: account-menu
|
|
@@ -245,9 +251,12 @@ shapes:
|
|
|
245
251
|
when those categories exist in the product; labels come from modules/i18n/messages/ui.yaml
|
|
246
252
|
and modules/i18n/messages/app-shell.yaml. ListBox panel alignment keeps rows and section
|
|
247
253
|
captions on the same content edge without padding wrappers. A row shows avatar, name,
|
|
248
|
-
trailing time, last message and unread badge; the open row is
|
|
249
|
-
|
|
250
|
-
|
|
254
|
+
trailing time, last message and unread badge; the open row is selected through HeroUI.
|
|
255
|
+
The name and preview each occupy one line with an ellipsis, and the title carries no group
|
|
256
|
+
glyph (owner:item-description-one-line). Pass emphasis="unread" for unread rows so Grammar
|
|
257
|
+
strengthens the label and preview; read rows keep the vendor label and muted preview.
|
|
258
|
+
Group identity uses AvatarGroup layout="grid" with the member/count arrangement declared
|
|
259
|
+
by presentation.groupIdentity.
|
|
251
260
|
notes: |
|
|
252
261
|
FE-RULE-OWNERSHIP in knowledge/patterns/fe/index.yaml separates universal app-shell semantics
|
|
253
262
|
from these family APIs, values, shapes and effects. @starci/eslint-grammar-starci owns their
|
|
@@ -463,6 +472,16 @@ guidance:
|
|
|
463
472
|
in a component.
|
|
464
473
|
|
|
465
474
|
presentation:
|
|
475
|
+
collectionItems:
|
|
476
|
+
ownership: FE-RULE-OWNERSHIP
|
|
477
|
+
requirement: |
|
|
478
|
+
ListBoxItem, DropdownMenuItem, SidebarItem and the ListOption shared by Select and ComboBox
|
|
479
|
+
render descriptions in HeroUI's Description slot. The family DNA's collectionItem.descriptionLines
|
|
480
|
+
defaults to one line with an ellipsis (owner:item-description-one-line); descriptionLines accepts
|
|
481
|
+
1, 2 or 3 for a bounded override through the packaged stylesheet's line clamp. The complete
|
|
482
|
+
description remains available to assistive technology. ListBoxItem emphasis="unread" uses
|
|
483
|
+
collectionItem.unreadWeight for both label and description; omit it for HeroUI's read treatment.
|
|
484
|
+
labelLeading remains an optional decorative title slot, separate from conversation identity.
|
|
466
485
|
glyphs:
|
|
467
486
|
ownership: FE-RULE-OWNERSHIP
|
|
468
487
|
implements: FE-ICON
|
|
@@ -478,13 +497,23 @@ presentation:
|
|
|
478
497
|
implements: COLLECTION-3
|
|
479
498
|
requirement: |
|
|
480
499
|
Compose AvatarGroup layout="grid" at one Avatar size, including size="control". Two members
|
|
481
|
-
split the round mark; three show one member above two below; four occupy a 2 by 2 grid.
|
|
482
|
-
|
|
483
|
-
the
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
500
|
+
split the round mark; three show one member above two below; four occupy a 2 by 2 grid. Every cell
|
|
501
|
+
has a ring in the surface colour. At five or more members the first three remain visible and the
|
|
502
|
+
fourth cell shows the total member count, such as 5 or 30, through Avatar's shared fallback path.
|
|
503
|
+
The row layout keeps its +N count. overflowLabel receives the hidden-member count in both layouts
|
|
504
|
+
for accessible naming, while the grid digits show the total. A single person uses Avatar.
|
|
505
|
+
Pass an uploaded image as `src`; otherwise Grammar always draws the bundled DiceBear `initials`
|
|
506
|
+
style, with the first letter of the first word and the first letter of the last word of `name`
|
|
507
|
+
(one word supplies one letter). `seed`, the user or entity id, determines colour when present;
|
|
508
|
+
otherwise `name` does. The image clips to the Avatar shape, circle by default, and keeps text
|
|
509
|
+
contrast in both themes (owner:avatar-default-dicebear-initials).
|
|
510
|
+
Avatar, AvatarButton and each AvatarGroup member accept `kind="person" | "agent"`, default person.
|
|
511
|
+
An agent's name is free text and uses exactly the same initials rule, without role-suffix parsing.
|
|
512
|
+
`kind="agent"` adds a HeroUI Badge at the Avatar's bottom-right edge with a bot glyph, a surface-colour
|
|
513
|
+
ring and an accessible name containing AI (owner:agent-avatar-initials-bot-badge). The family hides
|
|
514
|
+
the badge below 32px, including the 16px to 24px cells in a 2 by 2 grid; the identity's accessible
|
|
515
|
+
name still includes AI. Apps neither declare nor import `@dicebear/*`. Apps supply member identity
|
|
516
|
+
data; the renderer owns the geometry.
|
|
488
517
|
padding:
|
|
489
518
|
ownership: FE-RULE-OWNERSHIP
|
|
490
519
|
knowledge: knowledge/ui/presentation/padding.yaml
|
|
@@ -731,8 +760,10 @@ presentation:
|
|
|
731
760
|
when: A Grammar list sits inside a panel or its rows own their own inset
|
|
732
761
|
owner: "`SurfaceListCard` / `ListBox`"
|
|
733
762
|
render: ListBox resolves inset="panel" in a surface nesting context, or callers select it explicitly. Its
|
|
734
|
-
|
|
735
|
-
|
|
763
|
+
row boxes align with the panel content edge, like the search field and tabs boxes, without
|
|
764
|
+
negative-margin bleed (owner:panel-list-box-edge). The list retains HeroUI p-1; items retain
|
|
765
|
+
px-2 py-1.5, rounded-2xl and mt-1 between items, with the same padding and radius for hover,
|
|
766
|
+
pressed and selected backgrounds. Section captions share the inset content edge. Padding
|
|
736
767
|
wrappers with px-*, ps-* or pe-* stack this inset and fail FE_LIST_PANEL_PADDING in the universal
|
|
737
768
|
FE canon (R318).
|
|
738
769
|
- rule: PADDING-1
|
|
@@ -23,35 +23,35 @@ pins:
|
|
|
23
23
|
source: packages/heroicons/package.json
|
|
24
24
|
why: 'StarCi custom Heroicons-compatible cuts; eslint-grammar-starci (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
25
25
|
'@starci/grammar':
|
|
26
|
-
version: 0.12.
|
|
26
|
+
version: 0.12.4
|
|
27
27
|
group: starci
|
|
28
28
|
install: registry
|
|
29
29
|
side: fe
|
|
30
30
|
source: packages/grammar/package.json
|
|
31
31
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
32
32
|
'@starci/eslint-canon-be':
|
|
33
|
-
version: 3.4.
|
|
33
|
+
version: 3.4.45
|
|
34
34
|
group: starci
|
|
35
35
|
install: registry
|
|
36
36
|
side: be
|
|
37
37
|
source: packages/eslint/be/package.json
|
|
38
38
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
39
39
|
'@starci/eslint-canon-fe':
|
|
40
|
-
version: 9.
|
|
40
|
+
version: 9.1.0
|
|
41
41
|
group: starci
|
|
42
42
|
install: registry
|
|
43
43
|
side: fe
|
|
44
44
|
source: packages/eslint/fe/package.json
|
|
45
45
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
46
46
|
'@starci/eslint-grammar-starci':
|
|
47
|
-
version: 0.12.
|
|
47
|
+
version: 0.12.4
|
|
48
48
|
group: starci
|
|
49
49
|
install: registry
|
|
50
50
|
side: fe
|
|
51
51
|
source: packages/eslint/grammar-starci/package.json
|
|
52
52
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
53
53
|
'@starci/eslint-grammar-offset-pop':
|
|
54
|
-
version: 0.12.
|
|
54
|
+
version: 0.12.4
|
|
55
55
|
group: starci
|
|
56
56
|
install: registry
|
|
57
57
|
side: fe
|
|
@@ -91,14 +91,14 @@ pins:
|
|
|
91
91
|
source: packages/test-world/package.json
|
|
92
92
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
93
93
|
'@starci/cli':
|
|
94
|
-
version: 1.7.
|
|
94
|
+
version: 1.7.37
|
|
95
95
|
group: starci
|
|
96
96
|
install: registry
|
|
97
97
|
side: both
|
|
98
98
|
source: packages/cli/package.json
|
|
99
99
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
100
100
|
'@starci/hfs':
|
|
101
|
-
version: 4.7.
|
|
101
|
+
version: 4.7.37
|
|
102
102
|
group: starci
|
|
103
103
|
install: registry
|
|
104
104
|
side: both
|
|
@@ -121,10 +121,11 @@ concerns:
|
|
|
121
121
|
pattern: FE-UI
|
|
122
122
|
forbids: ['@dicebear/*', boring-avatars, 'react-nice-avatar', multiavatar]
|
|
123
123
|
why: >-
|
|
124
|
-
Grammar owns
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
Grammar owns avatars: pass the uploaded avatar as src and the user or identity id as seed on Avatar, AvatarButton
|
|
125
|
+
or AvatarGroup members. Without src, Grammar renders bundled DiceBear initials using the first and last words of
|
|
126
|
+
name, with colour seeded by seed when present and by name otherwise. kind="agent" adds the Grammar-owned bot badge
|
|
127
|
+
without changing the name rule. @dicebear/core and @dicebear/styles are Grammar runtime dependencies pinned in
|
|
128
|
+
canon-pins.yaml. Apps declare and import @starci/grammar, never @dicebear/*, and draw no avatar visuals.
|
|
128
129
|
- id: theme
|
|
129
130
|
tier: core
|
|
130
131
|
packages: [next-themes]
|