mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SuggestionBanner } from './SuggestionBanner';
|
||||
export type { SuggestionBannerProps, SuggestionBannerSuggestion } from './SuggestionBanner';
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user