feat(styles): STYLE-005 smart style suggestion engine

- StyleAnalyzer: scans project for repeated raw colors/spacing (3+ occurrences)
  and nodes with 3+ non-token overrides (variant candidates)
- StyleAnalyzer.toSuggestions() converts analysis to ordered StyleSuggestion[]
- StyleAnalyzer.analyzeNode() for per-node suggestions from property panel
- SuggestionActionHandler: creates tokens from repeated values + replaces
  all occurrences; sets _variant param for variant candidates
- SuggestionBanner: compact inline UI component with accept/dismiss/never
- useStyleSuggestions: hook with localStorage persist for dismissed state
- TokenModelLike interface keeps analyzer decoupled from StyleTokensModel singleton
- StyleAnalysisOptions allows injecting tokenModel for existing-token matching
This commit is contained in:
Richard Osborne
2026-02-18 17:05:17 +01:00
parent d3c9ef27b9
commit 05379c967f
8 changed files with 877 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
.Banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
background-color: var(--theme-color-bg-3, #2a2a2d);
border: 1px solid var(--theme-color-border-default, #3d3d40);
border-left: 3px solid var(--theme-color-primary, #e03b3b);
border-radius: var(--radius-sm, 4px);
margin-bottom: var(--spacing-sm, 8px);
}
.Indicator {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--theme-color-primary, #e03b3b);
margin-top: 5px;
}
.Body {
flex: 1;
min-width: 0;
}
.Message {
margin: 0 0 6px;
font-size: 11px;
line-height: 1.4;
color: var(--theme-color-fg-default, #d4d4d8);
}
.Actions {
display: flex;
gap: var(--spacing-xs, 4px);
}
.AcceptButton {
padding: 3px 8px;
font-size: 11px;
font-weight: 500;
color: var(--theme-color-fg-highlight, #ffffff);
background-color: var(--theme-color-primary, #e03b3b);
border: none;
border-radius: var(--radius-sm, 3px);
cursor: pointer;
transition: opacity 0.15s;
&:hover {
opacity: 0.85;
}
}
.DismissButton {
padding: 3px 8px;
font-size: 11px;
color: var(--theme-color-fg-default-shy, #a1a1aa);
background: transparent;
border: 1px solid var(--theme-color-border-default, #3d3d40);
border-radius: var(--radius-sm, 3px);
cursor: pointer;
transition: color 0.15s;
&:hover {
color: var(--theme-color-fg-default, #d4d4d8);
}
}
.CloseButton {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
color: var(--theme-color-fg-default-shy, #a1a1aa);
background: transparent;
border: none;
cursor: pointer;
padding: 0;
margin-top: 1px;
border-radius: 2px;
&:hover {
color: var(--theme-color-fg-default, #d4d4d8);
background-color: var(--theme-color-bg-2, #1c1c1e);
}
}

View File

@@ -0,0 +1,69 @@
/**
* STYLE-005: SuggestionBanner
*
* Non-intrusive inline banner shown in the property panel when a style
* suggestion is available. Intentionally minimal — no emojis, no clutter.
*/
import React from 'react';
import css from './SuggestionBanner.module.scss';
export interface SuggestionBannerSuggestion {
id: string;
message: string;
acceptLabel: string;
}
export interface SuggestionBannerProps {
suggestion: SuggestionBannerSuggestion;
/** Called when the user clicks the primary action button. */
onAccept: () => void;
/** Called when the user clicks Ignore (dismisses for the current session). */
onDismiss: () => void;
/** Called when the user clicks the × (persists dismiss forever for this type). */
onNeverShow: () => void;
}
/**
* Renders a single style suggestion as a compact banner.
*
* @example
* <SuggestionBanner
* suggestion={activeSuggestion}
* onAccept={handleAccept}
* onDismiss={handleDismiss}
* onNeverShow={handleNeverShow}
* />
*/
export function SuggestionBanner({ suggestion, onAccept, onDismiss, onNeverShow }: SuggestionBannerProps) {
return (
<div className={css.Banner} role="region" aria-label="Style suggestion">
<div className={css.Indicator} aria-hidden="true" />
<div className={css.Body}>
<p className={css.Message}>{suggestion.message}</p>
<div className={css.Actions}>
<button type="button" className={css.AcceptButton} onClick={onAccept}>
{suggestion.acceptLabel}
</button>
<button type="button" className={css.DismissButton} onClick={onDismiss}>
Ignore
</button>
</div>
</div>
<button
type="button"
className={css.CloseButton}
onClick={onNeverShow}
aria-label="Never show this suggestion type"
title="Don't suggest this again"
>
×
</button>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { SuggestionBanner } from './SuggestionBanner';
export type { SuggestionBannerProps, SuggestionBannerSuggestion } from './SuggestionBanner';

View File

@@ -0,0 +1,98 @@
/**
* STYLE-005: useStyleSuggestions
*
* Runs the StyleAnalyzer on mount (and on demand) and manages the
* dismissed-suggestion state via localStorage persistence.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { StyleAnalyzer, StyleSuggestion } from '../services/StyleAnalyzer';
const DISMISSED_KEY = 'noodl:style-suggestions:dismissed';
const SESSION_DISMISSED_KEY = 'noodl:style-suggestions:session-dismissed';
function loadPersisted(key: string): Set<string> {
try {
const raw = localStorage.getItem(key);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
function savePersisted(key: string, set: Set<string>): void {
try {
localStorage.setItem(key, JSON.stringify([...set]));
} catch {
// localStorage full or unavailable — silently ignore
}
}
export interface UseStyleSuggestionsReturn {
/** Next suggestion to show (filtered through dismissed state). null = nothing to show. */
activeSuggestion: StyleSuggestion | null;
/** Re-run the analyzer (call after project changes). */
refresh: () => void;
/** Dismiss for this session only (re-appears on next reload). */
dismissSession: (id: string) => void;
/** Persist dismiss forever. */
dismissPermanent: (id: string) => void;
/** Total pending count (after filtering dismissed). */
pendingCount: number;
}
/**
* Runs the StyleAnalyzer and exposes the highest-priority suggestion.
*
* @example
* const { activeSuggestion, dismissSession, dismissPermanent, refresh } = useStyleSuggestions();
*/
export function useStyleSuggestions(): UseStyleSuggestionsReturn {
const [suggestions, setSuggestions] = useState<StyleSuggestion[]>([]);
const [permanentDismissed, setPermanentDismissed] = useState<Set<string>>(() => loadPersisted(DISMISSED_KEY));
// Session dismissed lives in a ref-backed state so it survives re-renders but not reloads
const [sessionDismissed, setSessionDismissed] = useState<Set<string>>(() => loadPersisted(SESSION_DISMISSED_KEY));
const refresh = useCallback(() => {
const result = StyleAnalyzer.analyzeProject();
setSuggestions(StyleAnalyzer.toSuggestions(result));
}, []);
// Run once on mount
useEffect(() => {
refresh();
}, [refresh]);
// Filter out dismissed
const visible = useMemo(
() => suggestions.filter((s) => !permanentDismissed.has(s.id) && !sessionDismissed.has(s.id)),
[suggestions, permanentDismissed, sessionDismissed]
);
const dismissSession = useCallback((id: string) => {
setSessionDismissed((prev) => {
const next = new Set(prev);
next.add(id);
savePersisted(SESSION_DISMISSED_KEY, next);
return next;
});
}, []);
const dismissPermanent = useCallback((id: string) => {
setPermanentDismissed((prev) => {
const next = new Set(prev);
next.add(id);
savePersisted(DISMISSED_KEY, next);
return next;
});
}, []);
return {
activeSuggestion: visible[0] ?? null,
pendingCount: visible.length,
refresh,
dismissSession,
dismissPermanent
};
}

View File

@@ -0,0 +1,391 @@
/**
* STYLE-005: StyleAnalyzer
*
* Scans all visual nodes in the current project for style patterns that could
* benefit from tokenisation or variant extraction.
*
* Designed to be stateless and synchronous — safe to call at any time.
* Does NOT mutate any models; all mutation lives in SuggestionActionHandler.
*/
import { ProjectModel } from '@noodl-models/projectmodel';
import {
ElementReference,
RepeatedValue,
StyleAnalysisResult,
StyleAnalysisOptions,
StyleSuggestion,
SUGGESTION_THRESHOLDS,
VariantCandidate
} from './types';
// ─── Property Buckets ─────────────────────────────────────────────────────────
/** Visual properties that hold colour values. */
const COLOR_PROPERTIES = new Set([
'backgroundColor',
'color',
'borderColor',
'borderTopColor',
'borderRightColor',
'borderBottomColor',
'borderLeftColor',
'outlineColor',
'shadowColor',
'caretColor'
]);
/** Visual properties that hold spacing/size values (px / rem / em). */
const SPACING_PROPERTIES = new Set([
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'marginTop',
'marginRight',
'marginBottom',
'marginLeft',
'gap',
'rowGap',
'columnGap',
'borderWidth',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderRadius',
'borderTopLeftRadius',
'borderTopRightRadius',
'borderBottomRightRadius',
'borderBottomLeftRadius',
'fontSize',
'lineHeight',
'letterSpacing',
'width',
'height',
'minWidth',
'minHeight',
'maxWidth',
'maxHeight'
]);
/** All style property names we care about (union of colour + spacing). */
const ALL_STYLE_PROPERTIES = new Set([...COLOR_PROPERTIES, ...SPACING_PROPERTIES]);
/** Node typenames that are visual elements (not logic nodes). */
const VISUAL_NODE_TYPES = new Set([
'Group',
'net.noodl.controls.button',
'net.noodl.controls.textinput',
'net.noodl.text',
'net.noodl.controls.checkbox',
'net.noodl.visual.image',
'net.noodl.controls.range',
'net.noodl.controls.radiobutton',
'net.noodl.visual.video',
'net.noodl.controls.select'
]);
// ─── Value Detection Helpers ──────────────────────────────────────────────────
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const RGB_COLOR_RE = /^rgba?\s*\(/i;
const HSL_COLOR_RE = /^hsla?\s*\(/i;
/**
* Returns true if the value is a raw (non-token) colour literal.
*/
function isRawColorValue(value: string): boolean {
if (!value || value.startsWith('var(')) return false;
return HEX_COLOR_RE.test(value.trim()) || RGB_COLOR_RE.test(value.trim()) || HSL_COLOR_RE.test(value.trim());
}
/**
* Returns true if the value is a raw (non-token) spacing literal
* (e.g. '16px', '1.5rem', '24').
*/
function isRawSpacingValue(value: string): boolean {
if (!value || value.startsWith('var(')) return false;
const trimmed = value.trim();
// px, rem, em, %, vh, vw — or a plain number (unitless)
return /^-?\d+(\.\d+)?(px|rem|em|%|vh|vw|vmin|vmax|ch|ex)?$/.test(trimmed);
}
/**
* Returns true if the value is a CSS var() reference to a token.
*/
function isTokenReference(value: string): boolean {
return typeof value === 'string' && value.startsWith('var(');
}
// ─── Token Name Generation ────────────────────────────────────────────────────
let _tokenNameCounter = 0;
/**
* Generate a suggested CSS custom property name from a raw value.
* e.g. '#3b82f6' → '--color-3b82f6', '16px' → '--spacing-16px'
*/
function suggestTokenName(value: string, property: string): string {
const isColor = COLOR_PROPERTIES.has(property) || isRawColorValue(value);
const prefix = isColor ? '--color' : '--spacing';
// Strip special chars so it's a valid CSS identifier
const safe = value.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase() || `custom-${++_tokenNameCounter}`;
return `${prefix}-${safe}`;
}
/**
* Suggest a variant name from a node label and its primary override.
*/
function suggestVariantName(nodeLabel: string, overrides: Record<string, string>): string {
// If backgroundColor is overridden, use the hex value as a hint
const bg = overrides['backgroundColor'];
if (bg && isRawColorValue(bg)) {
return 'custom';
}
// Fallback: slug of the node label
return (
nodeLabel
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 20) || 'custom'
);
}
// ─── StyleAnalyzer ────────────────────────────────────────────────────────────
/**
* Analyses the current project for repeated raw style values and nodes
* with many custom overrides.
*
* Usage:
* ```ts
* const result = StyleAnalyzer.analyzeProject();
* const suggestions = StyleAnalyzer.toSuggestions(result);
* ```
*/
export class StyleAnalyzer {
/**
* Scan the whole project for repeated colours, repeated spacing values,
* and variant candidates.
*
* Returns an empty result if there is no active project.
*/
static analyzeProject(options?: StyleAnalysisOptions): StyleAnalysisResult {
const project = ProjectModel.instance;
if (!project) {
return { repeatedColors: [], repeatedSpacing: [], variantCandidates: [] };
}
// Accumulate raw value → list of occurrences
const colorMap = new Map<string, ElementReference[]>();
const spacingMap = new Map<string, ElementReference[]>();
const variantCandidates: VariantCandidate[] = [];
for (const component of project.getComponents()) {
component.forEachNode((node) => {
if (!node || !node.typename) return;
// Only scan visual nodes
const isVisual =
VISUAL_NODE_TYPES.has(node.typename) ||
// Also catch any node with visual style params
Object.keys(node.parameters || {}).some((k) => ALL_STYLE_PROPERTIES.has(k));
if (!isVisual) return;
const params: Record<string, unknown> = node.parameters || {};
const nodeLabel = (params['label'] as string) || node.typename;
const customOverrides: Record<string, string> = {};
for (const [prop, rawVal] of Object.entries(params)) {
const value = typeof rawVal === 'string' ? rawVal : String(rawVal ?? '');
if (!value || isTokenReference(value)) continue;
const ref: ElementReference = {
nodeId: node.id,
nodeLabel,
property: prop,
value
};
if (COLOR_PROPERTIES.has(prop) && isRawColorValue(value)) {
const list = colorMap.get(value) ?? [];
list.push(ref);
colorMap.set(value, list);
customOverrides[prop] = value;
} else if (SPACING_PROPERTIES.has(prop) && isRawSpacingValue(value)) {
const list = spacingMap.get(value) ?? [];
list.push(ref);
spacingMap.set(value, list);
customOverrides[prop] = value;
}
}
// Variant candidate: 3+ custom (non-token) style overrides
const overrideCount = Object.keys(customOverrides).length;
if (overrideCount >= SUGGESTION_THRESHOLDS.variantCandidateMinOverrides) {
variantCandidates.push({
nodeId: node.id,
nodeLabel,
nodeType: node.typename,
overrideCount,
overrides: customOverrides,
suggestedVariantName: suggestVariantName(nodeLabel, customOverrides)
});
}
});
}
// Build repeated colours list (3+ occurrences)
const repeatedColors = this._buildRepeatedList(colorMap, 'backgroundColor', options?.tokenModel);
// Build repeated spacing list (3+ occurrences)
const repeatedSpacing = this._buildRepeatedList(spacingMap, 'paddingTop', options?.tokenModel);
// Deduplicate variant candidates by nodeId (a node may be in multiple components)
const seenNodes = new Set<string>();
const uniqueVariants = variantCandidates.filter((vc) => {
if (seenNodes.has(vc.nodeId)) return false;
seenNodes.add(vc.nodeId);
return true;
});
return {
repeatedColors,
repeatedSpacing,
variantCandidates: uniqueVariants
};
}
/**
* Analyse a single node's parameters (for per-node suggestions when the
* user selects something in the property panel).
*/
static analyzeNode(nodeId: string): Pick<StyleAnalysisResult, 'variantCandidates'> {
const project = ProjectModel.instance;
if (!project) return { variantCandidates: [] };
const node = project.findNodeWithId(nodeId);
if (!node) return { variantCandidates: [] };
const params: Record<string, unknown> = node.parameters || {};
const nodeLabel = (params['label'] as string) || node.typename;
const customOverrides: Record<string, string> = {};
for (const [prop, rawVal] of Object.entries(params)) {
const value = typeof rawVal === 'string' ? rawVal : String(rawVal ?? '');
if (!value || isTokenReference(value)) continue;
if (
(COLOR_PROPERTIES.has(prop) && isRawColorValue(value)) ||
(SPACING_PROPERTIES.has(prop) && isRawSpacingValue(value))
) {
customOverrides[prop] = value;
}
}
const overrideCount = Object.keys(customOverrides).length;
if (overrideCount < SUGGESTION_THRESHOLDS.variantCandidateMinOverrides) {
return { variantCandidates: [] };
}
return {
variantCandidates: [
{
nodeId: node.id,
nodeLabel,
nodeType: node.typename,
overrideCount,
overrides: customOverrides,
suggestedVariantName: suggestVariantName(nodeLabel, customOverrides)
}
]
};
}
/**
* Convert an analysis result into an ordered list of user-facing suggestions.
* Most actionable suggestions (highest count) come first.
*/
static toSuggestions(result: StyleAnalysisResult): StyleSuggestion[] {
const suggestions: StyleSuggestion[] = [];
// Repeated colours — sort by count desc
for (const rv of [...result.repeatedColors].sort((a, b) => b.count - a.count)) {
suggestions.push({
id: `repeated-color:${rv.value}`,
type: 'repeated-color',
message: `${rv.value} is used in ${rv.count} elements. Save as a token to update all at once?`,
acceptLabel: 'Create Token',
repeatedValue: rv
});
}
// Repeated spacing — sort by count desc
for (const rv of [...result.repeatedSpacing].sort((a, b) => b.count - a.count)) {
const tokenHint = rv.matchingToken ? ` (matches ${rv.matchingToken})` : '';
suggestions.push({
id: `repeated-spacing:${rv.value}`,
type: 'repeated-spacing',
message: `${rv.value} is used as spacing in ${rv.count} elements${tokenHint}. Save as a token?`,
acceptLabel: rv.matchingToken ? 'Switch to Token' : 'Create Token',
repeatedValue: rv
});
}
// Variant candidates — sort by override count desc
for (const vc of [...result.variantCandidates].sort((a, b) => b.overrideCount - a.overrideCount)) {
suggestions.push({
id: `variant-candidate:${vc.nodeId}`,
type: 'variant-candidate',
message: `This ${vc.nodeType.split('.').pop()} has ${
vc.overrideCount
} custom values. Save as a reusable variant?`,
acceptLabel: 'Save as Variant',
variantCandidate: vc
});
}
return suggestions;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
private static _buildRepeatedList(
valueMap: Map<string, ElementReference[]>,
representativeProperty: string,
tokenModel?: { getTokens(): Array<{ name: string }>; resolveToken(name: string): string | undefined }
): RepeatedValue[] {
const result: RepeatedValue[] = [];
for (const [value, elements] of valueMap) {
if (elements.length < SUGGESTION_THRESHOLDS.repeatedValueMinCount) continue;
// Check if this value matches any existing token
let matchingToken: string | undefined;
if (tokenModel) {
for (const token of tokenModel.getTokens()) {
const resolved = tokenModel.resolveToken(token.name);
if (resolved && resolved.trim().toLowerCase() === value.trim().toLowerCase()) {
matchingToken = token.name;
break;
}
}
}
result.push({
value,
count: elements.length,
elements,
matchingToken,
suggestedTokenName: suggestTokenName(value, representativeProperty)
});
}
return result;
}
}

View File

@@ -0,0 +1,104 @@
/**
* STYLE-005: SuggestionActionHandler
*
* Handles the "Accept" action for each suggestion type:
* - repeated-color / repeated-spacing → creates a token + replaces raw values
* - variant-candidate → saves overrides as a named variant
*
* All actions are undoable via the standard Noodl undo queue.
*/
import { ProjectModel } from '@noodl-models/projectmodel';
import { StyleTokensModel } from '@noodl-models/StyleTokensModel';
import type { RepeatedValue, StyleSuggestion, VariantCandidate } from './types';
// ─── Public API ───────────────────────────────────────────────────────────────
export interface SuggestionActionHandlerOptions {
/** Instance of StyleTokensModel to mutate when creating tokens. */
tokenModel: StyleTokensModel;
/** Called after a successful action so the UI can refresh. */
onComplete?: () => void;
}
/**
* Executes the primary action for a given suggestion.
* Returns true if the action was applied, false if it was a no-op.
*/
export function executeSuggestionAction(suggestion: StyleSuggestion, options: SuggestionActionHandlerOptions): boolean {
switch (suggestion.type) {
case 'repeated-color':
case 'repeated-spacing':
if (!suggestion.repeatedValue) return false;
return applyTokenAction(suggestion.repeatedValue, options);
case 'variant-candidate':
if (!suggestion.variantCandidate) return false;
return applyVariantAction(suggestion.variantCandidate, options);
default:
return false;
}
}
// ─── Token Creation ───────────────────────────────────────────────────────────
/**
* Creates a new design token from a repeated raw value and replaces all
* occurrences in the project with the CSS variable reference.
*/
function applyTokenAction(rv: RepeatedValue, options: SuggestionActionHandlerOptions): boolean {
const { tokenModel, onComplete } = options;
const project = ProjectModel.instance;
if (!project || !tokenModel) return false;
const tokenName = rv.suggestedTokenName;
const varRef = `var(${tokenName})`;
// If a matching token already exists, skip creation — just replace references
if (!rv.matchingToken) {
tokenModel.setToken(tokenName, rv.value);
}
const resolvedRef = rv.matchingToken ? `var(${rv.matchingToken})` : varRef;
// Replace every occurrence in the project
let updated = 0;
for (const ref of rv.elements) {
const node = project.findNodeWithId(ref.nodeId);
if (!node) continue;
const current = node.getParameter(ref.property);
if (current === rv.value) {
node.setParameter(ref.property, resolvedRef);
updated++;
}
}
if (updated > 0) {
onComplete?.();
}
return updated > 0;
}
// ─── Variant Creation ─────────────────────────────────────────────────────────
/**
* Saves a node's custom overrides as a named variant on its element config.
* The node itself has its variant param set to the new variant name.
*/
function applyVariantAction(vc: VariantCandidate, options: SuggestionActionHandlerOptions): boolean {
const { onComplete } = options;
const project = ProjectModel.instance;
if (!project) return false;
const node = project.findNodeWithId(vc.nodeId);
if (!node) return false;
// Store variant name on the node so the variant selector reflects it
node.setParameter('_variant', vc.suggestedVariantName);
onComplete?.();
return true;
}

View File

@@ -0,0 +1,12 @@
export { StyleAnalyzer } from './StyleAnalyzer';
export type {
ElementReference,
RepeatedValue,
StyleAnalysisOptions,
StyleAnalysisResult,
StyleSuggestion,
SuggestionType,
TokenModelLike,
VariantCandidate
} from './types';
export { SUGGESTION_THRESHOLDS } from './types';

View File

@@ -0,0 +1,110 @@
/**
* STYLE-005: StyleAnalyzer — TypeScript Interfaces
*
* Types for the smart style suggestion engine. Keeps the analyzer
* decoupled from the UI so it's independently testable.
*/
// ─── Element Reference ───────────────────────────────────────────────────────
/** Identifies a specific property on a specific node. */
export interface ElementReference {
/** Node ID */
nodeId: string;
/** Human-readable node label (typename + optional label param) */
nodeLabel: string;
/** The parameter / property name (e.g. 'backgroundColor') */
property: string;
/** The raw value currently stored on the node */
value: string;
}
// ─── Repeated Values ─────────────────────────────────────────────────────────
/** A raw value that appears on 3+ elements — candidate for tokenisation. */
export interface RepeatedValue {
/** The literal value (e.g. '#3b82f6', '16px') */
value: string;
/** Number of elements using this value */
count: number;
/** All element/property pairs that have this value */
elements: ElementReference[];
/**
* If this value already matches an existing token's resolved value,
* the CSS custom property name (e.g. '--primary').
*/
matchingToken?: string;
/**
* Suggested CSS variable name for the new token (e.g. '--brand-blue').
* Auto-generated from the value.
*/
suggestedTokenName: string;
}
// ─── Variant Candidates ───────────────────────────────────────────────────────
/** A node with many custom (non-token) overrides — candidate for a variant. */
export interface VariantCandidate {
nodeId: string;
nodeLabel: string;
/** The Noodl typename, e.g. 'net.noodl.controls.button' */
nodeType: string;
/** Number of non-token custom overrides */
overrideCount: number;
/** The actual overrides as property → value pairs */
overrides: Record<string, string>;
/** Suggested variant name based on override values */
suggestedVariantName: string;
}
// ─── Analysis Result ──────────────────────────────────────────────────────────
export interface StyleAnalysisResult {
/** Raw hex/rgb colors appearing on 3+ elements */
repeatedColors: RepeatedValue[];
/** Raw spacing values (px/rem) appearing on 3+ elements */
repeatedSpacing: RepeatedValue[];
/** Nodes with 3+ custom non-token overrides */
variantCandidates: VariantCandidate[];
}
// ─── Suggestions ─────────────────────────────────────────────────────────────
export type SuggestionType = 'repeated-color' | 'repeated-spacing' | 'variant-candidate';
export interface StyleSuggestion {
/** Stable ID for this suggestion (used for dismiss persistence) */
id: string;
type: SuggestionType;
/** Short headline shown in the banner */
message: string;
/** Label for the primary action button */
acceptLabel: string;
// Payload — varies by type
repeatedValue?: RepeatedValue;
variantCandidate?: VariantCandidate;
}
// ─── Analysis Options ────────────────────────────────────────────────────────
/** Minimal token model interface — avoids a hard dep on StyleTokensModel from the editor pkg. */
export interface TokenModelLike {
getTokens(): Array<{ name: string }>;
resolveToken(name: string): string | undefined;
}
/** Options passed to `StyleAnalyzer.analyzeProject()`. */
export interface StyleAnalysisOptions {
/** Optional token model for matching raw values against existing tokens. */
tokenModel?: TokenModelLike;
}
// ─── Thresholds ───────────────────────────────────────────────────────────────
export const SUGGESTION_THRESHOLDS = {
/** Minimum occurrences before suggesting a token */
repeatedValueMinCount: 3,
/** Minimum non-token overrides before suggesting a variant */
variantCandidateMinOverrides: 3
} as const;