whale-igniter 1.2.2 → 1.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/README.md +89 -281
- package/dist/analyzer/insights.js +144 -0
- package/dist/commands/adopt.js +20 -0
- package/dist/commands/changes.js +127 -0
- package/dist/commands/ignite.js +142 -76
- package/dist/commands/insights.js +146 -9
- package/dist/commands/references.js +193 -0
- package/dist/commands/sync.js +8 -0
- package/dist/generators/wikiGenerator.js +8 -304
- package/dist/index.js +33 -2
- package/dist/mcp/server.js +29 -0
- package/dist/scanner/extractors/css.js +95 -0
- package/dist/scanner/extractors/inline.js +131 -0
- package/dist/scanner/extractors/styleBlocks.js +37 -0
- package/dist/scanner/normalizer.js +59 -0
- package/dist/scanner/tailwindScanner.js +39 -0
- package/dist/templates/claude.js +93 -0
- package/dist/templates/components.js +45 -0
- package/dist/templates/conventions.js +45 -0
- package/dist/templates/decisions.js +34 -0
- package/dist/templates/foundations.js +34 -0
- package/dist/templates/project.js +82 -0
- package/dist/utils/aiAvailability.js +25 -0
- package/dist/utils/wizardMapping.js +54 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import postcss from "postcss";
|
|
4
|
+
import { parsePxValue, TRACKED_PROPERTIES } from "../normalizer.js";
|
|
5
|
+
const IGNORE = ["node_modules/**", "dist/**", ".next/**", "build/**", "coverage/**"];
|
|
6
|
+
// Resolve a CSS variable reference against :root declarations in the same file.
|
|
7
|
+
// Follows one level only: var(--x) → value. Does not recurse.
|
|
8
|
+
function buildVarMap(root) {
|
|
9
|
+
const vars = new Map();
|
|
10
|
+
root.walkRules((rule) => {
|
|
11
|
+
if (rule.selector === ":root" || rule.selector === ":root,\n:root") {
|
|
12
|
+
rule.walkDecls((decl) => {
|
|
13
|
+
if (decl.prop.startsWith("--")) {
|
|
14
|
+
vars.set(decl.prop, decl.value);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
return vars;
|
|
20
|
+
}
|
|
21
|
+
function resolveValue(value, vars) {
|
|
22
|
+
const match = value.match(/^var\((--[^),\s]+)\)/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return value;
|
|
25
|
+
const resolved = vars.get(match[1]);
|
|
26
|
+
return resolved && !resolved.startsWith("var(") ? resolved : value;
|
|
27
|
+
}
|
|
28
|
+
async function extractFromFile(filePath, source, lineOffset = 0) {
|
|
29
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
30
|
+
let root;
|
|
31
|
+
try {
|
|
32
|
+
root = postcss.parse(content);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const vars = buildVarMap(root);
|
|
38
|
+
const observations = [];
|
|
39
|
+
root.walkDecls((decl) => {
|
|
40
|
+
const prop = decl.prop.toLowerCase();
|
|
41
|
+
if (!TRACKED_PROPERTIES.has(prop))
|
|
42
|
+
return;
|
|
43
|
+
const rawValue = decl.value;
|
|
44
|
+
const resolvedValue = resolveValue(rawValue, vars);
|
|
45
|
+
observations.push({
|
|
46
|
+
property: prop,
|
|
47
|
+
value: resolvedValue,
|
|
48
|
+
pxValue: parsePxValue(resolvedValue),
|
|
49
|
+
file: filePath,
|
|
50
|
+
line: (decl.source?.start?.line ?? 1) + lineOffset,
|
|
51
|
+
column: decl.source?.start?.column,
|
|
52
|
+
source
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return observations;
|
|
56
|
+
}
|
|
57
|
+
// Walk @media and @supports blocks too — postcss.walkDecls handles them already.
|
|
58
|
+
// Exported separately so styleBlocks extractor can reuse the logic.
|
|
59
|
+
export async function extractFromContent(content, filePath, lineOffset, source) {
|
|
60
|
+
let root;
|
|
61
|
+
try {
|
|
62
|
+
root = postcss.parse(content);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const vars = buildVarMap(root);
|
|
68
|
+
const observations = [];
|
|
69
|
+
root.walkDecls((decl) => {
|
|
70
|
+
const prop = decl.prop.toLowerCase();
|
|
71
|
+
if (!TRACKED_PROPERTIES.has(prop))
|
|
72
|
+
return;
|
|
73
|
+
const rawValue = decl.value;
|
|
74
|
+
const resolvedValue = resolveValue(rawValue, vars);
|
|
75
|
+
observations.push({
|
|
76
|
+
property: prop,
|
|
77
|
+
value: resolvedValue,
|
|
78
|
+
pxValue: parsePxValue(resolvedValue),
|
|
79
|
+
file: filePath,
|
|
80
|
+
line: (decl.source?.start?.line ?? 1) + lineOffset,
|
|
81
|
+
column: decl.source?.start?.column,
|
|
82
|
+
source
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
return observations;
|
|
86
|
+
}
|
|
87
|
+
export async function extractFromCssFiles(root) {
|
|
88
|
+
const files = await glob("**/*.{css,scss,sass,less}", {
|
|
89
|
+
cwd: root,
|
|
90
|
+
ignore: IGNORE,
|
|
91
|
+
absolute: true
|
|
92
|
+
});
|
|
93
|
+
const results = await Promise.all(files.map((f) => extractFromFile(f, "css")));
|
|
94
|
+
return results.flat();
|
|
95
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import { parse as babelParse } from "@babel/parser";
|
|
4
|
+
import * as t from "@babel/types";
|
|
5
|
+
import { parsePxValue, TRACKED_PROPERTIES, camelToCssProperty } from "../normalizer.js";
|
|
6
|
+
const IGNORE = ["node_modules/**", "dist/**", ".next/**", "build/**", "coverage/**"];
|
|
7
|
+
function countNewlines(str, upTo) {
|
|
8
|
+
let count = 1;
|
|
9
|
+
for (let i = 0; i < upTo && i < str.length; i++) {
|
|
10
|
+
if (str[i] === "\n")
|
|
11
|
+
count++;
|
|
12
|
+
}
|
|
13
|
+
return count;
|
|
14
|
+
}
|
|
15
|
+
// Manual AST walk — avoids @babel/traverse callable-default ESM issues
|
|
16
|
+
function walkAst(node, visitor) {
|
|
17
|
+
if (!node || typeof node !== "object")
|
|
18
|
+
return;
|
|
19
|
+
visitor(node);
|
|
20
|
+
for (const key of Object.keys(node)) {
|
|
21
|
+
const child = node[key];
|
|
22
|
+
if (!child || typeof child !== "object")
|
|
23
|
+
continue;
|
|
24
|
+
if (Array.isArray(child)) {
|
|
25
|
+
for (const item of child) {
|
|
26
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
27
|
+
walkAst(item, visitor);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else if ("type" in child) {
|
|
32
|
+
walkAst(child, visitor);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Parse HTML style="..." attributes with a regex
|
|
37
|
+
async function extractFromHtml(filePath) {
|
|
38
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
39
|
+
const observations = [];
|
|
40
|
+
const attrRegex = /style="([^"]+)"/gi;
|
|
41
|
+
let match;
|
|
42
|
+
while ((match = attrRegex.exec(content)) !== null) {
|
|
43
|
+
const line = countNewlines(content, match.index);
|
|
44
|
+
const declarations = match[1].split(";").map((s) => s.trim()).filter(Boolean);
|
|
45
|
+
for (const decl of declarations) {
|
|
46
|
+
const colonIdx = decl.indexOf(":");
|
|
47
|
+
if (colonIdx === -1)
|
|
48
|
+
continue;
|
|
49
|
+
const prop = decl.slice(0, colonIdx).trim().toLowerCase();
|
|
50
|
+
const value = decl.slice(colonIdx + 1).trim();
|
|
51
|
+
if (!TRACKED_PROPERTIES.has(prop))
|
|
52
|
+
continue;
|
|
53
|
+
observations.push({
|
|
54
|
+
property: prop,
|
|
55
|
+
value,
|
|
56
|
+
pxValue: parsePxValue(value),
|
|
57
|
+
file: filePath,
|
|
58
|
+
line,
|
|
59
|
+
source: "inline"
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return observations;
|
|
64
|
+
}
|
|
65
|
+
// Parse JSX style={{ }} using manual AST walk
|
|
66
|
+
async function extractFromJsx(filePath) {
|
|
67
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
68
|
+
let ast;
|
|
69
|
+
try {
|
|
70
|
+
ast = babelParse(content, {
|
|
71
|
+
sourceType: "module",
|
|
72
|
+
plugins: ["typescript", "jsx"],
|
|
73
|
+
errorRecovery: true
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const observations = [];
|
|
80
|
+
walkAst(ast, (node) => {
|
|
81
|
+
if (!t.isJSXAttribute(node))
|
|
82
|
+
return;
|
|
83
|
+
if (!t.isJSXIdentifier(node.name, { name: "style" }))
|
|
84
|
+
return;
|
|
85
|
+
if (!t.isJSXExpressionContainer(node.value))
|
|
86
|
+
return;
|
|
87
|
+
const expr = node.value.expression;
|
|
88
|
+
if (!t.isObjectExpression(expr))
|
|
89
|
+
return;
|
|
90
|
+
for (const prop of expr.properties) {
|
|
91
|
+
if (!t.isObjectProperty(prop))
|
|
92
|
+
continue;
|
|
93
|
+
if (!t.isIdentifier(prop.key) && !t.isStringLiteral(prop.key))
|
|
94
|
+
continue;
|
|
95
|
+
const keyName = t.isIdentifier(prop.key) ? prop.key.name : prop.key.value;
|
|
96
|
+
const cssProp = camelToCssProperty(keyName);
|
|
97
|
+
if (!cssProp)
|
|
98
|
+
continue;
|
|
99
|
+
let value;
|
|
100
|
+
if (t.isStringLiteral(prop.value)) {
|
|
101
|
+
value = prop.value.value;
|
|
102
|
+
}
|
|
103
|
+
else if (t.isNumericLiteral(prop.value)) {
|
|
104
|
+
value = `${prop.value.value}px`;
|
|
105
|
+
}
|
|
106
|
+
if (!value)
|
|
107
|
+
continue;
|
|
108
|
+
const line = prop.loc?.start.line ?? 0;
|
|
109
|
+
observations.push({
|
|
110
|
+
property: cssProp,
|
|
111
|
+
value,
|
|
112
|
+
pxValue: parsePxValue(value),
|
|
113
|
+
file: filePath,
|
|
114
|
+
line,
|
|
115
|
+
source: "inline"
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return observations;
|
|
120
|
+
}
|
|
121
|
+
export async function extractFromInlineStyles(root) {
|
|
122
|
+
const [htmlFiles, jsxFiles] = await Promise.all([
|
|
123
|
+
glob("**/*.{html,htm}", { cwd: root, ignore: IGNORE, absolute: true }),
|
|
124
|
+
glob("**/*.{tsx,jsx}", { cwd: root, ignore: IGNORE, absolute: true })
|
|
125
|
+
]);
|
|
126
|
+
const results = await Promise.all([
|
|
127
|
+
...htmlFiles.map(extractFromHtml),
|
|
128
|
+
...jsxFiles.map(extractFromJsx)
|
|
129
|
+
]);
|
|
130
|
+
return results.flat();
|
|
131
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import { extractFromContent } from "./css.js";
|
|
4
|
+
const IGNORE = ["node_modules/**", "dist/**", ".next/**", "build/**", "coverage/**"];
|
|
5
|
+
const STYLE_BLOCK_REGEX = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
6
|
+
function linesBefore(content, index) {
|
|
7
|
+
let count = 0;
|
|
8
|
+
for (let i = 0; i < index; i++) {
|
|
9
|
+
if (content[i] === "\n")
|
|
10
|
+
count++;
|
|
11
|
+
}
|
|
12
|
+
return count;
|
|
13
|
+
}
|
|
14
|
+
async function extractFromFile(filePath) {
|
|
15
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
16
|
+
const observations = [];
|
|
17
|
+
let match;
|
|
18
|
+
STYLE_BLOCK_REGEX.lastIndex = 0;
|
|
19
|
+
while ((match = STYLE_BLOCK_REGEX.exec(content)) !== null) {
|
|
20
|
+
const blockStart = match.index;
|
|
21
|
+
const cssContent = match[1];
|
|
22
|
+
// +1 because the opening <style> tag is on that line, CSS starts on next
|
|
23
|
+
const lineOffset = linesBefore(content, blockStart) + 1;
|
|
24
|
+
const blockObs = await extractFromContent(cssContent, filePath, lineOffset, "style-block");
|
|
25
|
+
observations.push(...blockObs);
|
|
26
|
+
}
|
|
27
|
+
return observations;
|
|
28
|
+
}
|
|
29
|
+
export async function extractFromStyleBlocks(root) {
|
|
30
|
+
const files = await glob("**/*.{html,vue,svelte,astro}", {
|
|
31
|
+
cwd: root,
|
|
32
|
+
ignore: IGNORE,
|
|
33
|
+
absolute: true
|
|
34
|
+
});
|
|
35
|
+
const results = await Promise.all(files.map(extractFromFile));
|
|
36
|
+
return results.flat();
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const TRACKED_PROPERTIES = new Set([
|
|
2
|
+
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
|
3
|
+
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
|
4
|
+
"gap", "column-gap", "row-gap",
|
|
5
|
+
"color", "background-color", "border-color",
|
|
6
|
+
"border-radius",
|
|
7
|
+
"border-top-left-radius", "border-top-right-radius",
|
|
8
|
+
"border-bottom-left-radius", "border-bottom-right-radius",
|
|
9
|
+
"font-size", "font-weight", "line-height"
|
|
10
|
+
]);
|
|
11
|
+
const REM_BASE = 16;
|
|
12
|
+
export function parsePxValue(value) {
|
|
13
|
+
if (!value)
|
|
14
|
+
return undefined;
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
const pxMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)px$/);
|
|
17
|
+
if (pxMatch)
|
|
18
|
+
return parseFloat(pxMatch[1]);
|
|
19
|
+
const remMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)rem$/);
|
|
20
|
+
if (remMatch)
|
|
21
|
+
return parseFloat(remMatch[1]) * REM_BASE;
|
|
22
|
+
const emMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)em$/);
|
|
23
|
+
if (emMatch)
|
|
24
|
+
return parseFloat(emMatch[1]) * REM_BASE;
|
|
25
|
+
if (trimmed === "0")
|
|
26
|
+
return 0;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
// Camel-case JSX property names → CSS property names
|
|
30
|
+
const CAMEL_TO_CSS = {
|
|
31
|
+
padding: "padding",
|
|
32
|
+
paddingTop: "padding-top",
|
|
33
|
+
paddingRight: "padding-right",
|
|
34
|
+
paddingBottom: "padding-bottom",
|
|
35
|
+
paddingLeft: "padding-left",
|
|
36
|
+
margin: "margin",
|
|
37
|
+
marginTop: "margin-top",
|
|
38
|
+
marginRight: "margin-right",
|
|
39
|
+
marginBottom: "margin-bottom",
|
|
40
|
+
marginLeft: "margin-left",
|
|
41
|
+
gap: "gap",
|
|
42
|
+
columnGap: "column-gap",
|
|
43
|
+
rowGap: "row-gap",
|
|
44
|
+
color: "color",
|
|
45
|
+
backgroundColor: "background-color",
|
|
46
|
+
borderColor: "border-color",
|
|
47
|
+
borderRadius: "border-radius",
|
|
48
|
+
borderTopLeftRadius: "border-top-left-radius",
|
|
49
|
+
borderTopRightRadius: "border-top-right-radius",
|
|
50
|
+
borderBottomLeftRadius: "border-bottom-left-radius",
|
|
51
|
+
borderBottomRightRadius: "border-bottom-right-radius",
|
|
52
|
+
fontSize: "font-size",
|
|
53
|
+
fontWeight: "font-weight",
|
|
54
|
+
lineHeight: "line-height"
|
|
55
|
+
};
|
|
56
|
+
export function camelToCssProperty(camel) {
|
|
57
|
+
const css = CAMEL_TO_CSS[camel];
|
|
58
|
+
return (css && TRACKED_PROPERTIES.has(css)) ? css : null;
|
|
59
|
+
}
|
|
@@ -166,6 +166,45 @@ export function aggregateTailwind(classNameStrings) {
|
|
|
166
166
|
}
|
|
167
167
|
return Array.from(map.values()).sort((a, b) => b.count - a.count);
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Converts aggregated TailwindObservations to the unified StyleObservation type.
|
|
171
|
+
* Tailwind observations don't carry file/line info — they are class-level aggregates.
|
|
172
|
+
* We emit source: "tailwind" with line: 0 to signal this.
|
|
173
|
+
*/
|
|
174
|
+
export function tailwindToStyleObservations(observations, sourceFile = "(tailwind)") {
|
|
175
|
+
return observations.flatMap((obs) => {
|
|
176
|
+
if (obs.kind === "spacing") {
|
|
177
|
+
return [{
|
|
178
|
+
property: "padding",
|
|
179
|
+
value: obs.pxValue != null ? `${obs.pxValue}px` : obs.raw,
|
|
180
|
+
pxValue: obs.pxValue ?? undefined,
|
|
181
|
+
file: sourceFile,
|
|
182
|
+
line: 0,
|
|
183
|
+
source: "tailwind"
|
|
184
|
+
}];
|
|
185
|
+
}
|
|
186
|
+
if (obs.kind === "radius") {
|
|
187
|
+
return [{
|
|
188
|
+
property: "border-radius",
|
|
189
|
+
value: obs.pxValue != null ? `${obs.pxValue}px` : obs.raw,
|
|
190
|
+
pxValue: obs.pxValue ?? undefined,
|
|
191
|
+
file: sourceFile,
|
|
192
|
+
line: 0,
|
|
193
|
+
source: "tailwind"
|
|
194
|
+
}];
|
|
195
|
+
}
|
|
196
|
+
if (obs.kind === "color" && obs.colorToken) {
|
|
197
|
+
return [{
|
|
198
|
+
property: "color",
|
|
199
|
+
value: obs.colorToken,
|
|
200
|
+
file: sourceFile,
|
|
201
|
+
line: 0,
|
|
202
|
+
source: "tailwind"
|
|
203
|
+
}];
|
|
204
|
+
}
|
|
205
|
+
return [];
|
|
206
|
+
});
|
|
207
|
+
}
|
|
169
208
|
/**
|
|
170
209
|
* Try to read tailwind.config.{js,ts,mjs,cjs} to override default scales.
|
|
171
210
|
* We use a conservative approach: don't execute the file (no eval, no
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export function renderReferencesSection(uiReferences) {
|
|
2
|
+
if (uiReferences.length === 0)
|
|
3
|
+
return "";
|
|
4
|
+
const categoryDescriptions = {
|
|
5
|
+
forms: "buttons, inputs, selects, checkboxes, radios, toggles, textareas",
|
|
6
|
+
navigation: "nav bars, tabs, sidebar, breadcrumbs, dropdown menus, pagination, command palette",
|
|
7
|
+
feedback: "toasts, modals, alerts, progress, loading states, skeletons, empty states",
|
|
8
|
+
surface: "cards, popovers, tooltips, sheets, avatars, separators, badges",
|
|
9
|
+
layout: "container, stack, grid, responsive patterns, sidebar layout",
|
|
10
|
+
"data-display": "tables, lists, code blocks, stats, timelines"
|
|
11
|
+
};
|
|
12
|
+
const lines = [
|
|
13
|
+
"## UI references available",
|
|
14
|
+
"",
|
|
15
|
+
"This project ships with curated component references in `references/`.",
|
|
16
|
+
"Consult these when building UI — they define the quality bar and best",
|
|
17
|
+
"implementations to model.",
|
|
18
|
+
"",
|
|
19
|
+
"- `references/INDEX.md` — start here for category navigation"
|
|
20
|
+
];
|
|
21
|
+
for (const cat of uiReferences) {
|
|
22
|
+
const desc = categoryDescriptions[cat] ?? cat;
|
|
23
|
+
lines.push(`- \`references/${cat}.md\` — ${desc}`);
|
|
24
|
+
}
|
|
25
|
+
lines.push("", "When asked to build a component, consult the relevant references file first.", "Look for the **Minimum quality bar** checklist — every item must be satisfied", "before the component is considered complete.", "", "");
|
|
26
|
+
return lines.join("\n");
|
|
27
|
+
}
|
|
28
|
+
export function renderRootIndex(config, stats) {
|
|
29
|
+
const name = config.projectName ?? "this project";
|
|
30
|
+
const grid = config.foundations?.grid ?? 8;
|
|
31
|
+
const ctrl = config.foundations?.radius?.control ?? 2;
|
|
32
|
+
const cont = config.foundations?.radius?.container ?? 4;
|
|
33
|
+
const stack = config.stack ?? "css";
|
|
34
|
+
const packs = (config.packs ?? []).join(", ") || "(none)";
|
|
35
|
+
const refsSection = renderReferencesSection(config.uiReferences ?? []);
|
|
36
|
+
return `# AI Agent Context — ${name}
|
|
37
|
+
|
|
38
|
+
> This file is auto-generated by [Whale Igniter](https://github.com/whale-igniter).
|
|
39
|
+
> Do not edit by hand. Run \`whale sync\` after changes to regenerate.
|
|
40
|
+
|
|
41
|
+
## What this project is
|
|
42
|
+
|
|
43
|
+
You are working in **${name}** — a ${config.projectType ?? "web"} project managed by Whale Igniter.
|
|
44
|
+
Whale maintains machine-readable operational context so AI agents understand
|
|
45
|
+
the design system, conventions and decisions without re-explaining them every session.
|
|
46
|
+
|
|
47
|
+
- **Stack:** ${stack}
|
|
48
|
+
- **Grid:** ${grid}px — all spacing must be a multiple
|
|
49
|
+
- **Border radius:** ${ctrl}px (controls) · ${cont}px (containers)
|
|
50
|
+
- **Active packs:** ${packs}
|
|
51
|
+
|
|
52
|
+
## What to read
|
|
53
|
+
|
|
54
|
+
Whale stores structured context in two locations:
|
|
55
|
+
|
|
56
|
+
1. **\`intelligence/\`** — source of truth (JSON). Prefer these for precise data:
|
|
57
|
+
- \`intelligence/refinements.json\` — approved validator overrides
|
|
58
|
+
- \`intelligence/decisions.json\` — architectural and product decisions
|
|
59
|
+
- \`intelligence/components.json\` — component catalog
|
|
60
|
+
|
|
61
|
+
2. **\`llm-wiki/\`** — human + AI readable markdown rendered from the JSON:
|
|
62
|
+
- \`llm-wiki/FOUNDATIONS.md\` — design tokens and grid rules
|
|
63
|
+
- \`llm-wiki/CONVENTIONS.md\` — coding and design conventions
|
|
64
|
+
- \`llm-wiki/DECISIONS.md\` — decision log in narrative form
|
|
65
|
+
- \`llm-wiki/COMPONENTS.md\` — component catalog
|
|
66
|
+
- \`llm-wiki/WORKFLOWS.md\` — how the team uses Whale day to day
|
|
67
|
+
|
|
68
|
+
**Current status:** ${stats.errors} error(s) · ${stats.warnings} warning(s) · ${stats.decisionCount} decision(s) · ${stats.refinementCount} refinement(s) · ${stats.componentCount} component(s)
|
|
69
|
+
|
|
70
|
+
${refsSection}## How to work here
|
|
71
|
+
|
|
72
|
+
1. **Spacing.** Every padding, margin and gap must be a multiple of \`${grid}px\`. No exceptions.
|
|
73
|
+
2. **Radius.** Buttons, inputs, selects → \`${ctrl}px\`. Cards, modals, sheets → \`${cont}px\`.
|
|
74
|
+
3. **Color.** Prefer semantic tokens over raw hex. Adding a hex requires a refinement note.
|
|
75
|
+
4. **Focus.** Every interactive element needs a visible \`:focus-visible\` state.
|
|
76
|
+
5. **Decisions.** Non-obvious choices → propose \`whale decision\` so future sessions inherit the reasoning.
|
|
77
|
+
6. **Refinements.** Honor \`intelligence/refinements.json\` — those are intentional team-approved exceptions.
|
|
78
|
+
7. **Components.** Before building, check \`intelligence/components.json\`. After building, run \`whale component add <name>\`.
|
|
79
|
+
|
|
80
|
+
## Commands available
|
|
81
|
+
|
|
82
|
+
- \`whale validate\` — run validators, exits non-zero on errors
|
|
83
|
+
- \`whale refine "<note>"\` — record a validator override
|
|
84
|
+
- \`whale decision\` — record an architectural decision (interactive)
|
|
85
|
+
- \`whale component add <name>\` — register a component
|
|
86
|
+
- \`whale sync\` — regenerate this file and the wiki
|
|
87
|
+
- \`whale insights drift <category>\` — review style drift interactively
|
|
88
|
+
- \`whale docs\` — generate human-facing reports
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
_Last sync: ${new Date().toISOString()}_
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function renderComponents(components) {
|
|
2
|
+
const lines = ["# Component Catalog", ""];
|
|
3
|
+
if (components.length === 0) {
|
|
4
|
+
lines.push("_No components catalogued yet. Use `whale component add <name>` to register one._");
|
|
5
|
+
lines.push("", "_Generated by Whale Igniter._");
|
|
6
|
+
return lines.join("\n");
|
|
7
|
+
}
|
|
8
|
+
const sorted = [...components].sort((a, b) => a.name.localeCompare(b.name));
|
|
9
|
+
// Group by category
|
|
10
|
+
const grouped = new Map();
|
|
11
|
+
for (const c of sorted) {
|
|
12
|
+
const key = c.category ?? "uncategorized";
|
|
13
|
+
if (!grouped.has(key))
|
|
14
|
+
grouped.set(key, []);
|
|
15
|
+
grouped.get(key).push(c);
|
|
16
|
+
}
|
|
17
|
+
for (const [category, entries] of grouped) {
|
|
18
|
+
lines.push(`## ${capitalize(category)}`, "");
|
|
19
|
+
for (const c of entries) {
|
|
20
|
+
lines.push(`### ${c.name}`, "");
|
|
21
|
+
if (c.description)
|
|
22
|
+
lines.push(c.description, "");
|
|
23
|
+
const meta = [];
|
|
24
|
+
if (c.variants?.length)
|
|
25
|
+
meta.push(`Variants: ${c.variants.join(", ")}`);
|
|
26
|
+
if (c.states?.length)
|
|
27
|
+
meta.push(`States: ${c.states.join(", ")}`);
|
|
28
|
+
if (c.tokens?.length)
|
|
29
|
+
meta.push(`Tokens: ${c.tokens.join(", ")}`);
|
|
30
|
+
if (meta.length)
|
|
31
|
+
lines.push(meta.map((m) => `- ${m}`).join("\n"), "");
|
|
32
|
+
if (c.files?.length) {
|
|
33
|
+
lines.push("**Files:**");
|
|
34
|
+
for (const f of c.files)
|
|
35
|
+
lines.push(`- \`${f}\``);
|
|
36
|
+
lines.push("");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
lines.push("_Generated by Whale Igniter._");
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
function capitalize(s) {
|
|
44
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function renderConventions(config, refinements) {
|
|
2
|
+
const grid = config.foundations?.grid ?? 8;
|
|
3
|
+
const ctrl = config.foundations?.radius?.control ?? 2;
|
|
4
|
+
const cont = config.foundations?.radius?.container ?? 4;
|
|
5
|
+
const scoped = refinements.filter((r) => r.scope);
|
|
6
|
+
const scopeless = refinements.filter((r) => !r.scope);
|
|
7
|
+
const lines = [
|
|
8
|
+
"# Conventions",
|
|
9
|
+
"",
|
|
10
|
+
"## Confirmed conventions",
|
|
11
|
+
"",
|
|
12
|
+
`- Spacing is a multiple of ${grid}px. No exceptions without a refinement.`,
|
|
13
|
+
`- Controls (buttons, inputs, selects) use border-radius ${ctrl}px.`,
|
|
14
|
+
`- Containers (cards, modals, sheets) use border-radius ${cont}px.`,
|
|
15
|
+
"- Every interactive element has a visible `:focus-visible` state.",
|
|
16
|
+
"- Prefer semantic tokens over raw color values.",
|
|
17
|
+
""
|
|
18
|
+
];
|
|
19
|
+
if (scoped.length > 0) {
|
|
20
|
+
lines.push("### Active refinements (approved exceptions)", "");
|
|
21
|
+
lines.push("These have been reviewed and approved. The validator suppresses matching issues.", "");
|
|
22
|
+
for (const r of scoped) {
|
|
23
|
+
const scopeParts = [];
|
|
24
|
+
if (r.scope?.issueType)
|
|
25
|
+
scopeParts.push(`type=\`${r.scope.issueType}\``);
|
|
26
|
+
if (r.scope?.selector)
|
|
27
|
+
scopeParts.push(`selector=\`${r.scope.selector}\``);
|
|
28
|
+
if (r.scope?.file)
|
|
29
|
+
scopeParts.push(`file=\`${r.scope.file}\``);
|
|
30
|
+
const scope = scopeParts.length ? ` _(${scopeParts.join(", ")})_` : "";
|
|
31
|
+
lines.push(`- **${r.timestamp.slice(0, 10)}** — ${r.note}${scope}`);
|
|
32
|
+
}
|
|
33
|
+
lines.push("");
|
|
34
|
+
}
|
|
35
|
+
if (scopeless.length > 0) {
|
|
36
|
+
lines.push("## Open questions", "");
|
|
37
|
+
lines.push("These notes do not yet have a scope — the validator cannot act on them.", "Review and convert to scoped refinements or decisions.", "");
|
|
38
|
+
for (const r of scopeless) {
|
|
39
|
+
lines.push(`> ⚠️ Unresolved — **${r.timestamp.slice(0, 10)}**: ${r.note}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push("");
|
|
42
|
+
}
|
|
43
|
+
lines.push("_Generated by Whale Igniter._");
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const MAX_DETAIL = 500;
|
|
2
|
+
function truncate(text) {
|
|
3
|
+
return text.length > MAX_DETAIL ? text.slice(0, MAX_DETAIL) + "…" : text;
|
|
4
|
+
}
|
|
5
|
+
export function renderDecisions(decisions) {
|
|
6
|
+
const lines = [
|
|
7
|
+
"# Decision Log",
|
|
8
|
+
"",
|
|
9
|
+
"Architectural, product and tooling decisions, most recent first.",
|
|
10
|
+
""
|
|
11
|
+
];
|
|
12
|
+
if (decisions.length === 0) {
|
|
13
|
+
lines.push("_No decisions logged yet. Use `whale decision` to record one._");
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const sorted = [...decisions].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
17
|
+
for (const d of sorted) {
|
|
18
|
+
lines.push(`## ${d.title}`);
|
|
19
|
+
lines.push("");
|
|
20
|
+
lines.push(`_${d.timestamp.slice(0, 10)} · ${d.category} · ${d.status}_`);
|
|
21
|
+
lines.push("");
|
|
22
|
+
if (d.context) {
|
|
23
|
+
lines.push("**Context**", "", truncate(d.context), "");
|
|
24
|
+
}
|
|
25
|
+
lines.push("**Decision**", "", truncate(d.decision));
|
|
26
|
+
if (d.consequences) {
|
|
27
|
+
lines.push("", "**Consequences**", "", truncate(d.consequences));
|
|
28
|
+
}
|
|
29
|
+
lines.push("", "---", "");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
lines.push("_Generated by Whale Igniter._");
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function renderFoundations(config, decisions = []) {
|
|
2
|
+
const grid = config.foundations?.grid ?? 8;
|
|
3
|
+
const ctrl = config.foundations?.radius?.control ?? 2;
|
|
4
|
+
const cont = config.foundations?.radius?.container ?? 4;
|
|
5
|
+
const foundationDecisions = decisions.filter((d) => d.category === "design-system" && d.status === "active");
|
|
6
|
+
const lines = [
|
|
7
|
+
"# Foundations",
|
|
8
|
+
"",
|
|
9
|
+
"The invariants of this project. All UI must conform unless explicitly refined.",
|
|
10
|
+
"",
|
|
11
|
+
"## Core values",
|
|
12
|
+
"",
|
|
13
|
+
`| Property | Value | Rule |`,
|
|
14
|
+
`| --- | --- | --- |`,
|
|
15
|
+
`| Grid unit | **${grid}px** | All spacing must be a multiple |`,
|
|
16
|
+
`| Control radius | **${ctrl}px** | Buttons, inputs, selects, toggles |`,
|
|
17
|
+
`| Container radius | **${cont}px** | Cards, modals, sheets, popovers |`,
|
|
18
|
+
];
|
|
19
|
+
if (config.branding?.accent) {
|
|
20
|
+
lines.push(`| Accent | **${config.branding.accent}** | Brand color |`);
|
|
21
|
+
}
|
|
22
|
+
lines.push("", "## Spacing scale", "");
|
|
23
|
+
lines.push(`Grid: **${grid}px**. Allowed values:`);
|
|
24
|
+
lines.push("");
|
|
25
|
+
lines.push("`" + [1, 2, 3, 4, 5, 6, 8, 10, 12].map((n) => `${n * grid}px`).join(" ") + "`");
|
|
26
|
+
if (foundationDecisions.length > 0) {
|
|
27
|
+
lines.push("", "## Why these foundations", "");
|
|
28
|
+
for (const d of foundationDecisions.slice(0, 5)) {
|
|
29
|
+
lines.push(`- **${d.title}** _(${d.timestamp.slice(0, 10)})_ — ${d.decision}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
lines.push("", "_Generated by Whale Igniter._");
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|