diff --git a/packages/noodl-core-ui/src/components/StyleSuggestions/SuggestionBanner.module.scss b/packages/noodl-core-ui/src/components/StyleSuggestions/SuggestionBanner.module.scss
new file mode 100644
index 0000000..c3fa8bc
--- /dev/null
+++ b/packages/noodl-core-ui/src/components/StyleSuggestions/SuggestionBanner.module.scss
@@ -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);
+ }
+}
diff --git a/packages/noodl-core-ui/src/components/StyleSuggestions/SuggestionBanner.tsx b/packages/noodl-core-ui/src/components/StyleSuggestions/SuggestionBanner.tsx
new file mode 100644
index 0000000..6ae7d6b
--- /dev/null
+++ b/packages/noodl-core-ui/src/components/StyleSuggestions/SuggestionBanner.tsx
@@ -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
+ *
+ */
+export function SuggestionBanner({ suggestion, onAccept, onDismiss, onNeverShow }: SuggestionBannerProps) {
+ return (
+
+
+
+
+
{suggestion.message}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/noodl-core-ui/src/components/StyleSuggestions/index.ts b/packages/noodl-core-ui/src/components/StyleSuggestions/index.ts
new file mode 100644
index 0000000..c727ea7
--- /dev/null
+++ b/packages/noodl-core-ui/src/components/StyleSuggestions/index.ts
@@ -0,0 +1,2 @@
+export { SuggestionBanner } from './SuggestionBanner';
+export type { SuggestionBannerProps, SuggestionBannerSuggestion } from './SuggestionBanner';
diff --git a/packages/noodl-editor/src/editor/src/hooks/useStyleSuggestions.ts b/packages/noodl-editor/src/editor/src/hooks/useStyleSuggestions.ts
new file mode 100644
index 0000000..0b54ad2
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/hooks/useStyleSuggestions.ts
@@ -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 {
+ 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): 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([]);
+ const [permanentDismissed, setPermanentDismissed] = useState>(() => loadPersisted(DISMISSED_KEY));
+ // Session dismissed lives in a ref-backed state so it survives re-renders but not reloads
+ const [sessionDismissed, setSessionDismissed] = useState>(() => 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
+ };
+}
diff --git a/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/StyleAnalyzer.ts b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/StyleAnalyzer.ts
new file mode 100644
index 0000000..e762302
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/StyleAnalyzer.ts
@@ -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 {
+ // 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();
+ const spacingMap = new Map();
+ 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 = node.parameters || {};
+ const nodeLabel = (params['label'] as string) || node.typename;
+ const customOverrides: Record = {};
+
+ 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();
+ 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 {
+ const project = ProjectModel.instance;
+ if (!project) return { variantCandidates: [] };
+
+ const node = project.findNodeWithId(nodeId);
+ if (!node) return { variantCandidates: [] };
+
+ const params: Record = node.parameters || {};
+ const nodeLabel = (params['label'] as string) || node.typename;
+ const customOverrides: Record = {};
+
+ 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,
+ 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;
+ }
+}
diff --git a/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/SuggestionActionHandler.ts b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/SuggestionActionHandler.ts
new file mode 100644
index 0000000..ce41a6a
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/SuggestionActionHandler.ts
@@ -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;
+}
diff --git a/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/index.ts b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/index.ts
new file mode 100644
index 0000000..f6b615e
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/index.ts
@@ -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';
diff --git a/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/types.ts b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/types.ts
new file mode 100644
index 0000000..ea514e1
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/services/StyleAnalyzer/types.ts
@@ -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;
+ /** 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;