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';