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 ( +
+ + ); +} 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;