@cssints/devtools 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cssints/devtools",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A dev overlay inspector for cssints: an element's classes by the call that made them, the cascade of the sheet, checked against the browser",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "sideEffects": false,
18
18
  "exports": {
19
19
  "./overlay": "./dist/overlay.js",
20
+ "./opencode": "./dist/opencode.js",
20
21
  "./core": {
21
22
  "types": "./src/core/index.ts",
22
23
  "default": "./src/core/index.ts"
@@ -28,11 +29,11 @@
28
29
  "./inject": "./src/core/inject.ts"
29
30
  },
30
31
  "dependencies": {
31
- "@cssints/css-grammar": "^0.1.0",
32
+ "@cssints/css-grammar": "^0.3.0",
32
33
  "effect": "4.0.0"
33
34
  },
34
35
  "peerDependencies": {
35
- "cssints": "^0.1.0"
36
+ "cssints": "^0.3.0"
36
37
  },
37
38
  "engines": {
38
39
  "node": ">=22"
@@ -78,6 +78,8 @@ export interface TokenRow {
78
78
 
79
79
  export interface Inspection {
80
80
  element: string;
81
+ /** the readable classes of the element's cn() and cv() calls (`cs-Landing-logo`, dev only), not in the table */
82
+ names: string[];
81
83
  root: string;
82
84
  groups: Group[];
83
85
  rows: Row[];
@@ -87,6 +89,8 @@ export interface Inspection {
87
89
  const lh = (prop: string): readonly string[] => longhands[prop] ?? [prop];
88
90
  const isScope = (w: string) => /^@scope\b/i.test(w);
89
91
  const CLASSY = /\._[\w-]/;
92
+ // the readable class of a cn() or cv() site that the dev server adds (instrument.ts `tagged`)
93
+ const READABLE = /^cs-[\w-]+$/;
90
94
  const UNLAYERED = 1e9;
91
95
  // Computed values of these depend on the element's content, which the clone does not have.
92
96
  const LAYOUT =
@@ -346,7 +350,10 @@ export const inspect = Effect.fn("devtools.inspect")(function* (model: Model, el
346
350
  groups.push({ site, classes: left.filter((c) => site.classes.includes(c)).map(classRow) });
347
351
  left = left.filter((c) => !site.classes.includes(c));
348
352
  }
349
- const rest = classes.filter((c) => !groups.some((g) => g.classes.some((r) => r.cls === c)));
353
+ const readable = classes.filter((c) => !table.classes[c] && READABLE.test(c));
354
+ const rest = classes.filter(
355
+ (c) => !readable.includes(c) && !groups.some((g) => g.classes.some((r) => r.cls === c)),
356
+ );
350
357
  if (rest.length) groups.push({ classes: rest.map(classRow) });
351
358
 
352
359
  // the tokens the element's rules read
@@ -371,6 +378,7 @@ export const inspect = Effect.fn("devtools.inspect")(function* (model: Model, el
371
378
  }
372
379
  return {
373
380
  element: yield* page.label(el),
381
+ names: readable,
374
382
  root: table.root,
375
383
  groups,
376
384
  rows,
package/src/core/index.ts CHANGED
@@ -15,10 +15,10 @@ export { parseSheet, specificity, stripStates } from "./css.ts";
15
15
  export type { Decl, Sheet, SheetRule } from "./css.ts";
16
16
  export { domPage } from "./dom.ts";
17
17
  export { Page } from "./page.ts";
18
- export { buildPrompt, relative } from "./prompt.ts";
18
+ export { buildPrompt, pagePrompt, relative } from "./prompt.ts";
19
19
  export type { Foreign, Observed, Probe } from "./page.ts";
20
- export { ASK, ASKED, session, STALE, TABLE, Transport } from "./session.ts";
21
- export type { Asked } from "./session.ts";
20
+ export { ASK, ASKED, LINKED, session, STALE, TABLE, Transport } from "./session.ts";
21
+ export type { Asked, Linked } from "./session.ts";
22
22
  export type { Session, TransportApi } from "./session.ts";
23
23
  export { toModel, Table, TableError } from "./table.ts";
24
24
  export type { ClassRecord, Model, Site } from "./table.ts";
@@ -1,6 +1,7 @@
1
1
  // The prompt of "ask OpenCode": the picked element as cssints knows it (the css.* calls that style it, the winner of each
2
- // property and what it beat, the tokens) plus the user's instruction. Every list is cut, so a page of a thousand rules sends
3
- // a few kilobytes; the dev server cuts the whole once more (packages/cssints/src/devtools-server.ts).
2
+ // property and what it beat, the tokens) plus the user's instruction, the route and how the page is displayed. Every list is
3
+ // cut, so a page of a thousand rules sends a few kilobytes; the dev server adds an `App:` line on top and cuts the whole once
4
+ // more, from the end (packages/cssints/src/devtools-server.ts): the instruction and the header come first and stay.
4
5
  import type { Candidate, Inspection } from "./cascade.ts";
5
6
 
6
7
  const SITES = 8;
@@ -8,6 +9,23 @@ const ROWS = 24;
8
9
  const BEATEN = 3;
9
10
  const TOKENS = 12;
10
11
  const CALL = 400;
12
+ const INSTRUCTION = 4000;
13
+ const VALUE = 80;
14
+ const ATTRS = 8;
15
+ const HTML_ATTRS = 12;
16
+ // what tells what an element is; never `value`: a form's values stay in the page
17
+ const IDENTITY = new Set([
18
+ "id",
19
+ "role",
20
+ "aria-label",
21
+ "href",
22
+ "src",
23
+ "alt",
24
+ "type",
25
+ "name",
26
+ "placeholder",
27
+ "title",
28
+ ]);
11
29
 
12
30
  /**
13
31
  * A file name relative to the root of the app, when it is under it.
@@ -21,17 +39,88 @@ export const relative = (root: string, file: string) =>
21
39
  const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text);
22
40
  const flat = (text: string | null) => (text ?? "").replace(/\s+/g, " ").trim();
23
41
 
42
+ const label = (e: Element) => `${e.localName}${e.id ? `#${e.id}` : ""}`;
43
+
24
44
  // html > body > main > p: the element and its three nearest ancestors
25
45
  const domPath = (el: Element) => {
26
46
  const parent = el.parentElement;
27
47
  const up2 = parent?.parentElement;
28
- return [up2?.parentElement, up2, parent, el]
29
- .flatMap((e) => (e ? [`${e.localName}${e.id ? `#${e.id}` : ""}`] : []))
30
- .join(" > ");
48
+ return [up2?.parentElement, up2, parent, el].flatMap((e) => (e ? [label(e)] : [])).join(" > ");
31
49
  };
32
50
 
33
51
  const where = (c: Candidate) => `${c.selector} (${c.layer || "unlayered"})`;
34
52
 
53
+ // name="value" of the attributes `keep` names, each value cut
54
+ const attrs = (el: Element, keep: (name: string) => boolean, max: number) =>
55
+ [...el.attributes]
56
+ .flatMap((a) => (keep(a.name) ? [`${a.name}="${cut(flat(a.value), VALUE)}"`] : []))
57
+ .slice(0, max)
58
+ .join(" ");
59
+
60
+ // the data-* of the element and its three nearest ancestors (<html>'s are in the header)
61
+ const dataLines = (el: Element) => {
62
+ const parent = el.parentElement;
63
+ const up2 = parent?.parentElement;
64
+ return [el, parent, up2, up2?.parentElement].flatMap((e) => {
65
+ const data =
66
+ e && e !== el.ownerDocument.documentElement && attrs(e, (n) => n.startsWith("data-"), ATTRS);
67
+ return e && data ? [`Data of ${label(e)}: ${data}`] : [];
68
+ });
69
+ };
70
+
71
+ // the route, the title and what decides how the page looks now: the same for both prompts
72
+ const header = (doc: Document, el?: Element) => {
73
+ const { defaultView: win, location: at } = doc;
74
+ if (!win) return [];
75
+ const { innerWidth: w, innerHeight: h, devicePixelRatio: dpr } = win;
76
+ const root = attrs(
77
+ doc.documentElement,
78
+ (n) => n === "class" || n.startsWith("data-"),
79
+ HTML_ATTRS,
80
+ );
81
+ const lines = [
82
+ `Page: ${cut(at.pathname + at.search + at.hash, 300)}`,
83
+ `Title: ${cut(flat(doc.title), 120) || "(none)"}`,
84
+ "Display:",
85
+ `- viewport: ${w}x${h}${dpr === 1 ? "" : `, devicePixelRatio ${dpr}`}`,
86
+ ];
87
+ if (root) lines.push(`- <html> ${root}`);
88
+ const dark = win.matchMedia("(prefers-color-scheme: dark)").matches;
89
+ const still = win.matchMedia("(prefers-reduced-motion: reduce)").matches;
90
+ lines.push(
91
+ `- prefers-color-scheme: ${dark ? "dark" : "light"}${still ? "; prefers-reduced-motion: reduce" : ""}`,
92
+ );
93
+ if (el) {
94
+ const r = el.getBoundingClientRect();
95
+ const state = [
96
+ el.matches(":hover") ? ["hovered"] : [],
97
+ doc.activeElement === el ? ["focused"] : [],
98
+ el.matches(":disabled") || el.getAttribute("aria-disabled") === "true" ? ["disabled"] : [],
99
+ ].flat();
100
+ lines.push(
101
+ `- rect: ${Math.round(r.width)}x${Math.round(r.height)} at ${Math.round(r.x)},${Math.round(r.y)} in the viewport${state.length ? `; ${state.join(", ")}` : ""}`,
102
+ );
103
+ }
104
+ return lines;
105
+ };
106
+
107
+ /**
108
+ * The prompt with no element picked: the instruction, the page it is about and how it is displayed.
109
+ * @param {string} instruction what the user asked for
110
+ * @param {Document} doc the document of the page
111
+ * @returns {string} the text to send to the agent
112
+ */
113
+ export const pagePrompt = (instruction: string, doc: Document) =>
114
+ [
115
+ "Change the UI of the running app as the instruction says.",
116
+ "The styles are written with cssints (`css.*` calls): edit those calls, not the generated class names.",
117
+ "Find the markup from the route (Page) and the app (App).",
118
+ "",
119
+ `Instruction: ${cut(instruction.trim(), INSTRUCTION)}`,
120
+ "",
121
+ ...header(doc),
122
+ ].join("\n");
123
+
35
124
  /**
36
125
  * The prompt for an element: the instruction first, then what cssints knows of the element.
37
126
  * @param {Inspection} inspection the cascade of the element (`inspect`)
@@ -44,15 +133,20 @@ export const buildPrompt = (inspection: Inspection, el: Element, instruction: st
44
133
  const sites = inspection.groups.flatMap((g) => (g.site ? [g.site] : []));
45
134
  const lines = [
46
135
  "Change the UI of the running app as the instruction says.",
47
- "The styles are written with cssints: each `css.*` call below produced the classes of the element,",
48
- "so edit those calls (or the markup that uses them), not the generated class names.",
136
+ "The styles are written with cssints: each `css.*` call under `Styled by` produced the classes of the element,",
137
+ "so start from those files and edit those calls, not the generated class names.",
138
+ "For the markup, start from the route (Page) and the app (App).",
139
+ "",
140
+ `Instruction: ${cut(instruction.trim(), INSTRUCTION)}`,
49
141
  "",
50
- `Instruction: ${instruction.trim()}`,
142
+ ...header(el.ownerDocument, el),
51
143
  "",
52
144
  `Element: <${inspection.element}>`,
53
145
  `DOM path: ${domPath(el)}`,
54
146
  `Text: ${cut(flat(el.textContent), 120) || "(none)"}`,
55
147
  `Classes: ${[...el.classList].slice(0, 12).join(" ") || "(none)"}`,
148
+ `Attributes: ${attrs(el, (n) => IDENTITY.has(n), IDENTITY.size) || "(none)"}`,
149
+ ...dataLines(el),
56
150
  "",
57
151
  "Styled by (file:line, the call as written):",
58
152
  ];
@@ -19,12 +19,30 @@ export interface Asked {
19
19
  readonly message?: string;
20
20
  }
21
21
 
22
+ /** the browser asks whether an OpenCode service is found (no session is created); the answer is a LINKED */
23
+ export const LINK = "cssints:devtools:link";
24
+ export const LINKED = "cssints:devtools:linked";
25
+
26
+ /** `message` is set when the client is missing or the lookup failed, not when OpenCode simply is not running. */
27
+ export interface Linked {
28
+ readonly connected: boolean;
29
+ /** the address of the service */
30
+ readonly url?: string;
31
+ /** the folder the sessions open in */
32
+ readonly directory: string;
33
+ /** the session `/cssints-devtools` attached to this dev server: prompts go there, none starts a new one */
34
+ readonly session?: string;
35
+ readonly message?: string;
36
+ }
37
+
22
38
  export interface TransportApi {
23
39
  /** asks the server for the table (`cssints:devtools:get`); it answers with TABLE */
24
40
  readonly request: () => void;
25
41
  readonly on: (event: string, listener: (data: unknown) => void) => void;
26
42
  /** only when the server has `opencode` on: sends a prompt, the answers come as ASKED; the overlay shows its prompt box if it is set */
27
43
  readonly ask?: (ref: number, prompt: string) => void;
44
+ /** next to `ask`: probes for the service, the answer comes as LINKED */
45
+ readonly link?: () => void;
28
46
  }
29
47
 
30
48
  export class Transport extends Context.Service<Transport, TransportApi>()(