synthesisui 0.16.473 → 0.16.475
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.js +23 -1
- package/dist/commands/hook.js +9 -3
- package/dist/commands/import.js +7 -0
- package/dist/commands/mcp.js +5 -2
- package/dist/doctor/scan.js +51 -0
- package/dist/install-marks.js +11 -1
- package/dist/last-sync.js +20 -0
- package/dist/what-grew.js +38 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -385,8 +385,16 @@ measured) {
|
|
|
385
385
|
* unidade. Uma raiz por tabela seria a forma mais silenciosa de discordar.
|
|
386
386
|
*/
|
|
387
387
|
const theirs = await harvestOwnTokens([root], measured);
|
|
388
|
+
const table = withTheirNames(ours, theirs);
|
|
389
|
+
/**
|
|
390
|
+
* AS LARGURAS DO SISTEMA - ver `breakpoints` em `TokenTable`. `var()` não funciona em `@media`, então o `tokens.css`
|
|
391
|
+
* não as carrega e a escala sai do documento (bateria, 28/09).
|
|
392
|
+
*/
|
|
393
|
+
const breakpoints = breakpointsOf(documents, table.rootPx);
|
|
394
|
+
if (breakpoints.size > 0)
|
|
395
|
+
table.breakpoints = breakpoints;
|
|
388
396
|
return {
|
|
389
|
-
table
|
|
397
|
+
table,
|
|
390
398
|
theirs,
|
|
391
399
|
recipes,
|
|
392
400
|
documents,
|
|
@@ -1977,3 +1985,17 @@ export async function doctor(opts) {
|
|
|
1977
1985
|
process.exitCode = 1;
|
|
1978
1986
|
}
|
|
1979
1987
|
}
|
|
1988
|
+
/** `foundations.breakpoints` de cada documento instalado, em px - `{ md: "768px" }` -> `md` -> 768. */
|
|
1989
|
+
export function breakpointsOf(documents, rootPx) {
|
|
1990
|
+
const out = new Map();
|
|
1991
|
+
for (const doc of documents) {
|
|
1992
|
+
const bps = doc?.foundations?.breakpoints;
|
|
1993
|
+
for (const [name, raw] of Object.entries(bps ?? {})) {
|
|
1994
|
+
const m = /^\s*([\d.]+)(px|rem|em)\s*$/.exec(String(raw));
|
|
1995
|
+
if (!m || out.has(name))
|
|
1996
|
+
continue;
|
|
1997
|
+
out.set(name, m[2] === "px" ? Number(m[1]) : Number(m[1]) * rootPx);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return out;
|
|
2001
|
+
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -5,7 +5,7 @@ import { changedSince } from "../changed-files.js";
|
|
|
5
5
|
import { emptyTally, exportedNames, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
6
6
|
import { checkContracts } from "../doctor/contract-check.js";
|
|
7
7
|
import { appendEvent, ledgerPath } from "../doctor/ledger.js";
|
|
8
|
-
import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
|
|
8
|
+
import { describeOffScale, diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
|
|
9
9
|
import { declarados, falaDaPrateleira, naPrateleira } from "../doctor/shelf.js";
|
|
10
10
|
import { governs, ungovernedIn } from "../governed.js";
|
|
11
11
|
import { readMode } from "../mode.js";
|
|
@@ -307,6 +307,8 @@ async function report(root, filePath, mode) {
|
|
|
307
307
|
*/
|
|
308
308
|
const unnamed = d.findings.filter((f) => !nameToWrite(f));
|
|
309
309
|
const phantoms = d.files.flatMap((f) => f.phantoms ?? []);
|
|
310
|
+
/** Fora da escala - ver `OffScale` (bateria, 28/09). Entra nas mesmas portas que os `phantoms`. */
|
|
311
|
+
const offScale = describeOffScale(d.files);
|
|
310
312
|
// Every check leaves one line in the ledger - INCLUDING the clean ones,
|
|
311
313
|
// because a fix is itself a write, so the clean re-check of a file that was
|
|
312
314
|
// just flagged is the evidence the agent read the report and acted. Without
|
|
@@ -326,6 +328,7 @@ async function report(root, filePath, mode) {
|
|
|
326
328
|
broke.length === 0 &&
|
|
327
329
|
named.length === 0 &&
|
|
328
330
|
phantoms.length === 0 &&
|
|
331
|
+
offScale.length === 0 &&
|
|
329
332
|
unnamed.length === 0) {
|
|
330
333
|
const hello = await greet(root, rel);
|
|
331
334
|
return hello ? say(hello) : null;
|
|
@@ -362,7 +365,8 @@ async function report(root, filePath, mode) {
|
|
|
362
365
|
if (composed.length === 0 &&
|
|
363
366
|
broke.length > 0 &&
|
|
364
367
|
named.length === 0 &&
|
|
365
|
-
phantoms.length === 0
|
|
368
|
+
phantoms.length === 0 &&
|
|
369
|
+
offScale.length === 0)
|
|
366
370
|
return say(broke.join("\n"));
|
|
367
371
|
const sample = unnamed
|
|
368
372
|
.slice(0, 3)
|
|
@@ -371,7 +375,8 @@ async function report(root, filePath, mode) {
|
|
|
371
375
|
if (composed.length === 0 &&
|
|
372
376
|
broke.length === 0 &&
|
|
373
377
|
named.length === 0 &&
|
|
374
|
-
phantoms.length === 0
|
|
378
|
+
phantoms.length === 0 &&
|
|
379
|
+
offScale.length === 0)
|
|
375
380
|
return say([
|
|
376
381
|
`${rel} - ${unnamed.length} value${unnamed.length === 1 ? "" : "s"} here ${unnamed.length === 1 ? "has" : "have"} no name in this system, and nothing to replace ${unnamed.length === 1 ? "it" : "them"} with: ${sample}${unnamed.length > 3 ? `, +${unnamed.length - 3} more` : ""}.`,
|
|
377
382
|
"Do not invent a name - leave them, or ask the person what they would call it.",
|
|
@@ -419,6 +424,7 @@ async function report(root, filePath, mode) {
|
|
|
419
424
|
if (phantoms.length > 0) {
|
|
420
425
|
lines.push("", "Names this system does not declare. These look tokenized and apply nothing at all:", ...phantoms.slice(0, 20).map((p) => ` line ${p.line} ${p.name}`), "", "Use a name the system has, or say which value you need and what you would call it. Do NOT invent a token.");
|
|
421
426
|
}
|
|
427
|
+
lines.push(...offScale);
|
|
422
428
|
return say(lines.join("\n"));
|
|
423
429
|
}
|
|
424
430
|
/**
|
package/dist/commands/import.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { importMark, writeSyncMark } from "../last-sync.js";
|
|
2
3
|
import { readStyleSheet } from "../doctor/scss-lens.js";
|
|
3
4
|
import { tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
|
|
4
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
@@ -5234,6 +5235,12 @@ export async function runImport(opts) {
|
|
|
5234
5235
|
* mais longa do produto ficar mais longa no lugar onde ela já é mais difícil de ler.
|
|
5235
5236
|
*/
|
|
5236
5237
|
if (payload?.slug) {
|
|
5238
|
+
/**
|
|
5239
|
+
* O PONTO DE PARTIDA DO PRIMEIRO `sync` - ver `importMark`. Sem ele, o que o agente cria depois do import nunca é
|
|
5240
|
+
* anunciado (bateria, 28/09: 16 componentes no b7, nenhum dito). Só aqui, com o sistema nascido: um `--dry` não
|
|
5241
|
+
* envia nada, e uma marca sem envio mentiria sobre o que a plataforma tem.
|
|
5242
|
+
*/
|
|
5243
|
+
await writeSyncMark(root, importMark(census, opts.cli));
|
|
5237
5244
|
/**
|
|
5238
5245
|
* O QUE O CSS DELE AINDA IMPORTA - e este aviso sai do CLI, não da skill.
|
|
5239
5246
|
*
|
package/dist/commands/mcp.js
CHANGED
|
@@ -10,7 +10,7 @@ import { declareForm } from "../doctor/declared-forms.js";
|
|
|
10
10
|
import { describeTriage, triageLedger } from "../doctor/gap-triage.js";
|
|
11
11
|
import { readEvents } from "../doctor/ledger.js";
|
|
12
12
|
import { fileRequest } from "../doctor/requests.js";
|
|
13
|
-
import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
|
|
13
|
+
import { describeOffScale, diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
|
|
14
14
|
import { nearestToken, normalizeValue, tokenFor } from "../doctor/tokens.js";
|
|
15
15
|
import { ruleLine } from "../doctrine.js";
|
|
16
16
|
import { fromCensus } from "../memory/observation.js";
|
|
@@ -437,7 +437,10 @@ async function checkFile(root, path) {
|
|
|
437
437
|
if (phantoms.length > 0) {
|
|
438
438
|
out.push("", "Names this system does not declare. These look tokenized and apply nothing at all:", ...phantoms);
|
|
439
439
|
}
|
|
440
|
-
|
|
440
|
+
/** Fora da escala - ver `OffScale` (bateria, 28/09). */
|
|
441
|
+
const offScale = describeOffScale(d.files);
|
|
442
|
+
out.push(...offScale);
|
|
443
|
+
if (d.findings.length === 0 && phantoms.length === 0 && offScale.length === 0)
|
|
441
444
|
out.push("", "Nothing to fix here.");
|
|
442
445
|
return fromContract(out.join("\n"));
|
|
443
446
|
}
|
package/dist/doctor/scan.js
CHANGED
|
@@ -534,6 +534,7 @@ const DECLARES_ALL = /(?:^|[\s{;])(--[a-z0-9-]+)\s*:\s*([^;{}]+);/gi;
|
|
|
534
534
|
function scanCore(file, source, table) {
|
|
535
535
|
const findings = [];
|
|
536
536
|
const phantoms = [];
|
|
537
|
+
const offScale = [];
|
|
537
538
|
const ownDeclared = [];
|
|
538
539
|
/**
|
|
539
540
|
* OS NOMES SASS DELE, VISTOS PELA LENTE - rodada 10 do refinamento, 27/09. Num `.scss` lido por `scss-lens.ts`,
|
|
@@ -653,6 +654,8 @@ function scanCore(file, source, table) {
|
|
|
653
654
|
}
|
|
654
655
|
for (const name of findPhantoms(line, table))
|
|
655
656
|
phantoms.push({ name, line: at });
|
|
657
|
+
for (const o of offScaleOf(file, line, table, setAside))
|
|
658
|
+
offScale.push({ ...o, line: at });
|
|
656
659
|
// Depth at the START of this line, carried before the early return so a
|
|
657
660
|
// blank line inside an <svg> cannot close the region by accident. The
|
|
658
661
|
// per-match depth is recomputed below, because an icon is often written on
|
|
@@ -904,6 +907,7 @@ function scanCore(file, source, table) {
|
|
|
904
907
|
findings,
|
|
905
908
|
tokenUses,
|
|
906
909
|
...(phantoms.length > 0 ? { phantoms } : null),
|
|
910
|
+
...(offScale.length > 0 ? { offScale } : null),
|
|
907
911
|
...(ownDeclared.length > 0 ? { ownDeclared } : null),
|
|
908
912
|
...(ownCandidates.size > 0
|
|
909
913
|
? {
|
|
@@ -1066,3 +1070,50 @@ context = []) {
|
|
|
1066
1070
|
repeats,
|
|
1067
1071
|
};
|
|
1068
1072
|
}
|
|
1073
|
+
const MEDIA_WIDTH = /\b(?:(?:min|max)-)?width\s*(?::|[<>]=?)\s*([\d.]+)(px|rem|em)\b/g;
|
|
1074
|
+
const TOKEN_ARITHMETIC = /(?:\$\{\s*|:\s*)([A-Za-z_$][\w$]*(?:\.[\w$]+)*\.(?:[\w$]*(?:space|spacing|gap|radius|size|font)[\w$]*)(?:\.[\w$]+)*)\s*([+\-*/])\s*(\d+(?:\.\d+)?)\b/gi;
|
|
1075
|
+
const BORDER_WIDTH = /\bborder(?:-(?:top|right|bottom|left))?(?:-width)?\s*:\s*(\d+(?:\.\d+)?px)\b/g;
|
|
1076
|
+
const BORDER_NAME = /border|line|hairline|stroke|rule|divider|thin/i;
|
|
1077
|
+
/**
|
|
1078
|
+
* FORA DA ESCALA, linha a linha - ver `OffScale`. `setAside` recebe o que foi medido e está certo: o ponto de quebra
|
|
1079
|
+
* na escala é dito pelo nome ("breakpoint md (768px), on your system's scale"), para o silêncio ter motivo.
|
|
1080
|
+
*/
|
|
1081
|
+
function offScaleOf(file, line, table, setAside) {
|
|
1082
|
+
const out = [];
|
|
1083
|
+
const scale = table.breakpoints;
|
|
1084
|
+
if (scale && scale.size > 0 && /^@media\b/.test(line)) {
|
|
1085
|
+
for (const m of line.matchAll(MEDIA_WIDTH)) {
|
|
1086
|
+
const n = Number(m[1]);
|
|
1087
|
+
const px = m[2] === "px" ? n : n * table.rootPx;
|
|
1088
|
+
let best = null;
|
|
1089
|
+
for (const [name, bp] of scale)
|
|
1090
|
+
if (!best || Math.abs(bp - px) < Math.abs(best[1] - px))
|
|
1091
|
+
best = [name, bp];
|
|
1092
|
+
if (!best)
|
|
1093
|
+
continue;
|
|
1094
|
+
if (Math.abs(best[1] - px) < 0.5)
|
|
1095
|
+
setAside(`breakpoint ${best[0]} (${best[1]}px), on your system's scale`);
|
|
1096
|
+
else
|
|
1097
|
+
out.push({ literal: `${m[1]}${m[2]}`, why: "breakpoint", nearest: `${best[0]} ${best[1]}px` });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (/\.(t|j)sx?$/.test(file))
|
|
1101
|
+
for (const m of line.matchAll(TOKEN_ARITHMETIC))
|
|
1102
|
+
out.push({ literal: `${m[1]} ${m[2]} ${m[3]}`, why: "arithmetic" });
|
|
1103
|
+
for (const m of line.matchAll(BORDER_WIDTH)) {
|
|
1104
|
+
const v = normalizeValue(m[1], table.rootPx);
|
|
1105
|
+
const theirs = [table.aliases.get(`spacing:${v}`), table.aliases.get(`radius:${v}`)].find((a) => a && BORDER_NAME.test(a.name));
|
|
1106
|
+
if (theirs)
|
|
1107
|
+
out.push({ literal: m[1], why: "named", nearest: `var(${theirs.name})` });
|
|
1108
|
+
}
|
|
1109
|
+
return out;
|
|
1110
|
+
}
|
|
1111
|
+
/** As linhas que o `check_file` e o fim do turno dizem sobre `OffScale` - uma frase por caso, a mesma nos dois. */
|
|
1112
|
+
export function describeOffScale(files) {
|
|
1113
|
+
const rows = files.flatMap((f) => (f.offScale ?? []).map((o) => o.why === "breakpoint"
|
|
1114
|
+
? ` ${f.file}:${o.line} ${o.literal} - a breakpoint off your system's widths; the nearest is ${o.nearest} (a media query cannot use a token, so match the width)`
|
|
1115
|
+
: o.why === "arithmetic"
|
|
1116
|
+
? ` ${f.file}:${o.line} ${o.literal} - arithmetic on a token lands between the steps; use a step the system has, or ask for one`
|
|
1117
|
+
: ` ${f.file}:${o.line} ${o.literal} - this repo calls it ${o.nearest}`));
|
|
1118
|
+
return rows.length > 0 ? ["", "Off this system's scale:", ...rows] : [];
|
|
1119
|
+
}
|
package/dist/install-marks.js
CHANGED
|
@@ -485,7 +485,17 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
|
|
|
485
485
|
* `:root { --q-stone: #44403c; … }` de uma linha atribui ao token. Um hook anterior manda trocar a definição pelo
|
|
486
486
|
* próprio nome, e quem obedece apaga a cor.
|
|
487
487
|
*/
|
|
488
|
-
|
|
488
|
+
/**
|
|
489
|
+
* 0.16.473 -> 0.16.474 em 28/09, e o passo 1 dá **SIM** (a bateria, b5): o que o fim do turno diz de um componente novo
|
|
490
|
+
* passa a medir as regras da folha global que ele veste pelo `className`. Um hook anterior diz "no colour or spacing of
|
|
491
|
+
* its own to measure" sobre o componente de um projeto que estiliza por classe global.
|
|
492
|
+
*/
|
|
493
|
+
/**
|
|
494
|
+
* 0.16.474 -> 0.16.475 em 28/09, e o passo 1 dá **SIM** (a bateria, e a régua dele de 17:39-17:41): o fim do turno diz o
|
|
495
|
+
* que sai da escala - o ponto de quebra fora das larguras do sistema, a conta em cima de um token, e o `1px` de borda
|
|
496
|
+
* num projeto que tem nome para ele. Um hook anterior aprova os três.
|
|
497
|
+
*/
|
|
498
|
+
export const CHECKER_SINCE = "0.16.475";
|
|
489
499
|
/**
|
|
490
500
|
* A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
|
|
491
501
|
*
|
package/dist/last-sync.js
CHANGED
|
@@ -45,6 +45,26 @@ components) {
|
|
|
45
45
|
}
|
|
46
46
|
return out;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A MARCA QUE O IMPORT DEIXA - o ponto de partida do primeiro `sync`. Bateria, 28/09 (b7): um projeto sem componente
|
|
50
|
+
* ganhou 16 ao longo de cinco telas e nenhum foi anunciado, porque só o `sync` gravava marca e `whatGrew` sem marca
|
|
51
|
+
* anterior não anuncia nada. Os mesmos campos que o `sync` grava, lidos do mesmo censo - nunca uma segunda conta.
|
|
52
|
+
* `components` é o que o censo admitiu, que é o que o import envia.
|
|
53
|
+
*/
|
|
54
|
+
export function importMark(census, cli) {
|
|
55
|
+
const sum = (r) => Object.values(r ?? {}).reduce((n, v) => n + v, 0);
|
|
56
|
+
const readings = fingerprintReadings(census.components);
|
|
57
|
+
return {
|
|
58
|
+
at: new Date().toISOString(),
|
|
59
|
+
components: Object.keys(readings).length,
|
|
60
|
+
declarations: census.coverage?.declarations ?? 0,
|
|
61
|
+
seen: sum(census.ledger?.counted),
|
|
62
|
+
interpreted: sum(census.ledger?.interpreted),
|
|
63
|
+
unread: Object.fromEntries((census.ledger?.unread ?? []).map((g) => [g.reason ?? "unknown", g.uses ?? 0])),
|
|
64
|
+
...(cli ? { cli } : {}),
|
|
65
|
+
readings,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
48
68
|
const path = (root) => join(root, "_synthesisui", LAST_SYNC_FILE);
|
|
49
69
|
export async function readSyncMark(root) {
|
|
50
70
|
try {
|
package/dist/what-grew.js
CHANGED
|
@@ -16,12 +16,16 @@ before) {
|
|
|
16
16
|
return { born: [], gone };
|
|
17
17
|
const { table } = await loadSystem(root).catch(() => ({ table: null }));
|
|
18
18
|
const context = [];
|
|
19
|
+
/** As folhas GLOBAIS (não `.module.*`) - ver `globalRulesFor`. */
|
|
20
|
+
const globals = [];
|
|
19
21
|
if (table) {
|
|
20
22
|
for await (const file of walkAll([root])) {
|
|
21
23
|
const rel = relative(root, file);
|
|
22
24
|
if (!/\.(css|scss|sass|less)$/i.test(rel))
|
|
23
25
|
continue;
|
|
24
26
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
27
|
+
if (src && !/\.module\.[a-z]+$/i.test(rel))
|
|
28
|
+
globals.push({ rel, src });
|
|
25
29
|
const report = src ? scanSource(rel, src, table) : null;
|
|
26
30
|
if (report?.ownDeclared?.length)
|
|
27
31
|
context.push(report);
|
|
@@ -49,6 +53,17 @@ before) {
|
|
|
49
53
|
if (css)
|
|
50
54
|
reports.push(scanSource(sheet, css, table));
|
|
51
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* E AS REGRAS GLOBAIS QUE ELE VESTE - bateria, 28/09 (b5): o componente de um projeto que estiliza por classe global
|
|
58
|
+
* não importa folha nenhuma, e saía "no colour or spacing of its own to measure".
|
|
59
|
+
*/
|
|
60
|
+
const worn = classesWorn(src);
|
|
61
|
+
if (worn.size > 0)
|
|
62
|
+
for (const g of globals) {
|
|
63
|
+
const rules = globalRulesFor(g.src, worn);
|
|
64
|
+
if (rules)
|
|
65
|
+
reports.push(scanSource(g.rel, rules, table));
|
|
66
|
+
}
|
|
52
67
|
const d = diagnose(reports, context);
|
|
53
68
|
/** Classes utilitárias numa string qualquer do arquivo - `className="…"`, `cn("…")`, `cva("…")` (f11, 28/09). */
|
|
54
69
|
const utilities = !d.reach.measured &&
|
|
@@ -117,3 +132,26 @@ export async function takeGrowthNote(root) {
|
|
|
117
132
|
return null;
|
|
118
133
|
}
|
|
119
134
|
}
|
|
135
|
+
/** As classes escritas por extenso no `className` - `className="a b"`, `'a'` ou `` `a b` `` sem interpolação. */
|
|
136
|
+
function classesWorn(src) {
|
|
137
|
+
const out = new Set();
|
|
138
|
+
for (const m of src.matchAll(/className=(?:"([^"]*)"|'([^']*)'|\{`([^`$]*)`\})/g))
|
|
139
|
+
for (const c of (m[1] ?? m[2] ?? m[3] ?? "").split(/\s+/))
|
|
140
|
+
if (c)
|
|
141
|
+
out.add(c);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* AS REGRAS DE UMA FOLHA GLOBAL CUJO SELETOR NOMEIA UMA DAS CLASSES QUE ELE VESTE, recortadas como CSS - só o bloco
|
|
146
|
+
* mais interno (`[^{}]`), então uma regra dentro de `@media` também entra. `null` quando nenhuma casa.
|
|
147
|
+
*/
|
|
148
|
+
function globalRulesFor(css, worn) {
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
151
|
+
const selector = (m[1] ?? "").trim();
|
|
152
|
+
const names = [...selector.matchAll(/\.([A-Za-z_][\w-]*)/g)].map((x) => x[1]);
|
|
153
|
+
if (names.some((n) => worn.has(n)))
|
|
154
|
+
out.push(`${selector} { ${(m[2] ?? "").trim()} }`);
|
|
155
|
+
}
|
|
156
|
+
return out.length > 0 ? `${out.join("\n")}\n` : null;
|
|
157
|
+
}
|
package/package.json
CHANGED