@patrizzos/stylespeak 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stylespeak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # StyleSpeak
2
+
3
+ A companion MCP server and CLI to [stylesafe](https://www.npmjs.com/package/stylesafe) that makes CSS legible to AI agents — resolving cascade, tracing properties, and explaining what applies and why before an agent touches a single line of styles.
4
+
5
+ ## The problem
6
+
7
+ AI coding agents write CSS without being able to see its effect. They modify a rule, assume it worked, and move on — unaware that a higher-specificity rule elsewhere already overrides it, or that a combinator rule in another file is silently winning. stylespeak gives agents a structured knowledge layer to consult *before* making changes.
8
+
9
+ ## What it does
10
+
11
+ **`resolve_styles`** — answers "what CSS actually applies to this selector?"
12
+
13
+ Given a selector and a set of files, returns every CSS property the selector would receive, which rule wins for each, and which rules were overridden — with confidence levels since no real DOM is available.
14
+
15
+ **`trace_property`** — answers "everywhere this property is set, who wins?"
16
+
17
+ Given a property name and a set of files, returns every rule that sets it, groups competing rules that target overlapping selectors, and shows the full cascade chain for each group. Use this to understand the blast radius of a change before making it.
18
+
19
+ ## Quick start
20
+
21
+ ### As a CLI tool
22
+
23
+ ```bash
24
+ npm install -g stylespeak
25
+ ```
26
+
27
+ Resolve what applies to a selector:
28
+ ```bash
29
+ stylespeak resolve ".btn.primary" src/styles/main.css
30
+ stylespeak resolve "#header a" src/styles/base.css src/styles/header.css
31
+ stylespeak resolve ".card-title" --projectRoot src/styles
32
+ ```
33
+
34
+ Trace a property across files:
35
+ ```bash
36
+ stylespeak trace "color" src/styles/main.css
37
+ stylespeak trace "background-color" --projectRoot src/styles
38
+ ```
39
+
40
+ ### As an MCP server
41
+
42
+ Add to your MCP client config (Cursor: `.cursor/mcp.json`, VS Code: `.vscode/mcp.json`):
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "stylespeak": {
48
+ "command": "node",
49
+ "args": ["/absolute/path/to/stylespeak/src/server.js"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Once connected, agents can call:
56
+ - `resolve_styles({ selector, files, projectRoot })`
57
+ - `trace_property({ property, files, projectRoot })`
58
+
59
+ ## Example output
60
+
61
+ ```bash
62
+ stylespeak resolve ".btn.primary" src/styles/buttons.css
63
+ ```
64
+
65
+ ```json
66
+ {
67
+ "query": { "selector": ".btn.primary", "filesAnalyzed": ["buttons.css"] },
68
+ "properties": {
69
+ "background-color": {
70
+ "winner": {
71
+ "value": "darkblue",
72
+ "selector": ".btn.primary",
73
+ "specificity": "(0,0,2,0)",
74
+ "source": { "file": "buttons.css", "line": 9 }
75
+ },
76
+ "overridden": [
77
+ {
78
+ "value": "blue",
79
+ "selector": ".btn",
80
+ "specificity": "(0,0,1,0)",
81
+ "reason": "lower specificity"
82
+ }
83
+ ],
84
+ "confidence": "certain"
85
+ }
86
+ },
87
+ "summary": "Found 6 properties applying to \".btn.primary\" from 5 matched rule(s).",
88
+ "agentNote": "1 property is certain. 2 properties are likely. 3 properties are possible (combinator rules — depends on DOM context). 3 properties have overridden rules — review before modifying."
89
+ }
90
+ ```
91
+
92
+ ## Confidence levels
93
+
94
+ Since stylespeak performs static analysis without a real DOM, every resolved property carries a confidence level:
95
+
96
+ | Level | Meaning |
97
+ |---|---|
98
+ | `certain` | Exact selector match — rule definitively applies |
99
+ | `likely` | Rule tokens are a subset of the queried selector — applies in most cases |
100
+ | `possible` | Combinator rule (e.g. `.sidebar .btn`) — depends on DOM ancestry, unknown without rendering |
101
+
102
+ An agent should treat `certain` and `likely` results as ground truth, and `possible` results as conditional — they may apply depending on where the element lives in the DOM.
103
+
104
+ ## Use cases
105
+
106
+ - **Before editing styles** — call `resolve_styles` to understand the full cascade context first
107
+ - **Before changing a property** — call `trace_property` to see the blast radius across all files
108
+ - **Debugging "why isn't my CSS working"** — trace the property to find the higher-specificity rule that's winning
109
+ - **Style audits** — run across a project directory to map what's actually applying where
110
+
111
+ ## CLI options
112
+
113
+ Both commands accept:
114
+ - One or more file paths as positional arguments
115
+ - `--projectRoot <dir>` to analyze all CSS/SCSS files in a directory recursively
116
+
117
+ ```bash
118
+ stylespeak resolve ".btn" src/main.css src/components.css
119
+ stylespeak trace "padding" --projectRoot src/styles
120
+ ```
121
+
122
+ ## How it pairs with stylesafe
123
+
124
+ **stylesafe** catches problems in your CSS — conflicts, dead rules, Tailwind clashes — before they ship.
125
+
126
+ **stylespeak** explains your CSS — resolving cascade, tracing properties, mapping what applies and why — so agents understand before they act.
127
+
128
+ Use stylesafe as a post-edit check. Use stylespeak as a pre-edit consultation. Together they give AI coding agents a complete feedback loop on styles.
129
+
130
+ ## GitHub Actions
131
+
132
+ ```yaml
133
+ name: style check
134
+
135
+ on: [pull_request]
136
+
137
+ jobs:
138
+ stylespeak:
139
+ runs-on: ubuntu-latest
140
+ steps:
141
+ - uses: actions/checkout@v4
142
+ - uses: actions/setup-node@v4
143
+ with:
144
+ node-version: 20
145
+ - run: npm install -g stylespeak
146
+ - run: stylespeak trace "color" --projectRoot src/styles
147
+ ```
148
+
149
+ ## Architecture
150
+
151
+ ```
152
+ src/
153
+ cssParser.js — dependency-free CSS tokenizer with line number tracking
154
+ specificity.js — standard (id, class, type) specificity calculator
155
+ cssomBuilder.js — builds in-memory cascade model from multiple files
156
+ selectorMatcher.js — heuristic selector matching with confidence levels
157
+ resolveStyles.js — resolve_styles tool implementation
158
+ traceProperty.js — trace_property tool implementation
159
+ server.js — MCP server (stdio JSON-RPC) + CLI entry point
160
+ ```
161
+
162
+ Zero external dependencies. Requires Node.js 18+.
163
+
164
+ ## Roadmap
165
+
166
+ - **v0.2** — CSS custom property (variable) resolution, CSS Modules scope awareness
167
+ - **v0.3** — AST component graph analysis for more accurate selector matching without a DOM
168
+ - **v1.0** — Chrome DevTools Protocol integration for live cascade resolution against a running browser
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@patrizzos/stylespeak",
3
+ "mcpName": "io.github.Patrizzos/stylespeak",
4
+ "version": "0.1.0",
5
+ "description": "MCP server and CLI that makes CSS legible to AI agents — resolves cascade, traces properties, explains what applies and why.",
6
+ "main": "src/server.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Patrizzos/stylespeak.git"
10
+ },
11
+ "bin": {
12
+ "stylespeak": "src/server.js"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "start": "node src/server.js",
21
+ "test": "node test/run.js"
22
+ },
23
+ "keywords": ["mcp", "css", "tailwind", "cascade", "agent", "ai", "devtools"],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "license": "MIT"
28
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * CSS Parser for stylescope.
3
+ * Improved over stylesafe's version with:
4
+ * - Line number tracking for every rule (for source location in output)
5
+ * - At-rule context preservation (@media, @layer, @supports)
6
+ * - Returns { rules } where each rule has { selectors, declarations, source: { file, line }, mediaContext }
7
+ */
8
+
9
+ function stripComments(css) {
10
+ return css.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\n]/g, ' '));
11
+ }
12
+
13
+ function getLineNumber(text, index) {
14
+ return text.slice(0, index).split('\n').length;
15
+ }
16
+
17
+ function parseDeclarations(block) {
18
+ const decls = [];
19
+ const parts = block.split(';');
20
+ for (const part of parts) {
21
+ const trimmed = part.trim();
22
+ if (!trimmed) continue;
23
+ const colonIdx = trimmed.indexOf(':');
24
+ if (colonIdx === -1) continue;
25
+ const prop = trimmed.slice(0, colonIdx).trim().toLowerCase();
26
+ let value = trimmed.slice(colonIdx + 1).trim();
27
+ if (!prop) continue;
28
+ const important = /!important\s*$/i.test(value);
29
+ if (important) value = value.replace(/!important\s*$/i, '').trim();
30
+ decls.push({ prop, value, important });
31
+ }
32
+ return decls;
33
+ }
34
+
35
+ function findMatchingBrace(css, openIdx) {
36
+ let depth = 1;
37
+ let i = openIdx + 1;
38
+ while (i < css.length && depth > 0) {
39
+ if (css[i] === '{') depth++;
40
+ else if (css[i] === '}') depth--;
41
+ i++;
42
+ }
43
+ return i - 1;
44
+ }
45
+
46
+ function parseCSS(cssText, filename = '<input>') {
47
+ const css = stripComments(cssText);
48
+ const rules = [];
49
+ let i = 0;
50
+ const atStack = [];
51
+
52
+ while (i < css.length) {
53
+ while (i < css.length && /\s/.test(css[i])) i++;
54
+ if (i >= css.length) break;
55
+
56
+ if (css[i] === '@') {
57
+ const braceIdx = css.indexOf('{', i);
58
+ const semiIdx = css.indexOf(';', i);
59
+ if (braceIdx === -1 || (semiIdx !== -1 && semiIdx < braceIdx)) {
60
+ i = semiIdx === -1 ? css.length : semiIdx + 1;
61
+ continue;
62
+ }
63
+ const atHeader = css.slice(i, braceIdx).trim();
64
+ atStack.push(atHeader);
65
+ i = braceIdx + 1;
66
+ continue;
67
+ }
68
+
69
+ if (css[i] === '}') {
70
+ if (atStack.length > 0) atStack.pop();
71
+ i++;
72
+ continue;
73
+ }
74
+
75
+ const braceIdx = css.indexOf('{', i);
76
+ if (braceIdx === -1) break;
77
+
78
+ const selectorText = css.slice(i, braceIdx).trim();
79
+ if (!selectorText) { i = braceIdx + 1; continue; }
80
+
81
+ const closeIdx = findMatchingBrace(css, braceIdx);
82
+ const declBlock = css.slice(braceIdx + 1, closeIdx);
83
+ const declarations = parseDeclarations(declBlock);
84
+ const selectors = selectorText.split(',').map(s => s.trim()).filter(Boolean);
85
+ const line = getLineNumber(css, i);
86
+
87
+ rules.push({
88
+ selectors,
89
+ declarations,
90
+ mediaContext: atStack.length > 0 ? atStack.join(' > ') : null,
91
+ source: { file: filename, line },
92
+ });
93
+
94
+ i = closeIdx + 1;
95
+ }
96
+
97
+ return rules;
98
+ }
99
+
100
+ module.exports = { parseCSS };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * CSSOM Builder — reads one or more CSS files and builds an in-memory cascade model.
3
+ *
4
+ * The model is a flat ordered list of enriched rules, each carrying:
5
+ * - selectors (array)
6
+ * - declarations (array of { prop, value, important })
7
+ * - source { file, line }
8
+ * - mediaContext (string | null)
9
+ * - specificity per selector (computed on build)
10
+ * - sourceOrder (global integer, cross-file, determines cascade tiebreaking)
11
+ *
12
+ * This is the shared foundation both resolveStyles and traceProperty query against.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { parseCSS } = require('./cssParser');
18
+ const { calculateSpecificity, compareSpecificity, specificityToString } = require('./specificity');
19
+
20
+ const STYLE_EXTENSIONS = new Set(['.css', '.scss']);
21
+
22
+ function buildCSSOM(filePaths) {
23
+ const flatRules = [];
24
+ let sourceOrder = 0;
25
+
26
+ for (const filePath of filePaths) {
27
+ if (!fs.existsSync(filePath)) continue;
28
+ const ext = path.extname(filePath).toLowerCase();
29
+ if (!STYLE_EXTENSIONS.has(ext)) continue;
30
+
31
+ const cssText = fs.readFileSync(filePath, 'utf8');
32
+ const rules = parseCSS(cssText, filePath);
33
+
34
+ for (const rule of rules) {
35
+ for (const selector of rule.selectors) {
36
+ flatRules.push({
37
+ selector: selector.trim(),
38
+ declarations: rule.declarations,
39
+ source: rule.source,
40
+ mediaContext: rule.mediaContext,
41
+ specificity: calculateSpecificity(selector),
42
+ sourceOrder: sourceOrder++,
43
+ });
44
+ }
45
+ }
46
+ }
47
+
48
+ return { rules: flatRules, fileCount: filePaths.length };
49
+ }
50
+
51
+ /**
52
+ * Discovers all CSS/SCSS files under a directory recursively.
53
+ */
54
+ function discoverStyleFiles(dir) {
55
+ const results = [];
56
+ if (!fs.existsSync(dir)) return results;
57
+
58
+ function walk(current) {
59
+ const entries = fs.readdirSync(current, { withFileTypes: true });
60
+ for (const entry of entries) {
61
+ const fullPath = path.join(current, entry.name);
62
+ if (entry.isDirectory()) {
63
+ if (!['node_modules', '.git', 'dist', 'build', '.next', 'out'].includes(entry.name)) {
64
+ walk(fullPath);
65
+ }
66
+ } else if (STYLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
67
+ results.push(fullPath);
68
+ }
69
+ }
70
+ }
71
+
72
+ walk(dir);
73
+ return results;
74
+ }
75
+
76
+ /**
77
+ * Resolves which value wins for a given property across a set of rules
78
+ * that all potentially apply to an element (pre-filtered to matching rules).
79
+ * Returns { winner, overridden[] }.
80
+ */
81
+ function resolveCascade(matchingRules, prop) {
82
+ const relevant = matchingRules.filter(r =>
83
+ r.declarations.some(d => d.prop === prop)
84
+ );
85
+
86
+ if (relevant.length === 0) return null;
87
+
88
+ // Sort by cascade order: !important first, then specificity, then source order
89
+ const withDecl = relevant.map(rule => ({
90
+ rule,
91
+ decl: rule.declarations.find(d => d.prop === prop),
92
+ }));
93
+
94
+ const CONFIDENCE_RANK = { certain: 0, likely: 1, possible: 2 };
95
+
96
+ withDecl.sort((a, b) => {
97
+ // !important always wins
98
+ if (a.decl.important && !b.decl.important) return -1;
99
+ if (!a.decl.important && b.decl.important) return 1;
100
+ // confidence first — certain/likely beats possible (possible rules need DOM context)
101
+ const confA = CONFIDENCE_RANK[a.rule.matchConfidence] ?? 1;
102
+ const confB = CONFIDENCE_RANK[b.rule.matchConfidence] ?? 1;
103
+ if (confA !== confB) return confA - confB;
104
+ // then specificity
105
+ const cmp = compareSpecificity(b.rule.specificity, a.rule.specificity);
106
+ if (cmp !== 0) return cmp;
107
+ // then source order (later wins)
108
+ return b.rule.sourceOrder - a.rule.sourceOrder;
109
+ });
110
+
111
+ const [winning, ...losing] = withDecl;
112
+
113
+ return {
114
+ winner: {
115
+ value: winning.decl.value,
116
+ important: winning.decl.important,
117
+ selector: winning.rule.selector,
118
+ specificity: specificityToString(winning.rule.specificity),
119
+ source: winning.rule.source,
120
+ mediaContext: winning.rule.mediaContext,
121
+ },
122
+ overridden: losing.map(({ rule, decl }) => ({
123
+ value: decl.value,
124
+ important: decl.important,
125
+ selector: rule.selector,
126
+ specificity: specificityToString(rule.specificity),
127
+ source: rule.source,
128
+ mediaContext: rule.mediaContext,
129
+ reason: losing.length > 0
130
+ ? decl.important && !winning.decl.important ? 'overridden by !important'
131
+ : compareSpecificity(rule.specificity, winning.rule.specificity) < 0 ? 'lower specificity'
132
+ : 'earlier in source order'
133
+ : 'lower specificity',
134
+ })),
135
+ };
136
+ }
137
+
138
+ module.exports = { buildCSSOM, discoverStyleFiles, resolveCascade, specificityToString };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * resolve_styles — answers "what CSS actually applies to this selector?"
3
+ *
4
+ * Given a selector string and a set of files, returns a structured map of every
5
+ * CSS property that could apply, who wins, and who got overridden — with confidence
6
+ * levels so agents know how much to trust each answer.
7
+ */
8
+
9
+ const path = require('path');
10
+ const { buildCSSOM, discoverStyleFiles, resolveCascade } = require('./cssomBuilder');
11
+ const { matchSelector } = require('./selectorMatcher');
12
+
13
+ function resolveStyles({ selector, files = [], projectRoot = null }) {
14
+ if (!selector) return { error: 'selector is required' };
15
+
16
+ // Resolve file list
17
+ let filePaths = [...files];
18
+ if (projectRoot) filePaths.push(...discoverStyleFiles(projectRoot));
19
+ if (filePaths.length === 0) return { error: 'No CSS files found. Provide files[] or projectRoot.' };
20
+
21
+ const cssom = buildCSSOM(filePaths);
22
+
23
+ // Find all rules that could apply to this selector, tagged with match confidence
24
+ const matchingRules = [];
25
+ for (const rule of cssom.rules) {
26
+ const confidence = matchSelector(rule.selector, selector);
27
+ if (confidence !== 'none') {
28
+ matchingRules.push({ ...rule, matchConfidence: confidence });
29
+ }
30
+ }
31
+
32
+ if (matchingRules.length === 0) {
33
+ return {
34
+ query: { selector, filesAnalyzed: filePaths.map(f => path.basename(f)) },
35
+ properties: {},
36
+ matchedRules: 0,
37
+ summary: 'No rules found matching "' + selector + '" across ' + filePaths.length + ' file(s).',
38
+ agentNote: 'Either the selector does not exist yet, the files provided do not contain it, or the selector uses a pattern the static analyzer cannot match. Try broadening the file list or checking the selector spelling.',
39
+ };
40
+ }
41
+
42
+ // Collect all unique properties across matching rules
43
+ const allProps = new Set();
44
+ for (const rule of matchingRules) {
45
+ for (const decl of rule.declarations) allProps.add(decl.prop);
46
+ }
47
+
48
+ // Resolve each property through the cascade
49
+ const properties = {};
50
+ let overrideCount = 0;
51
+
52
+ for (const prop of allProps) {
53
+ const resolution = resolveCascade(matchingRules, prop);
54
+ if (!resolution) continue;
55
+
56
+ // Confidence for the property = lowest confidence among rules involved
57
+ const involvedRules = matchingRules.filter(r => r.declarations.some(d => d.prop === prop));
58
+ const lowestConfidence = involvedRules.some(r => r.matchConfidence === 'possible') ? 'possible'
59
+ : involvedRules.some(r => r.matchConfidence === 'likely') ? 'likely' : 'certain';
60
+
61
+ if (resolution.overridden.length > 0) overrideCount++;
62
+
63
+ properties[prop] = {
64
+ winner: resolution.winner,
65
+ overridden: resolution.overridden,
66
+ confidence: lowestConfidence,
67
+ };
68
+ }
69
+
70
+ // Group by confidence for the agent note
71
+ const certain = Object.entries(properties).filter(([, v]) => v.confidence === 'certain').map(([k]) => k);
72
+ const likely = Object.entries(properties).filter(([, v]) => v.confidence === 'likely').map(([k]) => k);
73
+ const possible = Object.entries(properties).filter(([, v]) => v.confidence === 'possible').map(([k]) => k);
74
+ const mediaWinners = Object.values(properties).filter(p => p.winner.mediaContext).length;
75
+
76
+ const agentNoteParts = [];
77
+ if (certain.length) agentNoteParts.push(certain.length + ' propert' + (certain.length === 1 ? 'y is' : 'ies are') + ' certain (exact selector match).');
78
+ if (likely.length) agentNoteParts.push(likely.length + ' propert' + (likely.length === 1 ? 'y is' : 'ies are') + ' likely (rule applies to this selector based on token overlap).');
79
+ if (possible.length) agentNoteParts.push(possible.length + ' propert' + (possible.length === 1 ? 'y is' : 'ies are') + ' possible (via ancestor/combinator rule - depends on DOM context).');
80
+ if (overrideCount) agentNoteParts.push(overrideCount + ' propert' + (overrideCount === 1 ? 'y has' : 'ies have') + ' overridden rules - review before modifying.');
81
+ if (mediaWinners > 0) agentNoteParts.push(mediaWinners + ' propert' + (mediaWinners === 1 ? "y's" : "ies'") + ' winning value comes from a @media rule - only applies at that breakpoint.');
82
+
83
+ return {
84
+ query: {
85
+ selector,
86
+ filesAnalyzed: filePaths.map(f => path.basename(f)),
87
+ rulesScanned: cssom.rules.length,
88
+ },
89
+ properties,
90
+ matchedRules: matchingRules.length,
91
+ summary: 'Found ' + Object.keys(properties).length + ' properties applying to "' + selector + '" from ' + matchingRules.length + ' matched rule(s) across ' + filePaths.length + ' file(s).',
92
+ agentNote: agentNoteParts.join(' '),
93
+ };
94
+ }
95
+
96
+ module.exports = { resolveStyles };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Selector matcher — determines whether a CSS rule's selector could apply
3
+ * to a queried selector string, without a real DOM.
4
+ *
5
+ * Three match levels, each returned with a confidence score:
6
+ *
7
+ * 'certain' — selectors are identical (exact match)
8
+ * 'likely' — the rule selector's rightmost compound is a subset of the query
9
+ * (e.g. rule ".btn" matches query ".btn.primary" — .btn would apply)
10
+ * 'possible' — the rule uses a combinator that could apply to an ancestor/sibling
11
+ * of the queried element (e.g. ".sidebar .btn" when querying ".btn")
12
+ *
13
+ * 'none' means no meaningful relationship — skip.
14
+ */
15
+
16
+ function parseCompound(selector) {
17
+ // Split on combinators, return rightmost compound selector's tokens
18
+ const parts = selector.trim().split(/(?<=[^\s>+~])\s*[\s>+~]+\s*/);
19
+ const rightmost = parts[parts.length - 1].trim();
20
+ const tokens = new Set();
21
+
22
+ // IDs
23
+ for (const m of rightmost.matchAll(/#([\w-]+)/g)) tokens.add('#' + m[1]);
24
+ // Classes
25
+ for (const m of rightmost.matchAll(/\.([\w-]+)/g)) tokens.add('.' + m[1]);
26
+ // Tag
27
+ const tagMatch = rightmost.match(/^([a-zA-Z][\w-]*)/);
28
+ if (tagMatch && tagMatch[1] !== '*') tokens.add(tagMatch[1].toLowerCase());
29
+ // Attrs
30
+ for (const m of rightmost.matchAll(/\[([^\]]+)\]/g)) tokens.add('[' + m[1] + ']');
31
+
32
+ return { tokens, hasCombinator: parts.length > 1, full: selector.trim() };
33
+ }
34
+
35
+ function matchSelector(ruleSelector, querySelector) {
36
+ const rule = parseCompound(ruleSelector);
37
+ const query = parseCompound(querySelector);
38
+
39
+ // Exact match
40
+ if (rule.full === query.full) return 'certain';
41
+
42
+ // Combinator check must come before subset check — a rule like ".sidebar .btn"
43
+ // has rightmost compound ".btn" which would falsely match as 'likely' against
44
+ // a query of ".btn" if we checked subsets first. Combinator rules always require
45
+ // DOM context so the best they can be is 'possible'.
46
+ if (rule.hasCombinator) {
47
+ for (const t of rule.tokens) {
48
+ if (query.tokens.has(t)) return 'possible';
49
+ }
50
+ return 'none';
51
+ }
52
+
53
+ // No combinator — rule tokens are a subset of query tokens → likely
54
+ // e.g. rule ".btn" vs query ".btn.primary" → .btn would apply to .btn.primary
55
+ if (rule.tokens.size > 0) {
56
+ let allMatch = true;
57
+ for (const t of rule.tokens) {
58
+ if (!query.tokens.has(t)) { allMatch = false; break; }
59
+ }
60
+ if (allMatch) return 'likely';
61
+ }
62
+
63
+ return 'none';
64
+ }
65
+
66
+ module.exports = { matchSelector, parseCompound };
package/src/server.js ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stylespeak — MCP server + CLI
4
+ *
5
+ * MCP mode: reads JSON-RPC from stdin, exposes two tools:
6
+ * resolve_styles — what CSS applies to a selector, who wins, who loses
7
+ * trace_property — every rule setting a property, competing groups, blast radius
8
+ *
9
+ * CLI mode: node server.js resolve .btn src/styles/main.css
10
+ * node server.js trace color --projectRoot src
11
+ */
12
+
13
+ const readline = require('readline');
14
+ const { resolveStyles } = require('./resolveStyles');
15
+ const { traceProperty } = require('./traceProperty');
16
+
17
+ // ─── Tool definitions ────────────────────────────────────────────────────────
18
+
19
+ const TOOLS = [
20
+ {
21
+ name: 'resolve_styles',
22
+ description:
23
+ 'Resolves what CSS actually applies to a given selector. Returns every property ' +
24
+ 'the selector would receive, which rule wins for each property, and which rules ' +
25
+ 'were overridden — with confidence levels (certain/likely/possible) since no real ' +
26
+ 'DOM is available. Call this before modifying styles for an element to understand ' +
27
+ 'the full cascade context first.',
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {
31
+ selector: {
32
+ type: 'string',
33
+ description: 'CSS selector to resolve, e.g. ".btn.primary", "#header a:hover"',
34
+ },
35
+ files: {
36
+ type: 'array',
37
+ items: { type: 'string' },
38
+ description: 'Absolute paths to CSS/SCSS files to analyze.',
39
+ },
40
+ projectRoot: {
41
+ type: 'string',
42
+ description: 'Optional: path to a project root. All CSS/SCSS files will be discovered recursively.',
43
+ },
44
+ },
45
+ required: ['selector'],
46
+ },
47
+ },
48
+ {
49
+ name: 'trace_property',
50
+ description:
51
+ 'Traces a CSS property across all provided files — finding every rule that sets it, ' +
52
+ 'grouping competing rules that target overlapping selectors, and showing who wins in ' +
53
+ 'each group. Use this to understand the blast radius of a property before changing it, ' +
54
+ 'or to find out why a property value isn\'t applying as expected.',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ property: {
59
+ type: 'string',
60
+ description: 'CSS property name to trace, e.g. "color", "background-color", "padding"',
61
+ },
62
+ files: {
63
+ type: 'array',
64
+ items: { type: 'string' },
65
+ description: 'Absolute paths to CSS/SCSS files to analyze.',
66
+ },
67
+ projectRoot: {
68
+ type: 'string',
69
+ description: 'Optional: path to a project root. All CSS/SCSS files will be discovered recursively.',
70
+ },
71
+ },
72
+ required: ['property'],
73
+ },
74
+ },
75
+ ];
76
+
77
+ // ─── MCP server ──────────────────────────────────────────────────────────────
78
+
79
+ function send(message) {
80
+ process.stdout.write(JSON.stringify(message) + '\n');
81
+ }
82
+
83
+ function runTool(name, args) {
84
+ if (name === 'resolve_styles') return resolveStyles(args || {});
85
+ if (name === 'trace_property') return traceProperty(args || {});
86
+ throw new Error(`Unknown tool: ${name}`);
87
+ }
88
+
89
+ function handleRequest(req) {
90
+ const { id, method, params } = req;
91
+
92
+ if (method === 'initialize') {
93
+ return send({
94
+ jsonrpc: '2.0', id,
95
+ result: {
96
+ protocolVersion: '2024-11-05',
97
+ capabilities: { tools: {} },
98
+ serverInfo: { name: 'stylespeak', version: '0.1.0' },
99
+ },
100
+ });
101
+ }
102
+
103
+ if (method === 'notifications/initialized') return;
104
+
105
+ if (method === 'tools/list') {
106
+ return send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
107
+ }
108
+
109
+ if (method === 'tools/call') {
110
+ const { name, arguments: args } = params || {};
111
+ try {
112
+ const result = runTool(name, args);
113
+ return send({
114
+ jsonrpc: '2.0', id,
115
+ result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] },
116
+ });
117
+ } catch (err) {
118
+ return send({
119
+ jsonrpc: '2.0', id,
120
+ result: { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true },
121
+ });
122
+ }
123
+ }
124
+
125
+ if (id !== undefined) {
126
+ send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
127
+ }
128
+ }
129
+
130
+ // ─── CLI mode ────────────────────────────────────────────────────────────────
131
+
132
+ function runCLI(args) {
133
+ const command = args[0];
134
+
135
+ if (!command || command === '--help' || command === '-h') {
136
+ console.log('stylespeak — CSS knowledge layer for AI agents\n');
137
+ console.log('Usage:');
138
+ console.log(' node server.js resolve <selector> [file...] [--projectRoot <dir>]');
139
+ console.log(' node server.js trace <property> [file...] [--projectRoot <dir>]\n');
140
+ console.log('Examples:');
141
+ console.log(' node server.js resolve ".btn.primary" src/styles/main.css');
142
+ console.log(' node server.js trace "color" --projectRoot src/styles');
143
+ console.log(' node server.js resolve "#header a" src/styles/base.css src/styles/header.css');
144
+ return;
145
+ }
146
+
147
+ const projectRootIdx = args.indexOf('--projectRoot');
148
+ const projectRoot = projectRootIdx !== -1 ? args[projectRootIdx + 1] : null;
149
+ const fileArgs = args.slice(1).filter(a => !a.startsWith('--') && a !== projectRoot);
150
+
151
+ let result;
152
+ if (command === 'resolve') {
153
+ const selector = fileArgs[0];
154
+ const files = fileArgs.slice(1);
155
+ result = resolveStyles({ selector, files, projectRoot });
156
+ } else if (command === 'trace') {
157
+ const property = fileArgs[0];
158
+ const files = fileArgs.slice(1);
159
+ result = traceProperty({ property, files, projectRoot });
160
+ } else {
161
+ console.error(`Unknown command: ${command}. Use resolve or trace.`);
162
+ process.exit(1);
163
+ }
164
+
165
+ console.log(JSON.stringify(result, null, 2));
166
+ }
167
+
168
+ // ─── Entry point ─────────────────────────────────────────────────────────────
169
+
170
+ const cliArgs = process.argv.slice(2);
171
+
172
+ if (cliArgs.length > 0) {
173
+ runCLI(cliArgs);
174
+ } else {
175
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
176
+ rl.on('line', line => {
177
+ const trimmed = line.trim();
178
+ if (!trimmed) return;
179
+ try { handleRequest(JSON.parse(trimmed)); } catch { /* ignore malformed */ }
180
+ });
181
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * CSS Specificity calculator.
3
+ * Returns [inline, id, class, type] as a 4-tuple.
4
+ * Handles :where() (zero specificity), :not()/:is()/:has() (argument specificity).
5
+ */
6
+
7
+ function calculateSpecificity(selector) {
8
+ let sel = selector.trim();
9
+ let id = 0, cls = 0, typ = 0;
10
+
11
+ // pseudo-elements count as type
12
+ sel = sel.replace(/::?(before|after|first-line|first-letter|placeholder|selection|marker|backdrop|cue)\b/gi, () => { typ++; return ''; });
13
+
14
+ // :where() contributes zero
15
+ sel = sel.replace(/:where\([^)]*\)/gi, '');
16
+
17
+ // :not(), :is(), :has() — recurse into args (simplified: treat contents as normal)
18
+ sel = sel.replace(/:(not|is|has)\(([^)]*)\)/gi, (_, _n, inner) => ` ${inner} `);
19
+
20
+ // IDs
21
+ sel = sel.replace(/#[\w-]+/g, () => { id++; return ''; });
22
+
23
+ // classes, attributes, pseudo-classes
24
+ sel = sel.replace(/\.[\w-]+/g, () => { cls++; return ''; });
25
+ sel = sel.replace(/\[[^\]]*\]/g, () => { cls++; return ''; });
26
+ sel = sel.replace(/:[\w-]+(\([^)]*\))?/g, () => { cls++; return ''; });
27
+
28
+ // type selectors
29
+ const typeMatches = sel.match(/(^|[\s>+~])([a-zA-Z][\w-]*)/g);
30
+ if (typeMatches) {
31
+ for (const m of typeMatches) {
32
+ const tag = m.replace(/^[\s>+~]+/, '');
33
+ if (tag && tag !== '*') typ++;
34
+ }
35
+ }
36
+
37
+ return [0, id, cls, typ];
38
+ }
39
+
40
+ function compareSpecificity(a, b) {
41
+ for (let i = 0; i < 4; i++) {
42
+ if (a[i] !== b[i]) return a[i] - b[i];
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ function specificityToString(s) {
48
+ return `(${s[0]},${s[1]},${s[2]},${s[3]})`;
49
+ }
50
+
51
+ module.exports = { calculateSpecificity, compareSpecificity, specificityToString };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * trace_property — answers "everywhere this CSS property is set, who wins?"
3
+ *
4
+ * Given a property name and a set of files, returns every rule that touches
5
+ * that property — grouped by selector, showing who wins and who loses for each
6
+ * competing group. Useful for agents trying to understand the full blast radius
7
+ * of a property before changing it.
8
+ */
9
+
10
+ const path = require('path');
11
+ const { buildCSSOM, discoverStyleFiles, resolveCascade, specificityToString } = require('./cssomBuilder');
12
+ const { matchSelector } = require('./selectorMatcher');
13
+
14
+ function traceProperty({ property, files = [], projectRoot = null }) {
15
+ if (!property) return { error: 'property is required' };
16
+
17
+ let filePaths = [...files];
18
+ if (projectRoot) filePaths.push(...discoverStyleFiles(projectRoot));
19
+ if (filePaths.length === 0) return { error: 'No CSS files found. Provide files[] or projectRoot.' };
20
+
21
+ const cssom = buildCSSOM(filePaths);
22
+
23
+ // Find every rule that sets this property
24
+ const occurrences = cssom.rules.filter(rule =>
25
+ rule.declarations.some(d => d.prop === property.toLowerCase())
26
+ );
27
+
28
+ if (occurrences.length === 0) {
29
+ return {
30
+ query: { property, filesAnalyzed: filePaths.map(f => path.basename(f)) },
31
+ occurrences: [],
32
+ competingGroups: [],
33
+ summary: `"${property}" is not set in any of the ${filePaths.length} analyzed file(s).`,
34
+ agentNote: `The property doesn't exist yet — safe to introduce. Check inherited values or browser defaults if you're seeing unexpected behavior.`,
35
+ };
36
+ }
37
+
38
+ // Build competing groups: selectors that could target overlapping elements.
39
+ // Two rules compete if EITHER direction of matchSelector returns non-'none' —
40
+ // e.g. .btn competes with .btn.primary because .btn applies to .btn.primary elements,
41
+ // even though .btn.primary does not apply to plain .btn elements.
42
+ const groups = [];
43
+ const assigned = new Set();
44
+
45
+ for (let i = 0; i < occurrences.length; i++) {
46
+ if (assigned.has(i)) continue;
47
+ const group = [occurrences[i]];
48
+ assigned.add(i);
49
+
50
+ for (let j = i + 1; j < occurrences.length; j++) {
51
+ if (assigned.has(j)) continue;
52
+ const confAB = matchSelector(occurrences[j].selector, occurrences[i].selector);
53
+ const confBA = matchSelector(occurrences[i].selector, occurrences[j].selector);
54
+ if (confAB !== 'none' || confBA !== 'none') {
55
+ group.push(occurrences[j]);
56
+ assigned.add(j);
57
+ }
58
+ }
59
+
60
+ if (group.length > 1) {
61
+ const resolution = resolveCascade(group, property.toLowerCase());
62
+ groups.push({
63
+ selectors: group.map(r => r.selector),
64
+ winner: resolution ? resolution.winner : null,
65
+ overridden: resolution ? resolution.overridden : [],
66
+ });
67
+ }
68
+ }
69
+
70
+ // Format all occurrences for output
71
+ const formatted = occurrences.map(rule => {
72
+ const decl = rule.declarations.find(d => d.prop === property.toLowerCase());
73
+ return {
74
+ selector: rule.selector,
75
+ value: decl.value,
76
+ important: decl.important,
77
+ specificity: specificityToString(rule.specificity),
78
+ source: rule.source,
79
+ mediaContext: rule.mediaContext || null,
80
+ };
81
+ });
82
+
83
+ const affectedFiles = [...new Set(occurrences.map(r => path.basename(r.source.file)))];
84
+
85
+ return {
86
+ query: {
87
+ property,
88
+ filesAnalyzed: filePaths.map(f => path.basename(f)),
89
+ rulesScanned: cssom.rules.length,
90
+ },
91
+ occurrences: formatted,
92
+ competingGroups: groups,
93
+ summary: `"${property}" is set ${occurrences.length} time(s) across ${affectedFiles.join(', ')}.${groups.length > 0 ? ` ${groups.length} competing group(s) found where rules override each other.` : ''}`,
94
+ agentNote: groups.length > 0
95
+ ? `Changing "${property}" in one rule may have no effect if a competing rule with higher specificity or later source order wins. Review competing groups before editing.`
96
+ : `No competing rules found — changes to "${property}" in these selectors should apply cleanly.`,
97
+ };
98
+ }
99
+
100
+ module.exports = { traceProperty };