@rahmantech/litedesign 0.1.0 → 0.2.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.
@@ -396,6 +396,7 @@ var __litedesign = (() => {
396
396
  selector: input.selector,
397
397
  rule: input.rule,
398
398
  severity: input.severity,
399
+ kind: input.kind,
399
400
  actual: input.actual,
400
401
  expected: input.expected,
401
402
  autoFix: input.autoFix
@@ -435,6 +436,7 @@ var __litedesign = (() => {
435
436
  selector: worst.node.id,
436
437
  key: spec.edge,
437
438
  severity: "P2",
439
+ kind: "candidate",
438
440
  actual: `${Math.round(worst.value * 10) / 10}px`,
439
441
  expected: `${Math.round(target.value * 10) / 10}px`,
440
442
  autoFix: false
@@ -465,6 +467,7 @@ var __litedesign = (() => {
465
467
  rule: "contrast",
466
468
  selector: node.id,
467
469
  severity: "P1",
470
+ kind: "violation",
468
471
  actual: `${Math.round(ratio * 100) / 100}:1`,
469
472
  expected: `>= ${minimum}:1`,
470
473
  autoFix: false
@@ -513,6 +516,7 @@ var __litedesign = (() => {
513
516
  selector: node.id,
514
517
  key: "x",
515
518
  severity: "P2",
519
+ kind: "violation",
516
520
  actual: `${node.box.scrollW}px content in ${node.box.clientW}px`,
517
521
  expected: "content fits horizontally",
518
522
  autoFix: false
@@ -526,6 +530,7 @@ var __litedesign = (() => {
526
530
  selector: node.id,
527
531
  key: "y",
528
532
  severity: "P2",
533
+ kind: "violation",
529
534
  actual: `${node.box.scrollH}px content in ${node.box.clientH}px`,
530
535
  expected: "content fits vertically",
531
536
  autoFix: false
@@ -564,6 +569,7 @@ var __litedesign = (() => {
564
569
  rule: "radius-consistency",
565
570
  selector: node.id,
566
571
  severity: "P3",
572
+ kind: "candidate",
567
573
  actual: value,
568
574
  expected: expectedValue,
569
575
  autoFix: true
@@ -602,6 +608,7 @@ var __litedesign = (() => {
602
608
  selector: gap.a,
603
609
  key: `${gap.axis}:${gap.b}`,
604
610
  severity: "P2",
611
+ kind: "candidate",
605
612
  actual: `${actual}px`,
606
613
  expected: `${Math.round(actual / SPACING_SCALE) * SPACING_SCALE}px`,
607
614
  autoFix: true
@@ -756,7 +763,7 @@ var __litedesign = (() => {
756
763
  }
757
764
 
758
765
  // packages/core/src/render/layers.ts
759
- var LAYERS = ["grid", "guide", "gap", "issue", "hover"];
766
+ var LAYERS = ["grid", "guide", "gap", "issue", "dim", "focus", "hover"];
760
767
  var GUIDE_COLOR = {
761
768
  left: "rgba(236, 72, 153, 0.55)",
762
769
  right: "rgba(236, 72, 153, 0.55)",
@@ -785,16 +792,28 @@ var __litedesign = (() => {
785
792
  }
786
793
  return layers;
787
794
  }
788
- function drawGrid(layer) {
795
+ function gridBackground(step, axis) {
796
+ const line = "rgba(59, 130, 246, 0.16)";
797
+ const vertical = `repeating-linear-gradient(to right, ${line} 0 1px, transparent 1px ${step}px)`;
798
+ const horizontal = `repeating-linear-gradient(to bottom, ${line} 0 1px, transparent 1px ${step}px)`;
799
+ if (axis === "vertical") return vertical;
800
+ if (axis === "horizontal") return horizontal;
801
+ return `${vertical}, ${horizontal}`;
802
+ }
803
+ function drawGrid(layer, grid) {
789
804
  layer.replaceChildren();
790
- layer.style.backgroundImage = `repeating-linear-gradient(to right, rgba(59, 130, 246, 0.16) 0 1px, transparent 1px ${GRID_STEP}px)`;
805
+ layer.style.backgroundImage = gridBackground(grid.step, grid.axis);
806
+ }
807
+ function focusedGuides(graph, focus) {
808
+ if (focus === null) return graph.guides;
809
+ return graph.guides.filter((guide) => guide.members.includes(focus.id));
791
810
  }
792
- function drawGuides(layer, graph) {
811
+ function drawGuides(layer, graph, focus = null) {
793
812
  layer.replaceChildren();
794
813
  const byId = new Map(graph.nodes.map((node) => [node.id, node]));
795
814
  const scrollX = window.scrollX;
796
815
  const scrollY = window.scrollY;
797
- for (const guide of graph.guides.slice(0, MAX_MARKERS)) {
816
+ for (const guide of focusedGuides(graph, focus).slice(0, MAX_MARKERS)) {
798
817
  const span = spanOf(guide, byId);
799
818
  if (span === null) continue;
800
819
  const color = GUIDE_COLOR[guide.edge];
@@ -901,6 +920,147 @@ var __litedesign = (() => {
901
920
  }
902
921
  var HOVER_BORDER = "rgba(59, 130, 246, 0.9)";
903
922
  var HOVER_FILL = "rgba(59, 130, 246, 0.08)";
923
+ var FOCUS_DIM = "rgba(15, 23, 42, 0.55)";
924
+ var FOCUS_BORDER = "#3b82f6";
925
+ var SVG_NS = "http://www.w3.org/2000/svg";
926
+ var MIN_RADIUS = 4;
927
+ var TANGENT_COLOR = "rgba(148, 163, 184, 0.9)";
928
+ function parseRadius(css, rect) {
929
+ const text = css.trim();
930
+ if (text === "" || text.includes("/")) return null;
931
+ const parts = text.split(/\s+/);
932
+ if (parts.length > 4) return null;
933
+ const values = [];
934
+ for (const part of parts) {
935
+ const resolved = part.endsWith("%") ? Math.min(rect.w, rect.h) * (Number.parseFloat(part) / 100) : Number.parseFloat(part);
936
+ if (!Number.isFinite(resolved) || resolved < 0) return null;
937
+ values.push(resolved);
938
+ }
939
+ const [first = 0, second = first, third = first, fourth = second] = values;
940
+ const clamp = (radius) => Math.min(radius, rect.w / 2, rect.h / 2);
941
+ return {
942
+ tl: clamp(first),
943
+ tr: clamp(second),
944
+ br: clamp(third),
945
+ bl: clamp(fourth)
946
+ };
947
+ }
948
+ function radiusGeometry(rect, radii) {
949
+ const specs = [
950
+ { corner: "tl", r: radii.tl, x: rect.x, y: rect.y, sx: 1, sy: 1 },
951
+ { corner: "tr", r: radii.tr, x: rect.x + rect.w, y: rect.y, sx: -1, sy: 1 },
952
+ { corner: "br", r: radii.br, x: rect.x + rect.w, y: rect.y + rect.h, sx: -1, sy: -1 },
953
+ { corner: "bl", r: radii.bl, x: rect.x, y: rect.y + rect.h, sx: 1, sy: -1 }
954
+ ];
955
+ const marks = [];
956
+ for (const spec of specs) {
957
+ if (spec.r < MIN_RADIUS) continue;
958
+ const hx = spec.x + spec.sx * spec.r;
959
+ const hy = spec.y;
960
+ const vx = spec.x;
961
+ const vy = spec.y + spec.sy * spec.r;
962
+ const sweep = spec.sx * spec.sy === 1 ? 0 : 1;
963
+ marks.push({
964
+ corner: spec.corner,
965
+ radius: spec.r,
966
+ arc: `M ${hx} ${hy} A ${spec.r} ${spec.r} 0 0 ${sweep} ${vx} ${vy}`,
967
+ tangents: [
968
+ [spec.x, spec.y, hx, hy],
969
+ [spec.x, spec.y, vx, vy]
970
+ ]
971
+ });
972
+ }
973
+ return marks;
974
+ }
975
+ function createSubLayer(name, before) {
976
+ const layer = document.createElement("div");
977
+ layer.dataset.layer = name;
978
+ layer.style.cssText = `${BASE};inset:0`;
979
+ before.before(layer);
980
+ return layer;
981
+ }
982
+ var MARK_COLOR = "rgba(217, 119, 6, 0.95)";
983
+ function drawMarks(layer, marks, bySelector, interactive = false) {
984
+ layer.replaceChildren();
985
+ const badges = /* @__PURE__ */ new Map();
986
+ if (marks.length === 0) return badges;
987
+ const scrollX = window.scrollX;
988
+ const scrollY = window.scrollY;
989
+ for (const mark of marks) {
990
+ const node = bySelector.get(mark.selector);
991
+ if (node === void 0) continue;
992
+ const badge = box(
993
+ layer,
994
+ `left:${node.rect.x - scrollX}px;top:${node.rect.y - scrollY}px;transform:translateY(-100%);padding:2px 6px;border-radius:4px;background:${MARK_COLOR};color:#fff;font:10px/1.4 ui-monospace,monospace;white-space:nowrap` + (interactive ? ";pointer-events:auto;cursor:pointer" : "")
995
+ );
996
+ badge.textContent = mark.text;
997
+ badges.set(badge, mark);
998
+ }
999
+ return badges;
1000
+ }
1001
+ function drawRadius(layer, node) {
1002
+ layer.replaceChildren();
1003
+ if (node === null) return;
1004
+ const radii = parseRadius(node.styles.borderRadius, node.rect);
1005
+ if (radii === null) return;
1006
+ const view = {
1007
+ x: node.rect.x - window.scrollX,
1008
+ y: node.rect.y - window.scrollY,
1009
+ w: node.rect.w,
1010
+ h: node.rect.h
1011
+ };
1012
+ const marks = radiusGeometry(view, radii);
1013
+ if (marks.length === 0) return;
1014
+ const svg = document.createElementNS(SVG_NS, "svg");
1015
+ svg.style.cssText = `${BASE};inset:0;width:100%;height:100%`;
1016
+ for (const mark of marks) {
1017
+ const arc = document.createElementNS(SVG_NS, "path");
1018
+ arc.setAttribute("d", mark.arc);
1019
+ arc.setAttribute("fill", "none");
1020
+ arc.setAttribute("stroke", GAP_COLOR);
1021
+ arc.setAttribute("stroke-width", "2");
1022
+ svg.append(arc);
1023
+ for (const [x1, y1, x2, y2] of mark.tangents) {
1024
+ const line = document.createElementNS(SVG_NS, "line");
1025
+ line.setAttribute("x1", String(x1));
1026
+ line.setAttribute("y1", String(y1));
1027
+ line.setAttribute("x2", String(x2));
1028
+ line.setAttribute("y2", String(y2));
1029
+ line.setAttribute("stroke", TANGENT_COLOR);
1030
+ line.setAttribute("stroke-width", "1");
1031
+ line.setAttribute("stroke-dasharray", "3 3");
1032
+ svg.append(line);
1033
+ }
1034
+ }
1035
+ layer.append(svg);
1036
+ const labeled = marks.every((mark) => mark.radius === marks[0]?.radius) ? marks.slice(0, 1) : marks;
1037
+ for (const mark of labeled) {
1038
+ const inset = mark.radius * 0.55;
1039
+ const x = mark.corner === "tr" || mark.corner === "br" ? view.x + view.w - inset : view.x + inset;
1040
+ const y = mark.corner === "br" || mark.corner === "bl" ? view.y + view.h - inset : view.y + inset;
1041
+ label(layer, `${round(mark.radius, 1)}px`, x, y);
1042
+ }
1043
+ }
1044
+ function dimStyle(rect, scroll) {
1045
+ const left = rect.x - scroll.x;
1046
+ const top = rect.y - scroll.y;
1047
+ return `left:${left}px;top:${top}px;width:${rect.w}px;height:${rect.h}px;box-shadow:0 0 0 9999px ${FOCUS_DIM}`;
1048
+ }
1049
+ function outlineStyle(rect, scroll) {
1050
+ const left = rect.x - scroll.x;
1051
+ const top = rect.y - scroll.y;
1052
+ return `left:${left}px;top:${top}px;width:${rect.w}px;height:${rect.h}px;border:2px solid ${FOCUS_BORDER};border-radius:2px`;
1053
+ }
1054
+ function drawDim(layer, node) {
1055
+ layer.replaceChildren();
1056
+ if (node === null) return;
1057
+ box(layer, dimStyle(node.rect, { x: window.scrollX, y: window.scrollY }));
1058
+ }
1059
+ function drawFocus(layer, node) {
1060
+ layer.replaceChildren();
1061
+ if (node === null) return;
1062
+ box(layer, outlineStyle(node.rect, { x: window.scrollX, y: window.scrollY }));
1063
+ }
904
1064
  function drawHover(layer, node) {
905
1065
  layer.replaceChildren();
906
1066
  if (node === null) return;
@@ -958,6 +1118,21 @@ var __litedesign = (() => {
958
1118
  printStyle.textContent = "@media print { :host { display: none !important } }";
959
1119
  shadow.append(printStyle);
960
1120
  const layers = createLayers(shadow);
1121
+ const radiusLayer = createSubLayer("radius", layers.hover);
1122
+ const marksLayer = createSubLayer("marks", layers.hover);
1123
+ if (options.onMarkClick !== void 0) {
1124
+ const onMarkClick = options.onMarkClick;
1125
+ marksLayer.addEventListener("click", (event) => {
1126
+ if (!marksInteractive) return;
1127
+ const badge = event.target;
1128
+ if (!(badge instanceof HTMLDivElement)) return;
1129
+ const mark = badgeMarks.get(badge);
1130
+ if (mark === void 0) return;
1131
+ event.preventDefault();
1132
+ event.stopPropagation();
1133
+ onMarkClick(mark, badge.getBoundingClientRect());
1134
+ });
1135
+ }
961
1136
  if (options.toolbar !== void 0) shadow.append(options.toolbar);
962
1137
  document.documentElement.append(host);
963
1138
  let current = graph;
@@ -965,17 +1140,34 @@ var __litedesign = (() => {
965
1140
  let hovered = null;
966
1141
  let hoverTarget = null;
967
1142
  let hoverFrame = 0;
1143
+ let focusId = null;
968
1144
  let visible = new Set(options.layers ?? LAYERS);
1145
+ let marks = options.marks ?? [];
1146
+ let marksInteractive = false;
1147
+ let badgeMarks = /* @__PURE__ */ new Map();
1148
+ let grid = options.grid ?? { step: GRID_STEP, axis: "both" };
969
1149
  let frame = 0;
970
1150
  const draw = () => {
971
1151
  frame = 0;
972
1152
  for (const name of LAYERS) {
973
1153
  layers[name].style.display = visible.has(name) ? "block" : "none";
974
1154
  }
975
- drawGrid(layers.grid);
976
- drawGuides(layers.guide, current);
1155
+ drawGrid(layers.grid, grid);
977
1156
  drawGaps(layers.gap, current);
978
1157
  drawIssues(layers.issue, current);
1158
+ const focused = focusId === null ? null : byId.get(focusId) ?? null;
1159
+ drawGuides(layers.guide, current, focused);
1160
+ const outlineNode = visible.has("focus") ? focused : null;
1161
+ const dimNode = visible.has("dim") ? focused : null;
1162
+ drawDim(layers.dim, dimNode);
1163
+ drawFocus(layers.focus, outlineNode);
1164
+ drawRadius(radiusLayer, outlineNode);
1165
+ badgeMarks = drawMarks(
1166
+ marksLayer,
1167
+ marks,
1168
+ new Map(current.nodes.map((node) => [node.selector, node])),
1169
+ marksInteractive
1170
+ );
979
1171
  drawHover(layers.hover, hovered === null ? null : byId.get(hovered) ?? null);
980
1172
  };
981
1173
  const schedule = () => {
@@ -1020,12 +1212,26 @@ var __litedesign = (() => {
1020
1212
  update(next) {
1021
1213
  current = next;
1022
1214
  byId = indexNodes(next);
1215
+ if (focusId !== null && !byId.has(focusId)) focusId = null;
1023
1216
  draw();
1024
1217
  },
1025
1218
  setLayers(names) {
1026
1219
  visible = new Set(names);
1027
1220
  draw();
1028
1221
  },
1222
+ setFocus(id) {
1223
+ focusId = id;
1224
+ draw();
1225
+ },
1226
+ setMarks(next, interactive = false) {
1227
+ marks = next;
1228
+ marksInteractive = interactive;
1229
+ draw();
1230
+ },
1231
+ setGrid(next) {
1232
+ grid = next;
1233
+ draw();
1234
+ },
1029
1235
  destroy() {
1030
1236
  if (frame !== 0) cancelAnimationFrame(frame);
1031
1237
  if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
@@ -1042,7 +1248,7 @@ var __litedesign = (() => {
1042
1248
  }
1043
1249
 
1044
1250
  // packages/core/src/index.ts
1045
- var VERSION = "0.1.0";
1251
+ var VERSION = "0.2.1";
1046
1252
  return __toCommonJS(index_exports);
1047
1253
  })();
1048
1254
  //# sourceMappingURL=core.iife.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "litedesign",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "description": "Grid, gap, and alignment on the real web page.",
6
6
  "permissions": ["activeTab", "scripting", "storage"],
7
7
  "host_permissions": ["http://*/*", "https://*/*"],
@@ -205,6 +205,7 @@ async function route(action, payload) {
205
205
  case "graph":
206
206
  case "measure":
207
207
  case "overlay":
208
+ case "labels":
208
209
  return toTab(action, payload);
209
210
  case "screenshot":
210
211
  return capture("screenshot_prepare", payload);
package/dist/index.js CHANGED
@@ -44,7 +44,7 @@ function isMode(value) {
44
44
  }
45
45
 
46
46
  // packages/cli/src/version.ts
47
- var VERSION = "0.1.0";
47
+ var VERSION = "0.2.1";
48
48
 
49
49
  // packages/cli/src/ws.ts
50
50
  import { spawn } from "node:child_process";
@@ -371,7 +371,10 @@ var Hub = class _Hub {
371
371
  const previous = this.#socketTab.get(socket);
372
372
  if (previous !== void 0 && previous !== tabId) {
373
373
  const old = this.#clients.get(previous);
374
- if (old?.socket === socket) this.#clients.delete(previous);
374
+ if (old?.socket === socket) {
375
+ this.#clients.delete(previous);
376
+ this.#broadcast({ type: "page-gone", tabId: previous });
377
+ }
375
378
  }
376
379
  this.#clients.set(tabId, { ...page, socket });
377
380
  this.#socketTab.set(socket, tabId);
@@ -1096,6 +1099,7 @@ function issue(input) {
1096
1099
  selector: input.selector,
1097
1100
  rule: input.rule,
1098
1101
  severity: input.severity,
1102
+ kind: input.kind,
1099
1103
  actual: input.actual,
1100
1104
  expected: input.expected,
1101
1105
  autoFix: input.autoFix
@@ -1133,6 +1137,7 @@ var alignmentNearMiss = defineRule("alignment-near-miss", (graph) => {
1133
1137
  selector: worst.node.id,
1134
1138
  key: spec.edge,
1135
1139
  severity: "P2",
1140
+ kind: "candidate",
1136
1141
  actual: `${Math.round(worst.value * 10) / 10}px`,
1137
1142
  expected: `${Math.round(target.value * 10) / 10}px`,
1138
1143
  autoFix: false
@@ -1161,6 +1166,7 @@ var contrastMinimum = defineRule("contrast", (graph) => {
1161
1166
  rule: "contrast",
1162
1167
  selector: node.id,
1163
1168
  severity: "P1",
1169
+ kind: "violation",
1164
1170
  actual: `${Math.round(ratio * 100) / 100}:1`,
1165
1171
  expected: `>= ${minimum}:1`,
1166
1172
  autoFix: false
@@ -1207,6 +1213,7 @@ var overflowClipped = defineRule("overflow", (graph) => {
1207
1213
  selector: node.id,
1208
1214
  key: "x",
1209
1215
  severity: "P2",
1216
+ kind: "violation",
1210
1217
  actual: `${node.box.scrollW}px content in ${node.box.clientW}px`,
1211
1218
  expected: "content fits horizontally",
1212
1219
  autoFix: false
@@ -1220,6 +1227,7 @@ var overflowClipped = defineRule("overflow", (graph) => {
1220
1227
  selector: node.id,
1221
1228
  key: "y",
1222
1229
  severity: "P2",
1230
+ kind: "violation",
1223
1231
  actual: `${node.box.scrollH}px content in ${node.box.clientH}px`,
1224
1232
  expected: "content fits vertically",
1225
1233
  autoFix: false
@@ -1256,6 +1264,7 @@ var radiusConsistency = defineRule("radius-consistency", (graph) => {
1256
1264
  rule: "radius-consistency",
1257
1265
  selector: node.id,
1258
1266
  severity: "P3",
1267
+ kind: "candidate",
1259
1268
  actual: value,
1260
1269
  expected: expectedValue,
1261
1270
  autoFix: true
@@ -1292,6 +1301,7 @@ var spacingScale = defineRule("spacing-scale", (graph) => {
1292
1301
  selector: gap.a,
1293
1302
  key: `${gap.axis}:${gap.b}`,
1294
1303
  severity: "P2",
1304
+ kind: "candidate",
1295
1305
  actual: `${actual}px`,
1296
1306
  expected: `${Math.round(actual / SPACING_SCALE) * SPACING_SCALE}px`,
1297
1307
  autoFix: true
@@ -1308,7 +1318,7 @@ var defaultRules = [
1308
1318
  contrastMinimum
1309
1319
  ];
1310
1320
  var RULE_IDS = defaultRules.map((rule) => rule.id);
1311
- var LAYERS = ["grid", "guide", "gap", "issue", "hover"];
1321
+ var LAYERS = ["grid", "guide", "gap", "issue", "dim", "focus", "hover"];
1312
1322
 
1313
1323
  // packages/cli/src/drivers/extension.ts
1314
1324
  var ExtensionDriver = class {
@@ -1359,6 +1369,21 @@ var ExtensionDriver = class {
1359
1369
  }
1360
1370
  return result;
1361
1371
  }
1372
+ async labels() {
1373
+ const result = await this.#request("labels");
1374
+ if (result === void 0) {
1375
+ throw new ToolError(
1376
+ "the extension did not report labels. Reload the litedesign extension at chrome://extensions, then try again."
1377
+ );
1378
+ }
1379
+ const labels = [];
1380
+ for (const entry of result.labels ?? []) {
1381
+ if (typeof entry.text === "string" && typeof entry.selector === "string") {
1382
+ labels.push({ label: entry.text, selector: entry.selector });
1383
+ }
1384
+ }
1385
+ return labels;
1386
+ }
1362
1387
  async close() {
1363
1388
  }
1364
1389
  #request(action, payload) {
@@ -1493,22 +1518,46 @@ var PlaywrightDriver = class {
1493
1518
  );
1494
1519
  return page.screenshot({ type: "png" });
1495
1520
  }
1521
+ /**
1522
+ * Labels are marks the human makes through the toolbar; a headless page has
1523
+ * no human in it, so there is nothing to read.
1524
+ */
1525
+ async labels() {
1526
+ return [];
1527
+ }
1496
1528
  async overlay(options) {
1497
1529
  const page = await this.#requirePage();
1498
1530
  await this.#inject(page);
1499
1531
  const layers = options.enabled ? options.layers ?? [...LAYERS] : [];
1500
- await page.evaluate(
1532
+ const result = await page.evaluate(
1501
1533
  (input) => {
1502
1534
  const target = window;
1503
1535
  target.__litedesign_handle__?.destroy();
1504
1536
  target.__litedesign_handle__ = void 0;
1505
- if (!input.enabled) return;
1537
+ if (!input.enabled) {
1538
+ target.__litedesign_focus__ = null;
1539
+ return { enabled: false, layers: [], focus: null };
1540
+ }
1506
1541
  const graph = target.__litedesign.buildGraph(document.body);
1507
- target.__litedesign_handle__ = target.__litedesign.render(graph, { layers: input.layers });
1542
+ const handle = target.__litedesign.render(graph, { layers: input.layers });
1543
+ target.__litedesign_handle__ = handle;
1544
+ if (input.focus !== void 0) target.__litedesign_focus__ = input.focus;
1545
+ let focus = null;
1546
+ const wanted = input.focus !== void 0 ? input.focus : target.__litedesign_focus__ ?? null;
1547
+ if (wanted !== null) {
1548
+ const node = target.__litedesign.resolveNode(graph, wanted);
1549
+ if (node !== null) {
1550
+ handle.setFocus(node.id);
1551
+ focus = node.selector;
1552
+ const element = document.querySelector(node.selector);
1553
+ element?.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1554
+ }
1555
+ }
1556
+ return { enabled: true, layers: input.layers, focus };
1508
1557
  },
1509
- { enabled: options.enabled, layers }
1558
+ { enabled: options.enabled, layers, focus: options.focus }
1510
1559
  );
1511
- return { enabled: options.enabled, layers };
1560
+ return result;
1512
1561
  }
1513
1562
  async close() {
1514
1563
  await this.#context?.close().catch(() => void 0);
@@ -1741,12 +1790,20 @@ function formatTree(graph, maxLines = MAX_TREE_LINES) {
1741
1790
  }
1742
1791
  return lines.join("\n");
1743
1792
  }
1793
+ function labelsSummary(marks) {
1794
+ const names = [];
1795
+ for (const mark of marks) {
1796
+ if (!names.includes(mark.label)) names.push(mark.label);
1797
+ }
1798
+ return { count: marks.length, names };
1799
+ }
1744
1800
  function issuePayload(issues, limit) {
1745
1801
  return {
1746
1802
  issues: issues.slice(0, limit).map((issue2) => ({
1747
1803
  selector: issue2.selector,
1748
1804
  rule: issue2.rule,
1749
1805
  severity: issue2.severity,
1806
+ kind: issue2.kind,
1750
1807
  actual: issue2.actual,
1751
1808
  expected: issue2.expected,
1752
1809
  autoFix: issue2.autoFix
@@ -1817,6 +1874,7 @@ async function inspectTool(input) {
1817
1874
  throw error;
1818
1875
  }
1819
1876
  const graph = await driver.graph();
1877
+ const labels = labelsSummary(await driver.labels());
1820
1878
  const payload = {
1821
1879
  status: "ready",
1822
1880
  url: graph.url,
@@ -1824,7 +1882,8 @@ async function inspectTool(input) {
1824
1882
  summary: {
1825
1883
  nodes: graph.nodes.length,
1826
1884
  issues: graph.issues.length,
1827
- truncated: graph.truncated
1885
+ truncated: graph.truncated,
1886
+ labels
1828
1887
  },
1829
1888
  tree: formatTree(graph)
1830
1889
  };
@@ -1833,6 +1892,16 @@ async function inspectTool(input) {
1833
1892
  return imageResult(payload, image);
1834
1893
  }
1835
1894
 
1895
+ // packages/cli/src/mcp/tools/labels.ts
1896
+ async function labelsTool() {
1897
+ const driver = requirePage();
1898
+ const labels = await driver.labels();
1899
+ return textResult({
1900
+ labels,
1901
+ count: labels.length
1902
+ });
1903
+ }
1904
+
1836
1905
  // packages/cli/src/mcp/tools/measure.ts
1837
1906
  async function measureTool(input) {
1838
1907
  const measured = await requirePage().measure(input.selector);
@@ -1844,10 +1913,11 @@ async function overlayTool(input) {
1844
1913
  const driver = requirePage();
1845
1914
  const enabled = input.enabled !== false;
1846
1915
  const layers = parseLayers(input.layers);
1847
- const result = await driver.overlay({ enabled, layers });
1916
+ const result = await driver.overlay({ enabled, layers, focus: input.focus });
1848
1917
  return textResult({
1849
1918
  status: result.enabled ? "shown" : "hidden",
1850
- layers: result.layers
1919
+ layers: result.layers,
1920
+ focus: result.focus
1851
1921
  });
1852
1922
  }
1853
1923
  function parseLayers(requested) {
@@ -1876,6 +1946,17 @@ async function screenshotAnnotatedTool(input) {
1876
1946
  return imageResult({ ...pngSize(image), bytes: image.length }, image);
1877
1947
  }
1878
1948
 
1949
+ // packages/cli/src/mcp/workflow.ts
1950
+ var WORKFLOW_URI = "skill://litedesign/workflow";
1951
+ var WORKFLOW_TEXT = `# litedesign workflow
1952
+
1953
+ - Call \`inspect\` first, always. Its summary includes the labels the human pinned on the page.
1954
+ - If the human refers to a part of the page by name ("the login button", "the altcha box"), call \`labels\` and use the pinned selector \u2014 never guess CSS selectors.
1955
+ - Labels are read-only: you read them; the human creates, renames, and removes them.
1956
+ - To show the human what you are editing, call \`overlay\` with \`focus\`. Set \`layers\` once at the start if needed; after that, send \`focus\` only \u2014 the human owns the layer toggles in the toolbar.
1957
+ - Findings carry kind: "violation" is a measured fact; "candidate" is a heuristic that can be a false positive \u2014 verify it with \`measure\` before changing code.
1958
+ `;
1959
+
1879
1960
  // packages/cli/src/mcp/server.ts
1880
1961
  async function guard(run2) {
1881
1962
  try {
@@ -1899,6 +1980,18 @@ async function runMcpServer() {
1899
1980
  console.error(`[litedesign] extension bridge disabled: ${reason}`);
1900
1981
  }
1901
1982
  const server = new McpServer({ name: "litedesign", version: VERSION });
1983
+ server.registerResource(
1984
+ "workflow",
1985
+ WORKFLOW_URI,
1986
+ {
1987
+ title: "Workflow",
1988
+ description: "How the agent should work with this server: inspect first, resolve the human's names through labels, spotlight edits with focus.",
1989
+ mimeType: "text/markdown"
1990
+ },
1991
+ async () => ({
1992
+ contents: [{ uri: WORKFLOW_URI, text: WORKFLOW_TEXT, mimeType: "text/markdown" }]
1993
+ })
1994
+ );
1902
1995
  const viewport = z.object({
1903
1996
  w: z.number().int().positive().describe("Viewport width in CSS pixels"),
1904
1997
  h: z.number().int().positive().describe("Viewport height in CSS pixels")
@@ -1931,7 +2024,7 @@ async function runMcpServer() {
1931
2024
  "inspect",
1932
2025
  {
1933
2026
  title: "Inspect page",
1934
- description: 'Open a page and return a tree summary. Call this before measure, find_issues, overlay, screenshot, or screenshot_annotated. No image by default: the numbers the agent needs come from the DOM, so set screenshot to true only when you really need to see the page. With the extension driver this attaches to the active tab by itself; returns "awaiting_user" when no extension is connected and "mismatch" when the active tab is a different page.',
2027
+ description: 'Open a page and return a tree summary. Call this before measure, find_issues, overlay, screenshot, or screenshot_annotated. The summary includes labels: the count and unique names the human pinned onto parts of the page \u2014 when the human refers to a part by name, get its selector from the labels tool. The full workflow is available as the resource skill://litedesign/workflow. No image by default: the numbers the agent needs come from the DOM, so set screenshot to true only when you really need to see the page. With the extension driver this attaches to the active tab by itself; returns "awaiting_user" when no extension is connected and "mismatch" when the active tab is a different page.',
1935
2028
  inputSchema: {
1936
2029
  url: z.string().url(),
1937
2030
  viewport,
@@ -1968,7 +2061,7 @@ async function runMcpServer() {
1968
2061
  "find_issues",
1969
2062
  {
1970
2063
  title: "Find issues",
1971
- description: "Run the rule engine on the open page and return violations with selector, actual, expected, severity, and autoFix. Call inspect first.",
2064
+ description: 'Run layout rules on the open page and return findings. Each finding carries kind: "violation" is a measured fact (overflow, contrast below WCAG); "candidate" is a heuristic judgment (spacing scale, near-miss alignment, radius consistency) that can be a false positive \u2014 verify it with measure before changing code. Call inspect first.',
1972
2065
  inputSchema: {
1973
2066
  rules: z.array(z.string()).optional().describe(`Rules to run. Default: all. Valid: ${RULE_IDS.join(", ")}`),
1974
2067
  limit: z.number().int().min(1).max(200).optional().describe("Maximum issues to return (default 50)")
@@ -1980,14 +2073,26 @@ async function runMcpServer() {
1980
2073
  "overlay",
1981
2074
  {
1982
2075
  title: "Overlay",
1983
- description: "Show or hide the measurement overlay in the page the human is looking at: grid, guides, gap labels, and issue boxes. Use it to let the human see spacing without clicking anything. Call inspect first.",
2076
+ description: "Show or hide the measurement overlay in the page the human is looking at: grid, guides, gap labels, and issue boxes. Use focus to spotlight the component you are editing: the rest of the page dims, the target is outlined, and it scrolls to the viewport center \u2014 so the human sees exactly what you are changing. focus takes the same selector measure returns; null clears it. Set layers once at the start if you need specific ones; after that call with focus only \u2014 the human owns the layer toggles in the toolbar, and your calls must not override their choice. Call inspect first.",
1984
2077
  inputSchema: {
1985
2078
  enabled: z.boolean().optional().describe("false hides the overlay (default true)"),
1986
- layers: z.array(z.string()).optional().describe(`Layers to draw. Default: all. Valid: ${LAYERS.join(", ")}`)
2079
+ layers: z.array(z.string()).optional().describe(`Layers to draw. Default: all. Valid: ${LAYERS.join(", ")}`),
2080
+ focus: z.string().nullish().describe(
2081
+ "Selector of the component to spotlight (same format as measure). null clears the focus. Omit to leave it unchanged. Focusing scrolls the page, which changes what a later screenshot captures."
2082
+ )
1987
2083
  }
1988
2084
  },
1989
2085
  async (input) => guard(() => overlayTool(input))
1990
2086
  );
2087
+ server.registerTool(
2088
+ "labels",
2089
+ {
2090
+ title: "Labels",
2091
+ description: "Read the names the human pinned onto parts of the page through the toolbar label button. Each entry maps a human name to a selector you can pass to measure, find_issues, or overlay focus. When the human refers to a part by name, look it up here instead of guessing a selector. Labels are the human's marks: read-only for you. Returns an empty list when nothing is pinned yet.",
2092
+ inputSchema: {}
2093
+ },
2094
+ async () => guard(() => labelsTool())
2095
+ );
1991
2096
  server.registerTool(
1992
2097
  "screenshot",
1993
2098
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rahmantech/litedesign",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Grid, gap, and alignment on the real web page, plus an MCP bridge for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Muhammad Abdurrahman",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "!dist/**/*.map",
13
14
  "README.md"
14
15
  ],
15
16
  "engines": {
@@ -21,7 +22,7 @@
21
22
  "zod": "^4.6.7"
22
23
  },
23
24
  "devDependencies": {
24
- "@litedesign/core": "0.1.0",
25
+ "@litedesign/core": "0.2.1",
25
26
  "@types/ws": "^8.18.2"
26
27
  },
27
28
  "publishConfig": {