@starci/hfs 4.8.0 → 4.9.1
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/emit/operation-schemas.mjs +3 -0
- package/lint/bound-sys.cjs +2 -2
- package/package.json +1 -1
- package/runtime/engine/admission.mjs +1 -1
- package/runtime/engine/config.mjs +2 -2
- package/runtime/engine/db/machine.mjs +2 -3
- package/runtime/engine/invalid-config.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/scripts/guards/git-policy.mjs +2 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
- package/runtime/scripts/lib/package-at.mjs +5 -6
- package/runtime/scripts/lib/roots.mjs +2 -2
- package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
- package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
- package/scaffold/app.mjs +4 -4
- package/src/main.mjs +3 -3
- package/sync/index.mjs +14 -1
- package/sync/managed.mjs +3 -2
- package/templates/app/codegen-graphql.mjs.partial +4 -4
- package/templates/app/quality-config/sonar-project.properties +4 -2
- package/runtime/engine/canonical-json.mjs +0 -14
- package/runtime/engine/db/ledger-open.mjs +0 -38
- package/runtime/engine/db/ledger-paths.mjs +0 -87
- package/runtime/engine/db/ledger.mjs +0 -1068
- package/runtime/engine/db/provision-authority.mjs +0 -76
- package/runtime/engine/db/provision-operations.mjs +0 -146
- package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
- package/runtime/scripts/agent/codex-app-server.mjs +0 -79
- package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
- package/runtime/scripts/agent/host-agents.mjs +0 -80
- package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
- package/runtime/scripts/agent/trust-launch.mjs +0 -191
- package/runtime/scripts/agent/trust.mjs +0 -523
- package/runtime/scripts/api/fs/temp-path.mjs +0 -8
- package/runtime/scripts/api/git/diff-names.mjs +0 -8
- package/runtime/scripts/api/git/head-time.mjs +0 -10
- package/runtime/scripts/api/git/index-lock.mjs +0 -78
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/runtime/scripts/api/git/merge-tree.mjs +0 -5
- package/runtime/scripts/api/git/rev-list.mjs +0 -5
- package/runtime/scripts/api/git/rev-parse.mjs +0 -13
- package/runtime/scripts/api/git/status-query.mjs +0 -5
- package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
- package/runtime/scripts/api/npm/lib.mjs +0 -32
- package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
- package/runtime/scripts/api/npm/run-npm.mjs +0 -6
- package/runtime/scripts/api/npm/view.mjs +0 -30
- package/runtime/scripts/api/npm/whoami.mjs +0 -8
- package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
- package/runtime/scripts/api/process/kill-tree.mjs +0 -22
- package/runtime/scripts/api/process/lib.mjs +0 -125
- package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
- package/runtime/scripts/api/process/process-list.mjs +0 -28
- package/runtime/scripts/api/process/system-tool.mjs +0 -37
- package/runtime/scripts/gates/canon-digest.mjs +0 -81
- package/runtime/scripts/gates/canon-pins.mjs +0 -119
- package/runtime/scripts/gates/gate-input.mjs +0 -98
- package/runtime/scripts/gates/gate.mjs +0 -636
- package/runtime/scripts/gates/grammar-dist.mjs +0 -206
- package/runtime/scripts/gates/output.mjs +0 -40
- package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
- package/runtime/scripts/gates/test-world-run.mjs +0 -252
- package/runtime/scripts/gates/type-impact.mjs +0 -277
- package/runtime/scripts/gates/type-program.mjs +0 -113
- package/runtime/scripts/guards/assigned-command.mjs +0 -12
- package/runtime/scripts/guards/call-rights.mjs +0 -47
- package/runtime/scripts/guards/command-guard.mjs +0 -468
- package/runtime/scripts/guards/command-policy.mjs +0 -342
- package/runtime/scripts/guards/critic-reach.mjs +0 -82
- package/runtime/scripts/guards/deps-guard.mjs +0 -150
- package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
- package/runtime/scripts/guards/guards-root.mjs +0 -27
- package/runtime/scripts/guards/hook-install.mjs +0 -343
- package/runtime/scripts/guards/hook-verdict.mjs +0 -12
- package/runtime/scripts/guards/install-verdict.mjs +0 -41
- package/runtime/scripts/guards/kernel-seat.mjs +0 -63
- package/runtime/scripts/guards/launch-verdict.mjs +0 -74
- package/runtime/scripts/guards/op-starci.mjs +0 -16
- package/runtime/scripts/guards/op-write-scope.mjs +0 -27
- package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
- package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
- package/runtime/scripts/guards/protected-zone.mjs +0 -56
- package/runtime/scripts/guards/refusals.mjs +0 -27
- package/runtime/scripts/guards/rights.mjs +0 -268
- package/runtime/scripts/guards/seat-tools.mjs +0 -47
- package/runtime/scripts/guards/shell-commands.mjs +0 -328
- package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
- package/runtime/scripts/guards/verdict-process.mjs +0 -81
- package/runtime/scripts/lib/app-data.mjs +0 -11
- package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
- package/runtime/scripts/lib/check-format.mjs +0 -7
- package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
- package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
- package/runtime/scripts/lib/git-dir.mjs +0 -120
- package/runtime/scripts/lib/guard-command.mjs +0 -24
- package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
- package/runtime/scripts/lib/json-formats.mjs +0 -34
- package/runtime/scripts/lib/managed-json.mjs +0 -51
- package/runtime/scripts/lib/menu-parts.mjs +0 -11
- package/runtime/scripts/lib/op-gate.mjs +0 -15
- package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
- package/runtime/scripts/lib/release-version.mjs +0 -134
- package/runtime/scripts/lib/runtime-location.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
- package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
- package/runtime/scripts/lib/seat-env.mjs +0 -43
- package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
- package/runtime/scripts/lib/source-package-imports.mjs +0 -111
- package/runtime/scripts/lib/tar-files.mjs +0 -39
- package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
- package/runtime/scripts/lib/worktree-record.mjs +0 -14
- package/runtime/scripts/machine/ask-receipts.mjs +0 -113
- package/runtime/scripts/machine/evidence-store.mjs +0 -223
- package/runtime/scripts/machine/home.mjs +0 -216
- package/runtime/scripts/machine/host-lock.mjs +0 -184
- package/runtime/scripts/machine/lock-recovery.mjs +0 -55
- package/runtime/scripts/machine/roles-contract.mjs +0 -90
- package/runtime/scripts/machine/runtime-change.mjs +0 -20
- package/runtime/scripts/machine/workflow-tree.mjs +0 -122
- package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
- package/runtime/scripts/reconciler/guard-row.mjs +0 -25
- package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
- package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
- package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
- package/runtime/scripts/work/brand/brand.mjs +0 -892
- package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
- package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
- package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
- package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
- package/runtime/scripts/work/draw/draw-source.mjs +0 -412
- package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
- package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
- package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
- package/runtime/scripts/work/draw-grammar.mjs +0 -175
- package/runtime/scripts/work/draw-render-page.mjs +0 -188
- package/runtime/scripts/work/draw-render.mjs +0 -626
- package/runtime/scripts/work/png.mjs +0 -237
- package/runtime/scripts/work/render-tools.mjs +0 -64
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
// draw-layer.mjs — the layer and measure gates of a drawing (owner, 2026-09-28, StarCi Next SignInBase#signed-out:
|
|
2
|
-
// "this is KNOWLEDGE, not golden rules" - the runtime catches these from knowledge and gates, never from owner feedback).
|
|
3
|
-
//
|
|
4
|
-
// DRAW_NESTED_VARIANT knowledge/ui/proof/anatomy-source.yaml ANATOMY-2. The layer chain is page Background ->
|
|
5
|
-
// Surface -> nested control: every form control ON a painted surface (a SurfaceCard, a
|
|
6
|
-
// card, a dialog / drawer panel) carries HeroUI's nested `secondary` variant - the choice
|
|
7
|
-
// controls (Checkbox, RadioGroup, ...) as much as the text fields (case-1); a form region
|
|
8
|
-
// (fields plus a primary submit) stands in a Surface, never directly on the Background
|
|
9
|
-
// (case-3). Judged on the RENDERED DOM (draw-render's snapshot, or the html itself): the
|
|
10
|
-
// variant is the control root's data-grammar-variant, else its vendor `<part>--<variant>`
|
|
11
|
-
// class. A control whose vendor anatomy has no layer variant (Switch, Slider) is exempt.
|
|
12
|
-
// DRAW_MEASURE_UNCAPPED knowledge/ui/presentation/measure.yaml MEASURE-4 case-3/case-4. A form region (fields plus
|
|
13
|
-
// a primary submit) caps its measure; rendered wider than FORM_MEASURE_CAP_PX (W-3xl, 48rem)
|
|
14
|
-
// at any viewport it stretched across its column. Measured in the browser (measureLayer,
|
|
15
|
-
// run by draw-render; the record's `layer`). A chat composer (inside a ChatWorkspace, a
|
|
16
|
-
// composer part or a role=log conversation) is not a form and is skipped.
|
|
17
|
-
import fs from 'node:fs';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
import { isMain } from '../../lib/is-main.mjs';
|
|
20
|
-
import { classesOf, parseHtml, walkElements } from './draw-dna.mjs';
|
|
21
|
-
import { safeRemove } from '../../api/fs/safe-remove.mjs';
|
|
22
|
-
import { artifactHoldReason } from '../../machine/artifact-hold.mjs';
|
|
23
|
-
import {isFile} from '../../lib/fs-kind.mjs';
|
|
24
|
-
import {ancestorsOf} from '../../lib/dom-tree.mjs';
|
|
25
|
-
import { readEnv } from '../../lib/env.mjs';
|
|
26
|
-
import { invocationDir } from '../../lib/roots.mjs';
|
|
27
|
-
import { mapInOrder } from '../../lib/in-order.mjs';
|
|
28
|
-
import { makeTempDir } from '../../api/fs/make-temp-dir.mjs';
|
|
29
|
-
|
|
30
|
-
export const DRAW_NESTED_VARIANT = 'DRAW_NESTED_VARIANT';
|
|
31
|
-
export const DRAW_MEASURE_UNCAPPED = 'DRAW_MEASURE_UNCAPPED';
|
|
32
|
-
|
|
33
|
-
/** W-3xl of the Width scale (measure.yaml guidance width-scale): no form region renders wider. */
|
|
34
|
-
const FORM_MEASURE_CAP_PX = 768;
|
|
35
|
-
|
|
36
|
-
/** Grammar form controls that publish the layer variant (primary | secondary). */
|
|
37
|
-
const VARIANT_CONTROLS = Object.freeze(['Input', 'Textarea', 'Select', 'ComboBox', 'SearchField', 'NumberField', 'DateField', 'TimeField',
|
|
38
|
-
'DatePicker', 'DateRangePicker', 'Checkbox', 'CheckboxGroup', 'RadioGroup', 'OtpInput', 'PressableField']);
|
|
39
|
-
/** Every grammar field control (the variant ones plus those without a vendor variant): what makes a region a form. */
|
|
40
|
-
const FIELD_CONTROLS = Object.freeze([...VARIANT_CONTROLS, 'Switch', 'Slider', 'FileDropzone']);
|
|
41
|
-
/** Components that paint a surface of their own. */
|
|
42
|
-
const SURFACE_COMPONENTS = Object.freeze(['SurfaceCard', 'SurfaceListCard', 'Sidebar', 'Dialog', 'Drawer', 'AlertDialog', 'Popover']);
|
|
43
|
-
/** Boolean surface-owner hooks emitted by Grammar compositions. */
|
|
44
|
-
const SURFACE_STAMPS = Object.freeze(['data-grammar-surface', 'data-grammar-surface-card', 'data-grammar-surface-list', 'data-grammar-surface-accordion-card']);
|
|
45
|
-
/** The vendor class prefixes that carry the layer variant (`<prefix>--primary|secondary`). */
|
|
46
|
-
const VARIANT_CLASS_PREFIXES = Object.freeze(['input', 'input-group', 'textfield', 'textarea', 'select', 'combo-box', 'search-field', 'number-field',
|
|
47
|
-
'date-input-group', 'date-field', 'time-field', 'date-picker', 'checkbox', 'checkbox-group', 'radio-group', 'input-otp']);
|
|
48
|
-
|
|
49
|
-
const VARIANT_CLASS = new RegExp(`^(?:${VARIANT_CLASS_PREFIXES.join('|')})--(primary|secondary)$`);
|
|
50
|
-
const componentOf = (attrs) => attrs?.['data-component'] ?? attrs?.['data-grammar-component'] ?? null;
|
|
51
|
-
|
|
52
|
-
/** Whether one parsed/DOM element paints a surface; unpainted layout containers inherit their parent. */
|
|
53
|
-
function isSurface({ component, attrs, classes }) {
|
|
54
|
-
if (attrs['data-grammar-frame'] === 'frameless') return false;
|
|
55
|
-
if (SURFACE_COMPONENTS.includes(component)) return true;
|
|
56
|
-
if (attrs['data-grammar-nesting'] === 'surface') return true;
|
|
57
|
-
if (SURFACE_STAMPS.some((stamp) => attrs[stamp] === 'true')) return true;
|
|
58
|
-
if (attrs['data-grammar-surface-depth'] != null) return true;
|
|
59
|
-
if (attrs['data-grammar-overlay-surface'] != null) return true;
|
|
60
|
-
if (attrs.role === 'dialog' || attrs.role === 'alertdialog') return true;
|
|
61
|
-
return classes.some((name) => /(?:^|:)bg-surface(?:$|[-/])/.test(name))
|
|
62
|
-
|| classes.includes('card') || classes.includes('surface') || classes.includes('starci-core-surface');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The layer variant a control root renders: data-grammar-variant, else the first vendor variant class in it. */
|
|
66
|
-
function variantOfControl(root) {
|
|
67
|
-
const own = root.attrs?.['data-grammar-variant'];
|
|
68
|
-
if (own === 'primary' || own === 'secondary') return own;
|
|
69
|
-
for (const el of [root, ...walkElements(root)]) {
|
|
70
|
-
for (const c of classesOf(el)) { const m = VARIANT_CLASS.exec(c); if (m) return m[1]; }
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const describe = (el) => {
|
|
76
|
-
const component = componentOf(el.attrs);
|
|
77
|
-
const name = el.attrs?.name ?? el.attrs?.id ?? walkElements(el).find((d) => d.attrs?.name)?.attrs?.name ?? null;
|
|
78
|
-
const label = name ? ` "${name}"` : '';
|
|
79
|
-
return `${component ?? el.tag}${label}`;
|
|
80
|
-
};
|
|
81
|
-
const surfaceAncestor = (el) => ancestorsOf(el).find((a) => isSurface({ component: componentOf(a.attrs), attrs: a.attrs ?? {}, classes: classesOf(a) })) ?? null;
|
|
82
|
-
const isPrimaryButton = (el) => (componentOf(el.attrs) === 'Button' && (classesOf(el).includes('button--primary') || el.attrs?.['data-variant'] === 'primary' || el.attrs?.['data-grammar-variant'] === 'primary'))
|
|
83
|
-
|| (el.tag === 'button' && el.attrs?.type === 'submit');
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* DRAW_NESTED_VARIANT findings of one rendered DOM (html text): [{code, kind, detail}]. `label` names the capture.
|
|
87
|
-
*/
|
|
88
|
-
export function nestedVariantFindings(html, { label = 'the render' } = {}) {
|
|
89
|
-
const all = walkElements(parseHtml(html));
|
|
90
|
-
const out = [];
|
|
91
|
-
const controls = all.filter((el) => VARIANT_CONTROLS.includes(componentOf(el.attrs))
|
|
92
|
-
&& !ancestorsOf(el).some((a) => FIELD_CONTROLS.includes(componentOf(a.attrs))));
|
|
93
|
-
const bad = [];
|
|
94
|
-
for (const el of controls) {
|
|
95
|
-
if (!surfaceAncestor(el)) continue;
|
|
96
|
-
const variant = variantOfControl(el);
|
|
97
|
-
if (variant !== 'secondary') bad.push(`${describe(el)} (${variant ?? 'no layer variant'})`);
|
|
98
|
-
}
|
|
99
|
-
if (bad.length) out.push({ code: DRAW_NESTED_VARIANT, kind: 'control on a surface not nested', count: bad.length,
|
|
100
|
-
detail: `${label}: ${bad.length} form control(s) on a surface without the nested variant="secondary" - ${bad.slice(0, 6).join('; ')} (ANATOMY-2 case-1: every form control on a surface, choice controls included)` });
|
|
101
|
-
// ANATOMY-2 case-3: a form region (fields plus a primary submit) stands in a Surface, never on the page Background.
|
|
102
|
-
const loose = [];
|
|
103
|
-
for (const button of all.filter(isPrimaryButton)) {
|
|
104
|
-
const region = ancestorsOf(button).find((a) => walkElements(a).some((d) => FIELD_CONTROLS.includes(componentOf(d.attrs))));
|
|
105
|
-
if (!region || surfaceAncestor(button) || isSurface({ component: componentOf(region.attrs), attrs: region.attrs ?? {}, classes: classesOf(region) })) continue;
|
|
106
|
-
const fields = walkElements(region).filter((d) => FIELD_CONTROLS.includes(componentOf(d.attrs)));
|
|
107
|
-
// A form is one unit: fields on a surface with the submit on the Background is a split form; a field on a surface
|
|
108
|
-
// is otherwise judged as a nested control only, never also as a loose form.
|
|
109
|
-
const split = fields.some((d) => surfaceAncestor(d));
|
|
110
|
-
if (!loose.some((l) => l.region === region)) loose.push({ region, fields, split });
|
|
111
|
-
}
|
|
112
|
-
for (const { fields, split } of loose) out.push(split
|
|
113
|
-
? { code: DRAW_NESTED_VARIANT, kind: 'form-split-across-layers', count: 1,
|
|
114
|
-
detail: `${label}: form-split-across-layers - ${fields.slice(0, 4).map(describe).join(', ')} stand(s) on a Surface while the form's primary submit sits on the page Background; a form is one unit, its fields and its submit/actions belong in the same Surface (ANATOMY-2 case-3)` }
|
|
115
|
-
: { code: DRAW_NESTED_VARIANT, kind: 'form on the page background', count: 1,
|
|
116
|
-
detail: `${label}: a form region (${fields.slice(0, 4).map(describe).join(', ')} and its primary submit) stands directly on the page Background - it belongs in a Surface (SurfaceCard), its controls nested in variant="secondary" (ANATOMY-2 case-3)` });
|
|
117
|
-
return out;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* DRAW_MEASURE_UNCAPPED findings of one capture's measured layer (draw-render record `layer`, measureLayer):
|
|
122
|
-
* {viewport:{width,height}, forms:[{desc, width, fields}]}.
|
|
123
|
-
*/
|
|
124
|
-
export function measureFindings(layer, { label = 'the capture', cap = FORM_MEASURE_CAP_PX } = {}) {
|
|
125
|
-
const wide = (Array.isArray(layer?.forms) ? layer.forms : []).filter((f) => Number(f.width) > cap + 0.5);
|
|
126
|
-
return wide.map((f) => ({ code: DRAW_MEASURE_UNCAPPED, kind: 'form region past its measure', count: 1,
|
|
127
|
-
detail: `${label}: the form region ${f.desc ?? ''} (${(f.fields ?? []).slice(0, 4).join(', ') || 'fields'} and a primary submit) renders ${Math.round(Number(f.width))}px wide at ${layer.viewport?.width ?? '?'}px, past the ${cap}px (W-3xl) form cap - a drawn XBase owns its measure: SurfaceCard measure="form"/"formCompact", else w-full max-w-md mx-auto (MEASURE-4 case-3/case-4; MEASURE-1 is the page width, not the form's)`.replace(/\s+/g, ' ') }));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Runs IN THE PAGE (draw-render passes it to page.evaluate): every form region's rendered width. A form region is the
|
|
133
|
-
* nearest ancestor of a primary submit that also holds a field control; a region holding a table or grid is data, not
|
|
134
|
-
* a form, and is skipped. Self-contained: it closes over nothing.
|
|
135
|
-
*/
|
|
136
|
-
export function measureLayer({ fields, primary, chat }) {
|
|
137
|
-
const fieldSel = fields.map((n) => `[data-component="${n}"],[data-grammar-component="${n}"]`).join(',');
|
|
138
|
-
const forms = [];
|
|
139
|
-
const seen = new Set();
|
|
140
|
-
const isVisible = (button) => {
|
|
141
|
-
const cs = getComputedStyle(button);
|
|
142
|
-
return cs.display !== 'none' && cs.visibility !== 'hidden';
|
|
143
|
-
};
|
|
144
|
-
const regionOf = (button) => {
|
|
145
|
-
let region = button.parentElement;
|
|
146
|
-
while (region && region !== document.body && !region.querySelector(fieldSel)) region = region.parentElement;
|
|
147
|
-
return region;
|
|
148
|
-
};
|
|
149
|
-
// A chat / messaging composer (a text input and a send button in a chat workspace) follows its conversation column.
|
|
150
|
-
const isExcludedRegion = (region) => region.querySelector('table,[role="grid"],[role="table"]')
|
|
151
|
-
|| region.closest(chat) || region.querySelector(chat) || [...region.classList].some((c) => c.startsWith('starci-core-chat-'));
|
|
152
|
-
for (const button of document.querySelectorAll(primary)) {
|
|
153
|
-
if (!isVisible(button)) continue;
|
|
154
|
-
const region = regionOf(button);
|
|
155
|
-
if (!region || region === document.body || seen.has(region)) continue;
|
|
156
|
-
seen.add(region);
|
|
157
|
-
if (isExcludedRegion(region)) continue;
|
|
158
|
-
const names = [...region.querySelectorAll(fieldSel)].map((el) => el.dataset.component ?? el.dataset.grammarComponent ?? null);
|
|
159
|
-
const cls = typeof region.className === 'string' ? region.className.trim().split(/\s+/).slice(0, 3).join('.') : '';
|
|
160
|
-
const classLabel = cls ? `.${cls}` : '';
|
|
161
|
-
forms.push({ desc: `<${region.tagName.toLowerCase()}${classLabel}>`, width: region.getBoundingClientRect().width, fields: names });
|
|
162
|
-
}
|
|
163
|
-
return { viewport: { width: window.innerWidth, height: window.innerHeight }, forms };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** The argument draw-render passes measureLayer. */
|
|
167
|
-
export const LAYER_PROBE = Object.freeze({
|
|
168
|
-
fields: FIELD_CONTROLS,
|
|
169
|
-
chat: '[data-component="ChatWorkspace"],[data-grammar-component="ChatWorkspace"],[data-component*="Composer"],[data-grammar-component*="Composer"],[data-grammar-part*="composer"],[role="log"]',
|
|
170
|
-
primary: '[data-component="Button"].button--primary,[data-grammar-component="Button"].button--primary,[data-component="Button"][data-variant="primary"],[data-grammar-component="Button"][data-variant="primary"],button[type="submit"]',
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// ---------------------------------------------------------------------------------------------------------
|
|
174
|
-
// Existing renders: the CLI and the draw-gates gate
|
|
175
|
-
//
|
|
176
|
-
// starci work draw-layer <render dir | part png>... [--playwright <product dir>] [--json]
|
|
177
|
-
//
|
|
178
|
-
// Every drawn part under the paths (a <part>.png whose <part>.json is a starci/draw-render@1 record) is judged:
|
|
179
|
-
// DRAW_NESTED_VARIANT on its rendered DOM (<part>.dom.html, else the html beside it), DRAW_MEASURE_UNCAPPED on the
|
|
180
|
-
// record's measured `layer`. A record drawn before the measure existed carries no `layer`: the part is re-rendered at
|
|
181
|
-
// its viewport from the round's harness/index.html (a draw-loop round dir) or its html source, with the product's
|
|
182
|
-
// Playwright (--playwright, else the cwd), and measured; a part that cannot be re-measured says so (unmeasured).
|
|
183
|
-
// Exit 0 all clear, 1 a finding, 2 usage.
|
|
184
|
-
// ---------------------------------------------------------------------------------------------------------
|
|
185
|
-
|
|
186
|
-
const readJsonFile = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
|
|
187
|
-
|
|
188
|
-
/** Every drawn part (png with a draw-render record) under the paths, skipping node_modules and capture outputs. */
|
|
189
|
-
function partsUnder(paths) {
|
|
190
|
-
const out = [];
|
|
191
|
-
const visit = (p) => {
|
|
192
|
-
let st = null;
|
|
193
|
-
try { st = fs.statSync(p); } catch { return; }
|
|
194
|
-
if (st.isDirectory()) {
|
|
195
|
-
if (/^(node_modules|\.git|harness|layer-remeasure)$/.test(path.basename(p))) return;
|
|
196
|
-
for (const e of fs.readdirSync(p)) visit(path.join(p, e));
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
if (!/\.png$/i.test(p) || /\.redline\.png$/i.test(p)) return;
|
|
200
|
-
const record = readJsonFile(p.replace(/\.png$/i, '.json'));
|
|
201
|
-
if (record?.schema === 'starci/draw-render@1') out.push({ png: p, record });
|
|
202
|
-
};
|
|
203
|
-
for (const p of paths) visit(path.resolve(p));
|
|
204
|
-
return out;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** The html a part can be re-rendered from: its round's harness, its record's html source, the html beside it. */
|
|
208
|
-
function renderSourceOf({ png, record }) {
|
|
209
|
-
const harness = path.join(path.dirname(png), 'harness', 'index.html');
|
|
210
|
-
if (isFile(harness)) return harness;
|
|
211
|
-
// The html beside the part is the copy that was judged; the record's source path may point at a live repo since edited.
|
|
212
|
-
const beside = png.replace(/\.png$/i, '.html');
|
|
213
|
-
if (isFile(beside)) return beside;
|
|
214
|
-
const src = record?.source?.html?.path;
|
|
215
|
-
return src && isFile(src) ? src : null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** The dom snapshot of a part: the record's, the one beside it, or the html beside it; null when none exists. */
|
|
219
|
-
function domOfPart(part) {
|
|
220
|
-
const dom = part.record?.dom?.path && isFile(part.record.dom.path) ? part.record.dom.path : part.png.replace(/\.png$/i, '.dom.html');
|
|
221
|
-
if (isFile(dom)) return dom;
|
|
222
|
-
return isFile(part.png.replace(/\.png$/i, '.html')) ? part.png.replace(/\.png$/i, '.html') : null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Re-renders `source` for a part with no record layer: {layer, measured, dom} (dom is the snapshot it found, else `dom`). */
|
|
226
|
-
async function remeasuredLayer(source, viewport, { playwright, scratch, index }, dom) {
|
|
227
|
-
const found = { layer: null, measured: null, dom };
|
|
228
|
-
try {
|
|
229
|
-
const { captureHtml } = await import('../draw-render.mjs');
|
|
230
|
-
const [r] = await captureHtml({ html: source, out: path.join(scratch, `${index}`), viewports: [viewport], theme: 'light', fullPage: true, name: 'layer-remeasure', source: { mode: 'html' }, playwright, rationale: null });
|
|
231
|
-
if (r?.layer && !r.layer.error) { found.layer = r.layer; found.measured = `re-rendered ${path.relative(process.cwd(), source)}`; }
|
|
232
|
-
if (!dom && r?.dom?.path && isFile(r.dom.path)) found.dom = r.dom.path;
|
|
233
|
-
} catch (error) { found.measured = `re-render failed: ${String(error?.message ?? error).split(/\r?\n/)[0]}`; }
|
|
234
|
-
return found;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** The layer result of one part: {part, viewport, dom, measured, forms, findings}. */
|
|
238
|
-
async function layerResultOf(part, ctx, index) {
|
|
239
|
-
const label = path.basename(part.png).replace(/\.png$/i, '');
|
|
240
|
-
const viewport = part.record?.viewport ? { width: part.record.viewport.width, height: part.record.viewport.height } : null;
|
|
241
|
-
let dom = domOfPart(part);
|
|
242
|
-
let layer = part.record?.layer && !part.record.layer.error ? part.record.layer : null;
|
|
243
|
-
let measured = layer ? 'record' : null;
|
|
244
|
-
const source = !layer && ctx.playwright && viewport ? renderSourceOf(part) : null;
|
|
245
|
-
if (source) {
|
|
246
|
-
const found = await remeasuredLayer(source, viewport, { ...ctx, index }, dom);
|
|
247
|
-
layer = found.layer;
|
|
248
|
-
measured = found.measured ?? measured;
|
|
249
|
-
dom = found.dom;
|
|
250
|
-
}
|
|
251
|
-
const html = dom ? fs.readFileSync(dom, 'utf8') : null;
|
|
252
|
-
const findings = [...(html ? nestedVariantFindings(html, { label }) : []), ...measureFindings(layer, { label })];
|
|
253
|
-
return { part: part.png, viewport, dom, measured: measured ?? 'unmeasured (no record layer, no re-render source or Playwright)', forms: layer?.forms ?? null, findings };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* The layer findings of drawn parts: [{part, viewport, findings, measured, forms}]. `playwright` (loadPlaywright) re-measures
|
|
258
|
-
* a part whose record has no `layer`; without it such a part is reported unmeasured.
|
|
259
|
-
*/
|
|
260
|
-
export async function layerFindingsForParts(parts, { playwright = null } = {}) {
|
|
261
|
-
const scratch = playwright ? makeTempDir('draw-layer-') : null;
|
|
262
|
-
try {
|
|
263
|
-
return await mapInOrder(parts, (part, index) => layerResultOf(part, { playwright, scratch }, index));
|
|
264
|
-
} finally {
|
|
265
|
-
if (scratch) safeRemove(scratch, { hold: artifactHoldReason });
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** The draw-layer command line: {paths, opts} or {error} for an unknown flag. */
|
|
270
|
-
function parseLayerArgs(argv) {
|
|
271
|
-
const paths = [], opts = { playwright: null, json: false };
|
|
272
|
-
for (let i = 0; i < argv.length; i++) {
|
|
273
|
-
if (argv[i] === '--json') opts.json = true;
|
|
274
|
-
else if (argv[i] === '--playwright') opts.playwright = argv[++i];
|
|
275
|
-
else if (argv[i].startsWith('--')) return { error: `draw-layer: unknown flag ${argv[i]}\n` };
|
|
276
|
-
else paths.push(argv[i]);
|
|
277
|
-
}
|
|
278
|
-
return { paths, opts };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function printLayerResults(results, red) {
|
|
282
|
-
for (const r of results) {
|
|
283
|
-
const forms = r.forms ? `; forms ${r.forms.map((f) => String(Math.round(f.width)) + 'px').join(', ') || 'none'}` : '';
|
|
284
|
-
process.stdout.write(`${r.findings.length ? 'FAIL' : 'ok '} ${r.part} [${r.measured}${forms}]\n`);
|
|
285
|
-
for (const f of r.findings) process.stdout.write(` [${f.code}] ${f.detail}\n`);
|
|
286
|
-
}
|
|
287
|
-
process.stdout.write(`${results.length} part(s), ${red.length} red\n`);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function main(argv) {
|
|
291
|
-
const { paths, opts, error } = parseLayerArgs(argv);
|
|
292
|
-
if (error) { process.stderr.write(error); return 2; }
|
|
293
|
-
if (!paths.length) { process.stderr.write('use: starci work draw-layer <render dir | part png>... [--playwright <product dir>] [--json]\n'); return 2; }
|
|
294
|
-
const parts = partsUnder(paths);
|
|
295
|
-
let playwright = null;
|
|
296
|
-
try { const { loadPlaywright } = await import('../draw-render.mjs'); playwright = loadPlaywright([opts.playwright, readEnv('STARCI_PLAYWRIGHT_DIR'), invocationDir()].filter(Boolean)); } catch { playwright = null; }
|
|
297
|
-
const results = await layerFindingsForParts(parts, { playwright });
|
|
298
|
-
const red = results.filter((r) => r.findings.length);
|
|
299
|
-
if (opts.json) process.stdout.write(`${JSON.stringify({ schema: 'starci/draw-layer@1', parts: results.length, red: red.length, results }, null, 2)}\n`);
|
|
300
|
-
else printLayerResults(results, red);
|
|
301
|
-
return red.length ? 1 : 0;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (isMain(import.meta.url)) {
|
|
305
|
-
try { process.exitCode = await main(process.argv.slice(2)); } catch (e) { process.stderr.write(`draw-layer: ${e?.stack ?? e}\n`); process.exitCode = 2; }
|
|
306
|
-
}
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
// draw-rationale-page.mjs - the in-page halves of draw-rationale.mjs: measureRationale and drawRedlines run inside the
|
|
2
|
-
// browser (playwright serializes each function, so each is self-contained and reads nothing of this module).
|
|
3
|
-
/**
|
|
4
|
-
* Measure what the render uses: every distinct gap/padding/inset, radius, font-size, font-weight and line-height of a
|
|
5
|
-
* visible element (type values only where the element renders text), the colours it paints, its grids, its regions
|
|
6
|
-
* and its background-image art. `arg` {tokens: ['--accent', ...], whyAttr, redlineAttr, schema}.
|
|
7
|
-
*/
|
|
8
|
-
export function measureRationale(arg) {
|
|
9
|
-
const { tokens = [], whyAttr, redlineAttr, schema } = arg;
|
|
10
|
-
const px = (s) => { const n = Number.parseFloat(s); return Number.isFinite(n) ? Math.round(n * 100) / 100 : null; };
|
|
11
|
-
// Any CSS colour (rgb, oklch, color-mix ...) to sRGB hex through a 1px canvas; transparent is null.
|
|
12
|
-
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
13
|
-
const hexCache = new Map();
|
|
14
|
-
const hexOf = (c) => {
|
|
15
|
-
const key = String(c);
|
|
16
|
-
if (hexCache.has(key)) return hexCache.get(key);
|
|
17
|
-
let hex = null;
|
|
18
|
-
try {
|
|
19
|
-
ctx.clearRect(0, 0, 1, 1);
|
|
20
|
-
ctx.fillStyle = '#000'; ctx.fillStyle = key;
|
|
21
|
-
ctx.fillRect(0, 0, 1, 1);
|
|
22
|
-
const d = ctx.getImageData(0, 0, 1, 1).data;
|
|
23
|
-
if (d[3] > 5) hex = `#${[d[0], d[1], d[2]].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
24
|
-
} catch { hex = null; }
|
|
25
|
-
hexCache.set(key, hex);
|
|
26
|
-
return hex;
|
|
27
|
-
};
|
|
28
|
-
// The inset sides an author wrote (a used value is reported for every side of a positioned element, so only the
|
|
29
|
-
// sides a matching rule or the inline style sets are the render's decisions).
|
|
30
|
-
const insetRules = [];
|
|
31
|
-
const collect = (rules) => {
|
|
32
|
-
for (const r of rules ?? []) {
|
|
33
|
-
if (r.cssRules && r.conditionText != null) { if (window.matchMedia(r.conditionText).matches) { collect(r.cssRules); } continue; }
|
|
34
|
-
if (r.cssRules && !r.selectorText) { collect(r.cssRules); continue; }
|
|
35
|
-
if (!r.selectorText || !r.style) continue;
|
|
36
|
-
const sides = ['top', 'right', 'bottom', 'left'].filter((k) => { const v = r.style.getPropertyValue(k); return v && v !== 'auto'; });
|
|
37
|
-
if (sides.length) insetRules.push({ selector: r.selectorText, sides });
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
for (const sheet of document.styleSheets) { try { collect(sheet.cssRules); } catch { /* a cross-origin sheet */ } }
|
|
41
|
-
const insetSides = (el) => {
|
|
42
|
-
const sides = new Set(['top', 'right', 'bottom', 'left'].filter((k) => { const v = el.style?.getPropertyValue(k); return v && v !== 'auto'; }));
|
|
43
|
-
for (const r of insetRules) { try { if (el.matches(r.selector)) for (const k of r.sides) sides.add(k); } catch { /* an unsupported selector */ } }
|
|
44
|
-
return [...sides];
|
|
45
|
-
};
|
|
46
|
-
const sel = (el) => {
|
|
47
|
-
const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/).filter(Boolean).slice(0, 2).join('.') : '';
|
|
48
|
-
return el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (cls ? '.' + cls : '');
|
|
49
|
-
};
|
|
50
|
-
const whyOf = (el) => { const w = el.closest(`[${whyAttr}]`); return w ? w.getAttribute(whyAttr).split(/\s+/).filter(Boolean) : []; };
|
|
51
|
-
const excluded = (el) => Boolean(el.closest(`[${redlineAttr}],#redlines`));
|
|
52
|
-
const visible = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none'; };
|
|
53
|
-
const values = { spacing: new Map(), radius: new Map(), fontSize: new Map(), fontWeight: new Map(), lineHeight: new Map() };
|
|
54
|
-
const put = (cls, value, el) => {
|
|
55
|
-
if (value == null || value === 0) return;
|
|
56
|
-
if (!values[cls].has(value)) values[cls].set(value, []);
|
|
57
|
-
const at = values[cls].get(value);
|
|
58
|
-
if (at.length < 3) at.push(sel(el));
|
|
59
|
-
};
|
|
60
|
-
const colours = new Map();
|
|
61
|
-
const colour = (hex, use, el) => { if (!hex) { return; } const k = `${hex}|${use}`; if (!colours.has(k)) { colours.set(k, { hex, use, at: [] }); } const c = colours.get(k); if (c.at.length < 3) { c.at.push(sel(el)); } };
|
|
62
|
-
const grids = [], art = [];
|
|
63
|
-
const all = [...document.querySelectorAll('body *')].filter((el) => !excluded(el) && !(el instanceof SVGElement && el.tagName.toLowerCase() !== 'svg') && !['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT'].includes(el.tagName) && visible(el));
|
|
64
|
-
const collectSpacing = (el, cs) => {
|
|
65
|
-
for (const side of ['Top', 'Right', 'Bottom', 'Left']) put('spacing', px(cs[`padding${side}`]), el);
|
|
66
|
-
if (/flex|grid/.test(cs.display)) { put('spacing', px(cs.rowGap), el); put('spacing', px(cs.columnGap), el); }
|
|
67
|
-
if (cs.position !== 'static') {
|
|
68
|
-
for (const side of insetSides(el)) { const v = px(cs[side]); if (v != null) put('spacing', Math.abs(v), el); }
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
const collectRadii = (el, cs) => {
|
|
72
|
-
for (const corner of ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius']) {
|
|
73
|
-
const raw = String(cs[corner]).split(' ')[0];
|
|
74
|
-
if (!raw || raw === '0px') continue;
|
|
75
|
-
if (raw.endsWith('%')) put('radius', Number.parseFloat(raw) >= 50 ? 'circle' : raw, el);
|
|
76
|
-
else { const v = px(raw); const r = el.getBoundingClientRect(); put('radius', v != null && (v >= 999 || v >= Math.min(r.width, r.height) / 2 - 0.5) && v > 8 ? 'pill' : v, el); }
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
const collectText = (el, cs) => {
|
|
80
|
-
const text = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
|
81
|
-
if (text) {
|
|
82
|
-
put('fontSize', px(cs.fontSize), el);
|
|
83
|
-
put('fontWeight', Number(cs.fontWeight) || null, el);
|
|
84
|
-
if (cs.lineHeight !== 'normal') put('lineHeight', px(cs.lineHeight), el);
|
|
85
|
-
colour(hexOf(cs.color), 'text', el);
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
const collectBorders = (el, cs) => {
|
|
89
|
-
for (const side of ['Top', 'Right', 'Bottom', 'Left']) if (Number.parseFloat(cs[`border${side}Width`]) > 0 && cs[`border${side}Style`] !== 'none') colour(hexOf(cs[`border${side}Color`]), 'border', el);
|
|
90
|
-
};
|
|
91
|
-
// The tracks of a grid template: pieces split at whitespace unless the next parenthesis after it closes one.
|
|
92
|
-
const trackCountOf = (template) => {
|
|
93
|
-
let count = 0, from = 0;
|
|
94
|
-
const closesFirst = (at) => { const open = template.indexOf('(', at), close = template.indexOf(')', at); return close !== -1 && (open === -1 || close < open); };
|
|
95
|
-
for (const run of template.matchAll(/\s+/g)) {
|
|
96
|
-
if (closesFirst(run.index + run[0].length)) continue;
|
|
97
|
-
if (run.index > from) count += 1;
|
|
98
|
-
from = run.index + run[0].length;
|
|
99
|
-
}
|
|
100
|
-
return template.length > from ? count + 1 : count;
|
|
101
|
-
};
|
|
102
|
-
const collectGrid = (el, cs) => {
|
|
103
|
-
if (cs.display === 'grid') {
|
|
104
|
-
const kids = [...el.children].filter((k) => visible(k) && !excluded(k));
|
|
105
|
-
if (kids.length >= 2) grids.push({ selector: sel(el), why: whyOf(el), columns: trackCountOf(cs.gridTemplateColumns) });
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
const measureElement = (el) => {
|
|
109
|
-
const cs = getComputedStyle(el);
|
|
110
|
-
collectSpacing(el, cs);
|
|
111
|
-
collectRadii(el, cs);
|
|
112
|
-
collectText(el, cs);
|
|
113
|
-
colour(hexOf(cs.backgroundColor), 'background', el);
|
|
114
|
-
collectBorders(el, cs);
|
|
115
|
-
collectGrid(el, cs);
|
|
116
|
-
if (/url\(/.test(cs.backgroundImage)) art.push({ selector: sel(el), why: whyOf(el) });
|
|
117
|
-
};
|
|
118
|
-
for (const el of all) {
|
|
119
|
-
measureElement(el);
|
|
120
|
-
}
|
|
121
|
-
const pick = (m) => [...m.entries()].map(([value, at]) => ({ value, at })).sort((a, b) => String(a.value).localeCompare(String(b.value), 'en', { numeric: true }));
|
|
122
|
-
const probe = document.createElement('i');
|
|
123
|
-
document.body.appendChild(probe);
|
|
124
|
-
const tokenColours = {};
|
|
125
|
-
const rootStyle = getComputedStyle(document.documentElement);
|
|
126
|
-
for (const t of tokens) {
|
|
127
|
-
if (!rootStyle.getPropertyValue(t).trim()) continue;
|
|
128
|
-
probe.style.color = `var(${t})`;
|
|
129
|
-
const hex = hexOf(getComputedStyle(probe).color);
|
|
130
|
-
if (hex) tokenColours[t] = hex;
|
|
131
|
-
}
|
|
132
|
-
probe.remove();
|
|
133
|
-
const container = document.querySelector('[data-grammar-component="PageContainer"]') ?? document.querySelector('main') ?? document.body;
|
|
134
|
-
const regions = [...container.children].filter((k) => !excluded(k) && !['SCRIPT', 'STYLE', 'TEMPLATE'].includes(k.tagName) && visible(k));
|
|
135
|
-
return { schema, viewport: { width: innerWidth, height: innerHeight }, values: Object.fromEntries(Object.entries(values).map(([k, m]) => [k, pick(m)])),
|
|
136
|
-
colours: [...colours.values()], grids, art, tokenColours, regions: { count: regions.length, whys: regions.map(whyOf) } };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Draw the redline overlay into the page (html.annotate): spacing brackets with the value and the decision's first
|
|
141
|
-
* rule id for region gaps and card paddings, a DNA label at each component root, the labels kept apart (a label that
|
|
142
|
-
* would overlap another is moved, else dropped). `arg` {labels: redlineLabelsOf(entries), whyAttr, redlineAttr,
|
|
143
|
-
* leaf: REDLINE_LEAF_COMPONENTS}.
|
|
144
|
-
*/
|
|
145
|
-
export function drawRedlines(arg) {
|
|
146
|
-
const { labels = {}, whyAttr, redlineAttr, leaf = [] } = arg;
|
|
147
|
-
document.documentElement.classList.add('annotate');
|
|
148
|
-
const W = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth);
|
|
149
|
-
const H = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
|
|
150
|
-
const NS = 'http://www.w3.org/2000/svg';
|
|
151
|
-
const host = document.createElement('div');
|
|
152
|
-
host.setAttribute(redlineAttr, '');
|
|
153
|
-
host.setAttribute('aria-hidden', 'true');
|
|
154
|
-
host.style.cssText = `position:absolute;left:0;top:0;width:${W}px;height:${H}px;z-index:2147483647;pointer-events:none`;
|
|
155
|
-
const svg = document.createElementNS(NS, 'svg');
|
|
156
|
-
svg.setAttribute('width', W); svg.setAttribute('height', H); svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
157
|
-
svg.style.cssText = 'position:absolute;left:0;top:0;overflow:visible';
|
|
158
|
-
host.appendChild(svg);
|
|
159
|
-
document.body.appendChild(host);
|
|
160
|
-
const RED = '#d4001a', BLUE = '#1d4ed8';
|
|
161
|
-
const placed = [];
|
|
162
|
-
const hits = (r) => placed.some((p) => r.x < p.x + p.w && r.x + r.w > p.x && r.y < p.y + p.h && r.y + r.h > p.y);
|
|
163
|
-
const el = (tag, attrs) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) { n.setAttribute(k, v); } svg.appendChild(n); return n; };
|
|
164
|
-
const line = (x1, y1, x2, y2, c) => el('line', { x1, y1, x2, y2, stroke: c, 'stroke-width': 1 });
|
|
165
|
-
const label = (x, y, text, c) => {
|
|
166
|
-
const w = text.length * 6 + 6, h = 12;
|
|
167
|
-
for (const dy of [0, 13, -13, 26, -26, 39]) {
|
|
168
|
-
const r = { x: Math.min(Math.max(0, x), W - w), y: Math.max(0, y + dy - h), w, h };
|
|
169
|
-
if (hits(r)) continue;
|
|
170
|
-
placed.push(r);
|
|
171
|
-
el('rect', { x: r.x, y: r.y, width: w, height: h, fill: 'rgba(255,255,255,0.92)', stroke: c, 'stroke-width': 0.5, rx: 2 });
|
|
172
|
-
const t = el('text', { x: r.x + 3, y: r.y + 9, fill: c, 'font-size': 9, 'font-family': 'ui-monospace,Menlo,Consolas,monospace' });
|
|
173
|
-
t.textContent = text;
|
|
174
|
-
return true;
|
|
175
|
-
}
|
|
176
|
-
return false;
|
|
177
|
-
};
|
|
178
|
-
const box = (n) => { const b = n.getBoundingClientRect(); return { x: b.left + scrollX, y: b.top + scrollY, w: b.width, h: b.height }; };
|
|
179
|
-
const visible = (n) => { const b = n.getBoundingClientRect(); const cs = getComputedStyle(n); return b.width > 1 && b.height > 1 && cs.visibility !== 'hidden' && cs.display !== 'none'; };
|
|
180
|
-
const whys = (n) => (n.getAttribute(whyAttr) ?? '').split(/\s+/).filter(Boolean);
|
|
181
|
-
const labelOf = (n) => whys(n).map((id) => labels[id]).find(Boolean) ?? null;
|
|
182
|
-
const round = (v) => Math.round(v * 10) / 10;
|
|
183
|
-
const vtick = (x, y1, y2) => { line(x, y1, x, y2, RED); line(x - 3, y1, x + 3, y1, RED); line(x - 3, y2, x + 3, y2, RED); };
|
|
184
|
-
const htick = (x1, x2, y) => { line(x1, y, x2, y, RED); line(x1, y - 3, x1, y + 3, RED); line(x2, y - 3, x2, y + 3, RED); };
|
|
185
|
-
const all = [...document.querySelectorAll(`[${whyAttr}]`)].filter((n) => !n.closest(`[${redlineAttr}],#redlines`) && !(n instanceof SVGElement) && visible(n));
|
|
186
|
-
// Spacing brackets: the gaps between the children of a layout/spacing decision's element, and its padding.
|
|
187
|
-
const gapIsDecided = (ncs, gap) => gap <= 48 || [Number.parseFloat(ncs.rowGap), Number.parseFloat(ncs.columnGap)].some((d) => Number.isFinite(d) && Math.abs(d - gap) < 1);
|
|
188
|
-
const drawGapForPair = (first, second, shown, ncs, tag) => {
|
|
189
|
-
const a = box(first), b = box(second);
|
|
190
|
-
const vgap = round(b.y - (a.y + a.h)), hgap = round(b.x - (a.x + a.w));
|
|
191
|
-
if (vgap > 0.5 && gapIsDecided(ncs, vgap) && b.y >= a.y + a.h - 1) {
|
|
192
|
-
const x = Math.max(2, a.x - 5);
|
|
193
|
-
vtick(x, a.y + a.h, b.y);
|
|
194
|
-
if (!shown.has(`v${vgap}`)) { shown.add(`v${vgap}`); label(x + 4, a.y + a.h + vgap / 2 + 6, tag(vgap), RED); }
|
|
195
|
-
} else if (hgap > 0.5 && gapIsDecided(ncs, hgap) && Math.abs(a.y - b.y) < Math.max(a.h, b.h)) {
|
|
196
|
-
const y = a.y + Math.min(a.h, b.h) / 2;
|
|
197
|
-
htick(a.x + a.w, b.x, y);
|
|
198
|
-
if (!shown.has(`h${hgap}`)) { shown.add(`h${hgap}`); label(a.x + a.w, y - 4, tag(hgap), RED); }
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
const drawPadding = (n, tag) => {
|
|
202
|
-
const cs = getComputedStyle(n), b = box(n);
|
|
203
|
-
const pt = round(Number.parseFloat(cs.paddingTop) || 0), pl = round(Number.parseFloat(cs.paddingLeft) || 0);
|
|
204
|
-
if (pl > 0.5 && b.h > 24) htick(b.x, b.x + pl, b.y + Math.min(b.h - 4, 12));
|
|
205
|
-
if (pt > 0.5 && b.w > 24) vtick(b.x + Math.min(b.w - 4, 12), b.y, b.y + pt);
|
|
206
|
-
if (pt > 0.5 || pl > 0.5) label(b.x + pl + 2, b.y + pt + 11, tag(pt === pl || !pt || !pl ? `p ${pt || pl}` : `p ${pt}/${pl}`), RED);
|
|
207
|
-
};
|
|
208
|
-
const drawSpacingForElement = (n) => {
|
|
209
|
-
const l = labelOf(n);
|
|
210
|
-
if (!l || !['layout', 'spacing'].includes(l.kind)) return;
|
|
211
|
-
const tag = (v) => `${v}${l.rule ? ' · ' + l.rule : ''}`;
|
|
212
|
-
const kids = [...n.children].filter((k) => !(k instanceof SVGElement) && visible(k));
|
|
213
|
-
const shown = new Set();
|
|
214
|
-
// A decided gap is the container's gap (or a small margin); the free space of space-between is no decision.
|
|
215
|
-
const ncs = getComputedStyle(n);
|
|
216
|
-
for (let i = 0; i < kids.length - 1; i += 1) drawGapForPair(kids[i], kids[i + 1], shown, ncs, tag);
|
|
217
|
-
drawPadding(n, tag);
|
|
218
|
-
};
|
|
219
|
-
for (const n of all) drawSpacingForElement(n);
|
|
220
|
-
// DNA labels at component roots, regions outlined.
|
|
221
|
-
const drawComponentLabels = () => {
|
|
222
|
-
for (const n of document.querySelectorAll('[data-grammar-component]')) {
|
|
223
|
-
if (n.closest(`[${redlineAttr}],#redlines`) || !visible(n)) continue;
|
|
224
|
-
const name = n.dataset.grammarComponent;
|
|
225
|
-
const upName = n.parentElement?.closest('[data-grammar-component]')?.dataset.grammarComponent;
|
|
226
|
-
if (leaf.includes(name) || (upName === name && 'grammarPart' in n.dataset)) continue;
|
|
227
|
-
const b = box(n);
|
|
228
|
-
if (b.w < 48 || b.h < 24) continue;
|
|
229
|
-
const l = labelOf(n);
|
|
230
|
-
if (l?.kind === 'layout') el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, fill: 'none', stroke: BLUE, 'stroke-width': 1, 'stroke-dasharray': '3 2' });
|
|
231
|
-
const variant = n.dataset.variant ?? n.dataset.tone ?? null;
|
|
232
|
-
label(b.x + 2, b.y + 12, `${name}${variant ? ' ' + variant : ''}`, BLUE);
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
drawComponentLabels();
|
|
236
|
-
return { labels: placed.length };
|
|
237
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// draw-rationale-ref.mjs - a rationale rules[] entry read apart: `[namespace:] token [case-N]`.
|
|
2
|
-
const REF_RX = /^\s*(?:([a-z]+):\s*)?([^\s,;()]+)(?:\s+(case-\d+))?/i;
|
|
3
|
-
|
|
4
|
-
/** The match of a rules[] entry: [entry, namespace|undefined, token, case|undefined], or null. */
|
|
5
|
-
export const parseRuleRef = (text) => REF_RX.exec(text);
|
|
6
|
-
|
|
7
|
-
const TRAILING_MARKS = new Set(['.', ':']);
|
|
8
|
-
|
|
9
|
-
/** `token` without its trailing `.` and `:` marks. */
|
|
10
|
-
export function bareRuleToken(token) {
|
|
11
|
-
const kept = [...token];
|
|
12
|
-
while (kept.length && TRAILING_MARKS.has(kept.at(-1))) kept.pop();
|
|
13
|
-
return kept.join('');
|
|
14
|
-
}
|