@nodii/dev 0.6.14 → 0.6.16
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/README.md +81 -0
- package/dist/cli.js +25 -2
- package/dist/core.js +2 -2
- package/dist/design.js +407 -0
- package/dist/devops-openapi.json +1 -1
- package/dist/figma.js +41 -0
- package/dist/vendor/ds-conformance.mjs +921 -0
- package/package.json +1 -1
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
// ds-conformance.mjs: the design-system conformance check (DEC-0034 MW6-14 to
|
|
2
|
+
// MW6-17, as amended by DEC-0035, DEC-0036 and DEC-0045).
|
|
3
|
+
//
|
|
4
|
+
// Canonical source: design-system `infra/figma/ds-conformance.mjs`. Every copy
|
|
5
|
+
// (Dev's submit gate, nodii-clis' `design check`) vendors these
|
|
6
|
+
// exact bytes and pins their sha256 in a test, so a drift fails CI. Change it
|
|
7
|
+
// in the design system first, then re-vendor.
|
|
8
|
+
//
|
|
9
|
+
// Pure and dependency-free: no imports, no I/O. It reads Figma REST /nodes
|
|
10
|
+
// entries ({ document, components, componentSets, styles }) and returns
|
|
11
|
+
// findings; the caller fetches.
|
|
12
|
+
//
|
|
13
|
+
// checkConformance({ nodes, roles, mains, options })
|
|
14
|
+
// nodes: the /nodes response's `nodes` map for the submitted node ids
|
|
15
|
+
// roles: GET /design/roles rows ({ role, labels, componentName, nodeId,
|
|
16
|
+
// componentKey, variant, ... }); `variant` ({ prop: value })
|
|
17
|
+
// narrows a role to some variants of its set
|
|
18
|
+
// mains: componentId (as the submitted file names it) -> the /nodes
|
|
19
|
+
// entry of that main component (from the library file for a
|
|
20
|
+
// remote component, from the same file for a local one), or
|
|
21
|
+
// null when it can't be resolved
|
|
22
|
+
// options: { library?: boolean, declaredRoles?: string[] }
|
|
23
|
+
// returns { version, findings, staleOverrides, libraryUpdatePending },
|
|
24
|
+
// each a list of { nodeId, check, detail }; only `findings` fail
|
|
25
|
+
// neededMains(nodes, mains, options) -> componentIds still to fetch; call
|
|
26
|
+
// it until it returns [] (each round can reveal instances inside slot
|
|
27
|
+
// content).
|
|
28
|
+
|
|
29
|
+
export const VERSION = "1.0.3";
|
|
30
|
+
|
|
31
|
+
/** @typedef {{ nodeId: string, check: string, detail: string }} Finding */
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{ role: string, labels?: string[] | null,
|
|
34
|
+
* componentName?: string | null, nodeId?: string | null,
|
|
35
|
+
* componentKey?: string | null,
|
|
36
|
+
* variant?: Record<string, string> | null }} RoleRow
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {{ document?: any, components?: Record<string, any>,
|
|
40
|
+
* componentSets?: Record<string, any>, styles?: Record<string, any> }} NodesEntry
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// Fields an instance may override (text, swaps, variants, visibility, size,
|
|
44
|
+
// layout, prototype links). Anything else listed in `overriddenFields` is a
|
|
45
|
+
// visual override and fails unless its value equals the main's.
|
|
46
|
+
const ALLOWED_FIELDS = new Set([
|
|
47
|
+
"name",
|
|
48
|
+
"characters",
|
|
49
|
+
"text",
|
|
50
|
+
"characterStyleOverrides",
|
|
51
|
+
"styleOverrideTable",
|
|
52
|
+
"lineTypes",
|
|
53
|
+
"lineIndentations",
|
|
54
|
+
"componentProperties",
|
|
55
|
+
"componentPropertyReferences",
|
|
56
|
+
"visible",
|
|
57
|
+
"width",
|
|
58
|
+
"height",
|
|
59
|
+
"size",
|
|
60
|
+
"layoutGrow",
|
|
61
|
+
"layoutAlign",
|
|
62
|
+
"layoutSizingHorizontal",
|
|
63
|
+
"layoutSizingVertical",
|
|
64
|
+
"layoutPositioning",
|
|
65
|
+
"primaryAxisSizingMode",
|
|
66
|
+
"counterAxisSizingMode",
|
|
67
|
+
"primaryAxisAlignItems",
|
|
68
|
+
"counterAxisAlignItems",
|
|
69
|
+
"itemSpacing",
|
|
70
|
+
"counterAxisSpacing",
|
|
71
|
+
"paddingLeft",
|
|
72
|
+
"paddingRight",
|
|
73
|
+
"paddingTop",
|
|
74
|
+
"paddingBottom",
|
|
75
|
+
"textAutoResize",
|
|
76
|
+
"textTruncation",
|
|
77
|
+
"maxLines",
|
|
78
|
+
"clipsContent",
|
|
79
|
+
"overflowDirection",
|
|
80
|
+
"constraints",
|
|
81
|
+
"transitionNodeID",
|
|
82
|
+
"transitionDuration",
|
|
83
|
+
"transitionEasing",
|
|
84
|
+
"interactions",
|
|
85
|
+
"reactions",
|
|
86
|
+
"exportSettings",
|
|
87
|
+
"pluginData",
|
|
88
|
+
"sharedPluginData",
|
|
89
|
+
// Choosing one of the library's variable modes (a size or density mode)
|
|
90
|
+
// is a mode, like a variant, not a look override.
|
|
91
|
+
"explicitVariableModes",
|
|
92
|
+
"minWidth",
|
|
93
|
+
"maxWidth",
|
|
94
|
+
"minHeight",
|
|
95
|
+
"maxHeight",
|
|
96
|
+
"layoutWrap",
|
|
97
|
+
]);
|
|
98
|
+
const TEXT_STYLE_FIELDS = new Set([
|
|
99
|
+
"fontSize",
|
|
100
|
+
"fontFamily",
|
|
101
|
+
"fontWeight",
|
|
102
|
+
"fontPostScriptName",
|
|
103
|
+
"fontStyle",
|
|
104
|
+
"italic",
|
|
105
|
+
"letterSpacing",
|
|
106
|
+
"lineHeightPx",
|
|
107
|
+
"lineHeightPercent",
|
|
108
|
+
"lineHeightPercentFontSize",
|
|
109
|
+
"lineHeightUnit",
|
|
110
|
+
"textCase",
|
|
111
|
+
"textDecoration",
|
|
112
|
+
"paragraphSpacing",
|
|
113
|
+
"paragraphIndent",
|
|
114
|
+
"textAlignHorizontal",
|
|
115
|
+
"textAlignVertical",
|
|
116
|
+
]);
|
|
117
|
+
// Overridden-field names that point at a node's `styles` map.
|
|
118
|
+
const STYLE_FIELD = /^(inherit)?(Fill|Stroke|Text|Effect|Grid)StyleId$/i;
|
|
119
|
+
|
|
120
|
+
const SURFACE_NAME = /^Surface \//;
|
|
121
|
+
const CUSTOMER_HEADER =
|
|
122
|
+
/^(customer|client|contact|lead|applicant|candidate)s?( name)?$/i;
|
|
123
|
+
const EXTERNAL_PLANE = /customer portal|partner room|external/i;
|
|
124
|
+
// The viewer's own record (R5): the App Frame sidebar footer, the user menu
|
|
125
|
+
// and the profile. Not any footer: a dialog or table footer can list others.
|
|
126
|
+
const SELF_CONTAINER =
|
|
127
|
+
/sidebar ?footer|user button|user menu|account menu|profile/i;
|
|
128
|
+
const SELF_TEXT = /\(you\)|^you$/i;
|
|
129
|
+
const SIGNED_IN = /^(signed in( as\b.*)?|not you\?.*)$/i;
|
|
130
|
+
// The signed-in account card (Account Context, kind=Account) shows the
|
|
131
|
+
// viewer's own email.
|
|
132
|
+
const SELF_COMPONENT = /^account context$/i;
|
|
133
|
+
const NOT_A_PERSON = /^(you|the team)$/i;
|
|
134
|
+
const MASKED = /[*•●]/;
|
|
135
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
136
|
+
const ROLE_MAILBOX =
|
|
137
|
+
/^(no-?reply|support|help|team|admin|info|billing|hello|contact|ops|security|privacy|legal)$/i;
|
|
138
|
+
// A phone as people write one: international (+...) or grouped with
|
|
139
|
+
// separators, never a bare run of digits inside an id ("v2413049671").
|
|
140
|
+
const PHONE = /(?<![\w.+\-/])\+?\(?\d[\d ().-]{7,}\d(?![\w\-/])/g;
|
|
141
|
+
// Without a "+", a phone is grouped the way numbers are dialled (98765 43210,
|
|
142
|
+
// (555) 123-4567), not as an amount (1 000 000 000) or a date.
|
|
143
|
+
const LOCAL_PHONE = /^(\(\d{3,5}\)[ .-]?|\d{3,5}[ .-])\d{3,5}([ .-]\d{2,5})?$/;
|
|
144
|
+
const DATE = /^(\d{4}-\d{2}-\d{2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4})/;
|
|
145
|
+
const ROLE_ANNOTATION = /\[role:([a-z0-9._-]+)\]/gi;
|
|
146
|
+
|
|
147
|
+
/** "I12:34;56:78" -> ["12:34", "56:78"]; "12:34" -> ["12:34"]. */
|
|
148
|
+
const segments = (id) => (id.startsWith("I") ? id.slice(1) : id).split(";");
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The id a descendant of `instanceId` has in that instance's main component,
|
|
152
|
+
* or null when `id` is not under it. The instance itself maps to the main's
|
|
153
|
+
* root (returned as "").
|
|
154
|
+
*/
|
|
155
|
+
function mainIdOf(instanceId, id) {
|
|
156
|
+
const outer = segments(instanceId);
|
|
157
|
+
const inner = segments(id);
|
|
158
|
+
if (inner.length < outer.length) return null;
|
|
159
|
+
for (let i = 0; i < outer.length; i++) if (inner[i] !== outer[i]) return null;
|
|
160
|
+
const rest = inner.slice(outer.length);
|
|
161
|
+
if (rest.length === 0) return "";
|
|
162
|
+
return rest.length === 1 ? rest[0] : `I${rest.join(";")}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const visibleNode = (n) => n && n.visible !== false;
|
|
166
|
+
const visiblePaints = (paints) =>
|
|
167
|
+
Array.isArray(paints) &&
|
|
168
|
+
paints.some((p) => p && p.visible !== false && (p.opacity ?? 1) > 0);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A value made comparable. `opaque` turns every variable id into one marker:
|
|
172
|
+
* a library variable is "VariableID:<key>/<id>" in a consuming file but
|
|
173
|
+
* "VariableID:<id>" in the library, and without the variables scope the two
|
|
174
|
+
* can't be matched, so across files a binding is compared by being bound,
|
|
175
|
+
* and the bound value by the resolved colour or number beside it.
|
|
176
|
+
*/
|
|
177
|
+
function normalise(value, opaque = false) {
|
|
178
|
+
if (typeof value === "string")
|
|
179
|
+
return opaque && value.startsWith("VariableID:") ? "VariableID:*" : value;
|
|
180
|
+
if (Array.isArray(value)) return value.map((v) => normalise(v, opaque));
|
|
181
|
+
if (value && typeof value === "object") {
|
|
182
|
+
/** @type {Record<string, unknown>} */
|
|
183
|
+
const out = {};
|
|
184
|
+
for (const key of Object.keys(value).sort())
|
|
185
|
+
out[key] = normalise(value[key], opaque);
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
if (typeof value === "number") return Math.round(value * 1e4) / 1e4;
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
const KEYED_VARIABLE = /VariableID:[0-9a-f]{20,}\//;
|
|
192
|
+
/** Equal, or equal but for variable ids written in another file's form. */
|
|
193
|
+
function sameValue(a, b) {
|
|
194
|
+
const x = JSON.stringify(normalise(a));
|
|
195
|
+
const y = JSON.stringify(normalise(b));
|
|
196
|
+
if (x === y) return true;
|
|
197
|
+
if (KEYED_VARIABLE.test(x) === KEYED_VARIABLE.test(y)) return false;
|
|
198
|
+
return (
|
|
199
|
+
JSON.stringify(normalise(a, true)) === JSON.stringify(normalise(b, true))
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const styleKeys = (node, styles) => {
|
|
203
|
+
/** @type {Record<string, string>} */
|
|
204
|
+
const out = {};
|
|
205
|
+
for (const [kind, id] of Object.entries(node?.styles ?? {}))
|
|
206
|
+
out[kind] = styles?.[id]?.key ?? id;
|
|
207
|
+
return out;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** The value a visual override field reads on a node, comparable across files. */
|
|
211
|
+
function fieldValue(node, field, styles) {
|
|
212
|
+
if (STYLE_FIELD.test(field) || field === "styles")
|
|
213
|
+
return styleKeys(node, styles);
|
|
214
|
+
if (TEXT_STYLE_FIELDS.has(field) || field.startsWith("lineHeight"))
|
|
215
|
+
return node?.style?.[field];
|
|
216
|
+
// An image fill's picture is content (DEC-0048): only its scale mode,
|
|
217
|
+
// filters and opacity are look, so imageRef is left out of the comparison.
|
|
218
|
+
if (field === "fills" || field === "background")
|
|
219
|
+
return (node?.[field] ?? []).map((p) => {
|
|
220
|
+
if (p?.type !== "IMAGE") return p;
|
|
221
|
+
const { imageRef: _picture, gifRef: _gif, ...look } = p;
|
|
222
|
+
return look;
|
|
223
|
+
});
|
|
224
|
+
// A binding is judged per bound field: (un)binding a field the instance
|
|
225
|
+
// may override anyway (spacing, size) is that override, not a visual one.
|
|
226
|
+
if (field === "boundVariables") {
|
|
227
|
+
/** @type {Record<string, unknown>} */
|
|
228
|
+
const out = {};
|
|
229
|
+
for (const [key, value] of Object.entries(node?.boundVariables ?? {}))
|
|
230
|
+
if (!ALLOWED_FIELDS.has(key)) out[key] = value;
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
return node?.[field];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every node in a document by id, with its parent. */
|
|
237
|
+
function indexTree(root) {
|
|
238
|
+
/** @type {Map<string, any>} */
|
|
239
|
+
const byId = new Map();
|
|
240
|
+
/** @type {Map<string, any>} */
|
|
241
|
+
const parent = new Map();
|
|
242
|
+
const stack = [root];
|
|
243
|
+
while (stack.length > 0) {
|
|
244
|
+
const n = stack.pop();
|
|
245
|
+
if (!n || typeof n.id !== "string") continue;
|
|
246
|
+
byId.set(n.id, n);
|
|
247
|
+
// Children pushed last-first, so the walk (and the findings) follow
|
|
248
|
+
// document order.
|
|
249
|
+
const children = n.children ?? [];
|
|
250
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
251
|
+
parent.set(children[i].id, n);
|
|
252
|
+
stack.push(children[i]);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { byId, parent };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** One submitted root, walked once, with the facts every check needs. */
|
|
259
|
+
function analyse(entry, mains, library = false) {
|
|
260
|
+
const root = entry.document;
|
|
261
|
+
const components = entry.components ?? {};
|
|
262
|
+
const componentSets = entry.componentSets ?? {};
|
|
263
|
+
const { byId, parent } = indexTree(root);
|
|
264
|
+
/** @type {Map<string, ReturnType<typeof indexTree> | null>} */
|
|
265
|
+
const mainTrees = new Map();
|
|
266
|
+
const mainTree = (componentId) => {
|
|
267
|
+
if (!mainTrees.has(componentId)) {
|
|
268
|
+
const main = mains?.[componentId];
|
|
269
|
+
mainTrees.set(
|
|
270
|
+
componentId,
|
|
271
|
+
main?.document ? indexTree(main.document) : null,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return mainTrees.get(componentId) ?? null;
|
|
275
|
+
};
|
|
276
|
+
const ancestors = (n) => {
|
|
277
|
+
const out = [];
|
|
278
|
+
for (let p = parent.get(n.id); p; p = parent.get(p.id)) out.push(p);
|
|
279
|
+
return out;
|
|
280
|
+
};
|
|
281
|
+
/** The main node a node is drawn from by instance `inst`, if any. */
|
|
282
|
+
const mainNodeFor = (inst, id) => {
|
|
283
|
+
const tree = mainTree(inst.componentId);
|
|
284
|
+
const mainId = mainIdOf(inst.id, id);
|
|
285
|
+
if (!tree || mainId === null) return undefined;
|
|
286
|
+
return mainId === ""
|
|
287
|
+
? mains[inst.componentId]?.document
|
|
288
|
+
: tree.byId.get(mainId);
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* A node is composed by the library when some library instance above it
|
|
292
|
+
* draws it from its main. Anything else (top-level layers, slot content,
|
|
293
|
+
* and everything inside an instance of a local component, which is the
|
|
294
|
+
* screen's own drawing) is the screen's. A library instance whose main
|
|
295
|
+
* can't be read counts as composing everything under it, so nothing is
|
|
296
|
+
* flagged on a guess. In the library file itself every component is local
|
|
297
|
+
* and every instance composes.
|
|
298
|
+
*/
|
|
299
|
+
const composedMemo = new Map();
|
|
300
|
+
const composed = (n) => {
|
|
301
|
+
if (composedMemo.has(n.id)) return composedMemo.get(n.id);
|
|
302
|
+
let result = false;
|
|
303
|
+
for (const a of ancestors(n)) {
|
|
304
|
+
if (a.type !== "INSTANCE") continue;
|
|
305
|
+
if (!library && components[a.componentId]?.remote === false) continue;
|
|
306
|
+
const tree = mainTree(a.componentId);
|
|
307
|
+
if (!tree) {
|
|
308
|
+
result = true;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
if (mainNodeFor(a, n.id) !== undefined) {
|
|
312
|
+
result = true;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
composedMemo.set(n.id, result);
|
|
317
|
+
return result;
|
|
318
|
+
};
|
|
319
|
+
const shown = (n) =>
|
|
320
|
+
visibleNode(n) && ancestors(n).every((a) => visibleNode(a));
|
|
321
|
+
const componentOf = (inst) => components[inst.componentId] ?? null;
|
|
322
|
+
const setOf = (inst) => {
|
|
323
|
+
const c = componentOf(inst);
|
|
324
|
+
return c?.componentSetId ? (componentSets[c.componentSetId] ?? null) : null;
|
|
325
|
+
};
|
|
326
|
+
const componentLabel = (inst) =>
|
|
327
|
+
setOf(inst)?.name ?? componentOf(inst)?.name ?? inst.name;
|
|
328
|
+
return {
|
|
329
|
+
root,
|
|
330
|
+
components,
|
|
331
|
+
componentSets,
|
|
332
|
+
styles: entry.styles ?? {},
|
|
333
|
+
byId,
|
|
334
|
+
parent,
|
|
335
|
+
ancestors,
|
|
336
|
+
composed,
|
|
337
|
+
shown,
|
|
338
|
+
mainTree,
|
|
339
|
+
mainNodeFor,
|
|
340
|
+
componentOf,
|
|
341
|
+
setOf,
|
|
342
|
+
componentLabel,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Component ids whose mains the check still needs. */
|
|
347
|
+
export function neededMains(nodes, mains = {}, options = {}) {
|
|
348
|
+
const want = new Set();
|
|
349
|
+
for (const entry of Object.values(nodes ?? {})) {
|
|
350
|
+
if (!entry?.document) continue;
|
|
351
|
+
const a = analyse(entry, mains, Boolean(options.library));
|
|
352
|
+
for (const n of a.byId.values()) {
|
|
353
|
+
if (n.type !== "INSTANCE" || !n.componentId) continue;
|
|
354
|
+
if (Object.prototype.hasOwnProperty.call(mains, n.componentId)) continue;
|
|
355
|
+
// The screen's own instances (for overrides and slot content), and
|
|
356
|
+
// every table header cell (for its fill).
|
|
357
|
+
if (!a.composed(n) || headerCell(a, n)) want.add(n.componentId);
|
|
358
|
+
// An overridden instance inside one may be a swap or a new variant,
|
|
359
|
+
// read against its own main.
|
|
360
|
+
if (!a.composed(n))
|
|
361
|
+
for (const o of n.overrides ?? []) {
|
|
362
|
+
const target = a.byId.get(o?.id);
|
|
363
|
+
if (!target) continue;
|
|
364
|
+
for (const p of [target, ...a.ancestors(target)]) {
|
|
365
|
+
if (p === n) break;
|
|
366
|
+
if (
|
|
367
|
+
p.type === "INSTANCE" &&
|
|
368
|
+
p.componentId &&
|
|
369
|
+
!Object.prototype.hasOwnProperty.call(mains, p.componentId)
|
|
370
|
+
)
|
|
371
|
+
want.add(p.componentId);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return [...want].sort();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function headerCell(a, inst) {
|
|
380
|
+
const c = a.componentOf(inst);
|
|
381
|
+
const set = a.setOf(inst);
|
|
382
|
+
if (/^table header$/i.test(set?.name ?? c?.name ?? "")) return true;
|
|
383
|
+
// Exactly a header part: a Command list's part=group-heading has no fill.
|
|
384
|
+
const part = inst.componentProperties?.part?.value;
|
|
385
|
+
if (typeof part === "string" && /^head(er)?$/i.test(part)) return true;
|
|
386
|
+
return /(^|,\s*)part=head(er)?(\s*,|$)/i.test(c?.name ?? "");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function matchesRole(a, inst, role, library) {
|
|
390
|
+
const c = a.componentOf(inst);
|
|
391
|
+
const set = a.setOf(inst);
|
|
392
|
+
const component =
|
|
393
|
+
(role.componentKey &&
|
|
394
|
+
(c?.key === role.componentKey || set?.key === role.componentKey)) ||
|
|
395
|
+
(library &&
|
|
396
|
+
(inst.componentId === role.nodeId ||
|
|
397
|
+
c?.componentSetId === role.nodeId)) ||
|
|
398
|
+
(set?.name ?? c?.name) === role.componentName;
|
|
399
|
+
if (!component) return false;
|
|
400
|
+
// Every variant pair the role names, in the variant's name or properties.
|
|
401
|
+
const pairs = (c?.name ?? "").split(",").map((x) => x.trim().toLowerCase());
|
|
402
|
+
return Object.entries(role.variant ?? {}).every(([prop, value]) => {
|
|
403
|
+
const want = `${prop}=${value}`.toLowerCase();
|
|
404
|
+
return (
|
|
405
|
+
pairs.includes(want) ||
|
|
406
|
+
String(inst.componentProperties?.[prop]?.value ?? "").toLowerCase() ===
|
|
407
|
+
String(value).toLowerCase()
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Characters the screen chose: its own text, or an overridden label. */
|
|
413
|
+
function screenText(a, n, overridden) {
|
|
414
|
+
return !a.composed(n) || overridden.has(n.id);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function textOf(n) {
|
|
418
|
+
return typeof n.characters === "string" ? n.characters.trim() : "";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @param {{ nodes: Record<string, NodesEntry | null>, roles?: RoleRow[],
|
|
423
|
+
* mains?: Record<string, NodesEntry | null>,
|
|
424
|
+
* options?: { library?: boolean, declaredRoles?: string[] } }} input
|
|
425
|
+
* @returns {{ version: string, findings: Finding[], staleOverrides: Finding[],
|
|
426
|
+
* libraryUpdatePending: Finding[] }}
|
|
427
|
+
*/
|
|
428
|
+
export function checkConformance({
|
|
429
|
+
nodes,
|
|
430
|
+
roles: allRoles = [],
|
|
431
|
+
mains = {},
|
|
432
|
+
options = {},
|
|
433
|
+
}) {
|
|
434
|
+
const library = Boolean(options.library);
|
|
435
|
+
// A role the library has no component for yet (MW6-10) matches nothing.
|
|
436
|
+
const roles = allRoles.filter((r) => r.nodeId && r.componentName);
|
|
437
|
+
/** @type {Finding[]} */
|
|
438
|
+
const findings = [];
|
|
439
|
+
/** @type {Finding[]} */
|
|
440
|
+
const staleOverrides = [];
|
|
441
|
+
/** @type {Finding[]} */
|
|
442
|
+
const libraryUpdatePending = [];
|
|
443
|
+
const seen = new Set();
|
|
444
|
+
const push = (list, nodeId, check, detail) => {
|
|
445
|
+
const key = `${nodeId}|${check}|${detail}`;
|
|
446
|
+
if (seen.has(key)) return;
|
|
447
|
+
seen.add(key);
|
|
448
|
+
list.push({ nodeId, check, detail });
|
|
449
|
+
};
|
|
450
|
+
const roleByKey = new Map(roles.map((r) => [r.role, r]));
|
|
451
|
+
const labelRoles = new Map();
|
|
452
|
+
for (const r of roles)
|
|
453
|
+
for (const label of r.labels ?? [])
|
|
454
|
+
labelRoles.set(label.trim().toLowerCase(), r);
|
|
455
|
+
const librarySetNames = new Set();
|
|
456
|
+
const usedRoles = new Set();
|
|
457
|
+
|
|
458
|
+
for (const entry of Object.values(nodes ?? {})) {
|
|
459
|
+
if (!entry?.document) continue;
|
|
460
|
+
const a = analyse(entry, mains, library);
|
|
461
|
+
for (const set of Object.values(a.componentSets))
|
|
462
|
+
if (set?.remote) librarySetNames.add(set.name);
|
|
463
|
+
// Every (node, field) the screen overrode on an instance it placed.
|
|
464
|
+
/** @type {Map<string, { fields: Set<string>, from: any }>} */
|
|
465
|
+
const overrideFields = new Map();
|
|
466
|
+
const overridden = new Set();
|
|
467
|
+
for (const n of a.byId.values()) {
|
|
468
|
+
if (n.type !== "INSTANCE" || a.composed(n)) continue;
|
|
469
|
+
for (const o of n.overrides ?? []) {
|
|
470
|
+
if (!o?.id) continue;
|
|
471
|
+
const held = overrideFields.get(o.id) ?? { fields: new Set(), from: n };
|
|
472
|
+
const fields = held.fields;
|
|
473
|
+
for (const f of o.overriddenFields ?? []) fields.add(f);
|
|
474
|
+
overrideFields.set(o.id, held);
|
|
475
|
+
if (fields.has("characters") || fields.has("text"))
|
|
476
|
+
overridden.add(o.id);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// instanceOverride: a visual override on a library instance, bound or
|
|
481
|
+
// not; one equal to the main's value is a stale mark (DEC-0035).
|
|
482
|
+
for (const [id, { fields, from }] of overrideFields) {
|
|
483
|
+
const node = a.byId.get(id);
|
|
484
|
+
if (!node || !a.shown(node)) continue;
|
|
485
|
+
const base = baselineInstance(a, mains, from, node);
|
|
486
|
+
if (!base) continue;
|
|
487
|
+
const mainNode = a.mainNodeFor(base, id);
|
|
488
|
+
if (!mainNode) continue;
|
|
489
|
+
const mainStyles = mains[base.componentId]?.styles ?? {};
|
|
490
|
+
const differ = [];
|
|
491
|
+
const equal = [];
|
|
492
|
+
for (const field of [...fields].sort()) {
|
|
493
|
+
if (ALLOWED_FIELDS.has(field)) continue;
|
|
494
|
+
if (exemptPaint(a, from, node, field)) continue;
|
|
495
|
+
const mine = fieldValue(node, field, a.styles);
|
|
496
|
+
const theirs = fieldValue(mainNode, field, mainStyles);
|
|
497
|
+
if (sameValue(mine, theirs)) equal.push(field);
|
|
498
|
+
else
|
|
499
|
+
differ.push(
|
|
500
|
+
`${field} ${shortValue(mine)} (main ${shortValue(theirs)})`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
const where = `on ${node.name} (${a.componentLabel(from)})`;
|
|
504
|
+
if (differ.length > 0)
|
|
505
|
+
push(
|
|
506
|
+
findings,
|
|
507
|
+
id,
|
|
508
|
+
"instanceOverride",
|
|
509
|
+
`${differ.join(", ")} overridden ${where}`,
|
|
510
|
+
);
|
|
511
|
+
if (equal.length > 0)
|
|
512
|
+
push(
|
|
513
|
+
staleOverrides,
|
|
514
|
+
id,
|
|
515
|
+
"instanceOverride",
|
|
516
|
+
`${equal.join(", ")} overridden ${where}, equal to the main: a stale mark`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const n of a.byId.values()) {
|
|
521
|
+
if (!a.shown(n)) continue;
|
|
522
|
+
const own = !a.composed(n);
|
|
523
|
+
|
|
524
|
+
if (n.type === "INSTANCE")
|
|
525
|
+
for (const r of roles)
|
|
526
|
+
if (matchesRole(a, n, r, library)) usedRoles.add(r.role);
|
|
527
|
+
|
|
528
|
+
// tableHeaderFill (DEC-0036): every header cell keeps the library fill.
|
|
529
|
+
if (n.type === "INSTANCE" && headerCell(a, n)) {
|
|
530
|
+
const main = mains[n.componentId]?.document;
|
|
531
|
+
// The effective colour decides; the same colour on another variable
|
|
532
|
+
// id is an instance behind a library republish, not a failure.
|
|
533
|
+
if (main && !sameValue(n.fills ?? [], main.fills ?? []))
|
|
534
|
+
if (sameValue(paintColours(n.fills), paintColours(main.fills)))
|
|
535
|
+
push(
|
|
536
|
+
libraryUpdatePending,
|
|
537
|
+
n.id,
|
|
538
|
+
"libraryUpdatePending",
|
|
539
|
+
`${n.name} has the header colour on another variable id; accept the library update`,
|
|
540
|
+
);
|
|
541
|
+
else
|
|
542
|
+
push(
|
|
543
|
+
findings,
|
|
544
|
+
n.id,
|
|
545
|
+
"tableHeaderFill",
|
|
546
|
+
`${n.name} fill ${shortValue(n.fills)} differs from the library header fill ${shortValue(main.fills)} (${a.componentLabel(n)})`,
|
|
547
|
+
);
|
|
548
|
+
if (main && !visiblePaints(n.fills))
|
|
549
|
+
push(
|
|
550
|
+
findings,
|
|
551
|
+
n.id,
|
|
552
|
+
"tableHeaderFill",
|
|
553
|
+
`${n.name} has no visible header fill`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// notFromLibrary: local parts and hand-drawn stand-ins, off the library.
|
|
558
|
+
if (!library && own) {
|
|
559
|
+
if (n.type === "COMPONENT" || n.type === "COMPONENT_SET") {
|
|
560
|
+
const setName =
|
|
561
|
+
n.type === "COMPONENT" ? a.parent.get(n.id)?.name : null;
|
|
562
|
+
if (!SURFACE_NAME.test(n.name) && !SURFACE_NAME.test(setName ?? ""))
|
|
563
|
+
push(
|
|
564
|
+
findings,
|
|
565
|
+
n.id,
|
|
566
|
+
"notFromLibrary",
|
|
567
|
+
`local ${n.type === "COMPONENT_SET" ? "component set" : "component"} ${n.name}`,
|
|
568
|
+
);
|
|
569
|
+
} else if (n.type === "INSTANCE") {
|
|
570
|
+
const c = a.componentOf(n);
|
|
571
|
+
const set = a.setOf(n);
|
|
572
|
+
if (
|
|
573
|
+
c &&
|
|
574
|
+
c.remote === false &&
|
|
575
|
+
!SURFACE_NAME.test(c.name ?? "") &&
|
|
576
|
+
!SURFACE_NAME.test(set?.name ?? "")
|
|
577
|
+
)
|
|
578
|
+
push(
|
|
579
|
+
findings,
|
|
580
|
+
n.id,
|
|
581
|
+
"notFromLibrary",
|
|
582
|
+
`instance of local component ${set?.name ?? c.name}`,
|
|
583
|
+
);
|
|
584
|
+
} else if (n.type === "FRAME" || n.type === "GROUP") {
|
|
585
|
+
// A frame named for a library part that holds none of it: the
|
|
586
|
+
// Table set is the cell set, so a "Table" frame of its cells is
|
|
587
|
+
// the library's own pattern, not a stand-in.
|
|
588
|
+
const named =
|
|
589
|
+
roles.find((r) => r.componentName === n.name) ||
|
|
590
|
+
librarySetNames.has(n.name);
|
|
591
|
+
const holdsIt =
|
|
592
|
+
named &&
|
|
593
|
+
[...indexTree(n).byId.values()].some(
|
|
594
|
+
(x) => x.type === "INSTANCE" && a.componentLabel(x) === n.name,
|
|
595
|
+
);
|
|
596
|
+
const drawn = visiblePaints(n.fills) || visiblePaints(n.strokes);
|
|
597
|
+
if (named && !holdsIt && drawn)
|
|
598
|
+
push(
|
|
599
|
+
findings,
|
|
600
|
+
n.id,
|
|
601
|
+
"notFromLibrary",
|
|
602
|
+
`frame named ${n.name} stands in for the library component`,
|
|
603
|
+
);
|
|
604
|
+
else if (handDrawnButton(n))
|
|
605
|
+
push(
|
|
606
|
+
findings,
|
|
607
|
+
n.id,
|
|
608
|
+
"notFromLibrary",
|
|
609
|
+
`hand-drawn button "${buttonText(n)}"`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// roleMismatch by annotation: [role:<key>] in a layer name.
|
|
615
|
+
for (const match of n.name?.matchAll?.(ROLE_ANNOTATION) ?? []) {
|
|
616
|
+
const role = roleByKey.get(match[1]);
|
|
617
|
+
if (!role) continue;
|
|
618
|
+
if (n.type !== "INSTANCE" || !matchesRole(a, n, role, library))
|
|
619
|
+
push(
|
|
620
|
+
findings,
|
|
621
|
+
n.id,
|
|
622
|
+
"roleMismatch",
|
|
623
|
+
`${n.name} is annotated ${role.role} but is not a ${role.componentName}`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (n.type !== "TEXT") continue;
|
|
628
|
+
const text = textOf(n);
|
|
629
|
+
|
|
630
|
+
// roleMismatch by label: a mapped label on a button-like part that is
|
|
631
|
+
// not the role's component. The screen answers for a button it placed
|
|
632
|
+
// (even one whose variant carries the label) and for a label it
|
|
633
|
+
// overrode; a button a library block composes with its own label is
|
|
634
|
+
// the library's (MW6-17).
|
|
635
|
+
const role = labelRoles.get(text.toLowerCase());
|
|
636
|
+
if (role) {
|
|
637
|
+
const up = a.ancestors(n);
|
|
638
|
+
const holder = up
|
|
639
|
+
.slice(0, 3)
|
|
640
|
+
.find(
|
|
641
|
+
(x) =>
|
|
642
|
+
x.type === "INSTANCE" ||
|
|
643
|
+
((x.type === "FRAME" || x.type === "COMPONENT") &&
|
|
644
|
+
(visiblePaints(x.fills) || visiblePaints(x.strokes))),
|
|
645
|
+
);
|
|
646
|
+
if (holder && (screenText(a, n, overridden) || !a.composed(holder))) {
|
|
647
|
+
// Stop at the first instance the screen placed: anything above it
|
|
648
|
+
// is a container, not the button.
|
|
649
|
+
const stop = up.findIndex(
|
|
650
|
+
(x) => x.type === "INSTANCE" && !a.composed(x),
|
|
651
|
+
);
|
|
652
|
+
const chain = (stop === -1 ? up : up.slice(0, stop + 1)).filter(
|
|
653
|
+
(x) => x.type === "INSTANCE",
|
|
654
|
+
);
|
|
655
|
+
if (!chain.some((inst) => matchesRole(a, inst, role, library)))
|
|
656
|
+
push(
|
|
657
|
+
findings,
|
|
658
|
+
holder.id,
|
|
659
|
+
"roleMismatch",
|
|
660
|
+
`"${text}" is drawn with ${holder.type === "INSTANCE" ? a.componentLabel(holder) : "a hand-drawn frame"}; ${role.role} is the ${role.componentName}`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// otherPersonRevealed (DEC-0045). Never the viewer's own record (R5),
|
|
666
|
+
// never a colleague's display name on a staff surface (D1104).
|
|
667
|
+
if (
|
|
668
|
+
!screenText(a, n, overridden) ||
|
|
669
|
+
isSelf(a, n) ||
|
|
670
|
+
revealedByPermission(a, n)
|
|
671
|
+
)
|
|
672
|
+
continue;
|
|
673
|
+
for (const m of text.matchAll(EMAIL)) {
|
|
674
|
+
const local = m[0].split("@")[0] ?? "";
|
|
675
|
+
if (MASKED.test(local) || ROLE_MAILBOX.test(local)) continue;
|
|
676
|
+
push(findings, n.id, "otherPersonRevealed", `unmasked email ${m[0]}`);
|
|
677
|
+
}
|
|
678
|
+
for (const m of text.matchAll(PHONE)) {
|
|
679
|
+
const digits = m[0].replace(/\D/g, "");
|
|
680
|
+
if (DATE.test(m[0]) || MASKED.test(m[0])) continue;
|
|
681
|
+
const phoneShaped =
|
|
682
|
+
m[0].startsWith("+") || LOCAL_PHONE.test(m[0].trim());
|
|
683
|
+
if (phoneShaped && digits.length >= 10 && digits.length <= 15)
|
|
684
|
+
push(findings, n.id, "otherPersonRevealed", `unmasked phone ${m[0]}`);
|
|
685
|
+
}
|
|
686
|
+
if (
|
|
687
|
+
externalPlane(a, n) &&
|
|
688
|
+
nextToAvatar(a, n) &&
|
|
689
|
+
!NOT_A_PERSON.test(text) &&
|
|
690
|
+
!MASKED.test(text) &&
|
|
691
|
+
/\p{L}/u.test(text)
|
|
692
|
+
)
|
|
693
|
+
push(
|
|
694
|
+
findings,
|
|
695
|
+
n.id,
|
|
696
|
+
"otherPersonRevealed",
|
|
697
|
+
`"${text}" is named on an external plane; show "You" or "the team"`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// otherPersonRevealed (b): a customer column shows names unmasked.
|
|
702
|
+
for (const header of a.byId.values()) {
|
|
703
|
+
if (
|
|
704
|
+
header.type !== "TEXT" ||
|
|
705
|
+
!CUSTOMER_HEADER.test(textOf(header)) ||
|
|
706
|
+
!a.shown(header)
|
|
707
|
+
)
|
|
708
|
+
continue;
|
|
709
|
+
// A column header sits in a table, list or grid; a "Customer" form
|
|
710
|
+
// label does not head anything.
|
|
711
|
+
const table = a
|
|
712
|
+
.ancestors(header)
|
|
713
|
+
.find((x) => /table|list|grid/i.test(x.name ?? ""));
|
|
714
|
+
// The header's cell bounds its column, unless the parent is the whole
|
|
715
|
+
// header row: then the header text's own box does.
|
|
716
|
+
const cell = a.parent.get(header.id);
|
|
717
|
+
const tableWidth =
|
|
718
|
+
table?.absoluteBoundingBox?.width ?? Number.POSITIVE_INFINITY;
|
|
719
|
+
const cellBox = cell?.absoluteBoundingBox;
|
|
720
|
+
const box =
|
|
721
|
+
cellBox && cellBox.width < tableWidth * 0.6
|
|
722
|
+
? cellBox
|
|
723
|
+
: header.absoluteBoundingBox;
|
|
724
|
+
if (!table || !box) continue;
|
|
725
|
+
for (const n of indexTree(table).byId.values()) {
|
|
726
|
+
if (n.type !== "TEXT" || n.id === header.id || !a.shown(n)) continue;
|
|
727
|
+
if (!screenText(a, n, overridden)) continue;
|
|
728
|
+
const b = n.absoluteBoundingBox;
|
|
729
|
+
const text = textOf(n);
|
|
730
|
+
if (!b || b.y < box.y + box.height) continue;
|
|
731
|
+
const mid = b.x + b.width / 2;
|
|
732
|
+
if (mid < box.x || mid > box.x + box.width) continue;
|
|
733
|
+
if (
|
|
734
|
+
!/\p{L}{2}/u.test(text) ||
|
|
735
|
+
MASKED.test(text) ||
|
|
736
|
+
isSelf(a, n) ||
|
|
737
|
+
revealedByPermission(a, n)
|
|
738
|
+
)
|
|
739
|
+
continue;
|
|
740
|
+
push(
|
|
741
|
+
findings,
|
|
742
|
+
n.id,
|
|
743
|
+
"otherPersonRevealed",
|
|
744
|
+
`customer "${text}" shown unmasked under ${textOf(header)}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// roleMismatch by declared role: the brief named a role the screens never
|
|
751
|
+
// place.
|
|
752
|
+
const roots = Object.keys(nodes ?? {});
|
|
753
|
+
for (const declared of options.declaredRoles ?? []) {
|
|
754
|
+
const role = roleByKey.get(declared);
|
|
755
|
+
if (role && !usedRoles.has(role.role))
|
|
756
|
+
push(
|
|
757
|
+
findings,
|
|
758
|
+
roots[0] ?? "",
|
|
759
|
+
"roleMismatch",
|
|
760
|
+
`declared role ${role.role} has no ${role.componentName} instance`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
return { version: VERSION, findings, staleOverrides, libraryUpdatePending };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* The instance whose main holds an overridden node's un-overridden value:
|
|
768
|
+
* the screen-placed instance carrying the override, or, below it, the
|
|
769
|
+
* deepest instance that was swapped or given another variant (its defaults
|
|
770
|
+
* come from its own main). Null when a main on the way can't be read.
|
|
771
|
+
*/
|
|
772
|
+
function baselineInstance(a, mains, from, node) {
|
|
773
|
+
const path = [node, ...a.ancestors(node)];
|
|
774
|
+
const top = path.indexOf(from);
|
|
775
|
+
if (top === -1) return null;
|
|
776
|
+
let base = from;
|
|
777
|
+
for (let i = top - 1; i >= 0; i--) {
|
|
778
|
+
const p = path[i];
|
|
779
|
+
if (p.type !== "INSTANCE") continue;
|
|
780
|
+
if (!mains[base.componentId]?.document) return null;
|
|
781
|
+
const counterpart = a.mainNodeFor(base, p.id);
|
|
782
|
+
const ownKey = a.components[p.componentId]?.key;
|
|
783
|
+
const mainKey = counterpart
|
|
784
|
+
? mains[base.componentId]?.components?.[counterpart.componentId]?.key
|
|
785
|
+
: undefined;
|
|
786
|
+
if (!counterpart || ownKey !== mainKey) base = p;
|
|
787
|
+
}
|
|
788
|
+
return mains[base.componentId]?.document ? base : null;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const ICON = /^(icon|lucide)\//i;
|
|
792
|
+
/**
|
|
793
|
+
* Design-system rulings on paint overrides: an icon takes its colour from
|
|
794
|
+
* where it sits (currentColor), so a bound fill or stroke on an Icon/ or
|
|
795
|
+
* lucide/ instance or its vectors is allowed (a Logo/ is not), and so is its
|
|
796
|
+
* stroke weight, which scales with the icon's size; a PiiText in
|
|
797
|
+
* mode=revealed inherits ink, so its fill may be set.
|
|
798
|
+
*/
|
|
799
|
+
function exemptPaint(a, from, node, field) {
|
|
800
|
+
if (field !== "fills" && field !== "strokes" && field !== "strokeWeight")
|
|
801
|
+
return false;
|
|
802
|
+
const path = [node, ...a.ancestors(node)];
|
|
803
|
+
const upTo = path.slice(0, path.indexOf(from) + 1);
|
|
804
|
+
const inIcon = upTo.some(
|
|
805
|
+
(x) => x.type === "INSTANCE" && ICON.test(a.componentLabel(x)),
|
|
806
|
+
);
|
|
807
|
+
// An icon's stroke weight scales with the size it is placed at.
|
|
808
|
+
if (field === "strokeWeight") return inIcon;
|
|
809
|
+
const bound = (node[field] ?? []).every((p) => p?.boundVariables?.color);
|
|
810
|
+
if (bound && inIcon) return true;
|
|
811
|
+
return field === "fills" && revealedByPermission(a, node);
|
|
812
|
+
}
|
|
813
|
+
const paintColours = (paints) =>
|
|
814
|
+
(paints ?? [])
|
|
815
|
+
.filter((p) => p && p.visible !== false)
|
|
816
|
+
.map((p) => p.color ?? null);
|
|
817
|
+
|
|
818
|
+
/** A value as a finding shows it: a colour as hex, anything else as JSON. */
|
|
819
|
+
function shortValue(value) {
|
|
820
|
+
const paint = Array.isArray(value) ? value.find((p) => p?.color) : null;
|
|
821
|
+
if (paint) {
|
|
822
|
+
const { r, g, b } = paint.color;
|
|
823
|
+
const hex = [r, g, b]
|
|
824
|
+
.map((c) =>
|
|
825
|
+
Math.round(c * 255)
|
|
826
|
+
.toString(16)
|
|
827
|
+
.padStart(2, "0"),
|
|
828
|
+
)
|
|
829
|
+
.join("");
|
|
830
|
+
return `#${hex.toUpperCase()}${paint.boundVariables ? " (bound)" : ""}`;
|
|
831
|
+
}
|
|
832
|
+
const text = JSON.stringify(value === undefined ? null : normalise(value));
|
|
833
|
+
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function buttonText(n) {
|
|
837
|
+
const texts = [...indexTree(n).byId.values()].filter(
|
|
838
|
+
(x) => x.type === "TEXT",
|
|
839
|
+
);
|
|
840
|
+
return texts.map(textOf).join(" ").trim();
|
|
841
|
+
}
|
|
842
|
+
function handDrawnButton(n) {
|
|
843
|
+
if (n.type !== "FRAME") return false;
|
|
844
|
+
if (!n.layoutMode || n.layoutMode === "NONE") return false;
|
|
845
|
+
if (!(visiblePaints(n.fills) || visiblePaints(n.strokes))) return false;
|
|
846
|
+
const radius =
|
|
847
|
+
n.cornerRadius ?? Math.max(0, ...(n.rectangleCornerRadii ?? [0]));
|
|
848
|
+
if (!(radius > 0)) return false;
|
|
849
|
+
const h = n.absoluteBoundingBox?.height ?? 0;
|
|
850
|
+
if (h < 24 || h > 48) return false;
|
|
851
|
+
const tree = [...indexTree(n).byId.values()];
|
|
852
|
+
if (
|
|
853
|
+
tree.some(
|
|
854
|
+
(x) =>
|
|
855
|
+
x.type === "FRAME" &&
|
|
856
|
+
x.id !== n.id &&
|
|
857
|
+
(visiblePaints(x.fills) || visiblePaints(x.strokes)),
|
|
858
|
+
)
|
|
859
|
+
)
|
|
860
|
+
return false;
|
|
861
|
+
const texts = tree.filter((x) => x.type === "TEXT");
|
|
862
|
+
if (texts.length !== 1) return false;
|
|
863
|
+
const words = buttonText(n).split(/\s+/).filter(Boolean);
|
|
864
|
+
return words.length > 0 && words.length <= 3;
|
|
865
|
+
}
|
|
866
|
+
function isSelf(a, n) {
|
|
867
|
+
const up = a.ancestors(n);
|
|
868
|
+
if (up.some((x) => SELF_CONTAINER.test(x.name ?? ""))) return true;
|
|
869
|
+
const accountCard = (x) => {
|
|
870
|
+
const set =
|
|
871
|
+
x.type === "INSTANCE"
|
|
872
|
+
? a.componentLabel(x)
|
|
873
|
+
: x.type === "COMPONENT"
|
|
874
|
+
? a.parent.get(x.id)?.name
|
|
875
|
+
: null;
|
|
876
|
+
if (!SELF_COMPONENT.test(set ?? "")) return false;
|
|
877
|
+
const name = x.type === "INSTANCE" ? a.componentOf(x)?.name : x.name;
|
|
878
|
+
const kind = x.componentProperties?.kind?.value;
|
|
879
|
+
return (
|
|
880
|
+
kind === "Account" || /(^|,\s*)kind=account(\s*,|$)/i.test(name ?? "")
|
|
881
|
+
);
|
|
882
|
+
};
|
|
883
|
+
if (up.some(accountCard)) return true;
|
|
884
|
+
const siblings = a.parent.get(n.id)?.children ?? [];
|
|
885
|
+
if (
|
|
886
|
+
[n, ...siblings].some((x) => x.type === "TEXT" && SELF_TEXT.test(textOf(x)))
|
|
887
|
+
)
|
|
888
|
+
return true;
|
|
889
|
+
// "Signed in" or "Not you?" in the same card: the nearest instance or
|
|
890
|
+
// component holding it, never a whole row or screen.
|
|
891
|
+
const card = up.find((x) => x.type === "INSTANCE" || x.type === "COMPONENT");
|
|
892
|
+
return card
|
|
893
|
+
? [...indexTree(card).byId.values()].some(
|
|
894
|
+
(x) => x.type === "TEXT" && SIGNED_IN.test(textOf(x)),
|
|
895
|
+
)
|
|
896
|
+
: false;
|
|
897
|
+
}
|
|
898
|
+
function revealedByPermission(a, n) {
|
|
899
|
+
return [n, ...a.ancestors(n)].some((x) => {
|
|
900
|
+
if (x.type !== "INSTANCE") return false;
|
|
901
|
+
const name = a.componentLabel(x);
|
|
902
|
+
if (!/pii ?text/i.test(name)) return false;
|
|
903
|
+
const mode = x.componentProperties?.mode?.value;
|
|
904
|
+
return (
|
|
905
|
+
mode === "revealed" || /mode=revealed/i.test(a.componentOf(x)?.name ?? "")
|
|
906
|
+
);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
function externalPlane(a, n) {
|
|
910
|
+
return [n, ...a.ancestors(n)].some((x) => EXTERNAL_PLANE.test(x.name ?? ""));
|
|
911
|
+
}
|
|
912
|
+
function nextToAvatar(a, n) {
|
|
913
|
+
const p = a.parent.get(n.id);
|
|
914
|
+
const near = [
|
|
915
|
+
...(p?.children ?? []),
|
|
916
|
+
...(a.parent.get(p?.id ?? "")?.children ?? []),
|
|
917
|
+
];
|
|
918
|
+
return near.some(
|
|
919
|
+
(x) => x.type === "INSTANCE" && /avatar/i.test(a.componentLabel(x)),
|
|
920
|
+
);
|
|
921
|
+
}
|