@event4u/agent-config 14.17.0 → 14.19.0
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +207 -77
- package/README.md +72 -150
- package/dist/agent-src/README.md +1 -1
- package/dist/agent-src/commands/agent-handoff.md +2 -2
- package/dist/agent-src/commands/pr/merge.md +35 -44
- package/dist/agent-src/commands/review/changes.md +19 -19
- package/dist/agent-src/commands/review.md +2 -2
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +30 -3
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/git-history-discipline.md +2 -2
- package/dist/agent-src/rules/guidelines.md +1 -1
- package/dist/agent-src/rules/session-canary.md +2 -2
- package/dist/agent-src/rules/token-efficiency.md +1 -1
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
- package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
- package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
- package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
- package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
- package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
- package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
- package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
- package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
- package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
- package/dist/agent-src/skills/design-review/SKILL.md +3 -1
- package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
- package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
- package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
- package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
- package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
- package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
- package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +29 -2
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/commands/recordTriggerEval.js +2 -2
- package/dist/cli/main.js +1 -1
- package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-GZTMRYCN.js} +405 -341
- package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-IDHUQIXP.js} +283 -132
- package/dist/cli-delegate/cmd_adr_effective.js +318 -81
- package/dist/cli-delegate/cmd_conformance.js +749 -669
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +39 -38
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +2764 -2439
- package/dist/install/install.mjs +588 -527
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1381 -1138
- package/dist/scripts/_lib/model_tier.js +1 -1
- package/dist/shared/settingsConsent.js +1 -1
- package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
- package/docs/guidelines/design-antipatterns.md +87 -64
- package/package.json +1 -1
- package/src/agent-src/README.md +1 -1
- package/src/config/ci-local-parity.yml +6 -0
- package/src/config/conformance-claim-baseline.json +9 -0
- package/src/config/gate-coverage.yml +170 -0
- package/src/config/gate-violation-baselines.json +4 -4
- package/src/config/release-gate-locality.yml +141 -0
- package/src/scripts/_cli/cmd_doctor.ts +2 -2
- package/src/scripts/_cli/cmd_handoff.ts +1 -1
- package/src/scripts/_cli/cmd_update.ts +1 -1
- package/src/scripts/_cli/cmd_versions.ts +1 -1
- package/src/scripts/_lib/adr_frontmatter.ts +44 -0
- package/src/scripts/_lib/consequence_objects.ts +152 -0
- package/src/scripts/_lib/exec_evidence.ts +7 -0
- package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
- package/src/scripts/_lib/host_permission_checks.ts +251 -0
- package/src/scripts/_lib/model_tier.ts +1 -1
- package/src/scripts/_lib/obligation_frequency.ts +2 -2
- package/src/scripts/_lib/release_highlights.ts +265 -1
- package/src/scripts/_lib/release_material.ts +129 -0
- package/src/scripts/_lib/repeated_run.ts +77 -0
- package/src/scripts/_lib/runtime_journal.ts +26 -3
- package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
- package/src/scripts/_lib/session_register.ts +11 -6
- package/src/scripts/_lib/sqlite_schema.ts +22 -0
- package/src/scripts/_lib/surface.ts +86 -0
- package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
- package/src/scripts/_lib/ui_authority.ts +116 -0
- package/src/scripts/_lib/unified_diff.ts +121 -0
- package/src/scripts/adr/regenerate_index.ts +54 -1
- package/src/scripts/adr_cite_check.ts +71 -15
- package/src/scripts/ai_council/clients.ts +1 -1
- package/src/scripts/autonomy_friction_corpus.ts +229 -0
- package/src/scripts/build_proof.ts +15 -2
- package/src/scripts/check_beta_review_markers.ts +141 -17
- package/src/scripts/check_enforcement_coverage.ts +1 -1
- package/src/scripts/check_enforcement_denominator.ts +121 -11
- package/src/scripts/check_finding_dispositions.ts +239 -6
- package/src/scripts/check_gate_coverage.ts +96 -0
- package/src/scripts/check_installed_hooks_fresh.ts +381 -0
- package/src/scripts/check_release_highlights.ts +99 -14
- package/src/scripts/check_skill_activation_claim.ts +179 -0
- package/src/scripts/check_suppression_hygiene.ts +11 -0
- package/src/scripts/check_trigger_evals.ts +1 -1
- package/src/scripts/code_graph/build.ts +230 -33
- package/src/scripts/code_graph/extract.ts +168 -33
- package/src/scripts/code_graph/types.ts +37 -2
- package/src/scripts/code_graph/validate.ts +12 -1
- package/src/scripts/conformance_scan.ts +45 -4
- package/src/scripts/detect_silent_catch.ts +403 -0
- package/src/scripts/detect_verification_tampering.ts +538 -0
- package/src/scripts/explain_run.ts +1 -1
- package/src/scripts/generate_capability_matrix.ts +1 -1
- package/src/scripts/git_authorization_hook.ts +228 -18
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +47 -30
- package/src/scripts/hooks/augment-dispatcher.sh +1 -1
- package/src/scripts/hooks/block_no_verify.ts +3 -3
- package/src/scripts/hooks/category_a.ts +291 -0
- package/src/scripts/hooks/cline-dispatcher.sh +1 -1
- package/src/scripts/hooks/concern_registry.ts +2 -2
- package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
- package/src/scripts/hooks/dispatch_hook.ts +17 -22
- package/src/scripts/hooks/evidence_independence.ts +21 -2
- package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +175 -364
- package/src/scripts/hooks/host_lowering.ts +215 -0
- package/src/scripts/hooks/host_lowering.yaml +185 -0
- package/src/scripts/hooks/host_semantics.ts +105 -37
- package/src/scripts/hooks/journal_record_hook.ts +10 -0
- package/src/scripts/hooks/permission_gate.ts +117 -0
- package/src/scripts/hooks/push_settle_hook.ts +250 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
- package/src/scripts/hooks_status.ts +12 -10
- package/src/scripts/install-hooks.sh +134 -1
- package/src/scripts/install.ts +53 -82
- package/src/scripts/install_anthropic_key.sh +2 -2
- package/src/scripts/language_mirror_hook.ts +27 -5
- package/src/scripts/lint_design_antipattern_parity.ts +80 -0
- package/src/scripts/lint_evidence_artifacts.ts +45 -1
- package/src/scripts/lint_hook_manifest.ts +87 -3
- package/src/scripts/lint_motion_authority_drift.ts +731 -0
- package/src/scripts/lint_readme_size.ts +158 -12
- package/src/scripts/lint_reasoning_fixtures.ts +216 -0
- package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
- package/src/scripts/measure_sibling_naming.ts +150 -0
- package/src/scripts/redact_hook_capture.ts +1 -1
- package/src/scripts/release.ts +31 -143
- package/src/scripts/release_drill.ts +71 -1
- package/src/scripts/release_publication.ts +243 -3
- package/src/scripts/release_verify.ts +220 -0
- package/src/scripts/report_skill_activation.ts +319 -7
- package/src/scripts/schemas/review-findings.schema.json +6 -1
- package/src/scripts/schemas/skill.schema.json +12 -2
- package/src/scripts/self_review_gate.ts +10 -1
- package/src/scripts/session_canary_hook.ts +1 -1
- package/src/scripts/session_register_hook.ts +4 -3
- package/src/scripts/trigger_eval_grandfather.json +5 -1
- package/src/scripts/validate_agent_settings.ts +1 -1
- package/src/shared/settingsConsent.ts +1 -1
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lint_motion_authority_drift — one file states motion timing, the rest are checked against it.
|
|
3
|
+
*
|
|
4
|
+
* `road-to-one-motion-authority` Phase 1. Six shipped carriers in this tree
|
|
5
|
+
* state motion duration or easing and they disagreed: a hover band of
|
|
6
|
+
* 150–200 ms against a decision tree's 100–160 ms, page transitions running to
|
|
7
|
+
* 800 ms against a stated ceiling of 500 ms, and an elastic recipe supplied by
|
|
8
|
+
* one file while another forbids elastic easing outright and a `backed`
|
|
9
|
+
* detector enforces the prohibition. No reader could tell which value won.
|
|
10
|
+
*
|
|
11
|
+
* `src/skills/fe-design/references/design-patterns.md` § Motion is the sole
|
|
12
|
+
* carrier. This gate does not restate its numbers — it PARSES them, so the
|
|
13
|
+
* authority cannot drift away from its own enforcement. Five dependent files
|
|
14
|
+
* are checked against what it says.
|
|
15
|
+
*
|
|
16
|
+
* WHY TWO PARSING STRATEGIES, NOT ONE.
|
|
17
|
+
*
|
|
18
|
+
* Two dependents are structured (`motion.csv`, `styles.csv`): every row carries
|
|
19
|
+
* an explicit `Duration` / `Easing` field or a register description, so they are
|
|
20
|
+
* parsed mechanically. Three are prose or CSV free text
|
|
21
|
+
* (`design-rules-checklist.md`, `ux-guidelines.csv`, `app-interface.csv`), where
|
|
22
|
+
* a sentence can describe a rule rather than state one. A parser guessing at
|
|
23
|
+
* those over-fires, and a noisy gate gets disabled. So the prose carriers are
|
|
24
|
+
* checked against an EXPLICIT ALLOWLIST of the statements that carry a number or
|
|
25
|
+
* an easing token, each with a recorded verdict, and an unrecognised statement
|
|
26
|
+
* is a finding rather than a guess. A future edit to a motion sentence has to
|
|
27
|
+
* come back through this table.
|
|
28
|
+
*
|
|
29
|
+
* CEILING SEMANTICS, stated because they are a choice.
|
|
30
|
+
*
|
|
31
|
+
* A dependent's duration range fails when its UPPER bound exceeds the
|
|
32
|
+
* authority's band for the interaction it names. Being faster than the band is
|
|
33
|
+
* not a contradiction of a ceiling, and the authority's own hard statement is
|
|
34
|
+
* "Above 500ms: almost never" — an upper limit, not a window.
|
|
35
|
+
*
|
|
36
|
+
* WHAT THIS GATE DOES NOT CHECK.
|
|
37
|
+
*
|
|
38
|
+
* The authority bands five named interactions. A dependent row naming an
|
|
39
|
+
* interaction it does not band — scroll reveal, stagger list, parallax, loading
|
|
40
|
+
* loop — is reported as `unbanded` and NOT failed, because failing it would mean
|
|
41
|
+
* inventing a band the authority never stated. The unbanded count is printed so
|
|
42
|
+
* the gap is visible rather than silent.
|
|
43
|
+
*
|
|
44
|
+
* It also does not check exit DURATION relative to enter duration. That ratio is
|
|
45
|
+
* an open question the authority deliberately does not answer.
|
|
46
|
+
*
|
|
47
|
+
* Exit codes: 0 clean · 2 drift found · 1 usage or config error.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import fs from 'node:fs';
|
|
51
|
+
import os from 'node:os';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
54
|
+
|
|
55
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
56
|
+
import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
|
|
57
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
58
|
+
|
|
59
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
|
61
|
+
const SELF_REL = path.join('src', 'scripts', 'lint_motion_authority_drift.ts');
|
|
62
|
+
|
|
63
|
+
const AUTHORITY_REL = path.join(
|
|
64
|
+
'src', 'skills', 'fe-design', 'references', 'design-patterns.md',
|
|
65
|
+
);
|
|
66
|
+
const CHECKLIST_REL = path.join(
|
|
67
|
+
'src', 'skills', 'design-intelligence', 'references', 'design-rules-checklist.md',
|
|
68
|
+
);
|
|
69
|
+
const DATA_DIR = path.join('src', 'skills', 'design-intelligence', 'data');
|
|
70
|
+
const UX_REL = path.join(DATA_DIR, 'ux-guidelines.csv');
|
|
71
|
+
const APP_REL = path.join(DATA_DIR, 'app-interface.csv');
|
|
72
|
+
const STYLES_REL = path.join(DATA_DIR, 'styles.csv');
|
|
73
|
+
const MOTION_REL = path.join(DATA_DIR, 'motion.csv');
|
|
74
|
+
|
|
75
|
+
export interface Finding {
|
|
76
|
+
readonly target: string;
|
|
77
|
+
readonly reason: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* -------------------------------------------------------------------------- */
|
|
81
|
+
/* The authority */
|
|
82
|
+
/* -------------------------------------------------------------------------- */
|
|
83
|
+
|
|
84
|
+
export interface Band { readonly lo: number; readonly hi: number }
|
|
85
|
+
|
|
86
|
+
export interface Authority {
|
|
87
|
+
/** Lower-cased interaction name → permitted band, in milliseconds. */
|
|
88
|
+
readonly bands: ReadonlyMap<string, Band>;
|
|
89
|
+
/** The hard "above Nms: almost never" figure. */
|
|
90
|
+
readonly ceiling: number;
|
|
91
|
+
/** The curve the authority names for entering elements. */
|
|
92
|
+
readonly enterEasing: string;
|
|
93
|
+
/** The curve it names for exiting elements. */
|
|
94
|
+
readonly exitEasing: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Curve families the authority prohibits outright. Overshoot, by name. */
|
|
98
|
+
export const OVERSHOOT_FAMILIES = ['elastic', 'back', 'bounce'] as const;
|
|
99
|
+
|
|
100
|
+
const DASH = /[–—-]/;
|
|
101
|
+
|
|
102
|
+
function parseBandList(line: string): Map<string, Band> {
|
|
103
|
+
const out = new Map<string, Band>();
|
|
104
|
+
for (const clause of line.split('.')) {
|
|
105
|
+
const m = /^\s*-?\s*\**([A-Za-z][A-Za-z/ -]*?)\**\s*:\s*(\d+)\s*(?:[–—-]\s*(\d+))?\s*ms\s*$/
|
|
106
|
+
.exec(clause);
|
|
107
|
+
if (m?.[1] === undefined || m[2] === undefined) continue;
|
|
108
|
+
const lo = Number(m[2]);
|
|
109
|
+
const hi = m[3] === undefined ? lo : Number(m[3]);
|
|
110
|
+
out.set(m[1].trim().toLowerCase(), { lo, hi });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseAuthority(text: string): Authority | string {
|
|
116
|
+
const lines = text.split('\n');
|
|
117
|
+
const start = lines.findIndex((l) => /^## Motion\b/.test(l));
|
|
118
|
+
if (start < 0) return 'design-patterns.md carries no `## Motion` section';
|
|
119
|
+
let end = lines.length;
|
|
120
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
121
|
+
if (/^## /.test(lines[i] ?? '')) { end = i; break; }
|
|
122
|
+
}
|
|
123
|
+
const section = lines.slice(start, end);
|
|
124
|
+
|
|
125
|
+
let bands = new Map<string, Band>();
|
|
126
|
+
let ceiling = Number.NaN;
|
|
127
|
+
let enterEasing = '';
|
|
128
|
+
let exitEasing = '';
|
|
129
|
+
|
|
130
|
+
for (const l of section) {
|
|
131
|
+
if (bands.size === 0 && /\d\s*ms\b/.test(l) && l.includes(':')) bands = parseBandList(l);
|
|
132
|
+
const c = /[Aa]bove\s+(\d+)\s*ms\s*:\s*almost never/.exec(l);
|
|
133
|
+
if (c?.[1] !== undefined) ceiling = Number(c[1]);
|
|
134
|
+
const en = /Entering \(element appearing\)\?\s*\*\*([a-z-]+)\*\*/.exec(l);
|
|
135
|
+
if (en?.[1] !== undefined) enterEasing = en[1];
|
|
136
|
+
const ex = /Exiting \(element disappearing\)\?\s*\*\*([a-z-]+)\*\*/.exec(l);
|
|
137
|
+
if (ex?.[1] !== undefined) exitEasing = ex[1];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (bands.size === 0) return 'the § Motion band line could not be parsed';
|
|
141
|
+
if (!Number.isFinite(ceiling)) return 'the § Motion "above Nms: almost never" ceiling could not be parsed';
|
|
142
|
+
if (enterEasing === '' || exitEasing === '') return 'the § Motion enter/exit easing values could not be parsed';
|
|
143
|
+
return { bands, ceiling, enterEasing, exitEasing };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* -------------------------------------------------------------------------- */
|
|
147
|
+
/* CSV */
|
|
148
|
+
/* -------------------------------------------------------------------------- */
|
|
149
|
+
|
|
150
|
+
export function parseCsv(text: string): string[][] {
|
|
151
|
+
const rows: string[][] = [];
|
|
152
|
+
let row: string[] = [];
|
|
153
|
+
let cell = '';
|
|
154
|
+
let quoted = false;
|
|
155
|
+
for (let i = 0; i < text.length; i++) {
|
|
156
|
+
const ch = text[i];
|
|
157
|
+
if (quoted) {
|
|
158
|
+
if (ch === '"') {
|
|
159
|
+
if (text[i + 1] === '"') { cell += '"'; i += 1; } else quoted = false;
|
|
160
|
+
} else cell += ch;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === '"') { quoted = true; continue; }
|
|
164
|
+
if (ch === ',') { row.push(cell); cell = ''; continue; }
|
|
165
|
+
if (ch === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; continue; }
|
|
166
|
+
if (ch === '\r') continue;
|
|
167
|
+
cell += ch;
|
|
168
|
+
}
|
|
169
|
+
if (cell !== '' || row.length > 0) { row.push(cell); rows.push(row); }
|
|
170
|
+
return rows.filter((r) => r.length > 1 || (r[0] ?? '') !== '');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const col = (hdr: string[], row: string[], name: string): string => {
|
|
174
|
+
const i = hdr.indexOf(name);
|
|
175
|
+
return i < 0 ? '' : (row[i] ?? '');
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** A negated mention ("no bounce", "avoid elastic") states a prohibition, not a value. */
|
|
179
|
+
function negated(hay: string, at: number): boolean {
|
|
180
|
+
const before = hay.slice(Math.max(0, at - 24), at).toLowerCase();
|
|
181
|
+
return /\b(no|non|not|never|avoid|avoids|without|zero)\s*[-\s]?$/.test(before);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function overshootIn(text: string): string | null {
|
|
185
|
+
for (const fam of OVERSHOOT_FAMILIES) {
|
|
186
|
+
const re = new RegExp(`\\b${fam}(?:y|-back)?\\b`, 'gi');
|
|
187
|
+
let m: RegExpExecArray | null;
|
|
188
|
+
while ((m = re.exec(text)) !== null) {
|
|
189
|
+
if (!negated(text, m.index)) return fam;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// An output control point above 1 means the value passes its target and
|
|
193
|
+
// returns — overshoot decidable from the curve itself, same test as the M1
|
|
194
|
+
// detector in `design_slop_rules.ts`.
|
|
195
|
+
const cb = /cubic-bezier\(?\s*[-\d.]+\s*,\s*(-?[\d.]+)/gi;
|
|
196
|
+
let m: RegExpExecArray | null;
|
|
197
|
+
while ((m = cb.exec(text)) !== null) {
|
|
198
|
+
const y1 = Number(m[1]);
|
|
199
|
+
if (Number.isFinite(y1) && (y1 > 1 || y1 < 0) && !negated(text, m.index)) return 'cubic-bezier overshoot';
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* -------------------------------------------------------------------------- */
|
|
205
|
+
/* motion.csv */
|
|
206
|
+
/* -------------------------------------------------------------------------- */
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Category (plus a keyword, where the authority bands a narrower thing than the
|
|
210
|
+
* category) → the authority band name. A row that maps to nothing is unbanded.
|
|
211
|
+
*/
|
|
212
|
+
const MOTION_BAND_MAP: ReadonlyArray<{
|
|
213
|
+
category: string; keyword?: string; band: string;
|
|
214
|
+
}> = [
|
|
215
|
+
{ category: 'Hover Micro-interaction', keyword: 'button', band: 'button/micro-feedback' },
|
|
216
|
+
{ category: 'Page Transition', band: 'page transition' },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
export interface MotionResult { findings: Finding[]; scanned: number; unbanded: number }
|
|
220
|
+
|
|
221
|
+
export function checkMotionCsv(text: string, auth: Authority): MotionResult {
|
|
222
|
+
const rows = parseCsv(text);
|
|
223
|
+
const hdr = rows[0] ?? [];
|
|
224
|
+
const findings: Finding[] = [];
|
|
225
|
+
let unbanded = 0;
|
|
226
|
+
let scanned = 0;
|
|
227
|
+
|
|
228
|
+
for (const row of rows.slice(1)) {
|
|
229
|
+
const no = (row[0] ?? '').trim();
|
|
230
|
+
if (no === '') continue;
|
|
231
|
+
scanned += 1;
|
|
232
|
+
const target = `motion.csv row ${no}`;
|
|
233
|
+
const easing = col(hdr, row, 'Easing');
|
|
234
|
+
const snippet = col(hdr, row, 'GSAP Snippet');
|
|
235
|
+
const override = col(hdr, row, 'Override Condition').trim();
|
|
236
|
+
|
|
237
|
+
const bad = overshootIn(easing) ?? overshootIn(snippet);
|
|
238
|
+
if (bad !== null && override === '') {
|
|
239
|
+
findings.push({
|
|
240
|
+
target,
|
|
241
|
+
reason: `supplies ${/^[aeiou]/.test(bad) ? 'an' : 'a'} ${bad} curve; the authority forbids bounce and elastic easing in UI, and the row declares no override condition`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const category = col(hdr, row, 'Category').trim();
|
|
246
|
+
const keywords = col(hdr, row, 'Keywords').toLowerCase();
|
|
247
|
+
const mapped = MOTION_BAND_MAP.find(
|
|
248
|
+
(m) => m.category === category
|
|
249
|
+
&& (m.keyword === undefined || keywords.includes(m.keyword)),
|
|
250
|
+
);
|
|
251
|
+
const dur = col(hdr, row, 'Duration');
|
|
252
|
+
const range = new RegExp(`^\\s*(\\d+)\\s*(?:${DASH.source}\\s*(\\d+))?\\s*ms`).exec(dur);
|
|
253
|
+
if (mapped === undefined) { unbanded += 1; continue; }
|
|
254
|
+
if (range?.[1] === undefined) { unbanded += 1; continue; }
|
|
255
|
+
const hi = range[2] === undefined ? Number(range[1]) : Number(range[2]);
|
|
256
|
+
const band = auth.bands.get(mapped.band);
|
|
257
|
+
if (band === undefined) {
|
|
258
|
+
findings.push({ target, reason: `maps to authority band "${mapped.band}", which § Motion no longer states` });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (hi > band.hi) {
|
|
262
|
+
findings.push({
|
|
263
|
+
target,
|
|
264
|
+
reason: `states ${dur.trim()} for "${mapped.band}", above the authority's ${String(band.lo)}–${String(band.hi)}ms`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { findings, scanned, unbanded };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* -------------------------------------------------------------------------- */
|
|
272
|
+
/* styles.csv — a register catalog, checked for the bounce prohibition only */
|
|
273
|
+
/* -------------------------------------------------------------------------- */
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Tokens by which a style row declares the playful register that IS the
|
|
277
|
+
* catalog's M1 override condition. A row describing a deliberately bouncy visual
|
|
278
|
+
* language is not drifting from the authority; it is the case the override
|
|
279
|
+
* exists for.
|
|
280
|
+
*/
|
|
281
|
+
const PLAYFUL_TOKENS = [
|
|
282
|
+
'playful', 'toy', 'bubbly', 'bouncy', 'squishy', 'jelly', 'tactile',
|
|
283
|
+
'deformable', 'fun', 'whimsical', 'expressive', 'cartoon',
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
export function checkStylesCsv(text: string): { findings: Finding[]; scanned: number } {
|
|
287
|
+
const rows = parseCsv(text);
|
|
288
|
+
const hdr = rows[0] ?? [];
|
|
289
|
+
const findings: Finding[] = [];
|
|
290
|
+
let scanned = 0;
|
|
291
|
+
for (const row of rows.slice(1)) {
|
|
292
|
+
const no = (row[0] ?? '').trim();
|
|
293
|
+
if (no === '') continue;
|
|
294
|
+
scanned += 1;
|
|
295
|
+
const register = `${col(hdr, row, 'Keywords')} ${col(hdr, row, 'Best For')}`.toLowerCase();
|
|
296
|
+
if (PLAYFUL_TOKENS.some((t) => register.includes(t))) continue;
|
|
297
|
+
for (const field of ['Effects & Animation', 'CSS/Technical Keywords', 'Design System Variables']) {
|
|
298
|
+
const bad = overshootIn(col(hdr, row, field));
|
|
299
|
+
if (bad !== null) {
|
|
300
|
+
findings.push({
|
|
301
|
+
target: `styles.csv row ${no}`,
|
|
302
|
+
reason: `${field} supplies ${/^[aeiou]/.test(bad) ? 'an' : 'a'} ${bad} curve without declaring a playful register, which is the catalog's only override for the bounce prohibition`,
|
|
303
|
+
});
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { findings, scanned };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* -------------------------------------------------------------------------- */
|
|
312
|
+
/* The prose carriers — explicit allowlist */
|
|
313
|
+
/* -------------------------------------------------------------------------- */
|
|
314
|
+
|
|
315
|
+
type Verdict = 'permitted' | 'drift';
|
|
316
|
+
|
|
317
|
+
interface Allowed {
|
|
318
|
+
/** File, for the message. */ readonly file: string;
|
|
319
|
+
/** Stable key: a checklist rule id, or `<row No>/<column>` for a CSV. */
|
|
320
|
+
readonly key: string;
|
|
321
|
+
/** The exact statement, whitespace-normalised. */ readonly text: string;
|
|
322
|
+
readonly verdict: Verdict;
|
|
323
|
+
/** Why it is permitted, or what it contradicts. */ readonly reason: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const norm = (s: string): string => s.replace(/\s+/g, ' ').trim();
|
|
327
|
+
|
|
328
|
+
export const PROSE_ALLOWLIST: readonly Allowed[] = [
|
|
329
|
+
{
|
|
330
|
+
file: CHECKLIST_REL, key: 'table:7',
|
|
331
|
+
text: '| 7 | Animation | MEDIUM | `ux` | Duration within the authority\'s per-interaction bands, Motion conveys meaning, Spatial continuity | Decorative-only animation, Animating width/height, No reduced-motion |',
|
|
332
|
+
verdict: 'permitted', reason: 'points at the authority instead of restating a band',
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
file: CHECKLIST_REL, key: 'duration-timing',
|
|
336
|
+
text: '- `duration-timing` - Use the per-interaction bands in `design-patterns.md` § Motion; never above 500ms (MD)',
|
|
337
|
+
verdict: 'permitted', reason: 'defers to the authority and repeats only its hard ceiling',
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
file: CHECKLIST_REL, key: 'loading-states',
|
|
341
|
+
text: '- `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms',
|
|
342
|
+
verdict: 'permitted', reason: 'a wait threshold, not an animation duration',
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
file: CHECKLIST_REL, key: 'easing',
|
|
346
|
+
text: '- `easing` - Use ease-out in both directions, entering and exiting; avoid linear for UI transitions',
|
|
347
|
+
verdict: 'permitted', reason: 'matches the authority\'s single shared curve',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
file: CHECKLIST_REL, key: 'spring-physics',
|
|
351
|
+
text: '- `spring-physics` - Prefer critically damped spring curves (no overshoot) over linear for natural feel (Apple HIG fluid animations)',
|
|
352
|
+
verdict: 'permitted', reason: 'excludes overshoot, so it does not contradict the bounce prohibition',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
file: CHECKLIST_REL, key: 'stagger-sequence',
|
|
356
|
+
text: '- `stagger-sequence` - Stagger list/grid item entrance by 30–50ms per item; avoid all-at-once or too-slow reveals (MD)',
|
|
357
|
+
verdict: 'permitted', reason: 'a per-item delay, not a duration for an interaction the authority bands',
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
file: UX_REL, key: '8/Do',
|
|
361
|
+
text: 'Use the per-interaction bands in design-patterns.md § Motion',
|
|
362
|
+
verdict: 'permitted', reason: 'defers to the authority',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
file: UX_REL, key: '8/Code Example Good',
|
|
366
|
+
text: 'transition-colors duration-200',
|
|
367
|
+
verdict: 'permitted', reason: 'a generic example naming no interaction the authority bands',
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
file: UX_REL, key: '14/Do',
|
|
371
|
+
text: 'Use ease-out in both directions entering and exiting',
|
|
372
|
+
verdict: 'permitted', reason: 'matches the authority\'s single shared curve',
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
file: UX_REL, key: '14/Code Example Good',
|
|
376
|
+
text: 'ease-out', verdict: 'permitted', reason: 'the authority\'s curve',
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
file: APP_REL, key: '23/Description',
|
|
380
|
+
text: 'Micro-interactions follow the per-interaction bands in design-patterns.md § Motion, with native-like easing',
|
|
381
|
+
verdict: 'permitted', reason: 'defers to the authority',
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
file: APP_REL, key: '23/Do',
|
|
385
|
+
text: 'Use ease-out for both enter and exit',
|
|
386
|
+
verdict: 'permitted', reason: 'matches the authority\'s single shared curve',
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
file: APP_REL, key: '23/Code Example Good',
|
|
390
|
+
text: 'Animated.timing(..., { duration: 200, easing: Easing.out(Easing.quad) })',
|
|
391
|
+
verdict: 'permitted', reason: 'an out-family curve; names no interaction the authority bands',
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
const DUR_TOKEN = /\d+\s*[–—-]\s*\d+\s*ms|\b\d+\s*ms\b|duration[-:]\s*\d+/i;
|
|
396
|
+
const EASE_TOKEN = /ease-in-out|ease-in|ease-out|easeIn|easeOut|elastic|back\.out|bounce|cubic-bezier|Easing\./i;
|
|
397
|
+
/** A statement carries a motion value when it names a duration or a curve. */
|
|
398
|
+
export const carriesMotionValue = (s: string): boolean => DUR_TOKEN.test(s) || EASE_TOKEN.test(s);
|
|
399
|
+
|
|
400
|
+
/** Columns whose content is the carrier's own prohibition, never a binding value. */
|
|
401
|
+
const NEGATIVE_COLUMNS = new Set(["Don't", 'Code Example Bad']);
|
|
402
|
+
|
|
403
|
+
function checkStatement(
|
|
404
|
+
file: string, key: string, raw: string, findings: Finding[],
|
|
405
|
+
): void {
|
|
406
|
+
const text = norm(raw);
|
|
407
|
+
if (!carriesMotionValue(text)) return;
|
|
408
|
+
const hit = PROSE_ALLOWLIST.find((a) => a.file === file && a.key === key);
|
|
409
|
+
const target = `${path.basename(file)} ${key}`;
|
|
410
|
+
if (hit === undefined) {
|
|
411
|
+
findings.push({
|
|
412
|
+
target,
|
|
413
|
+
reason: `states a motion value the allowlist does not cover: "${text.slice(0, 120)}" — review it against the authority and record a verdict in PROSE_ALLOWLIST`,
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (hit.text !== text) {
|
|
418
|
+
findings.push({
|
|
419
|
+
target,
|
|
420
|
+
reason: `drifted from its reviewed wording. Expected "${hit.text.slice(0, 90)}"; found "${text.slice(0, 90)}"`,
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (hit.verdict === 'drift') findings.push({ target, reason: hit.reason });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function checkChecklist(text: string): { findings: Finding[]; scanned: number } {
|
|
428
|
+
const findings: Finding[] = [];
|
|
429
|
+
const lines = text.split('\n');
|
|
430
|
+
let inAnimation = false;
|
|
431
|
+
let scanned = 0;
|
|
432
|
+
for (const line of lines) {
|
|
433
|
+
if (/^### \d+\. Animation\b/.test(line)) { inAnimation = true; continue; }
|
|
434
|
+
if (inAnimation && /^### /.test(line)) inAnimation = false;
|
|
435
|
+
const isTableRow = /^\|\s*\d+\s*\|\s*Animation\s*\|/.test(line);
|
|
436
|
+
if (!inAnimation && !isTableRow) continue;
|
|
437
|
+
const rule = /^- `([a-z0-9-]+)`/.exec(line);
|
|
438
|
+
const key = rule?.[1] ?? (isTableRow ? `table:${(/^\|\s*(\d+)/.exec(line)?.[1] ?? '?')}` : null);
|
|
439
|
+
if (key === null) continue;
|
|
440
|
+
scanned += 1;
|
|
441
|
+
checkStatement(CHECKLIST_REL, key, line, findings);
|
|
442
|
+
}
|
|
443
|
+
return { findings, scanned };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function checkProseCsv(
|
|
447
|
+
rel: string, text: string,
|
|
448
|
+
): { findings: Finding[]; scanned: number } {
|
|
449
|
+
const rows = parseCsv(text);
|
|
450
|
+
const hdr = rows[0] ?? [];
|
|
451
|
+
const findings: Finding[] = [];
|
|
452
|
+
let scanned = 0;
|
|
453
|
+
for (const row of rows.slice(1)) {
|
|
454
|
+
if (col(hdr, row, 'Category').trim() !== 'Animation') continue;
|
|
455
|
+
const no = (row[0] ?? '').trim();
|
|
456
|
+
scanned += 1;
|
|
457
|
+
hdr.forEach((name, i) => {
|
|
458
|
+
if (NEGATIVE_COLUMNS.has(name)) return;
|
|
459
|
+
checkStatement(rel, `${no}/${name}`, row[i] ?? '', findings);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return { findings, scanned };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* -------------------------------------------------------------------------- */
|
|
466
|
+
|
|
467
|
+
const read = (root: string, rel: string): string | null => {
|
|
468
|
+
const abs = path.join(root, rel);
|
|
469
|
+
return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf-8') : null;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
473
|
+
if (argv.includes('--self-test')) return selfTest();
|
|
474
|
+
const rootFlag = argv.indexOf('--root');
|
|
475
|
+
const root = rootFlag >= 0 ? (argv[rootFlag + 1] ?? REPO_ROOT) : REPO_ROOT;
|
|
476
|
+
const quiet = argv.includes('--quiet');
|
|
477
|
+
|
|
478
|
+
const authorityText = read(root, AUTHORITY_REL);
|
|
479
|
+
if (authorityText === null) {
|
|
480
|
+
process.stderr.write(`❌ lint_motion_authority_drift: authority missing at ${AUTHORITY_REL}\n`);
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
const auth = parseAuthority(authorityText);
|
|
484
|
+
if (typeof auth === 'string') {
|
|
485
|
+
process.stderr.write(`❌ lint_motion_authority_drift: ${auth}\n`);
|
|
486
|
+
return 1;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const findings: Finding[] = [];
|
|
490
|
+
let scanned = 0;
|
|
491
|
+
let unbanded = 0;
|
|
492
|
+
const ledger = new GateLedger('lint_motion_authority_drift');
|
|
493
|
+
const dependents = [CHECKLIST_REL, UX_REL, APP_REL, STYLES_REL, MOTION_REL];
|
|
494
|
+
ledger.plan(dependents);
|
|
495
|
+
|
|
496
|
+
for (const rel of dependents) {
|
|
497
|
+
const text = read(root, rel);
|
|
498
|
+
if (text === null) {
|
|
499
|
+
findings.push({ target: rel, reason: 'dependent carrier missing — the authority binds a file that is not there' });
|
|
500
|
+
ledger.fail(rel, 'missing');
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
let res: { findings: Finding[]; scanned: number; unbanded?: number };
|
|
504
|
+
if (rel === MOTION_REL) res = checkMotionCsv(text, auth);
|
|
505
|
+
else if (rel === STYLES_REL) res = checkStylesCsv(text);
|
|
506
|
+
else if (rel === CHECKLIST_REL) res = checkChecklist(text);
|
|
507
|
+
else res = checkProseCsv(rel, text);
|
|
508
|
+
scanned += res.scanned;
|
|
509
|
+
unbanded += res.unbanded ?? 0;
|
|
510
|
+
findings.push(...res.findings);
|
|
511
|
+
if (res.findings.length > 0) ledger.fail(rel, res.findings[0]?.reason ?? 'drift');
|
|
512
|
+
else ledger.complete(rel);
|
|
513
|
+
}
|
|
514
|
+
ledger.report();
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
reportScanned({
|
|
518
|
+
gate: 'lint_motion_authority_drift',
|
|
519
|
+
scanned,
|
|
520
|
+
units: 'motion statement(s)',
|
|
521
|
+
roots: dependents,
|
|
522
|
+
});
|
|
523
|
+
} catch (err) {
|
|
524
|
+
process.stderr.write(`❌ ${(err as Error).message}\n`);
|
|
525
|
+
return 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (findings.length > 0) {
|
|
529
|
+
process.stderr.write(
|
|
530
|
+
`❌ lint_motion_authority_drift: ${String(findings.length)} statement(s) drift from ${AUTHORITY_REL} § Motion\n`,
|
|
531
|
+
);
|
|
532
|
+
for (const f of findings) process.stderr.write(` · ${f.target}: ${f.reason}\n`);
|
|
533
|
+
process.stderr.write(
|
|
534
|
+
'\n § Motion is the sole carrier of duration bands, easing choice and the\n'
|
|
535
|
+
+ ' bounce prohibition. Bring the dependent inside its bands, give the row an\n'
|
|
536
|
+
+ ' override condition, or change the authority — never both independently.\n',
|
|
537
|
+
);
|
|
538
|
+
return 2;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!quiet) {
|
|
542
|
+
process.stdout.write(
|
|
543
|
+
`✅ lint_motion_authority_drift: ${String(scanned)} motion statement(s) across `
|
|
544
|
+
+ `${String(dependents.length)} dependent carrier(s) agree with § Motion `
|
|
545
|
+
+ `(${String(unbanded)} row(s) name an interaction the authority does not band, and are not checked).\n`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* -------------------------------------------------------------------------- */
|
|
552
|
+
|
|
553
|
+
function selfTest(): number {
|
|
554
|
+
const AUTH = [
|
|
555
|
+
'## Motion — decision-tree and rationale',
|
|
556
|
+
'',
|
|
557
|
+
'This section is the **sole carrier** of motion duration bands.',
|
|
558
|
+
'',
|
|
559
|
+
'**2. Which easing?**',
|
|
560
|
+
'- Entering (element appearing)? **ease-out** (starts fast, slows to rest). Why: x.',
|
|
561
|
+
'- Exiting (element disappearing)? **ease-out** as well. Why: y.',
|
|
562
|
+
'- **Never bounce or elastic easing in UI.** Why: z.',
|
|
563
|
+
'',
|
|
564
|
+
'**3. How long?**',
|
|
565
|
+
'- Button/micro-feedback: 100–160ms. Modal: 200–350ms. Page transition: 300–500ms. Above 500ms: almost never.',
|
|
566
|
+
'',
|
|
567
|
+
'## Next',
|
|
568
|
+
'',
|
|
569
|
+
].join('\n');
|
|
570
|
+
|
|
571
|
+
const MOTION_HDR = 'No,Category,Intensity Tier,Keywords,Trigger,Duration,Easing,GSAP Snippet,Override Condition';
|
|
572
|
+
const cleanMotion = [
|
|
573
|
+
MOTION_HDR,
|
|
574
|
+
'1,Hover Micro-interaction,Subtle,"hover, button",hover,100-160ms,power1.out,"gsap.to(el)",',
|
|
575
|
+
'2,Page Transition,Subtle,"route change",route change,200-300ms,power1.inOut,"gsap.to(main)",',
|
|
576
|
+
'3,Scroll Reveal,Standard,"scroll",scroll,400-600ms,power2.out,"gsap.from(el)",',
|
|
577
|
+
].join('\n') + '\n';
|
|
578
|
+
|
|
579
|
+
const CLEAN_CHECKLIST = [
|
|
580
|
+
'### 7. Animation (MEDIUM)',
|
|
581
|
+
'',
|
|
582
|
+
'- `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms',
|
|
583
|
+
'',
|
|
584
|
+
'### 8. Next',
|
|
585
|
+
].join('\n');
|
|
586
|
+
const CLEAN_UX = 'No,Category,Issue,Platform,Description,Do,Don\'t,Code Example Good,Code Example Bad,Severity\n'
|
|
587
|
+
+ '14,Animation,Easing,All,x,Use ease-out in both directions entering and exiting,Use linear for UI transitions,ease-out,linear,Low\n';
|
|
588
|
+
const CLEAN_APP = 'No,Category,Issue,Keywords,Platform,Description,Do,Don\'t,Code Example Good,Code Example Bad,Severity\n'
|
|
589
|
+
+ '23,Animation,Duration & Easing,animation,iOS,Respect reduced motion,Use ease-out for both enter and exit,Use linear,"Animated.timing(..., { duration: 200, easing: Easing.out(Easing.quad) })",x,Medium\n';
|
|
590
|
+
const CLEAN_STYLES = 'No,Style Category,Type,Keywords,Best For,Effects & Animation,CSS/Technical Keywords,Design System Variables\n'
|
|
591
|
+
+ '1,Minimalism,General,"clean, spacious","dashboards","Subtle hover, smooth transitions","display: grid","--radius: 0"\n';
|
|
592
|
+
|
|
593
|
+
const fixture = (over: Record<string, string>): number => {
|
|
594
|
+
const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'motion-auth-'));
|
|
595
|
+
try {
|
|
596
|
+
const files: Record<string, string> = {
|
|
597
|
+
[AUTHORITY_REL]: AUTH,
|
|
598
|
+
[CHECKLIST_REL]: CLEAN_CHECKLIST,
|
|
599
|
+
[UX_REL]: CLEAN_UX,
|
|
600
|
+
[APP_REL]: CLEAN_APP,
|
|
601
|
+
[STYLES_REL]: CLEAN_STYLES,
|
|
602
|
+
[MOTION_REL]: cleanMotion,
|
|
603
|
+
...over,
|
|
604
|
+
};
|
|
605
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
606
|
+
const abs = path.join(dir, rel);
|
|
607
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
608
|
+
fs.writeFileSync(abs, body);
|
|
609
|
+
}
|
|
610
|
+
return runGateCli(REPO_ROOT, SELF_REL, ['--root', dir, '--quiet'], dir);
|
|
611
|
+
} finally {
|
|
612
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const cases: SelfTestCase[] = [
|
|
617
|
+
{ name: 'a tree agreeing with the authority passes', expect: 'accept', run: () => fixture({}) },
|
|
618
|
+
{
|
|
619
|
+
name: 'a duration above the authority band is refused',
|
|
620
|
+
expect: 'reject',
|
|
621
|
+
run: () => fixture({
|
|
622
|
+
[MOTION_REL]: cleanMotion.replace('100-160ms', '150-200ms'),
|
|
623
|
+
}),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: 'a page transition above the ceiling is refused',
|
|
627
|
+
expect: 'reject',
|
|
628
|
+
run: () => fixture({
|
|
629
|
+
[MOTION_REL]: cleanMotion.replace('200-300ms,power1.inOut', '500-800ms,expo.inOut'),
|
|
630
|
+
}),
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
name: 'an elastic curve with no override condition is refused',
|
|
634
|
+
expect: 'reject',
|
|
635
|
+
run: () => fixture({
|
|
636
|
+
[MOTION_REL]: cleanMotion.replace('power1.out', '"elastic.out(1,0.4)"'),
|
|
637
|
+
}),
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
name: 'the same elastic curve is accepted once the row declares an override',
|
|
641
|
+
expect: 'accept',
|
|
642
|
+
run: () => fixture({
|
|
643
|
+
[MOTION_REL]: cleanMotion
|
|
644
|
+
.replace('power1.out', '"elastic.out(1,0.4)"')
|
|
645
|
+
.replace('"gsap.to(el)",', '"gsap.to(el)",M1 — declared playful register'),
|
|
646
|
+
}),
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
name: 'an unbanded category is not failed for its duration',
|
|
650
|
+
expect: 'accept',
|
|
651
|
+
run: () => fixture({ [MOTION_REL]: cleanMotion.replace('400-600ms', '400-700ms') }),
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
name: 'a prose statement contradicting the shared curve is refused',
|
|
655
|
+
expect: 'reject',
|
|
656
|
+
run: () => fixture({
|
|
657
|
+
[UX_REL]: CLEAN_UX.replace(
|
|
658
|
+
'Use ease-out in both directions entering and exiting',
|
|
659
|
+
'Use ease-out for entering ease-in for exiting',
|
|
660
|
+
),
|
|
661
|
+
}),
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
name: 'a new unreviewed motion statement in a prose carrier is refused',
|
|
665
|
+
expect: 'reject',
|
|
666
|
+
run: () => fixture({
|
|
667
|
+
[CHECKLIST_REL]: CLEAN_CHECKLIST.replace(
|
|
668
|
+
'### 8. Next',
|
|
669
|
+
'- `new-rule` - Use 900ms for hero transitions\n\n### 8. Next',
|
|
670
|
+
),
|
|
671
|
+
}),
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
name: 'an overshoot curve in styles.csv without a playful register is refused',
|
|
675
|
+
expect: 'reject',
|
|
676
|
+
run: () => fixture({
|
|
677
|
+
[STYLES_REL]: CLEAN_STYLES.replace('"display: grid"', '"animation: bounce (cubic-bezier(0.34, 1.56, 0.64, 1))"'),
|
|
678
|
+
}),
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
name: 'the same overshoot is accepted where the row declares a playful register',
|
|
682
|
+
expect: 'accept',
|
|
683
|
+
run: () => fixture({
|
|
684
|
+
[STYLES_REL]: CLEAN_STYLES
|
|
685
|
+
.replace('"clean, spacious"', '"playful, bubbly"')
|
|
686
|
+
.replace('"display: grid"', '"animation: bounce (cubic-bezier(0.34, 1.56, 0.64, 1))"'),
|
|
687
|
+
}),
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
name: 'a negated mention ("no bounce") is not a violation',
|
|
691
|
+
expect: 'accept',
|
|
692
|
+
run: () => fixture({
|
|
693
|
+
[STYLES_REL]: CLEAN_STYLES.replace('"Subtle hover, smooth transitions"', '"instant transitions (no bounce)"'),
|
|
694
|
+
}),
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: 'an authority whose § Motion is gone is a config error, not a pass',
|
|
698
|
+
expect: 'reject',
|
|
699
|
+
run: () => fixture({ [AUTHORITY_REL]: '# design patterns\n\nno motion section here\n' }),
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
name: 'a missing dependent carrier is refused',
|
|
703
|
+
expect: 'reject',
|
|
704
|
+
run: () => {
|
|
705
|
+
const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'motion-auth-'));
|
|
706
|
+
try {
|
|
707
|
+
for (const [rel, body] of Object.entries({
|
|
708
|
+
[AUTHORITY_REL]: AUTH, [CHECKLIST_REL]: CLEAN_CHECKLIST,
|
|
709
|
+
[UX_REL]: CLEAN_UX, [APP_REL]: CLEAN_APP, [STYLES_REL]: CLEAN_STYLES,
|
|
710
|
+
})) {
|
|
711
|
+
const abs = path.join(dir, rel);
|
|
712
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
713
|
+
fs.writeFileSync(abs, body);
|
|
714
|
+
}
|
|
715
|
+
return runGateCli(REPO_ROOT, SELF_REL, ['--root', dir, '--quiet'], dir);
|
|
716
|
+
} finally {
|
|
717
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
];
|
|
722
|
+
|
|
723
|
+
return runSelfTest({
|
|
724
|
+
gate: 'lint_motion_authority_drift', cases, minCases: 12, minRejectCases: 6,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (process.argv[1] !== undefined) {
|
|
729
|
+
const invoked = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
730
|
+
if (invoked === import.meta.url) process.exitCode = main();
|
|
731
|
+
}
|