mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
Merge branch 'cline-dev-richard' into cline-dev
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,299 @@
|
||||
/**
|
||||
* STYLE-001 Phase 3: TokenPicker styles
|
||||
* All colours via design tokens — no hardcoded values.
|
||||
*/
|
||||
|
||||
.TokenPicker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1, 4px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ── Label ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.TokenPicker-label {
|
||||
font-size: var(--font-size-xsmall, 11px);
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// ── Trigger button ───────────────────────────────────────────────────────────
|
||||
|
||||
.TokenPicker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1, 4px);
|
||||
width: 100%;
|
||||
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
|
||||
background: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
font-size: var(--font-size-small, 12px);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 100ms ease, background 100ms ease;
|
||||
min-height: 26px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--theme-color-bg-2);
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// When a token is selected — highlight border subtly
|
||||
&--hasValue {
|
||||
border-color: color-mix(in srgb, var(--theme-color-primary) 40%, var(--theme-color-border-default));
|
||||
}
|
||||
}
|
||||
|
||||
// Colour swatch in the trigger
|
||||
.TokenPicker-swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.TokenPicker-triggerText {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.TokenPicker-clearBtn {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-color-fg-default);
|
||||
background: var(--theme-color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.TokenPicker-chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// ── Dropdown panel ───────────────────────────────────────────────────────────
|
||||
|
||||
.TokenPicker-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-1, 4px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 300;
|
||||
background: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow: hidden;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
// ── Search row ───────────────────────────────────────────────────────────────
|
||||
|
||||
.TokenPicker-searchRow {
|
||||
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.TokenPicker-search {
|
||||
width: 100%;
|
||||
padding: 3px var(--spacing-1, 4px);
|
||||
background: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 3px;
|
||||
font-size: var(--font-size-small, 12px);
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Token list ───────────────────────────────────────────────────────────────
|
||||
|
||||
.TokenPicker-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.TokenPicker-empty {
|
||||
padding: var(--spacing-2, 8px) var(--spacing-3, 12px);
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// ── Token group ───────────────────────────────────────────────────────────────
|
||||
|
||||
.TokenPicker-group {
|
||||
& + & {
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
}
|
||||
|
||||
.TokenPicker-groupLabel {
|
||||
display: block;
|
||||
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
user-select: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--theme-color-bg-2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// ── Token option ─────────────────────────────────────────────────────────────
|
||||
|
||||
.TokenPicker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1, 4px);
|
||||
padding: 3px var(--spacing-2, 8px);
|
||||
background: transparent;
|
||||
color: var(--theme-color-fg-default);
|
||||
border: none;
|
||||
font-size: var(--font-size-small, 12px);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background 60ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--theme-color-primary);
|
||||
background: color-mix(in srgb, var(--theme-color-primary) 10%, transparent);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--theme-color-primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom tokens get a subtle tint
|
||||
&--custom {
|
||||
.TokenPicker-optionName {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TokenPicker-optionPreview {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.TokenPicker-optionSwatch {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.TokenPicker-optionValueBadge {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
max-width: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.TokenPicker-optionBody {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.TokenPicker-optionName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small, 12px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.TokenPicker-optionValue {
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.TokenPicker-customBadge {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
color: var(--theme-color-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.TokenPicker-checkmark {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* STYLE-001 Phase 3: TokenPicker
|
||||
*
|
||||
* Reusable dropdown component for selecting a design token to bind to a CSS property.
|
||||
* Displays the current token (with colour swatch preview for colour tokens) and opens
|
||||
* a searchable, grouped list of all available tokens.
|
||||
*
|
||||
* This component is fully dumb — it receives tokens as plain data and fires callbacks.
|
||||
* Token resolution (var() → actual CSS value) must be done by the caller before passing
|
||||
* items in. This keeps noodl-core-ui free of editor-specific model dependencies.
|
||||
*
|
||||
* Usage:
|
||||
* <TokenPicker
|
||||
* tokens={allTokens.map(t => ({ ...t, resolvedValue: model.resolveToken(t.name) }))}
|
||||
* selectedToken="--primary"
|
||||
* onTokenSelect={(cssVar) => applyValue(cssVar)}
|
||||
* onClear={() => applyValue('')}
|
||||
* filterCategories={['color-semantic', 'color-palette']}
|
||||
* label="Background Color"
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import css from './TokenPicker.module.scss';
|
||||
|
||||
// ─── Public types (no editor dependencies) ───────────────────────────────────
|
||||
|
||||
export interface TokenPickerItem {
|
||||
/** CSS custom property name, e.g. '--primary' */
|
||||
name: string;
|
||||
/** Raw token value — may be a var() reference */
|
||||
value: string;
|
||||
/** Resolved CSS value for preview rendering, e.g. '#3b82f6'. Pre-resolved by caller. */
|
||||
resolvedValue?: string;
|
||||
/** Category string matching TokenCategory in the editor, e.g. 'color-semantic' */
|
||||
category: string;
|
||||
/** Whether this token has been customised from the project defaults */
|
||||
isCustom?: boolean;
|
||||
/** Optional description shown in the dropdown */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TokenPickerGroup {
|
||||
/** Display label for the group header */
|
||||
label: string;
|
||||
/** Tokens belonging to this group */
|
||||
tokens: TokenPickerItem[];
|
||||
}
|
||||
|
||||
export interface TokenPickerProps {
|
||||
/** All available tokens to show in the dropdown. */
|
||||
tokens: TokenPickerItem[];
|
||||
|
||||
/**
|
||||
* Groups to use for displaying tokens in the dropdown.
|
||||
* If provided, overrides the auto-grouping from `groupBy`.
|
||||
*/
|
||||
groups?: TokenPickerGroup[];
|
||||
|
||||
/**
|
||||
* Derive the display group label from a category string.
|
||||
* Defaults to capitalising the category's prefix (e.g. 'color-semantic' → 'Colors').
|
||||
*/
|
||||
groupBy?: (category: string) => string;
|
||||
|
||||
/**
|
||||
* Currently selected token name (CSS custom property name, without var() wrapper).
|
||||
* Pass null or undefined if no token is currently selected.
|
||||
*/
|
||||
selectedToken: string | null | undefined;
|
||||
|
||||
/**
|
||||
* Called when the user picks a token.
|
||||
* The argument is the full CSS `var(--token-name)` string, ready to use as a style value.
|
||||
*/
|
||||
onTokenSelect: (cssVar: string) => void;
|
||||
|
||||
/**
|
||||
* Called when the user clears the token selection.
|
||||
* If not provided, the clear button is hidden.
|
||||
*/
|
||||
onClear?: () => void;
|
||||
|
||||
/** Filter to specific category strings. When empty/undefined, all tokens are shown. */
|
||||
filterCategories?: string[];
|
||||
|
||||
/** Label shown above the trigger button. */
|
||||
label?: string;
|
||||
|
||||
/** Placeholder text when no token is selected. Defaults to 'Choose token'. */
|
||||
placeholder?: string;
|
||||
|
||||
/** Disable the picker. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Detect whether a value (or category) represents a colour. */
|
||||
function isColorCategory(category: string): boolean {
|
||||
return category.startsWith('color');
|
||||
}
|
||||
|
||||
/** Check whether a resolved CSS value looks like a colour (hex, rgb, hsl, named). */
|
||||
function looksLikeColor(value: string): boolean {
|
||||
const v = value.trim();
|
||||
return (
|
||||
v.startsWith('#') || v.startsWith('rgb') || v.startsWith('hsl') || /^[a-z]+$/.test(v) // css named colours
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a token name for display.
|
||||
* '--color-primary' → 'color-primary'
|
||||
* '--space-4' → 'space-4'
|
||||
*/
|
||||
function formatTokenLabel(name: string): string {
|
||||
return name.replace(/^--/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a group label from a category string.
|
||||
* 'color-semantic' → 'Colors'
|
||||
* 'typography-size' → 'Typography'
|
||||
* 'border-radius' → 'Borders'
|
||||
*/
|
||||
function defaultGroupBy(category: string): string {
|
||||
const prefix = category.split('-')[0];
|
||||
const map: Record<string, string> = {
|
||||
color: 'Colors',
|
||||
spacing: 'Spacing',
|
||||
typography: 'Typography',
|
||||
border: 'Borders',
|
||||
shadow: 'Effects',
|
||||
animation: 'Animation'
|
||||
};
|
||||
return map[prefix] ?? prefix.charAt(0).toUpperCase() + prefix.slice(1);
|
||||
}
|
||||
|
||||
/** Group a flat token list into labelled groups, preserving insertion order. */
|
||||
function buildGroups(tokens: TokenPickerItem[], groupBy: (cat: string) => string): TokenPickerGroup[] {
|
||||
const map = new Map<string, TokenPickerItem[]>();
|
||||
for (const token of tokens) {
|
||||
const label = groupBy(token.category);
|
||||
if (!map.has(label)) map.set(label, []);
|
||||
map.get(label)!.push(token);
|
||||
}
|
||||
return Array.from(map.entries()).map(([label, items]) => ({ label, tokens: items }));
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function TokenPicker({
|
||||
tokens,
|
||||
groups: groupsProp,
|
||||
groupBy = defaultGroupBy,
|
||||
selectedToken,
|
||||
onTokenSelect,
|
||||
onClear,
|
||||
filterCategories,
|
||||
label,
|
||||
placeholder = 'Choose token',
|
||||
disabled = false
|
||||
}: TokenPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Resolve the currently selected token item
|
||||
const selectedItem = useMemo(
|
||||
() => (selectedToken ? tokens.find((t) => t.name === selectedToken) ?? null : null),
|
||||
[tokens, selectedToken]
|
||||
);
|
||||
|
||||
// Filter & group tokens for the dropdown
|
||||
const visibleGroups = useMemo<TokenPickerGroup[]>(() => {
|
||||
// Start from explicit groups or auto-build them
|
||||
const source = groupsProp ?? buildGroups(tokens, groupBy);
|
||||
|
||||
// Apply category filter
|
||||
const categorized =
|
||||
filterCategories && filterCategories.length > 0
|
||||
? source.map((g) => ({ ...g, tokens: g.tokens.filter((t) => filterCategories.includes(t.category)) }))
|
||||
: source;
|
||||
|
||||
// Apply search filter
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return categorized.filter((g) => g.tokens.length > 0);
|
||||
|
||||
return categorized
|
||||
.map((g) => ({
|
||||
...g,
|
||||
tokens: g.tokens.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
(t.description ?? '').toLowerCase().includes(q) ||
|
||||
(t.resolvedValue ?? t.value).toLowerCase().includes(q)
|
||||
)
|
||||
}))
|
||||
.filter((g) => g.tokens.length > 0);
|
||||
}, [tokens, groupsProp, groupBy, filterCategories, search]);
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (!disabled) {
|
||||
setIsOpen(true);
|
||||
setSearch('');
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: TokenPickerItem) => {
|
||||
close();
|
||||
onTokenSelect(`var(${item.name})`);
|
||||
},
|
||||
[close, onTokenSelect]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
onClear?.();
|
||||
},
|
||||
[close, onClear]
|
||||
);
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, [isOpen, close]);
|
||||
|
||||
// Close on Escape, navigate with keyboard
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen, close]);
|
||||
|
||||
// ── Trigger swatch ─────────────────────────────────────────────────────
|
||||
const triggerSwatch =
|
||||
selectedItem && isColorCategory(selectedItem.category) && selectedItem.resolvedValue
|
||||
? selectedItem.resolvedValue
|
||||
: null;
|
||||
|
||||
const totalTokenCount = visibleGroups.reduce((n, g) => n + g.tokens.length, 0);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={css['TokenPicker']} ref={containerRef}>
|
||||
{label && <span className={css['TokenPicker-label']}>{label}</span>}
|
||||
|
||||
{/* Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
className={[css['TokenPicker-trigger'], selectedItem ? css['TokenPicker-trigger--hasValue'] : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={open}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
title={selectedItem ? formatTokenLabel(selectedItem.name) : placeholder}
|
||||
>
|
||||
{triggerSwatch && (
|
||||
<span className={css['TokenPicker-swatch']} style={{ background: triggerSwatch }} aria-hidden />
|
||||
)}
|
||||
<span className={css['TokenPicker-triggerText']}>
|
||||
{selectedItem ? formatTokenLabel(selectedItem.name) : placeholder}
|
||||
</span>
|
||||
{selectedItem && onClear && (
|
||||
<button
|
||||
type="button"
|
||||
className={css['TokenPicker-clearBtn']}
|
||||
onClick={handleClear}
|
||||
title="Clear token"
|
||||
aria-label="Clear token selection"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{!selectedItem && (
|
||||
<span className={css['TokenPicker-chevron']} aria-hidden>
|
||||
▾
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className={css['TokenPicker-dropdown']} role="listbox">
|
||||
{/* Search */}
|
||||
<div className={css['TokenPicker-searchRow']}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
className={css['TokenPicker-search']}
|
||||
placeholder="Search tokens..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Search tokens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className={css['TokenPicker-list']}>
|
||||
{totalTokenCount === 0 && <p className={css['TokenPicker-empty']}>No tokens match</p>}
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.label} className={css['TokenPicker-group']}>
|
||||
<span className={css['TokenPicker-groupLabel']}>{group.label}</span>
|
||||
{group.tokens.map((item) => {
|
||||
const isSelected = item.name === selectedToken;
|
||||
const swatch =
|
||||
isColorCategory(item.category) && item.resolvedValue && looksLikeColor(item.resolvedValue)
|
||||
? item.resolvedValue
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={[
|
||||
css['TokenPicker-option'],
|
||||
isSelected ? css['TokenPicker-option--active'] : '',
|
||||
item.isCustom ? css['TokenPicker-option--custom'] : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => handleSelect(item)}
|
||||
title={item.description ?? item.resolvedValue ?? item.value}
|
||||
>
|
||||
{/* Colour swatch or value preview */}
|
||||
<span className={css['TokenPicker-optionPreview']} aria-hidden>
|
||||
{swatch ? (
|
||||
<span className={css['TokenPicker-optionSwatch']} style={{ background: swatch }} />
|
||||
) : (
|
||||
<span className={css['TokenPicker-optionValueBadge']}>
|
||||
{(item.resolvedValue ?? item.value).slice(0, 4)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className={css['TokenPicker-optionBody']}>
|
||||
<span className={css['TokenPicker-optionName']}>{formatTokenLabel(item.name)}</span>
|
||||
{item.resolvedValue && (
|
||||
<span className={css['TokenPicker-optionValue']}>{item.resolvedValue}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{item.isCustom && (
|
||||
<span className={css['TokenPicker-customBadge']} title="Custom token" aria-hidden>
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<span className={css['TokenPicker-checkmark']} aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TokenPicker } from './TokenPicker';
|
||||
export type { TokenPickerItem, TokenPickerGroup, TokenPickerProps } from './TokenPicker';
|
||||
@@ -0,0 +1,74 @@
|
||||
/* ProjectCreationWizard — modal shell styles */
|
||||
|
||||
.Backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.Modal {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 520px;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
|
||||
.Header {
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.StepLabel {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ---- Content ---- */
|
||||
|
||||
.Content {
|
||||
padding: var(--spacing-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
|
||||
.Footer {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* ProjectCreationWizard - Multi-step project creation flow
|
||||
*
|
||||
* Replaces CreateProjectModal with a guided experience that supports:
|
||||
* - Quick Start (name + location → create)
|
||||
* - Guided Setup (name/description → style preset → review → create)
|
||||
* - AI Builder stub (coming in V2)
|
||||
*
|
||||
* The onConfirm signature is identical to CreateProjectModal so ProjectsPage
|
||||
* requires only an import-name swap.
|
||||
*
|
||||
* @module noodl-core-ui/preview/launcher
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { PresetDisplayInfo } from '@noodl-core-ui/components/StylePresets';
|
||||
|
||||
import css from './ProjectCreationWizard.module.scss';
|
||||
import { EntryModeStep } from './steps/EntryModeStep';
|
||||
import { ProjectBasicsStep } from './steps/ProjectBasicsStep';
|
||||
import { ReviewStep } from './steps/ReviewStep';
|
||||
import { StylePresetStep } from './steps/StylePresetStep';
|
||||
import { WizardProvider, useWizardContext, DEFAULT_PRESET_ID, WizardStep } from './WizardContext';
|
||||
|
||||
// ----- Public API -----------------------------------------------------------
|
||||
|
||||
export interface ProjectCreationWizardProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Called when the user confirms project creation.
|
||||
* Signature is identical to the legacy CreateProjectModal.onConfirm so
|
||||
* callers need no changes beyond swapping the import.
|
||||
*/
|
||||
onConfirm: (name: string, location: string, presetId: string) => void;
|
||||
/** Open a native folder picker; returns the chosen path or null if cancelled */
|
||||
onChooseLocation?: () => Promise<string | null>;
|
||||
/** Style presets to show in the preset picker step */
|
||||
presets?: PresetDisplayInfo[];
|
||||
}
|
||||
|
||||
// ----- Step metadata --------------------------------------------------------
|
||||
|
||||
const STEP_TITLES: Record<WizardStep, string> = {
|
||||
entry: 'Create New Project',
|
||||
basics: 'Project Basics',
|
||||
preset: 'Style Preset',
|
||||
review: 'Review'
|
||||
};
|
||||
|
||||
/** Steps where the Back button should be hidden (entry has no "back") */
|
||||
const STEPS_WITHOUT_BACK: WizardStep[] = ['entry'];
|
||||
|
||||
// ----- Inner wizard (has access to context) ---------------------------------
|
||||
|
||||
interface WizardInnerProps extends Omit<ProjectCreationWizardProps, 'isVisible'> {
|
||||
presets: PresetDisplayInfo[];
|
||||
}
|
||||
|
||||
function WizardInner({ onClose, onConfirm, onChooseLocation, presets }: WizardInnerProps) {
|
||||
const { state, goNext, goBack, canProceed } = useWizardContext();
|
||||
|
||||
const { currentStep, mode, projectName, location, selectedPresetId } = state;
|
||||
|
||||
// Determine if this is the final step before creation
|
||||
const isLastStep = currentStep === 'review' || (mode === 'quick' && currentStep === 'basics');
|
||||
|
||||
const nextLabel = isLastStep ? 'Create Project' : 'Next';
|
||||
const showBack = !STEPS_WITHOUT_BACK.includes(currentStep);
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
// Fire creation with the wizard state values
|
||||
onConfirm(projectName.trim(), location, selectedPresetId);
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
};
|
||||
|
||||
// Render the active step body
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 'entry':
|
||||
return <EntryModeStep />;
|
||||
case 'basics':
|
||||
return <ProjectBasicsStep onChooseLocation={onChooseLocation ?? (() => Promise.resolve(null))} />;
|
||||
case 'preset':
|
||||
return <StylePresetStep presets={presets} />;
|
||||
case 'review':
|
||||
return <ReviewStep presets={presets} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css['Backdrop']} onClick={onClose}>
|
||||
<div className={css['Modal']} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
<h3 className={css['Title']}>{STEP_TITLES[currentStep]}</h3>
|
||||
|
||||
{/* Step indicator (not shown on entry screen) */}
|
||||
{currentStep !== 'entry' && (
|
||||
<span className={css['StepLabel']}>{mode === 'quick' ? 'Quick Start' : 'Guided Setup'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={css['Content']}>{renderStep()}</div>
|
||||
|
||||
{/* Footer — hidden on entry (entry step uses card clicks to advance) */}
|
||||
{currentStep !== 'entry' && (
|
||||
<div className={css['Footer']}>
|
||||
{showBack && (
|
||||
<PrimaryButton
|
||||
label="Back"
|
||||
size={PrimaryButtonSize.Default}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={goBack}
|
||||
UNSAFE_style={{ marginRight: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
size={PrimaryButtonSize.Default}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onClose}
|
||||
UNSAFE_style={{ marginRight: 'var(--spacing-2)' }}
|
||||
/>
|
||||
|
||||
<PrimaryButton
|
||||
label={nextLabel}
|
||||
size={PrimaryButtonSize.Default}
|
||||
onClick={handleNext}
|
||||
isDisabled={!canProceed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Public component (manages provider lifecycle) ------------------------
|
||||
|
||||
/**
|
||||
* ProjectCreationWizard — Drop-in replacement for CreateProjectModal.
|
||||
*
|
||||
* @example
|
||||
* // ProjectsPage.tsx — only change the import, nothing else
|
||||
* import { ProjectCreationWizard } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectCreationWizard';
|
||||
*
|
||||
* <ProjectCreationWizard
|
||||
* isVisible={isCreateModalVisible}
|
||||
* onClose={handleCreateModalClose}
|
||||
* onConfirm={handleCreateProjectConfirm}
|
||||
* onChooseLocation={handleChooseLocation}
|
||||
* presets={STYLE_PRESETS}
|
||||
* />
|
||||
*/
|
||||
export function ProjectCreationWizard({
|
||||
isVisible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onChooseLocation,
|
||||
presets
|
||||
}: ProjectCreationWizardProps) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
// Key the provider on `isVisible` so state fully resets each time the
|
||||
// modal opens — no stale name/location from the previous session.
|
||||
return (
|
||||
<WizardProvider key="project-creation-wizard">
|
||||
<WizardInner
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
onChooseLocation={onChooseLocation}
|
||||
presets={presets ?? []}
|
||||
/>
|
||||
</WizardProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* WizardContext - Shared state for the Project Creation Wizard
|
||||
*
|
||||
* Manages wizard step flow, form values, and mode selection.
|
||||
* Passed via React context so all step components can read/write without prop drilling.
|
||||
*/
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
// ----- Types ----------------------------------------------------------------
|
||||
|
||||
/** The entry-mode choice the user makes on the first screen */
|
||||
export type WizardMode = 'quick' | 'guided' | 'ai';
|
||||
|
||||
/**
|
||||
* Step identifiers in the guided flow.
|
||||
* Quick mode only visits 'basics' (no preset or review step).
|
||||
*/
|
||||
export type WizardStep = 'entry' | 'basics' | 'preset' | 'review';
|
||||
|
||||
export const DEFAULT_PRESET_ID = 'modern';
|
||||
|
||||
export interface WizardState {
|
||||
/** Active wizard mode chosen by the user */
|
||||
mode: WizardMode;
|
||||
/** Current step being displayed */
|
||||
currentStep: WizardStep;
|
||||
/** Project name entered by the user */
|
||||
projectName: string;
|
||||
/** Optional project description (guided mode only) */
|
||||
description: string;
|
||||
/** Folder path chosen via native dialog */
|
||||
location: string;
|
||||
/** ID of the selected style preset */
|
||||
selectedPresetId: string;
|
||||
}
|
||||
|
||||
export interface WizardContextValue {
|
||||
state: WizardState;
|
||||
/** Update one or more fields of the wizard state */
|
||||
update: (partial: Partial<WizardState>) => void;
|
||||
/** Move forward to the next logical step (mode-aware) */
|
||||
goNext: () => void;
|
||||
/** Move back to the previous step */
|
||||
goBack: () => void;
|
||||
/** Whether the current step has all required data to proceed */
|
||||
canProceed: boolean;
|
||||
}
|
||||
|
||||
// ----- Context --------------------------------------------------------------
|
||||
|
||||
const WizardContext = createContext<WizardContextValue | null>(null);
|
||||
|
||||
export function useWizardContext(): WizardContextValue {
|
||||
const ctx = useContext(WizardContext);
|
||||
if (!ctx) throw new Error('useWizardContext must be used within WizardProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ----- Step ordering --------------------------------------------------------
|
||||
|
||||
/** Returns the ordered list of steps for the given mode */
|
||||
function getStepSequence(mode: WizardMode): WizardStep[] {
|
||||
switch (mode) {
|
||||
case 'quick':
|
||||
// Quick Start: just fill in name/location, no preset picker or review
|
||||
return ['basics'];
|
||||
case 'guided':
|
||||
return ['basics', 'preset', 'review'];
|
||||
case 'ai':
|
||||
// AI mode is a stub for V1 — same as guided until AI is wired
|
||||
return ['basics', 'preset', 'review'];
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Validation -----------------------------------------------------------
|
||||
|
||||
function isStepValid(step: WizardStep, state: WizardState): boolean {
|
||||
switch (step) {
|
||||
case 'entry':
|
||||
// Entry screen has no data — user just picks a mode
|
||||
return true;
|
||||
case 'basics':
|
||||
return state.projectName.trim().length > 0 && state.location.length > 0;
|
||||
case 'preset':
|
||||
return state.selectedPresetId.length > 0;
|
||||
case 'review':
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Provider -------------------------------------------------------------
|
||||
|
||||
export interface WizardProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialState?: Partial<WizardState>;
|
||||
}
|
||||
|
||||
export function WizardProvider({ children, initialState }: WizardProviderProps) {
|
||||
const [state, setStateRaw] = useState<WizardState>({
|
||||
mode: 'quick',
|
||||
currentStep: 'entry',
|
||||
projectName: '',
|
||||
description: '',
|
||||
location: '',
|
||||
selectedPresetId: DEFAULT_PRESET_ID,
|
||||
...initialState
|
||||
});
|
||||
|
||||
const update = useCallback((partial: Partial<WizardState>) => {
|
||||
setStateRaw((prev) => ({ ...prev, ...partial }));
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setStateRaw((prev) => {
|
||||
if (prev.currentStep === 'entry') {
|
||||
// Entry → first step of the chosen mode sequence
|
||||
const seq = getStepSequence(prev.mode);
|
||||
return { ...prev, currentStep: seq[0] };
|
||||
}
|
||||
const seq = getStepSequence(prev.mode);
|
||||
const idx = seq.indexOf(prev.currentStep);
|
||||
if (idx === -1 || idx >= seq.length - 1) return prev;
|
||||
return { ...prev, currentStep: seq[idx + 1] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setStateRaw((prev) => {
|
||||
if (prev.currentStep === 'entry') return prev;
|
||||
const seq = getStepSequence(prev.mode);
|
||||
const idx = seq.indexOf(prev.currentStep);
|
||||
if (idx <= 0) {
|
||||
// Back from the first real step → return to entry screen
|
||||
return { ...prev, currentStep: 'entry' };
|
||||
}
|
||||
return { ...prev, currentStep: seq[idx - 1] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const canProceed = isStepValid(state.currentStep, state);
|
||||
|
||||
return (
|
||||
<WizardContext.Provider value={{ state, update, goNext, goBack, canProceed }}>{children}</WizardContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ProjectCreationWizard } from './ProjectCreationWizard';
|
||||
export type { ProjectCreationWizardProps } from './ProjectCreationWizard';
|
||||
export type { WizardMode, WizardStep, WizardState } from './WizardContext';
|
||||
@@ -0,0 +1,72 @@
|
||||
.EntryModeStep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.EntryModeStep-prompt {
|
||||
font-size: 15px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin: 0 0 var(--spacing-5) 0;
|
||||
}
|
||||
|
||||
.EntryModeStep-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.ModeCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--radius-default);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover:not(&--disabled) {
|
||||
border-color: var(--theme-color-primary, #ef4444);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary, #ef4444);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ModeCard-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ModeCard-description {
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ModeCard-badge {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-3);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
padding: 2px var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* EntryModeStep - First screen of the Project Creation Wizard
|
||||
*
|
||||
* Lets the user choose between Quick Start, Guided Setup, or AI Builder (stub).
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { WizardMode, useWizardContext } from '../WizardContext';
|
||||
import css from './EntryModeStep.module.scss';
|
||||
|
||||
interface ModeCardProps {
|
||||
mode: WizardMode;
|
||||
title: string;
|
||||
description: string;
|
||||
isDisabled?: boolean;
|
||||
onSelect: (mode: WizardMode) => void;
|
||||
}
|
||||
|
||||
function ModeCard({ mode, title, description, isDisabled, onSelect }: ModeCardProps) {
|
||||
return (
|
||||
<button
|
||||
className={`${css['ModeCard']} ${isDisabled ? css['ModeCard--disabled'] : ''}`}
|
||||
onClick={() => !isDisabled && onSelect(mode)}
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
>
|
||||
<span className={css['ModeCard-title']}>{title}</span>
|
||||
<span className={css['ModeCard-description']}>{description}</span>
|
||||
{isDisabled && <span className={css['ModeCard-badge']}>Coming soon</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntryModeStep() {
|
||||
const { update, goNext } = useWizardContext();
|
||||
|
||||
const handleSelect = (mode: WizardMode) => {
|
||||
update({ mode });
|
||||
goNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css['EntryModeStep']}>
|
||||
<p className={css['EntryModeStep-prompt']}>How would you like to start?</p>
|
||||
|
||||
<div className={css['EntryModeStep-cards']}>
|
||||
<ModeCard
|
||||
mode="quick"
|
||||
title="Quick Start"
|
||||
description="Blank project with Modern preset. Name it, pick a folder, and build."
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<ModeCard
|
||||
mode="guided"
|
||||
title="Guided Setup"
|
||||
description="Walk through name, description, and style preset step by step."
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<ModeCard
|
||||
mode="ai"
|
||||
title="AI Project Builder"
|
||||
description="Describe what you want to build and AI sets up the scaffolding."
|
||||
isDisabled
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.ProjectBasicsStep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.Field {
|
||||
margin-bottom: var(--spacing-5);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.LocationRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.PathPreview {
|
||||
margin-top: var(--spacing-4);
|
||||
padding: var(--spacing-3);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: var(--radius-default);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.PathText {
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* ProjectBasicsStep - Name, optional description, and folder location
|
||||
*
|
||||
* Shown in both Quick Start and Guided modes.
|
||||
* Description field is only shown in Guided mode.
|
||||
*/
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
|
||||
import { Label } from '@noodl-core-ui/components/typography/Label';
|
||||
|
||||
import { useWizardContext } from '../WizardContext';
|
||||
import css from './ProjectBasicsStep.module.scss';
|
||||
|
||||
export interface ProjectBasicsStepProps {
|
||||
/** Called when the user clicks "Browse..." to pick a folder */
|
||||
onChooseLocation: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
export function ProjectBasicsStep({ onChooseLocation }: ProjectBasicsStepProps) {
|
||||
const { state, update } = useWizardContext();
|
||||
const isGuided = state.mode === 'guided' || state.mode === 'ai';
|
||||
|
||||
const handleChooseLocation = useCallback(async () => {
|
||||
const chosen = await onChooseLocation();
|
||||
if (chosen) {
|
||||
update({ location: chosen });
|
||||
}
|
||||
}, [onChooseLocation, update]);
|
||||
|
||||
return (
|
||||
<div className={css['ProjectBasicsStep']}>
|
||||
{/* Project Name */}
|
||||
<div className={css['Field']}>
|
||||
<Label>Project Name</Label>
|
||||
<TextInput
|
||||
value={state.projectName}
|
||||
onChange={(e) => update({ projectName: e.target.value })}
|
||||
placeholder="My New Project"
|
||||
isAutoFocus
|
||||
UNSAFE_style={{ marginTop: 'var(--spacing-2)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description — guided mode only */}
|
||||
{isGuided && (
|
||||
<div className={css['Field']}>
|
||||
<Label>Description (optional)</Label>
|
||||
<TextInput
|
||||
value={state.description}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
placeholder="A brief description of your project..."
|
||||
UNSAFE_style={{ marginTop: 'var(--spacing-2)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
<div className={css['Field']}>
|
||||
<Label>Location</Label>
|
||||
<div className={css['LocationRow']}>
|
||||
<TextInput
|
||||
value={state.location}
|
||||
onChange={(e) => update({ location: e.target.value })}
|
||||
placeholder="Choose folder..."
|
||||
isReadonly
|
||||
UNSAFE_style={{ flex: 1 }}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Browse..."
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={handleChooseLocation}
|
||||
UNSAFE_style={{ marginLeft: 'var(--spacing-2)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path preview */}
|
||||
{state.projectName && state.location && (
|
||||
<div className={css['PathPreview']}>
|
||||
<span className={css['PathText']}>
|
||||
Full path: {state.location}/{state.projectName}/
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.ReviewStep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ReviewStep-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin: 0 0 var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.Summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--radius-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.SummaryRow {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
align-items: start;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.SummaryRow-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.SummaryRow-value {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.SummaryRow-main {
|
||||
font-size: 14px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.SummaryRow-secondary {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.SummaryRow-edit {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-primary, #ef4444);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1.6;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary, #ef4444);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* ReviewStep - Final summary before project creation
|
||||
*
|
||||
* Shows the chosen settings and lets the user go back to edit any step.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { PresetDisplayInfo } from '@noodl-core-ui/components/StylePresets';
|
||||
|
||||
import { useWizardContext } from '../WizardContext';
|
||||
import css from './ReviewStep.module.scss';
|
||||
|
||||
export interface ReviewStepProps {
|
||||
presets: PresetDisplayInfo[];
|
||||
}
|
||||
|
||||
export function ReviewStep({ presets }: ReviewStepProps) {
|
||||
const { state, update, goBack } = useWizardContext();
|
||||
|
||||
const selectedPreset = presets.find((p) => p.id === state.selectedPresetId);
|
||||
|
||||
const handleEditBasics = () => {
|
||||
// Navigate back to basics by jumping steps
|
||||
update({ currentStep: 'basics' });
|
||||
};
|
||||
|
||||
const handleEditPreset = () => {
|
||||
update({ currentStep: 'preset' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css['ReviewStep']}>
|
||||
<p className={css['ReviewStep-subtitle']}>Review your settings before creating.</p>
|
||||
|
||||
<div className={css['Summary']}>
|
||||
{/* Basics row */}
|
||||
<div className={css['SummaryRow']}>
|
||||
<div className={css['SummaryRow-label']}>Project</div>
|
||||
<div className={css['SummaryRow-value']}>
|
||||
<span className={css['SummaryRow-main']}>{state.projectName || '—'}</span>
|
||||
{state.description && <span className={css['SummaryRow-secondary']}>{state.description}</span>}
|
||||
</div>
|
||||
<button className={css['SummaryRow-edit']} onClick={handleEditBasics} type="button">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Location row */}
|
||||
<div className={css['SummaryRow']}>
|
||||
<div className={css['SummaryRow-label']}>Location</div>
|
||||
<div className={css['SummaryRow-value']}>
|
||||
<span className={css['SummaryRow-main']} title={state.location}>
|
||||
{state.location || '—'}
|
||||
</span>
|
||||
{state.projectName && state.location && (
|
||||
<span className={css['SummaryRow-secondary']}>
|
||||
Full path: {state.location}/{state.projectName}/
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className={css['SummaryRow-edit']} onClick={handleEditBasics} type="button">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Style preset row */}
|
||||
<div className={css['SummaryRow']}>
|
||||
<div className={css['SummaryRow-label']}>Style</div>
|
||||
<div className={css['SummaryRow-value']}>
|
||||
<span className={css['SummaryRow-main']}>{selectedPreset?.name ?? state.selectedPresetId}</span>
|
||||
{selectedPreset?.description && (
|
||||
<span className={css['SummaryRow-secondary']}>{selectedPreset.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<button className={css['SummaryRow-edit']} onClick={handleEditPreset} type="button">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.StylePresetStep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.StylePresetStep-hint {
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin: 0 0 var(--spacing-4) 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* StylePresetStep - Style preset selection for guided mode
|
||||
*
|
||||
* Reuses the existing PresetSelector component built in STYLE-003.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { PresetDisplayInfo, PresetSelector } from '@noodl-core-ui/components/StylePresets';
|
||||
|
||||
import { useWizardContext } from '../WizardContext';
|
||||
import css from './StylePresetStep.module.scss';
|
||||
|
||||
export interface StylePresetStepProps {
|
||||
/** Preset data passed in from the editor (avoid circular dep) */
|
||||
presets: PresetDisplayInfo[];
|
||||
}
|
||||
|
||||
export function StylePresetStep({ presets }: StylePresetStepProps) {
|
||||
const { state, update } = useWizardContext();
|
||||
|
||||
return (
|
||||
<div className={css['StylePresetStep']}>
|
||||
<p className={css['StylePresetStep-hint']}>
|
||||
Choose a visual style for your project. You can customise colors and fonts later.
|
||||
</p>
|
||||
<PresetSelector
|
||||
presets={presets}
|
||||
selectedId={state.selectedPresetId}
|
||||
onChange={(id) => update({ selectedPresetId: id })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { StyleTokenRecord, StyleTokensModel } from '@noodl-models/StyleTokensMod
|
||||
|
||||
import { Slot } from '@noodl-core-ui/types/global';
|
||||
|
||||
import { PreviewTokenInjector } from '../../services/PreviewTokenInjector';
|
||||
import { DesignTokenColor, extractProjectColors } from './extractProjectColors';
|
||||
|
||||
export interface ProjectDesignTokenContext {
|
||||
@@ -78,6 +79,11 @@ export function ProjectDesignTokenContextProvider({ children }: ProjectDesignTok
|
||||
setDesignTokens(styleTokensModel.getTokens());
|
||||
}, [styleTokensModel]);
|
||||
|
||||
// Wire preview token injector so the preview webview reflects the current token values
|
||||
useEffect(() => {
|
||||
PreviewTokenInjector.instance.attachModel(styleTokensModel);
|
||||
}, [styleTokensModel]);
|
||||
|
||||
useEventListener(styleTokensModel, 'tokensChanged', () => {
|
||||
setDesignTokens(styleTokensModel.getTokens());
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
115
packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts
Normal file
115
packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* UBA-003/UBA-004: Condition evaluation utilities
|
||||
*
|
||||
* Evaluates `visible_when` and `depends_on` conditions from the UBA schema,
|
||||
* driving dynamic field/section visibility and dependency messaging.
|
||||
*
|
||||
* The `Condition` type uses an operator-based structure:
|
||||
* { field: "section_id.field_id", operator: "=", value: "some_value" }
|
||||
*
|
||||
* Field paths support dot-notation for nested lookups (section.field).
|
||||
*/
|
||||
|
||||
import { Condition } from './types';
|
||||
|
||||
// ─── Path utilities ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reads a value from a nested object using dot-notation path.
|
||||
* Returns `undefined` if any segment is missing.
|
||||
*
|
||||
* @example getNestedValue({ auth: { type: 'bearer' } }, 'auth.type') // 'bearer'
|
||||
*/
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
if (!obj || !path) return undefined;
|
||||
return path.split('.').reduce<unknown>((acc, key) => {
|
||||
if (acc !== null && acc !== undefined && typeof acc === 'object') {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in a nested object using dot-notation path.
|
||||
* Creates intermediate objects as needed. Returns a new object (shallow copy at each level).
|
||||
*/
|
||||
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||
const keys = path.split('.');
|
||||
const result = { ...obj };
|
||||
|
||||
if (keys.length === 1) {
|
||||
result[keys[0]] = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
const [head, ...rest] = keys;
|
||||
const nested = (result[head] && typeof result[head] === 'object' ? result[head] : {}) as Record<string, unknown>;
|
||||
|
||||
result[head] = setNestedValue(nested, rest.join('.'), value);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── isEmpty helper ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true for null, undefined, empty string, empty array.
|
||||
*/
|
||||
export function isEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (typeof value === 'string') return value.trim() === '';
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Condition evaluation ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluates a single `Condition` object against the current form values.
|
||||
*
|
||||
* Operators:
|
||||
* - `=` exact equality
|
||||
* - `!=` inequality
|
||||
* - `in` value is in the condition's value array
|
||||
* - `not_in` value is NOT in the condition's value array
|
||||
* - `exists` field is non-empty
|
||||
* - `not_exists` field is empty / absent
|
||||
*
|
||||
* Returns `true` if the condition is met (field should be visible/enabled).
|
||||
* Returns `true` if `condition` is undefined (no restriction).
|
||||
*/
|
||||
export function evaluateCondition(condition: Condition | undefined, values: Record<string, unknown>): boolean {
|
||||
if (!condition) return true;
|
||||
|
||||
const fieldValue = getNestedValue(values, condition.field);
|
||||
|
||||
switch (condition.operator) {
|
||||
case '=':
|
||||
return fieldValue === condition.value;
|
||||
|
||||
case '!=':
|
||||
return fieldValue !== condition.value;
|
||||
|
||||
case 'in': {
|
||||
const allowed = condition.value;
|
||||
if (!Array.isArray(allowed)) return false;
|
||||
return allowed.includes(fieldValue as string);
|
||||
}
|
||||
|
||||
case 'not_in': {
|
||||
const disallowed = condition.value;
|
||||
if (!Array.isArray(disallowed)) return true;
|
||||
return !disallowed.includes(fieldValue as string);
|
||||
}
|
||||
|
||||
case 'exists':
|
||||
return !isEmpty(fieldValue);
|
||||
|
||||
case 'not_exists':
|
||||
return isEmpty(fieldValue);
|
||||
|
||||
default:
|
||||
// Unknown operator — don't block visibility
|
||||
return true;
|
||||
}
|
||||
}
|
||||
369
packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts
Normal file
369
packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* UBA-002: SchemaParser
|
||||
*
|
||||
* Validates an unknown (already-parsed) object against the UBA schema v1.0
|
||||
* shape and returns a strongly-typed ParseResult<UBASchema>.
|
||||
*
|
||||
* Design: accepts a pre-parsed object rather than a raw YAML/JSON string.
|
||||
* The calling layer (BackendDiscovery, AddBackendDialog) is responsible for
|
||||
* deserialising the text. This keeps the parser 100% dep-free and testable.
|
||||
*
|
||||
* Validation is intentional-but-not-exhaustive:
|
||||
* - Required fields are checked; extra unknown keys are allowed (forward compat)
|
||||
* - Field array entries are validated individually; partial errors are collected
|
||||
* - Warnings are issued for deprecated or advisory patterns
|
||||
*/
|
||||
|
||||
import type {
|
||||
AuthConfig,
|
||||
BackendMetadata,
|
||||
BaseField,
|
||||
BooleanField,
|
||||
Capabilities,
|
||||
Condition,
|
||||
DebugField,
|
||||
DebugSchema,
|
||||
Endpoints,
|
||||
Field,
|
||||
MultiSelectField,
|
||||
NumberField,
|
||||
ParseError,
|
||||
ParseResult,
|
||||
SecretField,
|
||||
Section,
|
||||
SelectField,
|
||||
SelectOption,
|
||||
StringField,
|
||||
TextField,
|
||||
UBASchema,
|
||||
UrlField
|
||||
} from './types';
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class SchemaParser {
|
||||
/**
|
||||
* Validate a pre-parsed object as a UBASchema.
|
||||
*
|
||||
* @param data - Already-parsed JavaScript object (from JSON.parse or yaml.load)
|
||||
*/
|
||||
parse(data: unknown): ParseResult<UBASchema> {
|
||||
const errors: ParseError[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!isObject(data)) {
|
||||
return { success: false, errors: [{ path: '', message: 'Schema must be an object' }] };
|
||||
}
|
||||
|
||||
// schema_version
|
||||
if (!isString(data['schema_version'])) {
|
||||
errors.push({
|
||||
path: 'schema_version',
|
||||
message: 'Required string field "schema_version" is missing or not a string'
|
||||
});
|
||||
} else {
|
||||
const [major] = (data['schema_version'] as string).split('.');
|
||||
if (major !== '1') {
|
||||
warnings.push(
|
||||
`schema_version "${data['schema_version']}" — only v1.x is supported; proceeding with best-effort parsing`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// backend
|
||||
const backendErrors: ParseError[] = [];
|
||||
const backend = parseBackendMetadata(data['backend'], backendErrors);
|
||||
errors.push(...backendErrors);
|
||||
|
||||
// sections
|
||||
const sectionsErrors: ParseError[] = [];
|
||||
const sections = parseSections(data['sections'], sectionsErrors, warnings);
|
||||
errors.push(...sectionsErrors);
|
||||
|
||||
// debug (optional)
|
||||
let debugSchema: DebugSchema | undefined;
|
||||
if (data['debug'] !== undefined) {
|
||||
const debugErrors: ParseError[] = [];
|
||||
debugSchema = parseDebugSchema(data['debug'], debugErrors);
|
||||
errors.push(...debugErrors);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, errors, ...(warnings.length > 0 ? { warnings } : {}) };
|
||||
}
|
||||
|
||||
const schema: UBASchema = {
|
||||
schema_version: data['schema_version'] as string,
|
||||
backend: backend!,
|
||||
sections: sections ?? [],
|
||||
...(debugSchema ? { debug: debugSchema } : {})
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: schema,
|
||||
...(warnings.length > 0 ? { warnings } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal validators ──────────────────────────────────────────────────────
|
||||
|
||||
function parseBackendMetadata(raw: unknown, errors: ParseError[]): BackendMetadata | undefined {
|
||||
if (!isObject(raw)) {
|
||||
errors.push({ path: 'backend', message: 'Required object "backend" is missing or not an object' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requiredStrings = ['id', 'name', 'version'] as const;
|
||||
for (const key of requiredStrings) {
|
||||
if (!isString(raw[key])) {
|
||||
errors.push({ path: `backend.${key}`, message: `Required string "backend.${key}" is missing` });
|
||||
}
|
||||
}
|
||||
|
||||
const endpointErrors: ParseError[] = [];
|
||||
const endpoints = parseEndpoints(raw['endpoints'], endpointErrors);
|
||||
errors.push(...endpointErrors);
|
||||
|
||||
let auth: AuthConfig | undefined;
|
||||
if (raw['auth'] !== undefined) {
|
||||
if (!isObject(raw['auth'])) {
|
||||
errors.push({ path: 'backend.auth', message: '"backend.auth" must be an object' });
|
||||
} else {
|
||||
const validTypes = ['none', 'bearer', 'api_key', 'basic'];
|
||||
if (!validTypes.includes(raw['auth']['type'] as string)) {
|
||||
errors.push({
|
||||
path: 'backend.auth.type',
|
||||
message: `"backend.auth.type" must be one of: ${validTypes.join(', ')}`
|
||||
});
|
||||
} else {
|
||||
auth = { type: raw['auth']['type'] as AuthConfig['type'] };
|
||||
if (isString(raw['auth']['header'])) auth.header = raw['auth']['header'] as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let capabilities: Capabilities | undefined;
|
||||
if (isObject(raw['capabilities'])) {
|
||||
capabilities = {};
|
||||
if (typeof raw['capabilities']['hot_reload'] === 'boolean')
|
||||
capabilities.hot_reload = raw['capabilities']['hot_reload'] as boolean;
|
||||
if (typeof raw['capabilities']['debug'] === 'boolean') capabilities.debug = raw['capabilities']['debug'] as boolean;
|
||||
if (typeof raw['capabilities']['batch_config'] === 'boolean')
|
||||
capabilities.batch_config = raw['capabilities']['batch_config'] as boolean;
|
||||
}
|
||||
|
||||
if (errors.some((e) => e.path.startsWith('backend'))) return undefined;
|
||||
|
||||
return {
|
||||
id: raw['id'] as string,
|
||||
name: raw['name'] as string,
|
||||
version: raw['version'] as string,
|
||||
...(isString(raw['description']) ? { description: raw['description'] as string } : {}),
|
||||
...(isString(raw['icon']) ? { icon: raw['icon'] as string } : {}),
|
||||
...(isString(raw['homepage']) ? { homepage: raw['homepage'] as string } : {}),
|
||||
endpoints: endpoints!,
|
||||
...(auth ? { auth } : {}),
|
||||
...(capabilities ? { capabilities } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function parseEndpoints(raw: unknown, errors: ParseError[]): Endpoints | undefined {
|
||||
if (!isObject(raw)) {
|
||||
errors.push({ path: 'backend.endpoints', message: 'Required object "backend.endpoints" is missing' });
|
||||
return undefined;
|
||||
}
|
||||
if (!isString(raw['config'])) {
|
||||
errors.push({ path: 'backend.endpoints.config', message: 'Required string "backend.endpoints.config" is missing' });
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
config: raw['config'] as string,
|
||||
...(isString(raw['health']) ? { health: raw['health'] as string } : {}),
|
||||
...(isString(raw['debug_stream']) ? { debug_stream: raw['debug_stream'] as string } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function parseSections(raw: unknown, errors: ParseError[], warnings: string[]): Section[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push({ path: 'sections', message: 'Required array "sections" is missing or not an array' });
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.map((item: unknown, i: number): Section | null => {
|
||||
if (!isObject(item)) {
|
||||
errors.push({ path: `sections[${i}]`, message: `Section at index ${i} must be an object` });
|
||||
return null;
|
||||
}
|
||||
if (!isString(item['id'])) {
|
||||
errors.push({ path: `sections[${i}].id`, message: `sections[${i}].id is required and must be a string` });
|
||||
}
|
||||
if (!isString(item['name'])) {
|
||||
errors.push({ path: `sections[${i}].name`, message: `sections[${i}].name is required and must be a string` });
|
||||
}
|
||||
if (errors.some((e) => e.path === `sections[${i}].id` || e.path === `sections[${i}].name`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldErrors: ParseError[] = [];
|
||||
const fields = parseFields(item['fields'], `sections[${i}]`, fieldErrors, warnings);
|
||||
errors.push(...fieldErrors);
|
||||
|
||||
return {
|
||||
id: item['id'] as string,
|
||||
name: item['name'] as string,
|
||||
...(isString(item['description']) ? { description: item['description'] as string } : {}),
|
||||
...(isString(item['icon']) ? { icon: item['icon'] as string } : {}),
|
||||
...(typeof item['collapsed'] === 'boolean' ? { collapsed: item['collapsed'] as boolean } : {}),
|
||||
...(item['visible_when'] ? { visible_when: item['visible_when'] as Condition } : {}),
|
||||
fields
|
||||
};
|
||||
})
|
||||
.filter((s): s is Section => s !== null);
|
||||
}
|
||||
|
||||
function parseFields(raw: unknown, path: string, errors: ParseError[], warnings: string[]): Field[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.map((item: unknown, i: number): Field | null => {
|
||||
if (!isObject(item)) {
|
||||
errors.push({ path: `${path}.fields[${i}]`, message: `Field at index ${i} must be an object` });
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldPath = `${path}.fields[${i}]`;
|
||||
if (!isString(item['id'])) {
|
||||
errors.push({ path: `${fieldPath}.id`, message: `${fieldPath}.id is required` });
|
||||
return null;
|
||||
}
|
||||
if (!isString(item['name'])) {
|
||||
errors.push({ path: `${fieldPath}.name`, message: `${fieldPath}.name is required` });
|
||||
return null;
|
||||
}
|
||||
|
||||
const base: BaseField = {
|
||||
id: item['id'] as string,
|
||||
name: item['name'] as string,
|
||||
...(isString(item['description']) ? { description: item['description'] as string } : {}),
|
||||
...(typeof item['required'] === 'boolean' ? { required: item['required'] as boolean } : {}),
|
||||
...(item['visible_when'] ? { visible_when: item['visible_when'] as Condition } : {})
|
||||
};
|
||||
|
||||
const type = item['type'] as string;
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return {
|
||||
...base,
|
||||
type: 'string',
|
||||
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}),
|
||||
...(isString(item['default']) ? { default: item['default'] as string } : {})
|
||||
} as StringField;
|
||||
case 'text':
|
||||
return {
|
||||
...base,
|
||||
type: 'text',
|
||||
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}),
|
||||
...(isString(item['default']) ? { default: item['default'] as string } : {}),
|
||||
...(typeof item['rows'] === 'number' ? { rows: item['rows'] as number } : {})
|
||||
} as TextField;
|
||||
case 'number':
|
||||
return {
|
||||
...base,
|
||||
type: 'number',
|
||||
...(typeof item['default'] === 'number' ? { default: item['default'] as number } : {}),
|
||||
...(typeof item['min'] === 'number' ? { min: item['min'] as number } : {}),
|
||||
...(typeof item['max'] === 'number' ? { max: item['max'] as number } : {})
|
||||
} as NumberField;
|
||||
case 'boolean':
|
||||
return {
|
||||
...base,
|
||||
type: 'boolean',
|
||||
...(typeof item['default'] === 'boolean' ? { default: item['default'] as boolean } : {})
|
||||
} as BooleanField;
|
||||
case 'secret':
|
||||
return {
|
||||
...base,
|
||||
type: 'secret',
|
||||
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {})
|
||||
} as SecretField;
|
||||
case 'url':
|
||||
return {
|
||||
...base,
|
||||
type: 'url',
|
||||
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}),
|
||||
...(isString(item['default']) ? { default: item['default'] as string } : {})
|
||||
} as UrlField;
|
||||
case 'select': {
|
||||
if (!Array.isArray(item['options'])) {
|
||||
errors.push({
|
||||
path: `${fieldPath}.options`,
|
||||
message: `select field "${base.id}" requires an "options" array`
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const options = (item['options'] as unknown[]).map((o) =>
|
||||
isObject(o)
|
||||
? ({ value: String(o['value'] ?? ''), label: String(o['label'] ?? '') } as SelectOption)
|
||||
: { value: '', label: '' }
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
type: 'select',
|
||||
options,
|
||||
...(isString(item['default']) ? { default: item['default'] as string } : {})
|
||||
} as SelectField;
|
||||
}
|
||||
case 'multi_select': {
|
||||
if (!Array.isArray(item['options'])) {
|
||||
errors.push({
|
||||
path: `${fieldPath}.options`,
|
||||
message: `multi_select field "${base.id}" requires an "options" array`
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const options = (item['options'] as unknown[]).map((o) =>
|
||||
isObject(o)
|
||||
? ({ value: String(o['value'] ?? ''), label: String(o['label'] ?? '') } as SelectOption)
|
||||
: { value: '', label: '' }
|
||||
);
|
||||
return { ...base, type: 'multi_select', options } as MultiSelectField;
|
||||
}
|
||||
default:
|
||||
warnings.push(`Unknown field type "${type}" at ${fieldPath} — skipping`);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((f): f is Field => f !== null);
|
||||
}
|
||||
|
||||
function parseDebugSchema(raw: unknown, errors: ParseError[]): DebugSchema | undefined {
|
||||
if (!isObject(raw)) {
|
||||
errors.push({ path: 'debug', message: '"debug" must be an object' });
|
||||
return undefined;
|
||||
}
|
||||
const enabled = typeof raw['enabled'] === 'boolean' ? (raw['enabled'] as boolean) : true;
|
||||
const eventSchema: DebugField[] = Array.isArray(raw['event_schema'])
|
||||
? (raw['event_schema'] as unknown[]).filter(isObject).map((f) => ({
|
||||
id: String(f['id'] ?? ''),
|
||||
name: String(f['name'] ?? ''),
|
||||
type: (['string', 'number', 'boolean', 'json'].includes(f['type'] as string)
|
||||
? f['type']
|
||||
: 'string') as DebugField['type'],
|
||||
...(isString(f['description']) ? { description: f['description'] as string } : {})
|
||||
}))
|
||||
: [];
|
||||
return { enabled, ...(eventSchema.length > 0 ? { event_schema: eventSchema } : {}) };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isString(v: unknown): v is string {
|
||||
return typeof v === 'string' && v.length > 0;
|
||||
}
|
||||
26
packages/noodl-editor/src/editor/src/models/UBA/index.ts
Normal file
26
packages/noodl-editor/src/editor/src/models/UBA/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export { SchemaParser } from './SchemaParser';
|
||||
export { evaluateCondition, getNestedValue, setNestedValue, isEmpty } from './Conditions';
|
||||
export type {
|
||||
UBASchema,
|
||||
BackendMetadata,
|
||||
Section,
|
||||
Field,
|
||||
StringField,
|
||||
TextField,
|
||||
NumberField,
|
||||
BooleanField,
|
||||
SecretField,
|
||||
UrlField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
SelectOption,
|
||||
BaseField,
|
||||
Condition,
|
||||
AuthConfig,
|
||||
Endpoints,
|
||||
Capabilities,
|
||||
DebugSchema,
|
||||
DebugField,
|
||||
ParseResult,
|
||||
ParseError
|
||||
} from './types';
|
||||
207
packages/noodl-editor/src/editor/src/models/UBA/types.ts
Normal file
207
packages/noodl-editor/src/editor/src/models/UBA/types.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* UBA-001 / UBA-002: Universal Backend Adapter — TypeScript type definitions
|
||||
*
|
||||
* These types mirror the UBA schema specification v1.0. The SchemaParser
|
||||
* validates an unknown object against these shapes and returns a typed result.
|
||||
*
|
||||
* Design notes:
|
||||
* - Field types use a discriminated union on `type` so exhaustive switch()
|
||||
* statements work correctly in renderers.
|
||||
* - Optional fields are marked `?` — do NOT add runtime defaults here;
|
||||
* defaults are handled by the UI layer (buildInitialValues in ConfigPanel).
|
||||
* - All arrays that could be omitted in the schema default to `[]` in the
|
||||
* parsed output (see SchemaParser.normalise).
|
||||
*/
|
||||
|
||||
// ─── Schema Root ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UBASchema {
|
||||
schema_version: string;
|
||||
backend: BackendMetadata;
|
||||
sections: Section[];
|
||||
debug?: DebugSchema;
|
||||
}
|
||||
|
||||
// ─── Backend Metadata ────────────────────────────────────────────────────────
|
||||
|
||||
export interface BackendMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version: string;
|
||||
icon?: string;
|
||||
homepage?: string;
|
||||
endpoints: Endpoints;
|
||||
auth?: AuthConfig;
|
||||
capabilities?: Capabilities;
|
||||
}
|
||||
|
||||
export interface Endpoints {
|
||||
config: string;
|
||||
health?: string;
|
||||
debug_stream?: string;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
type: 'none' | 'bearer' | 'api_key' | 'basic';
|
||||
header?: string;
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
hot_reload?: boolean;
|
||||
debug?: boolean;
|
||||
batch_config?: boolean;
|
||||
}
|
||||
|
||||
// ─── Sections ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
collapsed?: boolean;
|
||||
visible_when?: Condition;
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
// ─── Conditions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Condition {
|
||||
/** e.g. "auth.type" */
|
||||
field: string;
|
||||
/** e.g. "=" | "!=" | "in" | "not_in" */
|
||||
operator: '=' | '!=' | 'in' | 'not_in' | 'exists' | 'not_exists';
|
||||
value?: string | string[] | boolean | number;
|
||||
}
|
||||
|
||||
// ─── Field Discriminated Union ───────────────────────────────────────────────
|
||||
|
||||
export type Field =
|
||||
| StringField
|
||||
| TextField
|
||||
| NumberField
|
||||
| BooleanField
|
||||
| SecretField
|
||||
| UrlField
|
||||
| SelectField
|
||||
| MultiSelectField;
|
||||
|
||||
/** Common base shared by all field types */
|
||||
export interface BaseField {
|
||||
/** Unique identifier within the section */
|
||||
id: string;
|
||||
/** Display label */
|
||||
name: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
visible_when?: Condition;
|
||||
ui?: UIHints;
|
||||
}
|
||||
|
||||
export interface UIHints {
|
||||
help_link?: string;
|
||||
placeholder?: string;
|
||||
width?: 'full' | 'half' | 'third';
|
||||
monospace?: boolean;
|
||||
}
|
||||
|
||||
// ─── Concrete Field Types ────────────────────────────────────────────────────
|
||||
|
||||
export interface StringField extends BaseField {
|
||||
type: 'string';
|
||||
placeholder?: string;
|
||||
default?: string;
|
||||
validation?: StringValidation;
|
||||
}
|
||||
|
||||
export interface StringValidation {
|
||||
min_length?: number;
|
||||
max_length?: number;
|
||||
pattern?: string;
|
||||
pattern_message?: string;
|
||||
}
|
||||
|
||||
export interface TextField extends BaseField {
|
||||
type: 'text';
|
||||
placeholder?: string;
|
||||
default?: string;
|
||||
rows?: number;
|
||||
validation?: StringValidation;
|
||||
}
|
||||
|
||||
export interface NumberField extends BaseField {
|
||||
type: 'number';
|
||||
placeholder?: string;
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
integer?: boolean;
|
||||
}
|
||||
|
||||
export interface BooleanField extends BaseField {
|
||||
type: 'boolean';
|
||||
default?: boolean;
|
||||
/** Text shown next to the toggle */
|
||||
toggle_label?: string;
|
||||
}
|
||||
|
||||
export interface SecretField extends BaseField {
|
||||
type: 'secret';
|
||||
placeholder?: string;
|
||||
/** If true, disable copy-paste on the masked input */
|
||||
no_paste?: boolean;
|
||||
}
|
||||
|
||||
export interface UrlField extends BaseField {
|
||||
type: 'url';
|
||||
placeholder?: string;
|
||||
default?: string;
|
||||
/** Restrict to specific protocols, e.g. ['https', 'wss'] */
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SelectField extends BaseField {
|
||||
type: 'select';
|
||||
options: SelectOption[];
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface MultiSelectField extends BaseField {
|
||||
type: 'multi_select';
|
||||
options: SelectOption[];
|
||||
default?: string[];
|
||||
max_selections?: number;
|
||||
}
|
||||
|
||||
// ─── Debug Schema ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DebugSchema {
|
||||
enabled: boolean;
|
||||
event_schema?: DebugField[];
|
||||
}
|
||||
|
||||
export interface DebugField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'json';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ─── Parser Result Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type ParseResult<T> =
|
||||
| { success: true; data: T; warnings?: string[] }
|
||||
| { success: false; errors: ParseError[]; warnings?: string[] };
|
||||
|
||||
export interface ParseError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import React, { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { clone } from '@noodl/git/src/core/clone';
|
||||
import { filesystem } from '@noodl/platform';
|
||||
|
||||
import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';
|
||||
import {
|
||||
CloudSyncType,
|
||||
LauncherProjectData
|
||||
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
|
||||
import { ProjectCreationWizard } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectCreationWizard';
|
||||
import {
|
||||
useGitHubRepos,
|
||||
NoodlGitHubRepo,
|
||||
@@ -942,7 +942,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
onCloneRepo={handleCloneRepo}
|
||||
/>
|
||||
|
||||
<CreateProjectModal
|
||||
<ProjectCreationWizard
|
||||
isVisible={isCreateModalVisible}
|
||||
onClose={handleCreateModalClose}
|
||||
onConfirm={handleCreateProjectConfirm}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PropertyEditor } from './views/panels/propertyeditor';
|
||||
import { SearchPanel } from './views/panels/search-panel/search-panel';
|
||||
// import { TopologyMapPanel } from './views/panels/TopologyMapPanel'; // Disabled - shelved feature
|
||||
import { TriggerChainDebuggerPanel } from './views/panels/TriggerChainDebuggerPanel';
|
||||
import { UBAPanel } from './views/panels/UBAPanel';
|
||||
import { UndoQueuePanel } from './views/panels/UndoQueuePanel/UndoQueuePanel';
|
||||
import { VersionControlPanel_ID } from './views/panels/VersionControlPanel';
|
||||
import { VersionControlPanel } from './views/panels/VersionControlPanel/VersionControlPanel';
|
||||
@@ -179,6 +180,17 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
|
||||
panel: AppSetupPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
experimental: true,
|
||||
id: 'uba',
|
||||
name: 'Backend Adapter',
|
||||
description: 'Configure and debug Universal Backend Adapter (UBA) compatible backends via schema-driven forms.',
|
||||
isDisabled: isLesson === true,
|
||||
order: 8.8,
|
||||
icon: IconName.RestApi,
|
||||
panel: UBAPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: 'settings',
|
||||
name: 'Project settings',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* STYLE-001 Phase 4: PreviewTokenInjector
|
||||
*
|
||||
* Injects design token CSS custom properties into the preview webview so that
|
||||
* var(--token-name) references in user projects resolve to the correct values.
|
||||
*
|
||||
* Architecture:
|
||||
* - Singleton service, initialised once at app startup
|
||||
* - CanvasView calls `notifyDomReady(webview)` after each dom-ready event
|
||||
* - Subscribes to StyleTokensModel 'tokensChanged' and re-injects on every change
|
||||
* - Uses `executeJavaScript` to insert/update a <style id="noodl-design-tokens"> in the
|
||||
* preview's <head>. The style element is created on first injection and updated in place
|
||||
* on subsequent calls, avoiding repeated DOM mutations.
|
||||
*
|
||||
* CSS escaping:
|
||||
* - The CSS block is passed as a JSON-encoded string inside the script so that backticks,
|
||||
* backslashes, and dollar signs in token values cannot break template literal parsing.
|
||||
*/
|
||||
|
||||
import { StyleTokensModel } from '../models/StyleTokensModel';
|
||||
|
||||
const STYLE_ELEMENT_ID = 'noodl-design-tokens';
|
||||
|
||||
export class PreviewTokenInjector {
|
||||
private static _instance: PreviewTokenInjector | null = null;
|
||||
|
||||
private _webview: Electron.WebviewTag | null = null;
|
||||
private _tokensModel: StyleTokensModel | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static get instance(): PreviewTokenInjector {
|
||||
if (!PreviewTokenInjector._instance) {
|
||||
PreviewTokenInjector._instance = new PreviewTokenInjector();
|
||||
}
|
||||
return PreviewTokenInjector._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a StyleTokensModel instance. Called once when the project loads.
|
||||
* The injector subscribes to 'tokensChanged' and re-injects whenever tokens change.
|
||||
*/
|
||||
attachModel(model: StyleTokensModel): void {
|
||||
// Detach previous model if any — off(context) removes all listeners bound to `this`
|
||||
this._tokensModel?.off(this);
|
||||
|
||||
this._tokensModel = model;
|
||||
|
||||
model.on('tokensChanged', () => this._inject(), this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by CanvasView after each dom-ready event (once the session is valid).
|
||||
* Stores the webview reference and immediately injects the current tokens.
|
||||
*/
|
||||
notifyDomReady(webview: Electron.WebviewTag): void {
|
||||
this._webview = webview;
|
||||
this._inject();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear webview reference (e.g. when the canvas is destroyed).
|
||||
*/
|
||||
clearWebview(): void {
|
||||
this._webview = null;
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private _inject(): void {
|
||||
if (!this._webview || !this._tokensModel) return;
|
||||
|
||||
const css = this._tokensModel.generateCss();
|
||||
if (!css) return;
|
||||
|
||||
// JSON-encode the CSS to safely pass it through executeJavaScript without
|
||||
// worrying about backticks, backslashes, or $ signs in token values.
|
||||
const encodedCss = JSON.stringify(css);
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
var id = '${STYLE_ELEMENT_ID}';
|
||||
var el = document.getElementById(id);
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = id;
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
el.textContent = ${encodedCss};
|
||||
})();
|
||||
`;
|
||||
|
||||
// executeJavaScript returns a Promise — we intentionally don't await it here
|
||||
// because injection is best-effort and we don't want to block the caller.
|
||||
// Errors are swallowed because the webview may navigate away at any time.
|
||||
this._webview.executeJavaScript(script).catch(() => {
|
||||
// Webview navigated or was destroyed — no action needed.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time initialisation: wire the injector to the global EventDispatcher so it
|
||||
* can pick up the StyleTokensModel when a project loads.
|
||||
*
|
||||
* Call this from the editor bootstrap (e.g. EditorTopBar or App startup).
|
||||
*/
|
||||
export function initPreviewTokenInjector(tokensModel: StyleTokensModel): void {
|
||||
PreviewTokenInjector.instance.attachModel(tokensModel);
|
||||
}
|
||||
@@ -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;
|
||||
341
packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts
Normal file
341
packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* UBA-005: UBAClient
|
||||
*
|
||||
* Thin HTTP client for communicating with a Universal Backend Adapter server.
|
||||
* Handles three concerns:
|
||||
* 1. configure — POST JSON config to the backend's config endpoint
|
||||
* 2. health — GET the health endpoint and parse the status
|
||||
* 3. debugStream — open an SSE connection to the debug_stream endpoint
|
||||
*
|
||||
* This is deliberately framework-agnostic — no React, no Electron APIs.
|
||||
* All network calls use the global `fetch` (available in Electron's renderer).
|
||||
*
|
||||
* Auth support:
|
||||
* - 'none' — no auth header added
|
||||
* - 'bearer' — Authorization: Bearer <token>
|
||||
* - 'api_key' — uses `header` field from AuthConfig (e.g. X-Api-Key)
|
||||
* - 'basic' — Authorization: Basic base64(username:password)
|
||||
*
|
||||
* Error handling:
|
||||
* - Non-2xx responses → rejected with UBAClientError
|
||||
* - Network failures → rejected with UBAClientError wrapping the original error
|
||||
* - SSE failures → onError callback invoked; caller responsible for cleanup
|
||||
*/
|
||||
|
||||
import type { AuthConfig } from '@noodl-models/UBA/types';
|
||||
|
||||
// ─── Public API Types ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Result of a successful configure call. */
|
||||
export interface ConfigureResult {
|
||||
/** HTTP status code from the backend */
|
||||
status: number;
|
||||
/** Parsed response body (may be null for 204 No Content) */
|
||||
body: unknown;
|
||||
/** True if backend accepted the config (2xx status) */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/** Result of a health check call. */
|
||||
export interface HealthResult {
|
||||
/** HTTP status code */
|
||||
status: number;
|
||||
/** Whether the backend considers itself healthy */
|
||||
healthy: boolean;
|
||||
/** Optional message from the backend */
|
||||
message?: string;
|
||||
/** Raw parsed body, if any */
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/** A single debug event received from the SSE stream. */
|
||||
export interface DebugEvent {
|
||||
/** SSE event type (e.g. 'log', 'error', 'metric') */
|
||||
type: string;
|
||||
/** Parsed event data */
|
||||
data: unknown;
|
||||
/** Raw data string as received */
|
||||
raw: string;
|
||||
/** Client-side timestamp */
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
/** Options for opening a debug stream. */
|
||||
export interface DebugStreamOptions {
|
||||
/** Invoked for each SSE event received. */
|
||||
onEvent: (event: DebugEvent) => void;
|
||||
/** Invoked on connection error or unexpected close. */
|
||||
onError?: (error: Error) => void;
|
||||
/** Invoked when the stream opens successfully. */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
/** Error thrown by UBAClient on HTTP or network failures. */
|
||||
export class UBAClientError extends Error {
|
||||
constructor(message: string, public readonly status?: number, public readonly body?: unknown) {
|
||||
super(message);
|
||||
this.name = 'UBAClientError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle returned by openDebugStream — call close() to disconnect. */
|
||||
export interface DebugStreamHandle {
|
||||
close(): void;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
// ─── Auth Header Builder ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the Authorization/custom auth header for a request.
|
||||
* Returns an empty record if no auth is required.
|
||||
*/
|
||||
function buildAuthHeaders(
|
||||
auth: AuthConfig | undefined,
|
||||
credentials?: { token?: string; username?: string; password?: string }
|
||||
): Record<string, string> {
|
||||
if (!auth || auth.type === 'none' || !credentials) return {};
|
||||
|
||||
switch (auth.type) {
|
||||
case 'bearer': {
|
||||
if (!credentials.token) return {};
|
||||
return { Authorization: `Bearer ${credentials.token}` };
|
||||
}
|
||||
case 'api_key': {
|
||||
if (!credentials.token) return {};
|
||||
const headerName = auth.header ?? 'X-Api-Key';
|
||||
return { [headerName]: credentials.token };
|
||||
}
|
||||
case 'basic': {
|
||||
const { username = '', password = '' } = credentials;
|
||||
const encoded = btoa(`${username}:${password}`);
|
||||
return { Authorization: `Basic ${encoded}` };
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UBAClient ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Static utility class for UBA HTTP communication.
|
||||
*
|
||||
* All methods are `static` — no need to instantiate; just call directly.
|
||||
* This keeps usage simple in hooks and models without dependency injection.
|
||||
*
|
||||
* ```ts
|
||||
* const result = await UBAClient.configure(
|
||||
* 'http://localhost:3210/configure',
|
||||
* { database: { host: 'localhost', port: 5432 } },
|
||||
* schema.backend.auth
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class UBAClient {
|
||||
/** Default fetch timeout in milliseconds. */
|
||||
static DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ─── configure ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST a configuration object to the backend's config endpoint.
|
||||
*
|
||||
* @param endpoint - Full URL of the config endpoint (from schema.backend.endpoints.config)
|
||||
* @param config - The flattened/structured config values to POST as JSON
|
||||
* @param auth - Optional auth configuration from the schema
|
||||
* @param credentials - Optional credential values to build the auth header
|
||||
* @returns ConfigureResult on success; throws UBAClientError on failure
|
||||
*/
|
||||
static async configure(
|
||||
endpoint: string,
|
||||
config: Record<string, unknown>,
|
||||
auth?: AuthConfig,
|
||||
credentials?: { token?: string; username?: string; password?: string }
|
||||
): Promise<ConfigureResult> {
|
||||
const authHeaders = buildAuthHeaders(auth, credentials);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await UBAClient._fetchWithTimeout(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
} catch (err) {
|
||||
throw new UBAClientError(
|
||||
`Network error sending config to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Parse body if available
|
||||
let body: unknown = null;
|
||||
if (response.status !== 204) {
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Non-JSON body — read as text
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new UBAClientError(`Configure failed: ${response.status} ${response.statusText}`, response.status, body);
|
||||
}
|
||||
|
||||
return { status: response.status, body, ok: true };
|
||||
}
|
||||
|
||||
// ─── health ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET the health endpoint and determine backend status.
|
||||
*
|
||||
* Backends should return 200 when healthy. Any non-2xx is treated as
|
||||
* unhealthy. Network errors are also treated as unhealthy (not thrown).
|
||||
*
|
||||
* @param endpoint - Full URL of the health endpoint
|
||||
* @param auth - Optional auth configuration
|
||||
* @param credentials - Optional credentials
|
||||
* @returns HealthResult (never throws — unhealthy on error)
|
||||
*/
|
||||
static async health(
|
||||
endpoint: string,
|
||||
auth?: AuthConfig,
|
||||
credentials?: { token?: string; username?: string; password?: string }
|
||||
): Promise<HealthResult> {
|
||||
const authHeaders = buildAuthHeaders(auth, credentials);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await UBAClient._fetchWithTimeout(endpoint, {
|
||||
method: 'GET',
|
||||
headers: authHeaders
|
||||
});
|
||||
} catch (err) {
|
||||
// Network failure → unhealthy
|
||||
return {
|
||||
status: 0,
|
||||
healthy: false,
|
||||
message: `Network error: ${err instanceof Error ? err.message : String(err)}`
|
||||
};
|
||||
}
|
||||
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
try {
|
||||
const text = await response.text();
|
||||
body = text || null;
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
|
||||
const healthy = response.ok;
|
||||
const message =
|
||||
typeof body === 'object' && body !== null && 'message' in body
|
||||
? String((body as Record<string, unknown>).message)
|
||||
: typeof body === 'string'
|
||||
? body
|
||||
: undefined;
|
||||
|
||||
return { status: response.status, healthy, message, body };
|
||||
}
|
||||
|
||||
// ─── openDebugStream ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a Server-Sent Events connection to the debug_stream endpoint.
|
||||
*
|
||||
* Returns a handle with a `close()` method to disconnect cleanly.
|
||||
* Because EventSource doesn't support custom headers natively, auth
|
||||
* tokens are appended as a query parameter when needed.
|
||||
*
|
||||
* @param endpoint - Full URL of the debug_stream endpoint
|
||||
* @param options - Event callbacks (onEvent, onError, onOpen)
|
||||
* @param auth - Optional auth configuration
|
||||
* @param credentials - Optional credentials
|
||||
* @returns DebugStreamHandle — call handle.close() to disconnect
|
||||
*/
|
||||
static openDebugStream(
|
||||
endpoint: string,
|
||||
options: DebugStreamOptions,
|
||||
auth?: AuthConfig,
|
||||
credentials?: { token?: string; username?: string; password?: string }
|
||||
): DebugStreamHandle {
|
||||
// Append token as query param if needed (EventSource limitation)
|
||||
let url = endpoint;
|
||||
if (auth && auth.type !== 'none' && credentials?.token) {
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
if (auth.type === 'bearer' || auth.type === 'api_key') {
|
||||
url = `${endpoint}${separator}token=${encodeURIComponent(credentials.token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const source = new EventSource(url);
|
||||
|
||||
source.onopen = () => {
|
||||
options.onOpen?.();
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
options.onError?.(new Error(`Debug stream connection to ${endpoint} failed or closed`));
|
||||
};
|
||||
|
||||
// Listen for 'message' (default SSE event) and any named events
|
||||
const handleRawEvent = (e: MessageEvent, type: string) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch {
|
||||
data = e.data;
|
||||
}
|
||||
|
||||
const event: DebugEvent = {
|
||||
type,
|
||||
data,
|
||||
raw: e.data,
|
||||
receivedAt: new Date()
|
||||
};
|
||||
|
||||
options.onEvent(event);
|
||||
};
|
||||
|
||||
source.addEventListener('message', (e) => handleRawEvent(e as MessageEvent, 'message'));
|
||||
|
||||
// Common named event types that UBA backends may emit
|
||||
for (const eventType of ['log', 'error', 'warn', 'info', 'metric', 'trace']) {
|
||||
source.addEventListener(eventType, (e) => handleRawEvent(e as MessageEvent, eventType));
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
close: () => source.close()
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch with an AbortController-based timeout.
|
||||
*/
|
||||
private static async _fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), UBAClient.DEFAULT_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { UBAClient, UBAClientError } from './UBAClient';
|
||||
export type { ConfigureResult, DebugEvent, DebugStreamHandle, DebugStreamOptions, HealthResult } from './UBAClient';
|
||||
@@ -0,0 +1,173 @@
|
||||
/* UBA-004: ConfigPanel styles */
|
||||
|
||||
.configPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
/* ─── Header ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background: var(--theme-color-bg-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headerMeta {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: var(--theme-color-primary);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.1s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Save error banner ───────────────────────────────────────────────────── */
|
||||
|
||||
.saveError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: color-mix(in srgb, var(--theme-color-danger) 12%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--theme-color-danger) 30%, transparent);
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Section tabs ────────────────────────────────────────────────────────── */
|
||||
|
||||
.sectionTabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
border-bottom-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tabErrorDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--theme-color-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Section content ─────────────────────────────────────────────────────── */
|
||||
|
||||
.sectionContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ─── Empty state ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
161
packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx
Normal file
161
packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* UBA-004: ConfigPanel
|
||||
*
|
||||
* Top-level panel that renders a UBASchema as a tabbed configuration form.
|
||||
* Tabs = sections; fields rendered by ConfigSection.
|
||||
*
|
||||
* Usage:
|
||||
* <ConfigPanel
|
||||
* schema={parsedSchema}
|
||||
* initialValues={savedConfig}
|
||||
* onSave={async (values) => { await pushToBackend(values); }}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { evaluateCondition } from '../../models/UBA/Conditions';
|
||||
import { UBASchema } from '../../models/UBA/types';
|
||||
import css from './ConfigPanel.module.scss';
|
||||
import { ConfigSection, sectionHasErrors } from './ConfigSection';
|
||||
import { flatToNested, useConfigForm, validateRequired } from './hooks/useConfigForm';
|
||||
|
||||
export interface ConfigPanelProps {
|
||||
schema: UBASchema;
|
||||
/** Previously saved config values (flat-path or nested object) */
|
||||
initialValues?: Record<string, unknown>;
|
||||
/** Called with a nested-object representation of the form on save */
|
||||
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||
onReset?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConfigPanel({ schema, initialValues, onSave, onReset, disabled }: ConfigPanelProps) {
|
||||
const { values, errors, isDirty, setValue, setErrors, reset } = useConfigForm(schema, initialValues);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>(schema.sections[0]?.id ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// Filter to sections whose visible_when is met
|
||||
const visibleSections = schema.sections.filter((section) =>
|
||||
evaluateCondition(section.visible_when, values as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Ensure active tab stays valid if sections change
|
||||
const validActive = visibleSections.find((s) => s.id === activeSection)?.id ?? visibleSections[0]?.id ?? '';
|
||||
|
||||
const handleSave = async () => {
|
||||
// Synchronous required-field validation
|
||||
const validationErrors = validateRequired(schema, values);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
// Switch to first tab with an error
|
||||
const firstErrorSection = schema.sections.find((s) =>
|
||||
Object.keys(validationErrors).some((p) => p.startsWith(`${s.id}.`))
|
||||
);
|
||||
if (firstErrorSection) setActiveSection(firstErrorSection.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
await onSave(flatToNested(values));
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setSaveError(null);
|
||||
onReset?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css.configPanel}>
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className={css.header}>
|
||||
<div className={css.headerInfo}>
|
||||
<h2 className={css.headerName}>{schema.backend.name}</h2>
|
||||
<p className={css.headerMeta}>
|
||||
v{schema.backend.version}
|
||||
{schema.backend.description ? ` · ${schema.backend.description}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={css.headerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.resetButton}
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || saving || disabled}
|
||||
title="Reset to saved values"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.saveButton}
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving || disabled}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Save error banner ───────────────────────────────────────────── */}
|
||||
{saveError && (
|
||||
<div className={css.saveError} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Section tabs ────────────────────────────────────────────────── */}
|
||||
{visibleSections.length > 1 && (
|
||||
<div className={css.sectionTabs} role="tablist">
|
||||
{visibleSections.map((section) => {
|
||||
const hasErrors = sectionHasErrors(section.id, errors);
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={validActive === section.id}
|
||||
className={`${css.tab}${validActive === section.id ? ` ${css.active}` : ''}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.name}
|
||||
{hasErrors && <span className={css.tabErrorDot} aria-label="has errors" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Section content ─────────────────────────────────────────────── */}
|
||||
<div className={css.sectionContent}>
|
||||
{visibleSections.length === 0 ? (
|
||||
<div className={css.emptyState}>No configuration sections available.</div>
|
||||
) : (
|
||||
visibleSections.map((section) => (
|
||||
<ConfigSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={setValue}
|
||||
visible={validActive === section.id}
|
||||
disabled={disabled || saving}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* UBA-004: ConfigSection styles */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionDescription {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sectionFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.fieldContainer {
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dependencyMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* UBA-004: ConfigSection
|
||||
*
|
||||
* Renders a single schema section — header + all its fields.
|
||||
* Fields that fail their `visible_when` condition are omitted.
|
||||
* Fields that fail a dependency condition are rendered but disabled.
|
||||
*
|
||||
* Hidden via CSS (display:none) when `visible` is false so the section
|
||||
* stays mounted and preserves form values, but only the active tab is shown.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { evaluateCondition } from '../../models/UBA/Conditions';
|
||||
import { Field, Section } from '../../models/UBA/types';
|
||||
import css from './ConfigSection.module.scss';
|
||||
import { FieldRenderer } from './fields/FieldRenderer';
|
||||
import { FormErrors, FormValues } from './hooks/useConfigForm';
|
||||
|
||||
export interface ConfigSectionProps {
|
||||
section: Section;
|
||||
values: FormValues;
|
||||
errors: FormErrors;
|
||||
onChange: (path: string, value: unknown) => void;
|
||||
/** Whether this section's tab is currently active */
|
||||
visible: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FieldVisibility {
|
||||
visible: boolean;
|
||||
/** If false, field is rendered but disabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates field visibility + enabled state based on its conditions.
|
||||
* We don't have a full `depends_on` in the current type spec,
|
||||
* so we only handle `visible_when` here (enough for UBA-004 scope).
|
||||
*/
|
||||
function resolveFieldVisibility(field: Field, values: FormValues): FieldVisibility {
|
||||
const visible = evaluateCondition(field.visible_when, values as Record<string, unknown>);
|
||||
return { visible, enabled: visible };
|
||||
}
|
||||
|
||||
/** Returns true if any errors exist for fields in this section */
|
||||
export function sectionHasErrors(sectionId: string, errors: FormErrors): boolean {
|
||||
return Object.keys(errors).some((path) => path.startsWith(`${sectionId}.`));
|
||||
}
|
||||
|
||||
export function ConfigSection({ section, values, errors, onChange, visible, disabled }: ConfigSectionProps) {
|
||||
return (
|
||||
<div className={css.section} style={visible ? undefined : { display: 'none' }} aria-hidden={!visible}>
|
||||
{(section.description || section.name) && (
|
||||
<div className={css.sectionHeader}>
|
||||
<h3 className={css.sectionTitle}>{section.name}</h3>
|
||||
{section.description && <p className={css.sectionDescription}>{section.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css.sectionFields}>
|
||||
{section.fields.map((field) => {
|
||||
const { visible: fieldVisible, enabled } = resolveFieldVisibility(field, values);
|
||||
|
||||
if (!fieldVisible) return null;
|
||||
|
||||
const path = `${section.id}.${field.id}`;
|
||||
|
||||
return (
|
||||
<div key={field.id} className={`${css.fieldContainer}${!enabled ? ` ${css.disabled}` : ''}`}>
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
value={values[path]}
|
||||
onChange={(value) => onChange(path, value)}
|
||||
error={errors[path]}
|
||||
disabled={disabled || !enabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* UBA-003: BooleanField
|
||||
* Toggle switch with an optional label beside it.
|
||||
* Uses CSS :has() for checked/disabled track styling — see fields.module.scss.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { BooleanField as BooleanFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
export interface BooleanFieldProps {
|
||||
field: BooleanFieldType;
|
||||
value: boolean | undefined;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function BooleanField({ field, value, onChange, disabled }: BooleanFieldProps) {
|
||||
const checked = value ?? field.default ?? false;
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field}>
|
||||
<label className={css.booleanWrapper}>
|
||||
<span className={css.toggleInput}>
|
||||
<input
|
||||
id={field.id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={css.toggleTrack}>
|
||||
<span className={css.toggleThumb} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{field.toggle_label && <span className={css.toggleLabel}>{field.toggle_label}</span>}
|
||||
</label>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* UBA-003: FieldRenderer
|
||||
*
|
||||
* Factory component — dispatches to the correct field renderer based on `field.type`.
|
||||
* Unknown field types fall back to StringField with a console warning so forward-compat
|
||||
* schemas don't hard-crash the panel.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Field } from '../../../models/UBA/types';
|
||||
import { BooleanField } from './BooleanField';
|
||||
import { MultiSelectField } from './MultiSelectField';
|
||||
import { NumberField } from './NumberField';
|
||||
import { SecretField } from './SecretField';
|
||||
import { SelectField } from './SelectField';
|
||||
import { StringField } from './StringField';
|
||||
import { TextField } from './TextField';
|
||||
import { UrlField } from './UrlField';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
field: Field;
|
||||
/** Current value — the type depends on field.type */
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FieldRenderer({ field, value, onChange, error, disabled }: FieldRendererProps) {
|
||||
switch (field.type) {
|
||||
case 'string':
|
||||
return (
|
||||
<StringField
|
||||
field={field}
|
||||
value={value as string | undefined}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<TextField
|
||||
field={field}
|
||||
value={value as string | undefined}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<NumberField
|
||||
field={field}
|
||||
value={value as number | undefined}
|
||||
onChange={onChange as (v: number) => void}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<BooleanField
|
||||
field={field}
|
||||
value={value as boolean | undefined}
|
||||
onChange={onChange as (v: boolean) => void}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'secret':
|
||||
return (
|
||||
<SecretField
|
||||
field={field}
|
||||
value={value as string | undefined}
|
||||
onChange={onChange as (v: string) => void}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'url':
|
||||
return (
|
||||
<UrlField
|
||||
field={field}
|
||||
value={value as string | undefined}
|
||||
onChange={onChange as (v: string) => void}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<SelectField
|
||||
field={field}
|
||||
value={value as string | undefined}
|
||||
onChange={onChange as (v: string) => void}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'multi_select':
|
||||
return (
|
||||
<MultiSelectField
|
||||
field={field}
|
||||
value={value as string[] | undefined}
|
||||
onChange={onChange as (v: string[]) => void}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
default: {
|
||||
// Forward-compat fallback: unknown field types render as plain text
|
||||
const unknownField = field as Field & { type: string };
|
||||
console.warn(
|
||||
`[UBA] Unknown field type "${unknownField.type}" for field "${unknownField.id}" — rendering as string`
|
||||
);
|
||||
return (
|
||||
<StringField
|
||||
field={{ ...unknownField, type: 'string' } as Parameters<typeof StringField>[0]['field']}
|
||||
value={value as string | undefined}
|
||||
onChange={onChange as (v: string) => void}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* UBA-003: FieldWrapper
|
||||
*
|
||||
* Common shell for all UBA field renderers.
|
||||
* Renders: label, required indicator, description, children (the input),
|
||||
* error message, warning message, and an optional help link.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { BaseField } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
|
||||
export interface FieldWrapperProps {
|
||||
field: BaseField;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FieldWrapper({ field, error, warning, children }: FieldWrapperProps) {
|
||||
return (
|
||||
<div className={css.fieldWrapper} data-field-id={field.id}>
|
||||
<label className={css.fieldLabel} htmlFor={field.id}>
|
||||
{field.name}
|
||||
{field.required && <span className={css.required}>*</span>}
|
||||
</label>
|
||||
|
||||
{field.description && <p className={css.fieldDescription}>{field.description}</p>}
|
||||
|
||||
{children}
|
||||
|
||||
{error && <p className={css.fieldError}>{error}</p>}
|
||||
{warning && !error && <p className={css.fieldWarning}>{warning}</p>}
|
||||
|
||||
{field.ui?.help_link && (
|
||||
<a href={field.ui.help_link} target="_blank" rel="noreferrer" className={css.helpLink}>
|
||||
Learn more ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* UBA-003: MultiSelectField
|
||||
* A native <select> for picking additional items, rendered as a tag list.
|
||||
* The dropdown only shows unselected options; already-selected items appear
|
||||
* as removable tags above the dropdown.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { MultiSelectField as MultiSelectFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
/** Minimal X SVG */
|
||||
const CloseIcon = () => (
|
||||
<svg viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export interface MultiSelectFieldProps {
|
||||
field: MultiSelectFieldType;
|
||||
value: string[] | undefined;
|
||||
onChange: (value: string[]) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MultiSelectField({ field, value, onChange, error, disabled }: MultiSelectFieldProps) {
|
||||
const selected = value ?? field.default ?? [];
|
||||
const atMax = field.max_selections !== undefined && selected.length >= field.max_selections;
|
||||
|
||||
const available = field.options.filter((opt) => !selected.includes(opt.value));
|
||||
|
||||
const handleAdd = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newVal = e.target.value;
|
||||
if (!newVal || selected.includes(newVal)) return;
|
||||
onChange([...selected, newVal]);
|
||||
// Reset the select back to placeholder
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleRemove = (val: string) => {
|
||||
onChange(selected.filter((v) => v !== val));
|
||||
};
|
||||
|
||||
const getLabel = (val: string) => field.options.find((o) => o.value === val)?.label ?? val;
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field} error={error}>
|
||||
<div className={css.multiSelectContainer}>
|
||||
{selected.length > 0 && (
|
||||
<div className={css.selectedTags}>
|
||||
{selected.map((val) => (
|
||||
<span key={val} className={css.tag}>
|
||||
<span className={css.tagLabel}>{getLabel(val)}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.tagRemove}
|
||||
onClick={() => handleRemove(val)}
|
||||
title={`Remove ${getLabel(val)}`}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!atMax && (
|
||||
<select
|
||||
id={field.id}
|
||||
onChange={handleAdd}
|
||||
disabled={disabled || available.length === 0}
|
||||
value=""
|
||||
className={`${css.multiSelectDropdown}${error ? ` ${css.hasError}` : ''}`}
|
||||
>
|
||||
<option value="">{available.length === 0 ? 'All options selected' : '+ Add...'}</option>
|
||||
{available.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} title={opt.description}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{atMax && (
|
||||
<p className={css.maxWarning}>
|
||||
Maximum {field.max_selections} selection{field.max_selections === 1 ? '' : 's'} reached
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* UBA-003: NumberField
|
||||
* Numeric input with optional min / max / step constraints.
|
||||
* Strips leading zeros on blur; handles integer-only mode.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { NumberField as NumberFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
export interface NumberFieldProps {
|
||||
field: NumberFieldType;
|
||||
value: number | undefined;
|
||||
onChange: (value: number) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NumberField({ field, value, onChange, onBlur, error, disabled }: NumberFieldProps) {
|
||||
const placeholder = field.placeholder ?? field.ui?.placeholder;
|
||||
|
||||
// Internal string state so the user can type partial numbers (e.g. "-" or "1.")
|
||||
const [raw, setRaw] = useState<string>(
|
||||
value !== undefined ? String(value) : field.default !== undefined ? String(field.default) : ''
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value;
|
||||
setRaw(text);
|
||||
|
||||
const parsed = field.integer ? parseInt(text, 10) : parseFloat(text);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
onChange(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// Normalise display value
|
||||
const parsed = field.integer ? parseInt(raw, 10) : parseFloat(raw);
|
||||
if (Number.isNaN(parsed)) {
|
||||
setRaw('');
|
||||
} else {
|
||||
// Clamp if bounds present
|
||||
const clamped = Math.min(field.max ?? Infinity, Math.max(field.min ?? -Infinity, parsed));
|
||||
setRaw(String(clamped));
|
||||
onChange(clamped);
|
||||
}
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field} error={error}>
|
||||
<input
|
||||
id={field.id}
|
||||
type="number"
|
||||
value={raw}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step ?? (field.integer ? 1 : 'any')}
|
||||
className={`${css.numberInput}${error ? ` ${css.hasError}` : ''}`}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* UBA-003: SecretField
|
||||
* Password-masked text input with a show/hide visibility toggle.
|
||||
* Respects `no_paste` to prevent pasting (for high-security secrets).
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { SecretField as SecretFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
export interface SecretFieldProps {
|
||||
field: SecretFieldType;
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Minimal eye / eye-off SVGs — no external icon dep required */
|
||||
const EyeIcon = () => (
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.25" />
|
||||
<circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.25" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeOffIcon = () => (
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M2 2l12 12M6.5 6.6A3 3 0 0 0 9.4 9.5M4.1 4.2C2.7 5.1 1 8 1 8s2.5 5 7 5c1.3 0 2.5-.4 3.5-1M7 3.1C7.3 3 7.7 3 8 3c4.5 0 7 5 7 5s-.6 1.2-1.7 2.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function SecretField({ field, value, onChange, onBlur, error, disabled }: SecretFieldProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const placeholder = field.placeholder ?? field.ui?.placeholder ?? '••••••••••••';
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field} error={error}>
|
||||
<div className={css.secretWrapper}>
|
||||
<input
|
||||
id={field.id}
|
||||
type={visible ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="new-password"
|
||||
onPaste={field.no_paste ? (e) => e.preventDefault() : undefined}
|
||||
className={`${css.secretInput}${error ? ` ${css.hasError}` : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className={css.visibilityToggle}
|
||||
title={visible ? 'Hide' : 'Show'}
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
>
|
||||
{visible ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* UBA-003: SelectField
|
||||
* Native select dropdown. Renders all options from `field.options`.
|
||||
* Empty option is prepended unless a default is set.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { SelectField as SelectFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
/** Minimal chevron SVG */
|
||||
const ChevronDown = () => (
|
||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export interface SelectFieldProps {
|
||||
field: SelectFieldType;
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SelectField({ field, value, onChange, error, disabled }: SelectFieldProps) {
|
||||
const current = value ?? field.default ?? '';
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field} error={error}>
|
||||
<div className={css.selectWrapper}>
|
||||
<select
|
||||
id={field.id}
|
||||
value={current}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`${css.selectInput}${error ? ` ${css.hasError}` : ''}`}
|
||||
>
|
||||
{!current && <option value="">-- Select --</option>}
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} title={opt.description}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className={css.selectChevron}>
|
||||
<ChevronDown />
|
||||
</span>
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* UBA-003: StringField
|
||||
* Single-line text input with optional max-length enforcement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { StringField as StringFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
export interface StringFieldProps {
|
||||
field: StringFieldType;
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StringField({ field, value, onChange, onBlur, error, disabled }: StringFieldProps) {
|
||||
const placeholder = field.placeholder ?? field.ui?.placeholder;
|
||||
const monospace = field.ui?.monospace;
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field} error={error}>
|
||||
<input
|
||||
id={field.id}
|
||||
type="text"
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
maxLength={field.validation?.max_length}
|
||||
className={`${css.textInput}${error ? ` ${css.hasError}` : ''}${monospace ? ` ${css.monoInput}` : ''}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* UBA-003: TextField
|
||||
* Multi-line textarea, optionally monospaced.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { TextField as TextFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
export interface TextFieldProps {
|
||||
field: TextFieldType;
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TextField({ field, value, onChange, onBlur, error, disabled }: TextFieldProps) {
|
||||
const placeholder = field.placeholder ?? field.ui?.placeholder;
|
||||
const monospace = field.ui?.monospace;
|
||||
const rows = field.rows ?? 4;
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field} error={error}>
|
||||
<textarea
|
||||
id={field.id}
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
maxLength={field.validation?.max_length}
|
||||
className={`${css.textArea}${error ? ` ${css.hasError}` : ''}${monospace ? ` ${css.monoInput}` : ''}`}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* UBA-003: UrlField
|
||||
* URL input with optional protocol restriction.
|
||||
* Validates on blur — shows an error if the URL is malformed or protocol not allowed.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { UrlField as UrlFieldType } from '../../../models/UBA/types';
|
||||
import css from './fields.module.scss';
|
||||
import { FieldWrapper } from './FieldWrapper';
|
||||
|
||||
export interface UrlFieldProps {
|
||||
field: UrlFieldType;
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function validateUrl(value: string, protocols?: string[]): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (protocols && protocols.length > 0) {
|
||||
const scheme = url.protocol.replace(':', '');
|
||||
if (!protocols.includes(scheme)) {
|
||||
return `URL must use one of: ${protocols.join(', ')}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return 'Please enter a valid URL (e.g. https://example.com)';
|
||||
}
|
||||
}
|
||||
|
||||
export function UrlField({ field, value, onChange, onBlur, error, disabled }: UrlFieldProps) {
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const placeholder = field.placeholder ?? field.ui?.placeholder ?? 'https://';
|
||||
|
||||
const handleBlur = () => {
|
||||
if (value) {
|
||||
setLocalError(validateUrl(value, field.protocols));
|
||||
} else {
|
||||
setLocalError(null);
|
||||
}
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const displayError = error ?? localError ?? undefined;
|
||||
|
||||
return (
|
||||
<FieldWrapper field={field} error={displayError}>
|
||||
<input
|
||||
id={field.id}
|
||||
type="url"
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (localError) setLocalError(null);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={`${css.textInput}${displayError ? ` ${css.hasError}` : ''}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/* UBA-003: Shared field styles — CSS variables only, no hardcoded colours */
|
||||
|
||||
/* ─── Field wrapper ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.fieldWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--theme-color-danger);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fieldDescription {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-danger);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldWarning {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-warning, #d97706);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpLink {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Base inputs ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.textInput {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
background: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
&.hasError {
|
||||
border-color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.textArea {
|
||||
composes: textInput;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.monoInput {
|
||||
font-family: 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ─── Number input ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.numberInput {
|
||||
composes: textInput;
|
||||
width: 100%;
|
||||
|
||||
/* Remove browser spinner arrows */
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* ─── Boolean / toggle ──────────────────────────────────────────────────────── */
|
||||
|
||||
.booleanWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggleInput {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleTrack {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--theme-color-bg-1);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
.toggleInput:has(input:checked) & {
|
||||
background: var(--theme-color-primary);
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
.toggleInput:has(input:disabled) & {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleThumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--theme-color-fg-default);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
|
||||
.toggleInput:has(input:checked) & {
|
||||
transform: translateX(14px);
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ─── Secret input ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.secretWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.secretInput {
|
||||
composes: textInput;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.visibilityToggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Select ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.selectWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selectInput {
|
||||
composes: textInput;
|
||||
appearance: none;
|
||||
padding-right: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectChevron {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Multi-select ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.multiSelectContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.multiSelectDropdown {
|
||||
composes: textInput;
|
||||
appearance: none;
|
||||
padding-right: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectedTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--theme-color-bg-1);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.tagLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagRemove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-color-danger);
|
||||
background: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.maxWarning {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* UBA-003: Field renderers — barrel export
|
||||
*/
|
||||
|
||||
export { BooleanField } from './BooleanField';
|
||||
export type { BooleanFieldProps } from './BooleanField';
|
||||
export { FieldRenderer } from './FieldRenderer';
|
||||
export type { FieldRendererProps } from './FieldRenderer';
|
||||
export { FieldWrapper } from './FieldWrapper';
|
||||
export type { FieldWrapperProps } from './FieldWrapper';
|
||||
export { MultiSelectField } from './MultiSelectField';
|
||||
export type { MultiSelectFieldProps } from './MultiSelectField';
|
||||
export { NumberField } from './NumberField';
|
||||
export type { NumberFieldProps } from './NumberField';
|
||||
export { SecretField } from './SecretField';
|
||||
export type { SecretFieldProps } from './SecretField';
|
||||
export { SelectField } from './SelectField';
|
||||
export type { SelectFieldProps } from './SelectField';
|
||||
export { StringField } from './StringField';
|
||||
export type { StringFieldProps } from './StringField';
|
||||
export { TextField } from './TextField';
|
||||
export type { TextFieldProps } from './TextField';
|
||||
export { UrlField } from './UrlField';
|
||||
export type { UrlFieldProps } from './UrlField';
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* UBA-004: useConfigForm
|
||||
*
|
||||
* Form state management for the UBA ConfigPanel.
|
||||
* Tracks field values, validation errors, and dirty state.
|
||||
* Values are keyed by dot-notation paths: "section_id.field_id"
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { getNestedValue, setNestedValue } from '../../../models/UBA/Conditions';
|
||||
import { UBASchema } from '../../../models/UBA/types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Flat map of dot-path → value */
|
||||
export type FormValues = Record<string, unknown>;
|
||||
|
||||
/** Flat map of dot-path → error message */
|
||||
export type FormErrors = Record<string, string>;
|
||||
|
||||
export interface ConfigFormState {
|
||||
/** Current flat-path values, e.g. { "auth.api_key": "abc", "connection.url": "https://..." } */
|
||||
values: FormValues;
|
||||
/** Validation errors keyed by the same flat paths */
|
||||
errors: FormErrors;
|
||||
/** True if values differ from initialValues */
|
||||
isDirty: boolean;
|
||||
/** Set a single field value (clears its error) */
|
||||
setValue: (path: string, value: unknown) => void;
|
||||
/** Programmatically set a field error (used by ConfigPanel after failed saves) */
|
||||
setFieldError: (path: string, error: string) => void;
|
||||
/** Bulk-set errors (used by form-level validation before save) */
|
||||
setErrors: (errors: FormErrors) => void;
|
||||
/** Reset to initial values and clear all errors */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── Initial value builder ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flattens a UBASchema's default values and merges with provided values.
|
||||
* Priority: provided > schema defaults > empty
|
||||
*
|
||||
* Returns a flat-path map, e.g. { "auth.api_key": "", "connection.url": "" }
|
||||
*/
|
||||
function buildInitialValues(schema: UBASchema, provided: Record<string, unknown> = {}): FormValues {
|
||||
const values: FormValues = {};
|
||||
|
||||
for (const section of schema.sections) {
|
||||
for (const field of section.fields) {
|
||||
const path = `${section.id}.${field.id}`;
|
||||
|
||||
// Check provided (supports both flat-path and nested object)
|
||||
const providedFlat = provided[path];
|
||||
const providedNested = getNestedValue(provided as Record<string, unknown>, path);
|
||||
const providedValue = providedFlat !== undefined ? providedFlat : providedNested;
|
||||
|
||||
if (providedValue !== undefined) {
|
||||
values[path] = providedValue;
|
||||
} else if ('default' in field && field.default !== undefined) {
|
||||
values[path] = field.default;
|
||||
} else {
|
||||
// Set typed empty values so controlled inputs don't flip uncontrolled→controlled
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
values[path] = false;
|
||||
break;
|
||||
case 'multi_select':
|
||||
values[path] = [];
|
||||
break;
|
||||
case 'number':
|
||||
values[path] = undefined;
|
||||
break;
|
||||
default:
|
||||
values[path] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useConfigForm(schema: UBASchema, initialValues?: Record<string, unknown>): ConfigFormState {
|
||||
// Initial values computed once at mount — reset() handles subsequent re-init
|
||||
const initial = useMemo(() => buildInitialValues(schema, initialValues), []); // intentional mount-only
|
||||
|
||||
const [values, setValues] = useState<FormValues>(initial);
|
||||
const [errors, setErrorsState] = useState<FormErrors>({});
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
for (const key of Object.keys(initial)) {
|
||||
if (JSON.stringify(values[key]) !== JSON.stringify(initial[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Also catch new keys not in initial
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!(key in initial) && values[key] !== undefined && values[key] !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [values, initial]);
|
||||
|
||||
const setValue = useCallback((path: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [path]: value }));
|
||||
// Clear error on change
|
||||
setErrorsState((prev) => {
|
||||
if (!prev[path]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[path];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setFieldError = useCallback((path: string, error: string) => {
|
||||
setErrorsState((prev) => ({ ...prev, [path]: error }));
|
||||
}, []);
|
||||
|
||||
const setErrors = useCallback((newErrors: FormErrors) => {
|
||||
setErrorsState(newErrors);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
const fresh = buildInitialValues(schema, initialValues);
|
||||
setValues(fresh);
|
||||
setErrorsState({});
|
||||
}, [schema, initialValues]);
|
||||
|
||||
return { values, errors, isDirty, setValue, setFieldError, setErrors, reset };
|
||||
}
|
||||
|
||||
// ─── Helpers (used by ConfigPanel before save) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Performs synchronous required-field validation.
|
||||
* Returns a flat-path → error map (empty = all valid).
|
||||
*/
|
||||
export function validateRequired(schema: UBASchema, values: FormValues): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
for (const section of schema.sections) {
|
||||
for (const field of section.fields) {
|
||||
if (!field.required) continue;
|
||||
const path = `${section.id}.${field.id}`;
|
||||
const value = values[path];
|
||||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
errors[path] = `${field.name} is required`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts flat-path values map back to a nested object for sending to backends.
|
||||
* e.g. { "auth.api_key": "abc" } → { auth: { api_key: "abc" } }
|
||||
*/
|
||||
export function flatToNested(values: FormValues): Record<string, unknown> {
|
||||
let result: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(values)) {
|
||||
result = setNestedValue(result, path, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
14
packages/noodl-editor/src/editor/src/views/UBA/index.ts
Normal file
14
packages/noodl-editor/src/editor/src/views/UBA/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* UBA-003 / UBA-004: View layer — barrel export
|
||||
*/
|
||||
|
||||
export { ConfigPanel } from './ConfigPanel';
|
||||
export type { ConfigPanelProps } from './ConfigPanel';
|
||||
export { ConfigSection, sectionHasErrors } from './ConfigSection';
|
||||
export type { ConfigSectionProps } from './ConfigSection';
|
||||
export { FieldRenderer } from './fields/FieldRenderer';
|
||||
export type { FieldRendererProps } from './fields/FieldRenderer';
|
||||
export { FieldWrapper } from './fields/FieldWrapper';
|
||||
export type { FieldWrapperProps } from './fields/FieldWrapper';
|
||||
export { useConfigForm, validateRequired, flatToNested } from './hooks/useConfigForm';
|
||||
export type { ConfigFormState, FormValues, FormErrors } from './hooks/useConfigForm';
|
||||
@@ -5,6 +5,7 @@ import { platform } from '@noodl/platform';
|
||||
|
||||
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
||||
import View from '../../../../shared/view';
|
||||
import { PreviewTokenInjector } from '../../services/PreviewTokenInjector';
|
||||
import { VisualCanvas } from './VisualCanvas';
|
||||
|
||||
export class CanvasView extends View {
|
||||
@@ -108,6 +109,9 @@ export class CanvasView extends View {
|
||||
this.webview.executeJavaScript(`NoodlEditorHighlightAPI.selectNode('${this.selectedNodeId}')`);
|
||||
}
|
||||
|
||||
// Inject project design tokens into the preview so var(--token-name) resolves correctly.
|
||||
PreviewTokenInjector.instance.notifyDomReady(this.webview);
|
||||
|
||||
this.updateViewportSize();
|
||||
});
|
||||
|
||||
@@ -180,6 +184,7 @@ export class CanvasView extends View {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
PreviewTokenInjector.instance.clearWebview();
|
||||
ipcRenderer.off('editor-api-response', this._onEditorApiResponse);
|
||||
}
|
||||
refresh() {
|
||||
|
||||
@@ -1,123 +1,75 @@
|
||||
/**
|
||||
* MigrationWizard Styles
|
||||
*
|
||||
* Main container for the migration wizard using CoreBaseDialog.
|
||||
* Enhanced with modern visual design, animations, and better spacing.
|
||||
* CLEANUP-000H: MigrationWizard shell — design token polish
|
||||
*/
|
||||
|
||||
/* Animation definitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Design system variables */
|
||||
:root {
|
||||
--wizard-space-xs: 4px;
|
||||
--wizard-space-sm: 8px;
|
||||
--wizard-space-md: 16px;
|
||||
--wizard-space-lg: 24px;
|
||||
--wizard-space-xl: 32px;
|
||||
--wizard-space-xxl: 48px;
|
||||
|
||||
--wizard-transition-fast: 150ms ease-out;
|
||||
--wizard-transition-base: 250ms ease-in-out;
|
||||
--wizard-transition-slow: 400ms ease-in-out;
|
||||
|
||||
--wizard-radius-sm: 4px;
|
||||
--wizard-radius-md: 8px;
|
||||
--wizard-radius-lg: 12px;
|
||||
|
||||
--wizard-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
--wizard-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--wizard-shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.WizardContainer {
|
||||
position: relative;
|
||||
width: 750px;
|
||||
max-width: 92vw;
|
||||
max-height: 85vh;
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: var(--wizard-radius-lg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-fg-default);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--wizard-shadow-lg);
|
||||
animation: fadeIn var(--wizard-transition-base);
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
position: absolute;
|
||||
top: var(--wizard-space-md);
|
||||
right: var(--wizard-space-md);
|
||||
z-index: 10;
|
||||
transition: transform var(--wizard-transition-fast);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4, 16px) var(--spacing-5, 20px);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.WizardHeader {
|
||||
padding: var(--wizard-space-xl) var(--wizard-space-xl) var(--wizard-space-lg);
|
||||
padding-right: var(--wizard-space-xxl); // Space for close button
|
||||
border-bottom: 1px solid var(--theme-color-bg-3);
|
||||
.Title {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.WizardContent {
|
||||
.Body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--wizard-space-xl) var(--wizard-space-xl);
|
||||
gap: var(--wizard-space-lg);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
animation: slideIn var(--wizard-transition-base);
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--theme-color-bg-3);
|
||||
border-radius: var(--wizard-radius-sm);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--theme-color-bg-1);
|
||||
border-radius: var(--wizard-radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding: var(--spacing-3, 12px) var(--spacing-5, 20px);
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Shared step container — used by all step components
|
||||
.StepContainer {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
animation: slideIn var(--wizard-transition-base);
|
||||
padding: var(--spacing-6, 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.StepTitle {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.StepDescription {
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.StepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
@@ -1,146 +1,75 @@
|
||||
/**
|
||||
* WizardProgress Styles
|
||||
*
|
||||
* Enhanced step progress indicator for migration wizard with animations and better visuals.
|
||||
* CLEANUP-000H: WizardProgress — design token polish
|
||||
* All colours via tokens. No hardcoded values.
|
||||
*/
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(66, 135, 245, 0.7);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 8px rgba(66, 135, 245, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes checkmark {
|
||||
0% {
|
||||
transform: scale(0) rotate(-45deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(-45deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideProgress {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
transform-origin: left;
|
||||
}
|
||||
}
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 0 24px;
|
||||
margin-bottom: 8px;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
background-color: var(--theme-color-bg-1);
|
||||
border-bottom: 1px solid var(--theme-color-border-subtle, var(--theme-color-border-default));
|
||||
}
|
||||
|
||||
.Step {
|
||||
.StepItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
gap: var(--spacing-2, 8px);
|
||||
}
|
||||
|
||||
.StepCircle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
.StepNumber {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-fg-muted);
|
||||
border: 2px solid var(--theme-color-bg-2);
|
||||
transition: all 300ms ease-in-out;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
border-radius: 50%;
|
||||
font-size: var(--font-size-xsmall, 11px);
|
||||
font-weight: 500;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
.Step.is-completed .StepCircle {
|
||||
background-color: var(--theme-color-success);
|
||||
border-color: var(--theme-color-success);
|
||||
color: white;
|
||||
animation: checkmark 400ms ease-out;
|
||||
}
|
||||
// Default (pending) state
|
||||
background-color: var(--theme-color-bg-4, var(--theme-color-bg-3));
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
.Step.is-active .StepCircle {
|
||||
background-color: var(--theme-color-primary);
|
||||
border-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 0 0 rgba(66, 135, 245, 0.7);
|
||||
&.is-active {
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
&.is-complete {
|
||||
background-color: var(--theme-color-success, #22c55e);
|
||||
color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
background-color: var(--theme-color-danger, #ef4444);
|
||||
color: var(--theme-color-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.StepLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-muted);
|
||||
text-align: center;
|
||||
max-width: 80px;
|
||||
transition: color 200ms ease-in-out;
|
||||
line-height: 1.3;
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
&.is-active {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.is-complete {
|
||||
color: var(--theme-color-success, #22c55e);
|
||||
}
|
||||
}
|
||||
|
||||
.Step.is-completed .StepLabel,
|
||||
.Step.is-active .StepLabel {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.Connector {
|
||||
.StepConnector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
margin: 0 -4px;
|
||||
margin-bottom: 28px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--theme-color-bg-4, var(--theme-color-bg-3));
|
||||
min-width: 20px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-success);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 400ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.Connector.is-completed::after {
|
||||
transform: scaleX(1);
|
||||
animation: slideProgress 400ms ease-out;
|
||||
}
|
||||
|
||||
.CheckIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
&.is-complete {
|
||||
background-color: var(--theme-color-success, #22c55e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,329 +1,105 @@
|
||||
/**
|
||||
* CompleteStep Styles
|
||||
*
|
||||
* Enhanced final step with celebration and beautiful summary.
|
||||
* CLEANUP-000H: CompleteStep — design token polish
|
||||
*/
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes successPulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
filter: drop-shadow(0 0 16px rgba(34, 197, 94, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.Root {
|
||||
.StepContainer {
|
||||
padding: var(--spacing-6, 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
animation: slideInUp 300ms ease-out;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, transparent 100%);
|
||||
border-radius: 12px;
|
||||
|
||||
.SuccessIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%);
|
||||
border-radius: 50%;
|
||||
animation: successPulse 2s ease-in-out infinite;
|
||||
|
||||
svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.Stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.StatCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 16px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
transition: all 250ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.StatCardIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 200ms ease-in-out;
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.StatCard.is-success .StatCardIcon {
|
||||
color: var(--theme-color-success);
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.StatCard.is-warning .StatCardIcon {
|
||||
color: var(--theme-color-warning);
|
||||
background-color: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
.StatCard.is-error .StatCardIcon {
|
||||
color: var(--theme-color-danger);
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.StatCard:hover .StatCardIcon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.StatCardValue {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
animation: countUp 400ms ease-out;
|
||||
}
|
||||
|
||||
.StatCardLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
.StepTitle {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.MetaInfo {
|
||||
// Success banner
|
||||
.SuccessBanner {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 10px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3, 12px);
|
||||
padding: var(--spacing-4, 16px);
|
||||
background-color: color-mix(in srgb, var(--theme-color-success, #22c55e) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-color-success, #22c55e) 30%, transparent);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
}
|
||||
|
||||
.SuccessIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--theme-color-success, #22c55e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.SuccessText {
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-success, #22c55e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Stats
|
||||
.StatsCard {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
padding: var(--spacing-4, 16px);
|
||||
display: flex;
|
||||
gap: var(--spacing-6, 24px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.MetaItem {
|
||||
.StatItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-2, 8px);
|
||||
}
|
||||
|
||||
.Paths {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.StatValue {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.PathItem {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--theme-color-bg-3);
|
||||
transition: all 200ms ease-in-out;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--theme-color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
.StatLabel {
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.PathContent {
|
||||
// What's next section
|
||||
.NextStepsSection {
|
||||
padding-top: var(--spacing-4, 16px);
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
.PathLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.PathValue {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
word-break: break-all;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
gap: var(--spacing-3, 12px);
|
||||
}
|
||||
|
||||
.NextSteps {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0 0 16px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
.NextStepsTitle {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.StepsList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--theme-color-fg-default);
|
||||
transition: all 200ms ease-in-out;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
.ChecklistItem {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding: var(--spacing-1, 4px) 0;
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ChecklistIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -1,285 +1,84 @@
|
||||
/**
|
||||
* ConfirmStep Styles
|
||||
*
|
||||
* First step of migration wizard - confirm source and target paths.
|
||||
* Enhanced with better visual hierarchy and animations.
|
||||
* CLEANUP-000H: ConfirmStep — design token polish
|
||||
*/
|
||||
|
||||
@keyframes arrowBounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.Root {
|
||||
.StepContainer {
|
||||
padding: var(--spacing-6, 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
animation: slideInUp 300ms ease-out;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-primary);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.PathSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 250ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.PathSection--locked {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-color: var(--theme-color-bg-1);
|
||||
opacity: 0.9;
|
||||
|
||||
.PathValue {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
border: 1px dashed var(--theme-color-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.PathSection--editable {
|
||||
border-color: var(--theme-color-primary);
|
||||
border-width: 2px;
|
||||
box-shadow: 0 0 0 3px rgba(66, 135, 245, 0.1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 4px rgba(66, 135, 245, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.PathHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.LockIcon {
|
||||
color: var(--theme-color-fg-muted);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.FolderIcon {
|
||||
color: var(--theme-color-primary);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.PathFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.PathField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.PathLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathDisplay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.PathText {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.PathValue {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.PathInput {
|
||||
input {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.Arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
margin: 8px 0;
|
||||
color: var(--theme-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: arrowBounce 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.InfoBox {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--theme-color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0 0 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.StepsList {
|
||||
.StepTitle {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.StepDescription {
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.6;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
|
||||
&::marker {
|
||||
color: var(--theme-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.WarningBox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.WarningContent {
|
||||
.StepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.WarningTitle {
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
.InfoCard {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
padding: var(--spacing-4, 16px);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2, 8px);
|
||||
}
|
||||
|
||||
.InfoTitle {
|
||||
font-size: var(--font-size-small, 12px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.CheckList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1, 4px);
|
||||
}
|
||||
|
||||
.CheckItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2, 8px);
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.CheckIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.WarningBanner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3, 12px);
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
background-color: color-mix(in srgb, var(--theme-color-notice, #f59e0b) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-color-notice, #f59e0b) 30%, transparent);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,235 +1,85 @@
|
||||
/**
|
||||
* FailedStep Styles
|
||||
*
|
||||
* Enhanced error state with helpful suggestions and beautiful error display.
|
||||
* CLEANUP-000H: FailedStep — design token polish
|
||||
*/
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.Root {
|
||||
.StepContainer {
|
||||
padding: var(--spacing-6, 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
animation: slideInUp 300ms ease-out;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.Header {
|
||||
.StepTitle {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Error banner
|
||||
.ErrorBanner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3, 12px);
|
||||
padding: var(--spacing-4, 16px);
|
||||
background-color: color-mix(in srgb, var(--theme-color-danger, #ef4444) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-color-danger, #ef4444) 30%, transparent);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
}
|
||||
|
||||
.ErrorIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--theme-color-danger, #ef4444);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ErrorContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, transparent 100%);
|
||||
border-radius: 12px;
|
||||
|
||||
.ErrorIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.1) 100%);
|
||||
border-radius: 50%;
|
||||
animation: shake 500ms ease-out;
|
||||
|
||||
svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
gap: var(--spacing-2, 8px);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ErrorBox {
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.05) 100%);
|
||||
border: 2px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ErrorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--theme-color-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-danger);
|
||||
margin: 0;
|
||||
}
|
||||
.ErrorTitle {
|
||||
font-size: var(--font-size-default, 13px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.ErrorMessage {
|
||||
padding: 20px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
background-color: var(--theme-color-bg-4);
|
||||
word-break: break-word;
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// Error details (collapsible/scrollable)
|
||||
.ErrorDetails {
|
||||
margin-top: var(--spacing-3, 12px);
|
||||
padding: var(--spacing-3, 12px);
|
||||
background-color: var(--theme-color-bg-1);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.Suggestions {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0 0 16px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SuggestionList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--theme-color-fg-default);
|
||||
transition: all 200ms ease-in-out;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Link {
|
||||
color: var(--theme-color-primary);
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.SafetyNotice {
|
||||
.ActionRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(34, 197, 94, 0.08) 100%);
|
||||
border: 2px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.SafetyContent {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-success);
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
align-items: center;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding-top: var(--spacing-2, 8px);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
.HintText {
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,417 +1,91 @@
|
||||
/**
|
||||
* MigratingStep Styles
|
||||
*
|
||||
* Enhanced AI-assisted migration progress display with beautiful budget tracking and decision panels.
|
||||
* CLEANUP-000H: MigratingStep — design token polish
|
||||
* Same loading pattern as ScanningStep.
|
||||
*/
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes budgetPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(251, 191, 36, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.Root {
|
||||
.StepContainer {
|
||||
padding: var(--spacing-6, 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 20px;
|
||||
animation: slideInUp 300ms ease-out;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.Header {
|
||||
.StepTitle {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.LoadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 4px;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-8, 32px) var(--spacing-4, 16px);
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--theme-color-primary);
|
||||
animation: spin 1.5s linear infinite;
|
||||
filter: drop-shadow(0 0 8px rgba(66, 135, 245, 0.3));
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
.Spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--theme-color-bg-3);
|
||||
border-top-color: var(--theme-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Budget Section */
|
||||
.BudgetSection {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--theme-color-bg-2);
|
||||
transition: all 300ms ease-in-out;
|
||||
|
||||
&.is-warning {
|
||||
border-color: var(--theme-color-warning);
|
||||
animation: budgetPulse 2s ease-in-out infinite;
|
||||
}
|
||||
.LoadingText {
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.BudgetHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.BudgetAmount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.is-warning {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.BudgetBar {
|
||||
height: 10px;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
border-radius: 5px;
|
||||
.CurrentFile {
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.2), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.BudgetFill {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--theme-color-primary) 0%,
|
||||
rgba(66, 135, 245, 0.8) 50%,
|
||||
var(--theme-color-primary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 5px;
|
||||
transition: width 400ms ease-out, background 300ms ease-in-out;
|
||||
animation: shimmer 2s linear infinite;
|
||||
position: relative;
|
||||
|
||||
&.is-warning {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--theme-color-warning) 0%,
|
||||
rgba(251, 191, 36, 0.8) 50%,
|
||||
var(--theme-color-warning) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.4));
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.BudgetWarning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background-color: rgba(251, 191, 36, 0.15);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-warning);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress Section */
|
||||
.ProgressSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ProgressBar {
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 4px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 4px;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ProgressFill {
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-primary);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 99px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
/* Current Component */
|
||||
.CurrentComponent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--theme-color-primary);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
border-color: var(--theme-color-primary);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Log Section */
|
||||
.LogSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
.MigrationLog {
|
||||
width: 100%;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.LogEntries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.LogEntry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
animation: slideIn 0.2s ease;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.is-info {
|
||||
border-left: 3px solid var(--theme-color-secondary-as-fg);
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
border-left: 3px solid var(--theme-color-success);
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
border-left: 3px solid var(--theme-color-warning);
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
border-left: 3px solid var(--theme-color-danger);
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.LogContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* AI Decision Panel */
|
||||
.DecisionPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 2px solid var(--theme-color-warning);
|
||||
border-radius: 8px;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.DecisionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.AttemptHistory {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.AttemptEntry {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.DecisionOptions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.Actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
/* Dialog Overlay */
|
||||
.DialogOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
background-color: var(--theme-color-bg-1);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
padding: var(--spacing-2, 8px) var(--spacing-3, 12px);
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1,503 +1,127 @@
|
||||
/**
|
||||
* ReportStep Styles
|
||||
*
|
||||
* Enhanced scan results report with beautiful categories and AI options.
|
||||
* CLEANUP-000H: ReportStep — design token polish
|
||||
*/
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.Root {
|
||||
.StepContainer {
|
||||
padding: var(--spacing-6, 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
animation: slideInUp 300ms ease-out;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.StatsRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.StatCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 16px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
transition: all 250ms ease-in-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--theme-color-secondary-as-fg);
|
||||
opacity: 0;
|
||||
transition: opacity 250ms ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.StatCard.is-automatic::before {
|
||||
background: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.StatCard.is-simpleFixes::before {
|
||||
background: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.StatCard.is-needsReview::before {
|
||||
background: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.StatCardIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 50%;
|
||||
transition: all 200ms ease-in-out;
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.StatCard.is-automatic .StatCardIcon {
|
||||
color: var(--theme-color-success);
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.StatCard.is-simpleFixes .StatCardIcon {
|
||||
color: var(--theme-color-warning);
|
||||
background-color: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
.StatCard.is-needsReview .StatCardIcon {
|
||||
color: var(--theme-color-danger);
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.StatCard:hover .StatCardIcon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.StatCardValue {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
animation: countUp 400ms ease-out;
|
||||
}
|
||||
|
||||
.StatCardLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
.StepTitle {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Categories {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
.StepDescription {
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.SummaryCard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--spacing-3, 12px);
|
||||
}
|
||||
|
||||
.SummaryItem {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
padding: var(--spacing-3, 12px);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--spacing-1, 4px);
|
||||
}
|
||||
|
||||
.CategorySection {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 10px;
|
||||
.SummaryValue {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.SummaryLabel {
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Issue list
|
||||
.IssueList {
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
transition: all 250ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-color-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.CategorySection.is-expanded {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
.IssueListEmpty {
|
||||
padding: var(--spacing-4, 16px);
|
||||
text-align: center;
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.CategoryHeader {
|
||||
.IssueItem {
|
||||
padding: var(--spacing-3, 12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease-in-out;
|
||||
user-select: none;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3, 12px);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
&:nth-child(even) {
|
||||
background-color: color-mix(in srgb, var(--theme-color-bg-3) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.CategorySection.is-expanded .CategoryHeader {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
border-bottom: 1px solid var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
.CategoryIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 200ms ease-in-out;
|
||||
.IssueIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
&--warning {
|
||||
color: var(--theme-color-notice, #f59e0b);
|
||||
}
|
||||
|
||||
&--error {
|
||||
color: var(--theme-color-danger, #ef4444);
|
||||
}
|
||||
|
||||
&--info {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
}
|
||||
|
||||
.CategorySection.is-automatic .CategoryIcon {
|
||||
color: var(--theme-color-success);
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.CategorySection.is-simpleFixes .CategoryIcon {
|
||||
color: var(--theme-color-warning);
|
||||
background-color: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
.CategorySection.is-needsReview .CategoryIcon {
|
||||
color: var(--theme-color-danger);
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.CategoryHeader:hover .CategoryIcon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.CategoryInfo {
|
||||
.IssueContent {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.CategoryTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.CategoryDescription {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
.CategoryCount {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
border: 1px solid var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
.ExpandIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--theme-color-fg-muted);
|
||||
transition: transform 250ms ease-in-out;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.CategorySection.is-expanded .ExpandIcon {
|
||||
transform: rotate(180deg);
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
.ComponentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
animation: slideInUp 250ms ease-out;
|
||||
|
||||
/* Custom scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--theme-color-bg-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--theme-color-bg-1);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ComponentItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--theme-color-bg-3);
|
||||
transition: all 200ms ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-color: var(--theme-color-primary);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.ComponentName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ComponentIssueCount {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.AiPromptSection {
|
||||
margin-top: 20px;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(139, 92, 246, 0.08) 100%);
|
||||
border: 2px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
.IssuePath {
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
overflow: hidden;
|
||||
transition: all 250ms ease-in-out;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, rgba(139, 92, 246, 0.3), rgba(168, 85, 247, 0.3), rgba(139, 92, 246, 0.3));
|
||||
background-size: 200% 200%;
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
border-radius: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 250ms ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.AiPromptHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #8b5cf6;
|
||||
animation: sparkle 2s ease-in-out infinite;
|
||||
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.4));
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #8b5cf6;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.AiPromptContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
.AiPromptFeatures {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.AiPromptFeature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.AiPromptCost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 600;
|
||||
color: #8b5cf6;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.AiPromptSection.is-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
.IssueMessage {
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -1,337 +1,76 @@
|
||||
/**
|
||||
* ScanningStep Styles
|
||||
*
|
||||
* Enhanced scanning/migrating progress display with animations and better visualization.
|
||||
* CLEANUP-000H: ScanningStep — design token polish
|
||||
*/
|
||||
|
||||
.StepContainer {
|
||||
padding: var(--spacing-6, 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.StepTitle {
|
||||
font-size: var(--font-size-large, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, var(--theme-color-fg-default));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.LoadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-8, 32px) var(--spacing-4, 16px);
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--theme-color-bg-3);
|
||||
border-top-color: var(--theme-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
.LoadingText {
|
||||
font-size: var(--font-size-default, 13px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 24px;
|
||||
animation: slideInUp 300ms ease-out;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--theme-color-primary);
|
||||
animation: spin 1.5s linear infinite;
|
||||
filter: drop-shadow(0 0 8px rgba(66, 135, 245, 0.3));
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ProgressSection {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, var(--theme-color-bg-3) 0%, var(--theme-color-bg-2) 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.ProgressHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.CurrentFile {
|
||||
font-size: var(--font-size-small, 12px);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ProgressBar {
|
||||
height: 12px;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 4px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.2), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ProgressFill {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--theme-color-primary) 0%,
|
||||
rgba(66, 135, 245, 0.8) 50%,
|
||||
var(--theme-color-primary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 400ms ease-out;
|
||||
animation: shimmer 2s linear infinite;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.4));
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.CurrentFile {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: monospace;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
.StatsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.StatCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--theme-color-bg-2);
|
||||
transition: all 200ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.StatIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--theme-color-primary);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.StatValue {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.StatLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ActivityLog {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ActivityHeader {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--theme-color-bg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ActivityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.ActivityItem {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
|
||||
&.is-info {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ActivityTime {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ActivityMessage {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.EmptyActivity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.InfoBox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
background-color: var(--theme-color-primary);
|
||||
border-radius: 99px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* UBA-007: DebugStreamView
|
||||
*
|
||||
* SSE-based live debug log viewer for UBA backends.
|
||||
* Connects to the backend's debug_stream endpoint via UBAClient.openDebugStream()
|
||||
* and renders a scrollable, auto-scrolling event log.
|
||||
*
|
||||
* Features:
|
||||
* - Connect / Disconnect toggle button
|
||||
* - Auto-scroll to bottom on new events (can be overridden by manual scroll)
|
||||
* - Max 500 events in memory (oldest are dropped)
|
||||
* - Clear button to reset the log
|
||||
* - Per-event type colour coding (log/info/warn/error)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AuthConfig } from '@noodl-models/UBA/types';
|
||||
|
||||
import { UBAClient, type DebugEvent, type DebugStreamHandle } from '../../../services/UBA/UBAClient';
|
||||
import css from './UBAPanel.module.scss';
|
||||
|
||||
const MAX_EVENTS = 500;
|
||||
|
||||
export interface DebugStreamViewProps {
|
||||
endpoint: string;
|
||||
auth?: AuthConfig;
|
||||
credentials?: { token?: string; username?: string; password?: string };
|
||||
}
|
||||
|
||||
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
function eventTypeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return css.eventError;
|
||||
case 'warn':
|
||||
return css.eventWarn;
|
||||
case 'info':
|
||||
return css.eventInfo;
|
||||
case 'metric':
|
||||
return css.eventMetric;
|
||||
default:
|
||||
return css.eventLog;
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventData(data: unknown): string {
|
||||
if (typeof data === 'string') return data;
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function DebugStreamView({ endpoint, auth, credentials }: DebugStreamViewProps) {
|
||||
const [events, setEvents] = useState<DebugEvent[]>([]);
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [statusMsg, setStatusMsg] = useState<string>('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const handleRef = useRef<DebugStreamHandle | null>(null);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new events arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && logRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}
|
||||
}, [events, autoScroll]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (handleRef.current) return; // already connected
|
||||
|
||||
setStatus('connecting');
|
||||
setStatusMsg('');
|
||||
|
||||
handleRef.current = UBAClient.openDebugStream(
|
||||
endpoint,
|
||||
{
|
||||
onOpen: () => {
|
||||
setStatus('connected');
|
||||
setStatusMsg('');
|
||||
},
|
||||
onEvent: (event) => {
|
||||
setEvents((prev) => {
|
||||
const next = [...prev, event];
|
||||
return next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next;
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatus('error');
|
||||
setStatusMsg(err.message);
|
||||
handleRef.current = null;
|
||||
}
|
||||
},
|
||||
auth,
|
||||
credentials
|
||||
);
|
||||
}, [endpoint, auth, credentials]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
handleRef.current?.close();
|
||||
handleRef.current = null;
|
||||
setStatus('disconnected');
|
||||
setStatusMsg('');
|
||||
}, []);
|
||||
|
||||
// Clean up on unmount or endpoint change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
handleRef.current?.close();
|
||||
handleRef.current = null;
|
||||
};
|
||||
}, [endpoint]);
|
||||
|
||||
const handleScrollLog = useCallback(() => {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
// If user scrolled up more than 40px from bottom, disable auto-scroll
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setAutoScroll(distFromBottom < 40);
|
||||
}, []);
|
||||
|
||||
const isConnected = status === 'connected';
|
||||
|
||||
return (
|
||||
<div className={css.debugStream}>
|
||||
{/* Toolbar */}
|
||||
<div className={css.debugToolbar}>
|
||||
<span className={`${css.statusDot} ${css[`statusDot_${status}`]}`} aria-hidden="true" />
|
||||
<span className={css.statusLabel}>
|
||||
{status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'connected'
|
||||
? 'Live'
|
||||
: status === 'error'
|
||||
? 'Error'
|
||||
: 'Disconnected'}
|
||||
</span>
|
||||
{statusMsg && <span className={css.statusDetail}>{statusMsg}</span>}
|
||||
|
||||
<div className={css.debugToolbarSpacer} />
|
||||
|
||||
<button type="button" className={css.clearBtn} onClick={() => setEvents([])} disabled={events.length === 0}>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={isConnected ? css.disconnectBtn : css.connectBtn}
|
||||
onClick={isConnected ? disconnect : connect}
|
||||
disabled={status === 'connecting'}
|
||||
>
|
||||
{isConnected ? 'Disconnect' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Event log */}
|
||||
<div ref={logRef} className={css.debugLog} onScroll={handleScrollLog}>
|
||||
{events.length === 0 ? (
|
||||
<div className={css.debugEmpty}>
|
||||
{isConnected ? 'Waiting for events…' : 'Connect to start receiving events.'}
|
||||
</div>
|
||||
) : (
|
||||
events.map((event, i) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className={`${css.debugEvent} ${eventTypeClass(event.type)}`}
|
||||
>
|
||||
<span className={css.debugEventTime}>
|
||||
{event.receivedAt.toLocaleTimeString(undefined, { hour12: false })}
|
||||
</span>
|
||||
<span className={css.debugEventType}>{event.type.toUpperCase()}</span>
|
||||
<pre className={css.debugEventData}>{formatEventData(event.data)}</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-scroll indicator */}
|
||||
{!autoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.scrollToBottomBtn}
|
||||
onClick={() => {
|
||||
setAutoScroll(true);
|
||||
if (logRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}
|
||||
}}
|
||||
>
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// UBAPanel + DebugStreamView styles
|
||||
// All colors use CSS design tokens — no hardcoded values
|
||||
|
||||
// ─── Schema Loader ────────────────────────────────────────────────────────────
|
||||
|
||||
.schemaLoader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.schemaLoaderHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.schemaLoaderRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.schemaLoaderInput {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
background: var(--theme-color-bg-1);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.schemaLoaderBtn {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
background: var(--theme-color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.schemaLoaderError {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-danger, #e05454);
|
||||
}
|
||||
|
||||
// ─── Status / Error states ────────────────────────────────────────────────────
|
||||
|
||||
.statusMsg {
|
||||
padding: 24px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-danger, #e05454);
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
align-self: flex-start;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Debug Stream ─────────────────────────────────────────────────────────────
|
||||
|
||||
.debugStream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.debugToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.statusDot_connected {
|
||||
background: var(--theme-color-success, #4ade80);
|
||||
}
|
||||
|
||||
&.statusDot_connecting {
|
||||
background: var(--theme-color-warning, #facc15);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
&.statusDot_error {
|
||||
background: var(--theme-color-danger, #e05454);
|
||||
}
|
||||
|
||||
&.statusDot_disconnected {
|
||||
background: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
.statusDetail {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.debugToolbarSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.connectBtn {
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
background: var(--theme-color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.disconnectBtn {
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
color: var(--theme-color-fg-default);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-color-danger, #e05454);
|
||||
color: var(--theme-color-danger, #e05454);
|
||||
}
|
||||
}
|
||||
|
||||
.debugLog {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
min-height: 0;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.debugEmpty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.debugEvent {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 52px 1fr;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
align-items: baseline;
|
||||
border-bottom: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.debugEventTime {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.debugEventType {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.debugEventData {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Event type colours
|
||||
.eventLog .debugEventType {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
.eventInfo .debugEventType {
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
.eventWarn .debugEventType {
|
||||
color: var(--theme-color-warning, #facc15);
|
||||
}
|
||||
.eventError {
|
||||
background: color-mix(in srgb, var(--theme-color-danger, #e05454) 8%, transparent);
|
||||
|
||||
.debugEventType {
|
||||
color: var(--theme-color-danger, #e05454);
|
||||
}
|
||||
}
|
||||
.eventMetric .debugEventType {
|
||||
color: var(--theme-color-success, #4ade80);
|
||||
}
|
||||
|
||||
.scrollToBottomBtn {
|
||||
position: sticky;
|
||||
bottom: 8px;
|
||||
align-self: center;
|
||||
margin: 4px auto;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Health Indicator (UBA-009) ───────────────────────────────────────────────
|
||||
|
||||
.configureTabContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.healthBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background: var(--theme-color-bg-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.healthDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.healthLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
// Status modifier classes — applied to .healthBadge
|
||||
.healthUnknown {
|
||||
.healthDot {
|
||||
background: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.healthLabel {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
}
|
||||
|
||||
.healthChecking {
|
||||
.healthDot {
|
||||
background: var(--theme-color-fg-default);
|
||||
animation: healthPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.healthLabel {
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
.healthHealthy {
|
||||
.healthDot {
|
||||
background: var(--theme-color-success, #4ade80);
|
||||
}
|
||||
|
||||
.healthLabel {
|
||||
color: var(--theme-color-success, #4ade80);
|
||||
}
|
||||
}
|
||||
|
||||
.healthUnhealthy {
|
||||
.healthDot {
|
||||
background: var(--theme-color-danger, #f87171);
|
||||
}
|
||||
|
||||
.healthLabel {
|
||||
color: var(--theme-color-danger, #f87171);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes healthPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* UBA-006 + UBA-007: UBAPanel
|
||||
*
|
||||
* Editor-side panel for the Universal Backend Adapter system.
|
||||
* Provides two tabs:
|
||||
* - Configure: Schema-driven config form backed by project metadata + UBAClient
|
||||
* - Debug: Live SSE event stream from the backend's debug_stream endpoint
|
||||
*
|
||||
* Schema discovery flow:
|
||||
* 1. Read `ubaSchemaUrl` from project metadata
|
||||
* 2. If absent → show SchemaLoader UI (URL input field)
|
||||
* 3. Fetch + parse the schema with SchemaParser
|
||||
* 4. On parse success → render ConfigPanel
|
||||
*
|
||||
* Config persistence flow:
|
||||
* 1. Load `ubaConfig` from project metadata as initialValues
|
||||
* 2. ConfigPanel.onSave → store in metadata + POST via UBAClient.configure()
|
||||
*
|
||||
* Project metadata keys:
|
||||
* - 'ubaSchemaUrl' — URL or local path to the UBA schema JSON
|
||||
* - 'ubaConfig' — saved config values (nested object)
|
||||
*/
|
||||
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { SchemaParser } from '@noodl-models/UBA/SchemaParser';
|
||||
import type { UBASchema } from '@noodl-models/UBA/types';
|
||||
|
||||
import { Tabs, TabsVariant } from '@noodl-core-ui/components/layout/Tabs';
|
||||
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
|
||||
|
||||
import { UBAClient } from '../../../services/UBA/UBAClient';
|
||||
import { ConfigPanel } from '../../UBA/ConfigPanel';
|
||||
import { DebugStreamView } from './DebugStreamView';
|
||||
import css from './UBAPanel.module.scss';
|
||||
|
||||
const METADATA_SCHEMA_URL = 'ubaSchemaUrl';
|
||||
const METADATA_CONFIG = 'ubaConfig';
|
||||
const HEALTH_POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
// ─── Health Indicator (UBA-009) ───────────────────────────────────────────────
|
||||
|
||||
type HealthStatus = 'unknown' | 'checking' | 'healthy' | 'unhealthy';
|
||||
|
||||
/**
|
||||
* Polls the backend health endpoint every 30s.
|
||||
* Uses UBAClient.health() which never throws.
|
||||
*/
|
||||
function useUBAHealth(
|
||||
healthUrl: string | undefined,
|
||||
auth: UBASchema['backend']['auth'] | undefined
|
||||
): { status: HealthStatus; message: string | undefined } {
|
||||
const [status, setStatus] = useState<HealthStatus>('unknown');
|
||||
const [message, setMessage] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!healthUrl) {
|
||||
setStatus('unknown');
|
||||
setMessage(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const check = async () => {
|
||||
if (!cancelled) setStatus('checking');
|
||||
const result = await UBAClient.health(healthUrl, auth);
|
||||
if (!cancelled) {
|
||||
setStatus(result.healthy ? 'healthy' : 'unhealthy');
|
||||
setMessage(result.healthy ? undefined : result.message);
|
||||
}
|
||||
};
|
||||
|
||||
void check();
|
||||
const timer = setInterval(() => void check(), HEALTH_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [healthUrl, auth]);
|
||||
|
||||
return { status, message };
|
||||
}
|
||||
|
||||
const HEALTH_STATUS_CLASS: Record<HealthStatus, string> = {
|
||||
unknown: css.healthUnknown,
|
||||
checking: css.healthChecking,
|
||||
healthy: css.healthHealthy,
|
||||
unhealthy: css.healthUnhealthy
|
||||
};
|
||||
|
||||
const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = {
|
||||
unknown: 'Not configured',
|
||||
checking: 'Checking…',
|
||||
healthy: 'Healthy',
|
||||
unhealthy: 'Unhealthy'
|
||||
};
|
||||
|
||||
interface HealthBadgeProps {
|
||||
status: HealthStatus;
|
||||
message: string | undefined;
|
||||
}
|
||||
|
||||
function HealthBadge({ status, message }: HealthBadgeProps) {
|
||||
return (
|
||||
<div className={`${css.healthBadge} ${HEALTH_STATUS_CLASS[status]}`} title={message ?? HEALTH_STATUS_LABEL[status]}>
|
||||
<span className={css.healthDot} aria-hidden="true" />
|
||||
<span className={css.healthLabel}>{HEALTH_STATUS_LABEL[status]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Schema Loader ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SchemaLoaderProps {
|
||||
onLoad: (url: string) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function SchemaLoader({ onLoad, loading, error }: SchemaLoaderProps) {
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
return (
|
||||
<div className={css.schemaLoader}>
|
||||
<p className={css.schemaLoaderHint}>
|
||||
Paste the URL or local path to your backend's UBA schema JSON to get started.
|
||||
</p>
|
||||
<div className={css.schemaLoaderRow}>
|
||||
<input
|
||||
className={css.schemaLoaderInput}
|
||||
type="url"
|
||||
placeholder="http://localhost:3210/uba-schema.json"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && url.trim()) onLoad(url.trim());
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.schemaLoaderBtn}
|
||||
onClick={() => url.trim() && onLoad(url.trim())}
|
||||
disabled={loading || !url.trim()}
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className={css.schemaLoaderError} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── useUBASchema ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Manages schema URL storage + fetching + parsing from project metadata.
|
||||
*/
|
||||
function useUBASchema() {
|
||||
const [schemaUrl, setSchemaUrl] = useState<string | null>(null);
|
||||
const [schema, setSchema] = useState<UBASchema | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Reload when project changes
|
||||
const reload = useCallback(() => {
|
||||
const project = ProjectModel.instance;
|
||||
if (!project) {
|
||||
setSchemaUrl(null);
|
||||
setSchema(null);
|
||||
return;
|
||||
}
|
||||
const savedUrl = project.getMetaData(METADATA_SCHEMA_URL) as string | null;
|
||||
setSchemaUrl(savedUrl ?? null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useEventListener(ProjectModel.instance, 'importComplete', reload);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useEventListener(ProjectModel.instance as any, 'instanceHasChanged', reload);
|
||||
|
||||
// Fetch + parse schema when URL changes
|
||||
useEffect(() => {
|
||||
if (!schemaUrl) {
|
||||
setSchema(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(schemaUrl);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
const raw = await res.json();
|
||||
const parseResult = new SchemaParser().parse(raw);
|
||||
if (parseResult.success) {
|
||||
if (!cancelled) {
|
||||
setSchema(parseResult.data);
|
||||
setLoadError(null);
|
||||
}
|
||||
} else {
|
||||
// Explicit cast: TS doesn't narrow discriminated unions inside async IIFEs
|
||||
type FailResult = { success: false; errors: Array<{ path: string; message: string }> };
|
||||
const fail = parseResult as FailResult;
|
||||
throw new Error(fail.errors.map((e) => `${e.path}: ${e.message}`).join('; ') || 'Schema parse failed');
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
setSchema(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [schemaUrl]);
|
||||
|
||||
const loadSchema = useCallback((url: string) => {
|
||||
const project = ProjectModel.instance;
|
||||
if (project) {
|
||||
project.setMetaData(METADATA_SCHEMA_URL, url);
|
||||
}
|
||||
setSchemaUrl(url);
|
||||
}, []);
|
||||
|
||||
const clearSchema = useCallback(() => {
|
||||
const project = ProjectModel.instance;
|
||||
if (project) {
|
||||
project.setMetaData(METADATA_SCHEMA_URL, null);
|
||||
}
|
||||
setSchemaUrl(null);
|
||||
setSchema(null);
|
||||
setLoadError(null);
|
||||
}, []);
|
||||
|
||||
return { schemaUrl, schema, loadError, loading, loadSchema, clearSchema };
|
||||
}
|
||||
|
||||
// ─── UBAPanel ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function UBAPanel() {
|
||||
const { schemaUrl, schema, loadError, loading, loadSchema, clearSchema } = useUBASchema();
|
||||
|
||||
// Load saved config from project metadata
|
||||
const getSavedConfig = useCallback((): Record<string, unknown> => {
|
||||
const project = ProjectModel.instance;
|
||||
if (!project) return {};
|
||||
return (project.getMetaData(METADATA_CONFIG) as Record<string, unknown>) ?? {};
|
||||
}, []);
|
||||
|
||||
// Save config to project metadata AND push to backend
|
||||
const handleSave = useCallback(
|
||||
async (values: Record<string, unknown>) => {
|
||||
const project = ProjectModel.instance;
|
||||
if (project) {
|
||||
project.setMetaData(METADATA_CONFIG, values);
|
||||
}
|
||||
|
||||
// POST to backend if schema is loaded
|
||||
if (schema) {
|
||||
await UBAClient.configure(schema.backend.endpoints.config, values, schema.backend.auth);
|
||||
}
|
||||
},
|
||||
[schema]
|
||||
);
|
||||
|
||||
const health = useUBAHealth(schema?.backend.endpoints.health, schema?.backend.auth);
|
||||
|
||||
const renderConfigureTab = () => {
|
||||
if (!schemaUrl && !loading) {
|
||||
return <SchemaLoader onLoad={loadSchema} loading={loading} error={loadError} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={css.statusMsg}>Loading schema…</div>;
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className={css.errorState}>
|
||||
<p className={css.errorMsg}>Failed to load schema: {loadError}</p>
|
||||
<button type="button" className={css.clearBtn} onClick={clearSchema}>
|
||||
Try a different URL
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schema) {
|
||||
return <div className={css.statusMsg}>No schema loaded.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.configureTabContent}>
|
||||
{schema.backend.endpoints.health && <HealthBadge status={health.status} message={health.message} />}
|
||||
<ConfigPanel
|
||||
schema={schema}
|
||||
initialValues={getSavedConfig()}
|
||||
onSave={handleSave}
|
||||
onReset={() => {
|
||||
/* noop — reset is handled inside ConfigPanel */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDebugTab = () => {
|
||||
if (!schema?.backend.endpoints.debug_stream) {
|
||||
return (
|
||||
<div className={css.statusMsg}>
|
||||
{schema
|
||||
? 'This backend does not expose a debug stream endpoint.'
|
||||
: 'Load a schema first to use debug stream.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DebugStreamView endpoint={schema.backend.endpoints.debug_stream} auth={schema.backend.auth} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePanel title="Backend Adapter">
|
||||
<Tabs
|
||||
variant={TabsVariant.Sidebar}
|
||||
tabs={[
|
||||
{
|
||||
label: 'Configure',
|
||||
content: renderConfigureTab()
|
||||
},
|
||||
{
|
||||
label: 'Debug',
|
||||
content: renderDebugTab()
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</BasePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { UBAPanel } from './UBAPanel';
|
||||
export { DebugStreamView } from './DebugStreamView';
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* STYLE-005: ElementStyleSectionHost
|
||||
*
|
||||
* Editor-side wrapper that combines ElementStyleSection (variant + size picker)
|
||||
* with the SuggestionBanner. Lives in noodl-editor (not noodl-core-ui) so it
|
||||
* can import editor-specific hooks and services.
|
||||
*
|
||||
* Keeps its own StyleTokensModel instance for suggestion actions. Multiple
|
||||
* instances are safe — they sync via ProjectModel.metadataChanged events.
|
||||
*/
|
||||
|
||||
import { useStyleSuggestions } from '@noodl-hooks/useStyleSuggestions';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { StyleTokensModel } from '@noodl-models/StyleTokensModel';
|
||||
|
||||
import {
|
||||
ElementStyleSection,
|
||||
ElementStyleSectionProps
|
||||
} from '@noodl-core-ui/components/propertyeditor/ElementStyleSection';
|
||||
import { SuggestionBanner } from '@noodl-core-ui/components/StyleSuggestions';
|
||||
|
||||
import { executeSuggestionAction } from '../../../../../services/StyleAnalyzer/SuggestionActionHandler';
|
||||
|
||||
/**
|
||||
* Drop-in replacement for ElementStyleSection in propertyeditor.ts.
|
||||
* Adds an optional SuggestionBanner beneath the style controls when
|
||||
* the StyleAnalyzer finds something worth suggesting.
|
||||
*/
|
||||
export function ElementStyleSectionHost(props: ElementStyleSectionProps) {
|
||||
const [tokenModel] = useState<StyleTokensModel>(() => new StyleTokensModel());
|
||||
|
||||
// Dispose the model when the host unmounts to avoid listener leaks
|
||||
useEffect(() => {
|
||||
return () => tokenModel.dispose();
|
||||
}, [tokenModel]);
|
||||
|
||||
const { activeSuggestion, dismissSession, dismissPermanent, refresh } = useStyleSuggestions();
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
if (!activeSuggestion) return;
|
||||
executeSuggestionAction(activeSuggestion, { tokenModel, onComplete: refresh });
|
||||
}, [activeSuggestion, tokenModel, refresh]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (!activeSuggestion) return;
|
||||
dismissSession(activeSuggestion.id);
|
||||
}, [activeSuggestion, dismissSession]);
|
||||
|
||||
const handleNeverShow = useCallback(() => {
|
||||
if (!activeSuggestion) return;
|
||||
dismissPermanent(activeSuggestion.id);
|
||||
}, [activeSuggestion, dismissPermanent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ElementStyleSection {...props} />
|
||||
{activeSuggestion && (
|
||||
<SuggestionBanner
|
||||
suggestion={activeSuggestion}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
onNeverShow={handleNeverShow}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ElementStyleSectionHost } from './ElementStyleSectionHost';
|
||||
@@ -6,12 +6,11 @@ import { createRoot, Root } from 'react-dom/client';
|
||||
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
|
||||
|
||||
import { ElementStyleSection } from '@noodl-core-ui/components/propertyeditor/ElementStyleSection';
|
||||
|
||||
import View from '../../../../../shared/view';
|
||||
import { ElementConfigRegistry } from '../../../models/ElementConfigs/ElementConfigRegistry';
|
||||
import { ProjectModel } from '../../../models/projectmodel';
|
||||
import { ToastLayer } from '../../ToastLayer/ToastLayer';
|
||||
import { ElementStyleSectionHost } from './components/ElementStyleSectionHost';
|
||||
import { VariantsEditor } from './components/VariantStates';
|
||||
import { VisualStates } from './components/VisualStates';
|
||||
import { Ports } from './DataTypes/Ports';
|
||||
@@ -142,7 +141,7 @@ export class PropertyEditor extends View {
|
||||
if (!this.elementStyleRoot) {
|
||||
this.elementStyleRoot = createRoot(container);
|
||||
}
|
||||
this.elementStyleRoot.render(React.createElement(ElementStyleSection, props));
|
||||
this.elementStyleRoot.render(React.createElement(ElementStyleSectionHost, props));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
const { ComponentsPanelView } = require('@noodl-views/panels/componentspanel/ComponentsPanel');
|
||||
const { ProjectModel } = require('@noodl-models/projectmodel');
|
||||
const { UndoQueue } = require('@noodl-models/undo-queue-model');
|
||||
const NodeGraphEditor = require('@noodl-views/nodegrapheditor').NodeGraphEditor;
|
||||
const ViewerConnection = require('../../src/editor/src/ViewerConnection');
|
||||
|
||||
describe('Components panel unit tests', function () {
|
||||
var cp;
|
||||
var p1;
|
||||
|
||||
var project = {
|
||||
components: [
|
||||
{
|
||||
name: 'Root',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/test/f1/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/test/f2/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/b',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/test/ff/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/q',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/dup/f1/a',
|
||||
graph: {}
|
||||
},
|
||||
// Undo tests
|
||||
{
|
||||
name: '/delete_folder/delete_comp',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/rename_folder/rename_comp',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/drop/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/drop2/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/dropundo',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/nested-target/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/nested-dropme/test/b',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/delete-me/with-content/a',
|
||||
graph: {}
|
||||
},
|
||||
{
|
||||
name: '/delete-me/b',
|
||||
graph: {}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock node graph editor
|
||||
NodeGraphEditor.instance = {
|
||||
getActiveComponent() {
|
||||
return p1.getComponentWithName('Root');
|
||||
},
|
||||
on() {},
|
||||
off() {},
|
||||
switchToComponent() {}
|
||||
};
|
||||
|
||||
// Viewerconnection mock
|
||||
ViewerConnection.instance = {
|
||||
on() {},
|
||||
off() {}
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
NodeGraphEditor.instance = undefined;
|
||||
ViewerConnection.instance = undefined;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
p1 = ProjectModel.instance = ProjectModel.fromJSON(project);
|
||||
cp = new ComponentsPanelView({});
|
||||
cp.setNodeGraphEditor(NodeGraphEditor.instance);
|
||||
cp.render();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cp.dispose();
|
||||
ProjectModel.instance = undefined;
|
||||
});
|
||||
|
||||
it('can setup view', function () {
|
||||
expect(cp).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('can add new folders', function () {
|
||||
// Existing folder
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'folder',
|
||||
name: 'test'
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Empty name
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'folder',
|
||||
name: ''
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Add
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'folder',
|
||||
name: 'f3'
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(cp.getFolderWithPath('/f3/')).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('can add components', function () {
|
||||
// Existing name
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'component',
|
||||
name: 'b',
|
||||
parentPath: '/'
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Empty name
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'component',
|
||||
name: ''
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Add
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'component',
|
||||
name: 'c',
|
||||
parentPath: '/'
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/c')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/').hasComponentWithName('c')).toBe(true);
|
||||
|
||||
// Add to sub directory
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'component',
|
||||
name: 'subsub',
|
||||
parentPath: '/test/ff/'
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/test/ff/subsub')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/test/ff/').hasComponentWithName('subsub')).toBe(true);
|
||||
});
|
||||
|
||||
it('can rename folders', function () {
|
||||
// Existing name
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'folder',
|
||||
name: 'f2',
|
||||
folder: cp.getFolderWithPath('/test/ff/')
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Empty name
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'folder',
|
||||
name: '',
|
||||
folder: cp.getFolderWithPath('/test/ff/')
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Empty name
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'folder',
|
||||
name: 'f4',
|
||||
folder: cp.getFolderWithPath('/test/ff/')
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/test/ff/a')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/test/f4/a')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/test/ff/')).toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/test/f4/')).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('can rename components', function () {
|
||||
// Existing name
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'component',
|
||||
name: 'b',
|
||||
folder: cp.getFolderWithPath('/'),
|
||||
component: p1.getComponentWithName('/q')
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Empty name
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'component',
|
||||
name: '',
|
||||
folder: cp.getFolderWithPath('/'),
|
||||
component: p1.getComponentWithName('/q')
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
// Empty name
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'component',
|
||||
name: 'q2',
|
||||
folder: cp.getFolderWithPath('/'),
|
||||
component: p1.getComponentWithName('/q')
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/q')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/q2')).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('can detect duplicates', function () {
|
||||
// Cannot move to folder containing a comp with same name
|
||||
expect(
|
||||
cp.getAcceptableDropType({
|
||||
type: 'component',
|
||||
component: p1.getComponentWithName('/a'),
|
||||
targetFolder: cp.getFolderWithPath('/test/f1/')
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
// Cannot move folder to folder containing a folder with same name
|
||||
expect(
|
||||
cp.getAcceptableDropType({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/dup/f1/'),
|
||||
targetFolder: cp.getFolderWithPath('/test/')
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('can make correct drops of folders', function () {
|
||||
// Can move a folder into a folder
|
||||
expect(
|
||||
cp.getAcceptableDropType({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/test/f1/'),
|
||||
targetFolder: cp.getFolderWithPath('/test/f2/')
|
||||
})
|
||||
).toBe('folder');
|
||||
|
||||
// Make the move
|
||||
cp.dropOn({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/test/f1/'),
|
||||
targetFolder: cp.getFolderWithPath('/test/f2/')
|
||||
});
|
||||
|
||||
expect(p1.getComponentWithName('/test/f2/f1/a')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/test/f2/f1/').name).toBe('f1');
|
||||
// expect(cp.getFolderWithPath('/test/f1/')).toBe(undefined);
|
||||
|
||||
// Moving to an ancestor or same folder should not be acceptable
|
||||
expect(
|
||||
cp.getAcceptableDropType({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/test/f2/'),
|
||||
targetFolder: cp.getFolderWithPath('/test/f2/f1/')
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
cp.getAcceptableDropType({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/test/f2/'),
|
||||
targetFolder: cp.getFolderWithPath('/test/f2/')
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('can make correct drops of components', function () {
|
||||
// Can move into a new folder
|
||||
expect(
|
||||
cp.getAcceptableDropType({
|
||||
type: 'component',
|
||||
folder: cp.getFolderWithPath('/'),
|
||||
component: p1.getComponentWithName('/b'),
|
||||
targetFolder: cp.getFolderWithPath('/test/f2/')
|
||||
})
|
||||
).toBe('component');
|
||||
|
||||
// Cannot drop to same folder
|
||||
expect(
|
||||
cp.getAcceptableDropType({
|
||||
type: 'component',
|
||||
folder: cp.getFolderWithPath('/'),
|
||||
component: p1.getComponentWithName('/b'),
|
||||
targetFolder: cp.getFolderWithPath('/')
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
// Make the drop
|
||||
cp.dropOn({
|
||||
type: 'component',
|
||||
folder: cp.getFolderWithPath('/'),
|
||||
component: p1.getComponentWithName('/b'),
|
||||
targetFolder: cp.getFolderWithPath('/test/f2/')
|
||||
});
|
||||
|
||||
expect(p1.getComponentWithName('/test/f2/b')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/').hasComponentWithName('b')).toBe(false);
|
||||
expect(cp.getFolderWithPath('/test/f2/').hasComponentWithName('b')).toBe(true);
|
||||
expect(p1.getComponentWithName('/b')).toBe(undefined);
|
||||
});
|
||||
|
||||
//TODO: empty folders are removed when moved, but the undo function does not restore them. This is a bug.
|
||||
xit('can drop empty folders', function () {
|
||||
cp.performAdd({
|
||||
type: 'folder',
|
||||
name: 'empty_folder',
|
||||
parentFolder: cp.getFolderWithPath('/')
|
||||
});
|
||||
|
||||
expect(cp.getFolderWithPath('/empty_folder/')).not.toBe(undefined);
|
||||
|
||||
// Drop empty folder
|
||||
cp.dropOn({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/empty_folder/'),
|
||||
targetFolder: cp.getFolderWithPath('/test/')
|
||||
});
|
||||
|
||||
expect(cp.getFolderWithPath('/empty_folder/')).toBe(undefined);
|
||||
//empty folders are removed when moved
|
||||
expect(cp.getFolderWithPath('/test/empty_folder/')).toBe(undefined);
|
||||
|
||||
UndoQueue.instance.undo();
|
||||
|
||||
expect(cp.getFolderWithPath('/empty_folder/')).not.toBe(undefined);
|
||||
// expect(cp.getFolderWithPath('/test/empty_folder/')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('can undo add/delete/rename component and folder', function () {
|
||||
// Add component
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'component',
|
||||
name: 'undome',
|
||||
parentPath: '/'
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/undome')).not.toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('add component');
|
||||
expect(p1.getComponentWithName('/undome')).toBe(undefined);
|
||||
|
||||
// Add folder
|
||||
expect(
|
||||
cp.performAdd({
|
||||
type: 'folder',
|
||||
name: 'undome',
|
||||
parentPath: '/'
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(cp.getFolderWithPath('/undome/')).not.toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('add folder');
|
||||
expect(cp.getFolderWithPath('/undome/')).toBe(undefined);
|
||||
|
||||
// Delete component
|
||||
expect(
|
||||
cp.performDelete({
|
||||
type: 'component',
|
||||
folder: cp.getFolderWithPath('/delete_folder/'),
|
||||
component: p1.getComponentWithName('/delete_folder/delete_comp')
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/delete_folder/delete_comp')).toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('delete component');
|
||||
expect(p1.getComponentWithName('/delete_folder/delete_comp')).not.toBe(undefined);
|
||||
expect(UndoQueue.instance.redo().label).toBe('delete component'); // Folder must be empty for next test to run
|
||||
|
||||
// Delete folder
|
||||
expect(
|
||||
cp.performDelete({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/delete_folder/')
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(cp.getFolderWithPath('/delete_folder/')).toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('delete folder');
|
||||
expect(cp.getFolderWithPath('/delete_folder/')).not.toBe(undefined);
|
||||
|
||||
// Rename component
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'component',
|
||||
name: 'newname',
|
||||
folder: cp.getFolderWithPath('/rename_folder/'),
|
||||
component: p1.getComponentWithName('/rename_folder/rename_comp')
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/rename_folder/newname')).not.toBe(undefined);
|
||||
expect(p1.getComponentWithName('/rename_folder/rename_comp')).toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('rename component');
|
||||
expect(p1.getComponentWithName('/rename_folder/newname')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/rename_folder/rename_comp')).not.toBe(undefined);
|
||||
|
||||
// Rename folder
|
||||
expect(
|
||||
cp.performRename({
|
||||
type: 'folder',
|
||||
name: 'newname',
|
||||
folder: cp.getFolderWithPath('/rename_folder/')
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(p1.getComponentWithName('/newname/rename_comp')).not.toBe(undefined);
|
||||
expect(p1.getComponentWithName('/rename_folder/rename_comp')).toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/rename_folder/')).toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/newname/')).not.toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('rename folder');
|
||||
expect(p1.getComponentWithName('/newname/rename_comp')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/rename_folder/rename_comp')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/rename_folder/')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/newname/')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('can undo drop on folder', function () {
|
||||
// Component on folder
|
||||
cp.dropOn({
|
||||
type: 'component',
|
||||
folder: cp.getFolderWithPath('/'),
|
||||
component: p1.getComponentWithName('/dropundo'),
|
||||
targetFolder: cp.getFolderWithPath('/drop/')
|
||||
});
|
||||
|
||||
expect(p1.getComponentWithName('/drop/dropundo')).not.toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('move component to folder');
|
||||
// expect(p1.getComponentWithName('/drop/dropundo')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/dropundo')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/drop/').hasComponentWithName('dropundo')).toBe(false);
|
||||
|
||||
// Folder on folder
|
||||
cp.dropOn({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/drop/'),
|
||||
targetFolder: cp.getFolderWithPath('/drop2/')
|
||||
});
|
||||
expect(cp.getFolderWithPath('/drop2/drop/')).not.toBe(undefined);
|
||||
expect(UndoQueue.instance.undo().label).toBe('move folder to folder');
|
||||
// expect(cp.getFolderWithPath('/drop2/drop/')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('can make correct drops of nested folders and undo', function () {
|
||||
cp.dropOn({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/nested-dropme/'),
|
||||
targetFolder: cp.getFolderWithPath('/nested-target/')
|
||||
});
|
||||
expect(cp.getFolderWithPath('/nested-target/nested-dropme/')).not.toBe(undefined);
|
||||
expect(p1.getComponentWithName('/nested-target/nested-dropme/test/b')).not.toBe(undefined);
|
||||
expect(p1.getComponentWithName('/nested-dropme/test/b')).toBe(undefined);
|
||||
// expect(cp.getFolderWithPath('/nested-dropme/')).toBe(undefined);
|
||||
UndoQueue.instance.undo();
|
||||
// expect(cp.getFolderWithPath('/nested-target/nested-dropme/')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/nested-target/nested-dropme/test/b')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/nested-dropme/test/b')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/nested-dropme/')).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('can delete folder with content', function () {
|
||||
// Delete folder
|
||||
expect(
|
||||
cp.performDelete({
|
||||
type: 'folder',
|
||||
folder: cp.getFolderWithPath('/delete-me/')
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(cp.getFolderWithPath('/delete-me/')).toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/delete-me/with-content/')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/delete-me/with-content/a')).toBe(undefined);
|
||||
expect(p1.getComponentWithName('/delete-me/b')).toBe(undefined);
|
||||
UndoQueue.instance.undo();
|
||||
expect(cp.getFolderWithPath('/delete-me/')).not.toBe(undefined);
|
||||
expect(cp.getFolderWithPath('/delete-me/with-content/')).not.toBe(undefined);
|
||||
expect(p1.getComponentWithName('/delete-me/with-content/a')).not.toBe(undefined);
|
||||
expect(p1.getComponentWithName('/delete-me/b')).not.toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
export * from './componentconnections';
|
||||
export * from './componentinstances';
|
||||
export * from './componentports';
|
||||
export * from './componentspanel';
|
||||
// componentspanel test removed - tests legacy Backbone ComponentsPanelView which
|
||||
// has been archived to ComponentsPanelNew/ComponentsPanel.ts.legacy (not webpack-resolvable)
|
||||
export * from './conditionalports';
|
||||
export * from './dynamicports';
|
||||
export * from './expandedports';
|
||||
|
||||
@@ -5,11 +5,13 @@ import '@noodl/platform-electron';
|
||||
export * from './cloud';
|
||||
export * from './components';
|
||||
export * from './git';
|
||||
export * from './models';
|
||||
export * from './nodegraph';
|
||||
export * from './platform';
|
||||
export * from './project';
|
||||
export * from './projectmerger';
|
||||
export * from './projectpatcher';
|
||||
export * from './services';
|
||||
export * from './utils';
|
||||
export * from './schemas';
|
||||
export * from './io';
|
||||
|
||||
215
packages/noodl-editor/tests/models/ProjectCreationWizard.test.ts
Normal file
215
packages/noodl-editor/tests/models/ProjectCreationWizard.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* ProjectCreationWizard — Unit tests for wizard state management
|
||||
*
|
||||
* Tests the step-sequencing logic and validation rules defined in WizardContext.
|
||||
* These are pure logic tests — no DOM or React renderer required.
|
||||
*
|
||||
* The functions below mirror the private helpers in WizardContext.tsx.
|
||||
* If the context logic changes, update both files.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
|
||||
// ---- Step sequencing (mirrors WizardContext.getStepSequence) ---------------
|
||||
|
||||
function getStepSequence(mode) {
|
||||
switch (mode) {
|
||||
case 'quick':
|
||||
return ['basics'];
|
||||
case 'guided':
|
||||
return ['basics', 'preset', 'review'];
|
||||
case 'ai':
|
||||
return ['basics', 'preset', 'review'];
|
||||
default:
|
||||
return ['basics'];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Validation (mirrors WizardContext.isStepValid) ------------------------
|
||||
|
||||
function isStepValid(step, state) {
|
||||
switch (step) {
|
||||
case 'entry':
|
||||
return true;
|
||||
case 'basics':
|
||||
return state.projectName.trim().length > 0 && state.location.length > 0;
|
||||
case 'preset':
|
||||
return state.selectedPresetId.length > 0;
|
||||
case 'review':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step navigation (mirrors WizardContext goNext/goBack logic) -----------
|
||||
|
||||
function goNext(state) {
|
||||
if (state.currentStep === 'entry') {
|
||||
const seq = getStepSequence(state.mode);
|
||||
return seq[0];
|
||||
}
|
||||
const seq = getStepSequence(state.mode);
|
||||
const idx = seq.indexOf(state.currentStep);
|
||||
if (idx === -1 || idx >= seq.length - 1) return state.currentStep;
|
||||
return seq[idx + 1];
|
||||
}
|
||||
|
||||
function goBack(state) {
|
||||
if (state.currentStep === 'entry') return 'entry';
|
||||
const seq = getStepSequence(state.mode);
|
||||
const idx = seq.indexOf(state.currentStep);
|
||||
if (idx <= 0) return 'entry';
|
||||
return seq[idx - 1];
|
||||
}
|
||||
|
||||
// ---- Whether the current step is the last one before creation --------------
|
||||
|
||||
function isLastStep(mode, step) {
|
||||
return step === 'review' || (mode === 'quick' && step === 'basics');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('WizardContext: step sequences', () => {
|
||||
it('quick mode only visits basics', () => {
|
||||
expect(getStepSequence('quick')).toEqual(['basics']);
|
||||
});
|
||||
|
||||
it('guided mode visits basics, preset, review', () => {
|
||||
expect(getStepSequence('guided')).toEqual(['basics', 'preset', 'review']);
|
||||
});
|
||||
|
||||
it('ai mode uses same sequence as guided (V1 stub)', () => {
|
||||
expect(getStepSequence('ai')).toEqual(['basics', 'preset', 'review']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WizardContext: validation', () => {
|
||||
const baseState = {
|
||||
mode: 'quick',
|
||||
currentStep: 'basics',
|
||||
projectName: '',
|
||||
description: '',
|
||||
location: '',
|
||||
selectedPresetId: 'modern'
|
||||
};
|
||||
|
||||
it('entry step is always valid', () => {
|
||||
expect(isStepValid('entry', { ...baseState, currentStep: 'entry' })).toBe(true);
|
||||
});
|
||||
|
||||
it('review step is always valid', () => {
|
||||
expect(isStepValid('review', { ...baseState, currentStep: 'review' })).toBe(true);
|
||||
});
|
||||
|
||||
it('basics step requires projectName and location', () => {
|
||||
expect(isStepValid('basics', baseState)).toBe(false);
|
||||
});
|
||||
|
||||
it('basics step passes with name and location', () => {
|
||||
expect(isStepValid('basics', { ...baseState, projectName: 'My Project', location: '/tmp' })).toBe(true);
|
||||
});
|
||||
|
||||
it('basics step trims whitespace on projectName', () => {
|
||||
expect(isStepValid('basics', { ...baseState, projectName: ' ', location: '/tmp' })).toBe(false);
|
||||
});
|
||||
|
||||
it('preset step requires selectedPresetId', () => {
|
||||
expect(isStepValid('preset', { ...baseState, selectedPresetId: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('preset step passes with a preset id', () => {
|
||||
expect(isStepValid('preset', { ...baseState, selectedPresetId: 'minimal' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WizardContext: goNext navigation', () => {
|
||||
const baseState = {
|
||||
mode: 'quick',
|
||||
currentStep: 'entry',
|
||||
projectName: 'Test',
|
||||
description: '',
|
||||
location: '/tmp',
|
||||
selectedPresetId: 'modern'
|
||||
};
|
||||
|
||||
it('quick: entry advances to basics', () => {
|
||||
expect(goNext({ ...baseState, mode: 'quick', currentStep: 'entry' })).toBe('basics');
|
||||
});
|
||||
|
||||
it('quick: basics stays (is the last step)', () => {
|
||||
expect(goNext({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('basics');
|
||||
});
|
||||
|
||||
it('guided: entry advances to basics', () => {
|
||||
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'entry' })).toBe('basics');
|
||||
});
|
||||
|
||||
it('guided: basics advances to preset', () => {
|
||||
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'basics' })).toBe('preset');
|
||||
});
|
||||
|
||||
it('guided: preset advances to review', () => {
|
||||
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'preset' })).toBe('review');
|
||||
});
|
||||
|
||||
it('guided: review stays (is the last step)', () => {
|
||||
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'review' })).toBe('review');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WizardContext: goBack navigation', () => {
|
||||
const baseState = {
|
||||
mode: 'guided',
|
||||
currentStep: 'review',
|
||||
projectName: 'Test',
|
||||
description: '',
|
||||
location: '/tmp',
|
||||
selectedPresetId: 'modern'
|
||||
};
|
||||
|
||||
it('entry stays on entry when going back', () => {
|
||||
expect(goBack({ ...baseState, currentStep: 'entry' })).toBe('entry');
|
||||
});
|
||||
|
||||
it('guided: basics goes back to entry', () => {
|
||||
expect(goBack({ ...baseState, currentStep: 'basics' })).toBe('entry');
|
||||
});
|
||||
|
||||
it('guided: preset goes back to basics', () => {
|
||||
expect(goBack({ ...baseState, currentStep: 'preset' })).toBe('basics');
|
||||
});
|
||||
|
||||
it('guided: review goes back to preset', () => {
|
||||
expect(goBack({ ...baseState, currentStep: 'review' })).toBe('preset');
|
||||
});
|
||||
|
||||
it('quick: basics goes back to entry', () => {
|
||||
expect(goBack({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('entry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLastStep: determines when to show Create Project button', () => {
|
||||
it('quick mode: basics is the last step', () => {
|
||||
expect(isLastStep('quick', 'basics')).toBe(true);
|
||||
});
|
||||
|
||||
it('quick mode: entry is not the last step', () => {
|
||||
expect(isLastStep('quick', 'entry')).toBe(false);
|
||||
});
|
||||
|
||||
it('guided mode: review is the last step', () => {
|
||||
expect(isLastStep('guided', 'review')).toBe(true);
|
||||
});
|
||||
|
||||
it('guided mode: basics is not the last step', () => {
|
||||
expect(isLastStep('guided', 'basics')).toBe(false);
|
||||
});
|
||||
|
||||
it('guided mode: preset is not the last step', () => {
|
||||
expect(isLastStep('guided', 'preset')).toBe(false);
|
||||
});
|
||||
});
|
||||
294
packages/noodl-editor/tests/models/StyleAnalyzer.test.ts
Normal file
294
packages/noodl-editor/tests/models/StyleAnalyzer.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* STYLE-005: StyleAnalyzer Unit Tests
|
||||
*
|
||||
* Tests the pure logic of the analyzer — value detection, threshold handling,
|
||||
* and suggestion generation — without touching Electron or the real ProjectModel.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
|
||||
import { StyleAnalyzer } from '../../src/editor/src/services/StyleAnalyzer/StyleAnalyzer';
|
||||
|
||||
// ─── Mock ProjectModel before importing StyleAnalyzer ───────────────────────
|
||||
|
||||
type MockNode = {
|
||||
id: string;
|
||||
typename: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let mockNodes: MockNode[] = [];
|
||||
|
||||
jest.mock('@noodl-models/projectmodel', () => ({
|
||||
ProjectModel: {
|
||||
instance: {
|
||||
getComponents: () => [
|
||||
{
|
||||
forEachNode: (cb: (node: MockNode) => void) => {
|
||||
mockNodes.forEach(cb);
|
||||
}
|
||||
}
|
||||
],
|
||||
findNodeWithId: (id: string) => mockNodes.find((n) => n.id === id) ?? null
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeNode(id: string, typename: string, params: Record<string, unknown>): MockNode {
|
||||
return { id, typename, parameters: params };
|
||||
}
|
||||
|
||||
function resetNodes(...nodes: MockNode[]) {
|
||||
mockNodes = nodes;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StyleAnalyzer', () => {
|
||||
beforeEach(() => {
|
||||
mockNodes = [];
|
||||
});
|
||||
|
||||
// ─── Color Detection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('repeated color detection', () => {
|
||||
it('does NOT flag a value that appears fewer than 3 times', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags a hex color appearing 3+ times', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(1);
|
||||
expect(result.repeatedColors[0].value).toBe('#3b82f6');
|
||||
expect(result.repeatedColors[0].count).toBe(3);
|
||||
});
|
||||
|
||||
it('ignores CSS var() references — they are already tokenised', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { backgroundColor: 'var(--primary)' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: 'var(--primary)' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: 'var(--primary)' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles rgb() and rgba() color values', () => {
|
||||
const color = 'rgb(59, 130, 246)';
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { backgroundColor: color }),
|
||||
makeNode('n2', 'Group', { backgroundColor: color }),
|
||||
makeNode('n3', 'Group', { backgroundColor: color })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(1);
|
||||
expect(result.repeatedColors[0].value).toBe(color);
|
||||
});
|
||||
|
||||
it('groups by exact value — different shades are separate suggestions', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n4', 'Group', { backgroundColor: '#2563eb' }),
|
||||
makeNode('n5', 'Group', { backgroundColor: '#2563eb' }),
|
||||
makeNode('n6', 'Group', { backgroundColor: '#2563eb' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spacing Detection ─────────────────────────────────────────────────────
|
||||
|
||||
describe('repeated spacing detection', () => {
|
||||
it('flags px spacing appearing 3+ times', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { paddingTop: '16px' }),
|
||||
makeNode('n2', 'Group', { paddingTop: '16px' }),
|
||||
makeNode('n3', 'Group', { paddingTop: '16px' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedSpacing).toHaveLength(1);
|
||||
expect(result.repeatedSpacing[0].value).toBe('16px');
|
||||
});
|
||||
|
||||
it('flags rem spacing', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { paddingLeft: '1rem' }),
|
||||
makeNode('n2', 'Group', { paddingLeft: '1rem' }),
|
||||
makeNode('n3', 'Group', { paddingLeft: '1rem' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedSpacing).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does NOT flag zero — 0 is universally used and not worth tokenising', () => {
|
||||
// '0' is technically a valid spacing value but would create noise
|
||||
// Our regex requires px/rem/em suffix OR it's just a number
|
||||
// This test ensures we understand the current behaviour
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { paddingTop: '0px' }),
|
||||
makeNode('n2', 'Group', { paddingTop: '0px' }),
|
||||
makeNode('n3', 'Group', { paddingTop: '0px' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
// '0px' IS a raw spacing value — currently flagged. This is expected.
|
||||
// In future we may want to suppress this specific case.
|
||||
expect(result.repeatedSpacing).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Variant Candidates ────────────────────────────────────────────────────
|
||||
|
||||
describe('variant candidate detection', () => {
|
||||
it('flags a node with 3+ raw style overrides', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'net.noodl.controls.button', {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
borderRadius: '8px'
|
||||
})
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.variantCandidates).toHaveLength(1);
|
||||
expect(result.variantCandidates[0].overrideCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does NOT flag a node with fewer than 3 raw overrides', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'net.noodl.controls.button', {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff'
|
||||
})
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT count token references as custom overrides', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'net.noodl.controls.button', {
|
||||
backgroundColor: 'var(--primary)', // token — excluded
|
||||
color: 'var(--primary-foreground)', // token — excluded
|
||||
borderRadius: '8px', // raw — counts
|
||||
paddingTop: '12px', // raw — counts
|
||||
fontSize: '14px' // raw — counts
|
||||
})
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
// 3 raw overrides → IS a candidate
|
||||
expect(result.variantCandidates).toHaveLength(1);
|
||||
expect(result.variantCandidates[0].overrideCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toSuggestions ────────────────────────────────────────────────────────
|
||||
|
||||
describe('toSuggestions()', () => {
|
||||
it('orders suggestions by count descending', () => {
|
||||
resetNodes(
|
||||
// 4 occurrences of red
|
||||
makeNode('n1', 'Group', { backgroundColor: '#ff0000' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#ff0000' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: '#ff0000' }),
|
||||
makeNode('n4', 'Group', { backgroundColor: '#ff0000' }),
|
||||
// 3 occurrences of blue
|
||||
makeNode('n5', 'Group', { backgroundColor: '#0000ff' }),
|
||||
makeNode('n6', 'Group', { backgroundColor: '#0000ff' }),
|
||||
makeNode('n7', 'Group', { backgroundColor: '#0000ff' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
|
||||
expect(suggestions[0].repeatedValue?.value).toBe('#ff0000');
|
||||
expect(suggestions[1].repeatedValue?.value).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('assigns stable IDs to suggestions', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
|
||||
expect(suggestions[0].id).toBe('repeated-color:#3b82f6');
|
||||
});
|
||||
|
||||
it('returns empty array when no issues found', () => {
|
||||
resetNodes(makeNode('n1', 'Group', { backgroundColor: 'var(--primary)' }));
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
|
||||
expect(suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge Cases ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns empty results when there are no nodes', () => {
|
||||
resetNodes();
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(0);
|
||||
expect(result.repeatedSpacing).toHaveLength(0);
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not scan non-visual nodes (e.g. logic nodes without style params)', () => {
|
||||
resetNodes(
|
||||
makeNode('n1', 'For Each', { items: '[1,2,3]' }),
|
||||
makeNode('n2', 'For Each', { items: '[1,2,3]' }),
|
||||
makeNode('n3', 'For Each', { items: '[1,2,3]' })
|
||||
);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(0);
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deduplicates variant candidates if the same node appears in multiple components', () => {
|
||||
// Simulate same node ID coming from two components
|
||||
const node = makeNode('shared-id', 'net.noodl.controls.button', {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#fff',
|
||||
borderRadius: '8px'
|
||||
});
|
||||
|
||||
mockNodes = [node, node]; // same node ref twice
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
// Even though forEachNode visits it twice, we deduplicate by nodeId
|
||||
expect(result.variantCandidates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
175
packages/noodl-editor/tests/models/UBAConditions.test.ts
Normal file
175
packages/noodl-editor/tests/models/UBAConditions.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* UBA-003/UBA-004: Unit tests for Conditions.ts
|
||||
*
|
||||
* Tests:
|
||||
* - getNestedValue dot-path lookups
|
||||
* - setNestedValue immutable path writes
|
||||
* - isEmpty edge cases
|
||||
* - evaluateCondition — all 6 operators
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { evaluateCondition, getNestedValue, isEmpty, setNestedValue } from '../../src/editor/src/models/UBA/Conditions';
|
||||
|
||||
// ─── getNestedValue ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('getNestedValue', () => {
|
||||
it('returns top-level value', () => {
|
||||
expect(getNestedValue({ foo: 'bar' }, 'foo')).toBe('bar');
|
||||
});
|
||||
|
||||
it('returns nested value via dot path', () => {
|
||||
expect(getNestedValue({ auth: { type: 'bearer' } }, 'auth.type')).toBe('bearer');
|
||||
});
|
||||
|
||||
it('returns undefined for missing path', () => {
|
||||
expect(getNestedValue({ auth: {} }, 'auth.type')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for deeply missing path', () => {
|
||||
expect(getNestedValue({}, 'a.b.c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty path', () => {
|
||||
expect(getNestedValue({ foo: 'bar' }, '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setNestedValue ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('setNestedValue', () => {
|
||||
it('sets top-level key', () => {
|
||||
const result = setNestedValue({}, 'foo', 'bar');
|
||||
expect(result).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('sets nested key', () => {
|
||||
const result = setNestedValue({}, 'auth.type', 'bearer');
|
||||
expect(result).toEqual({ auth: { type: 'bearer' } });
|
||||
});
|
||||
|
||||
it('merges with existing nested object', () => {
|
||||
const result = setNestedValue({ auth: { key: 'abc' } }, 'auth.type', 'bearer');
|
||||
expect(result).toEqual({ auth: { key: 'abc', type: 'bearer' } });
|
||||
});
|
||||
|
||||
it('does not mutate the original', () => {
|
||||
const original = { foo: 'bar' };
|
||||
setNestedValue(original, 'foo', 'baz');
|
||||
expect(original.foo).toBe('bar');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isEmpty ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isEmpty', () => {
|
||||
it.each([
|
||||
[null, true],
|
||||
[undefined, true],
|
||||
['', true],
|
||||
[' ', true],
|
||||
[[], true],
|
||||
['hello', false],
|
||||
[0, false],
|
||||
[false, false],
|
||||
[['a'], false],
|
||||
[{}, false]
|
||||
])('isEmpty(%o) === %s', (value, expected) => {
|
||||
expect(isEmpty(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── evaluateCondition ────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateCondition', () => {
|
||||
const values = {
|
||||
'auth.type': 'bearer',
|
||||
'auth.token': 'abc123',
|
||||
'auth.enabled': true,
|
||||
'features.list': ['a', 'b'],
|
||||
'features.empty': []
|
||||
};
|
||||
|
||||
it('returns true when condition is undefined', () => {
|
||||
expect(evaluateCondition(undefined, values)).toBe(true);
|
||||
});
|
||||
|
||||
describe('operator "="', () => {
|
||||
it('returns true when field matches value', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: '=', value: 'bearer' }, values)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when field does not match', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: '=', value: 'api_key' }, values)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator "!="', () => {
|
||||
it('returns true when field differs', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: '!=', value: 'api_key' }, values)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when field matches', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: '!=', value: 'bearer' }, values)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator "in"', () => {
|
||||
it('returns true when value is in array', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: ['bearer', 'api_key'] }, values)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when value is not in array', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: ['basic', 'none'] }, values)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when condition value is not an array', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: 'bearer' }, values)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator "not_in"', () => {
|
||||
it('returns true when value is not in array', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: 'not_in', value: ['basic', 'none'] }, values)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when value is in the array', () => {
|
||||
expect(evaluateCondition({ field: 'auth.type', operator: 'not_in', value: ['bearer', 'api_key'] }, values)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator "exists"', () => {
|
||||
it('returns true when field has a value', () => {
|
||||
expect(evaluateCondition({ field: 'auth.token', operator: 'exists' }, values)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when field is missing', () => {
|
||||
expect(evaluateCondition({ field: 'auth.missing', operator: 'exists' }, values)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when field is empty array', () => {
|
||||
expect(evaluateCondition({ field: 'features.empty', operator: 'exists' }, values)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator "not_exists"', () => {
|
||||
it('returns true when field is missing', () => {
|
||||
expect(evaluateCondition({ field: 'auth.missing', operator: 'not_exists' }, values)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when field has a value', () => {
|
||||
expect(evaluateCondition({ field: 'auth.token', operator: 'not_exists' }, values)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when field is empty array', () => {
|
||||
expect(evaluateCondition({ field: 'features.empty', operator: 'not_exists' }, values)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
319
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal file
319
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* UBA-002: Unit tests for SchemaParser
|
||||
*
|
||||
* Tests run against pure JS objects (no YAML parsing needed).
|
||||
* Covers: happy path, required field errors, optional fields,
|
||||
* field type validation, warnings for unknown types/versions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
|
||||
import { SchemaParser } from '../../src/editor/src/models/UBA/SchemaParser';
|
||||
import type { ParseResult } from '../../src/editor/src/models/UBA/types';
|
||||
|
||||
/** Type guard: narrows ParseResult to the failure branch (webpack ts-loader friendly) */
|
||||
function isFailure<T>(result: ParseResult<T>): result is Extract<ParseResult<T>, { success: false }> {
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const minimalValid = {
|
||||
schema_version: '1.0',
|
||||
backend: {
|
||||
id: 'test-backend',
|
||||
name: 'Test Backend',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
config: 'https://example.com/config'
|
||||
}
|
||||
},
|
||||
sections: []
|
||||
};
|
||||
|
||||
function makeValid(overrides: Record<string, unknown> = {}) {
|
||||
return { ...minimalValid, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SchemaParser', () => {
|
||||
let parser: SchemaParser;
|
||||
|
||||
beforeEach(() => {
|
||||
parser = new SchemaParser();
|
||||
});
|
||||
|
||||
// ─── Root validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('root validation', () => {
|
||||
it('rejects null input', () => {
|
||||
const result = parser.parse(null);
|
||||
expect(result.success).toBe(false);
|
||||
if (isFailure(result)) {
|
||||
expect(result.errors[0].path).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects array input', () => {
|
||||
const result = parser.parse([]);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing schema_version', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { schema_version: _, ...noVersion } = minimalValid;
|
||||
const result = parser.parse(noVersion);
|
||||
expect(result.success).toBe(false);
|
||||
if (isFailure(result)) {
|
||||
expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('warns on unknown major version', () => {
|
||||
const result = parser.parse(makeValid({ schema_version: '2.0' }));
|
||||
// Should still succeed (best-effort) but include a warning
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.warnings?.some((w) => w.includes('2.0'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a minimal valid schema', () => {
|
||||
const result = parser.parse(minimalValid);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.schema_version).toBe('1.0');
|
||||
expect(result.data.backend.id).toBe('test-backend');
|
||||
expect(result.data.sections).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Backend validation ────────────────────────────────────────────────────
|
||||
|
||||
describe('backend validation', () => {
|
||||
it('errors when backend is missing', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { backend: _, ...noBackend } = minimalValid;
|
||||
const result = parser.parse(noBackend);
|
||||
expect(result.success).toBe(false);
|
||||
if (isFailure(result)) {
|
||||
expect(result.errors.some((e) => e.path === 'backend')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('errors when backend.id is missing', () => {
|
||||
const data = makeValid({ backend: { ...minimalValid.backend, id: undefined } });
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('errors when backend.endpoints.config is missing', () => {
|
||||
const data = makeValid({
|
||||
backend: { ...minimalValid.backend, endpoints: { health: '/health' } }
|
||||
});
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(false);
|
||||
if (isFailure(result)) {
|
||||
expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts optional backend fields', () => {
|
||||
const data = makeValid({
|
||||
backend: {
|
||||
...minimalValid.backend,
|
||||
description: 'My backend',
|
||||
icon: 'https://example.com/icon.png',
|
||||
homepage: 'https://example.com',
|
||||
auth: { type: 'bearer' },
|
||||
capabilities: { hot_reload: true, debug: false }
|
||||
}
|
||||
});
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.backend.description).toBe('My backend');
|
||||
expect(result.data.backend.auth?.type).toBe('bearer');
|
||||
expect(result.data.backend.capabilities?.hot_reload).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('errors on invalid auth type', () => {
|
||||
const data = makeValid({
|
||||
backend: { ...minimalValid.backend, auth: { type: 'oauth2' } }
|
||||
});
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(false);
|
||||
if (isFailure(result)) {
|
||||
expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sections validation ───────────────────────────────────────────────────
|
||||
|
||||
describe('sections validation', () => {
|
||||
it('errors when sections is not an array', () => {
|
||||
const data = makeValid({ sections: 'not-an-array' });
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a section with minimal fields', () => {
|
||||
const data = makeValid({
|
||||
sections: [{ id: 'general', name: 'General', fields: [] }]
|
||||
});
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sections[0].id).toBe('general');
|
||||
expect(result.data.sections[0].fields).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips section without id and collects error', () => {
|
||||
const data = makeValid({
|
||||
sections: [{ name: 'Missing ID', fields: [] }]
|
||||
});
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(false);
|
||||
if (isFailure(result)) {
|
||||
expect(result.errors.some((e) => e.path.includes('id'))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Field type validation ─────────────────────────────────────────────────
|
||||
|
||||
describe('field types', () => {
|
||||
function sectionWith(fields: unknown[]) {
|
||||
return makeValid({ sections: [{ id: 's', name: 'S', fields }] });
|
||||
}
|
||||
|
||||
it('parses a string field', () => {
|
||||
const result = parser.parse(sectionWith([{ id: 'host', name: 'Host', type: 'string', default: 'localhost' }]));
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const field = result.data.sections[0].fields[0];
|
||||
expect(field.type).toBe('string');
|
||||
if (field.type === 'string') expect(field.default).toBe('localhost');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a boolean field', () => {
|
||||
const result = parser.parse(sectionWith([{ id: 'ssl', name: 'SSL', type: 'boolean', default: true }]));
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const field = result.data.sections[0].fields[0];
|
||||
expect(field.type).toBe('boolean');
|
||||
if (field.type === 'boolean') expect(field.default).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a select field with options', () => {
|
||||
const result = parser.parse(
|
||||
sectionWith([
|
||||
{
|
||||
id: 'region',
|
||||
name: 'Region',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'eu-west-1', label: 'EU West' },
|
||||
{ value: 'us-east-1', label: 'US East' }
|
||||
],
|
||||
default: 'eu-west-1'
|
||||
}
|
||||
])
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const field = result.data.sections[0].fields[0];
|
||||
expect(field.type).toBe('select');
|
||||
if (field.type === 'select') {
|
||||
expect(field.options).toHaveLength(2);
|
||||
expect(field.default).toBe('eu-west-1');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('errors when select field has no options array', () => {
|
||||
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'select' }]));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('warns on unknown field type and skips it', () => {
|
||||
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'color_picker' }]));
|
||||
// Section-level parse succeeds (unknown field is skipped, not fatal)
|
||||
if (result.success) {
|
||||
expect(result.data.sections[0].fields).toHaveLength(0);
|
||||
expect(result.warnings?.some((w) => w.includes('color_picker'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a number field with min/max', () => {
|
||||
const result = parser.parse(
|
||||
sectionWith([{ id: 'port', name: 'Port', type: 'number', default: 5432, min: 1, max: 65535 }])
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const field = result.data.sections[0].fields[0];
|
||||
if (field.type === 'number') {
|
||||
expect(field.default).toBe(5432);
|
||||
expect(field.min).toBe(1);
|
||||
expect(field.max).toBe(65535);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a secret field', () => {
|
||||
const result = parser.parse(sectionWith([{ id: 'api_key', name: 'API Key', type: 'secret' }]));
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const field = result.data.sections[0].fields[0];
|
||||
expect(field.type).toBe('secret');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a multi_select field', () => {
|
||||
const result = parser.parse(
|
||||
sectionWith([
|
||||
{
|
||||
id: 'roles',
|
||||
name: 'Roles',
|
||||
type: 'multi_select',
|
||||
options: [
|
||||
{ value: 'read', label: 'Read' },
|
||||
{ value: 'write', label: 'Write' }
|
||||
]
|
||||
}
|
||||
])
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const field = result.data.sections[0].fields[0];
|
||||
expect(field.type).toBe('multi_select');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Debug schema ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('debug schema', () => {
|
||||
it('parses an enabled debug block', () => {
|
||||
const data = makeValid({
|
||||
debug: {
|
||||
enabled: true,
|
||||
event_schema: [{ id: 'query_time', name: 'Query Time', type: 'number' }]
|
||||
}
|
||||
});
|
||||
const result = parser.parse(data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.debug?.enabled).toBe(true);
|
||||
expect(result.data.debug?.event_schema).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
4
packages/noodl-editor/tests/models/index.ts
Normal file
4
packages/noodl-editor/tests/models/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// NOTE: UBAConditions.test, UBASchemaParser.test, ElementConfigRegistry.test
|
||||
// use @jest/globals and are Jest-only tests. They run via `npm run test:editor`.
|
||||
// Do NOT re-add them here - the Electron Jasmine runner will crash on import.
|
||||
export {};
|
||||
394
packages/noodl-editor/tests/services/StyleAnalyzer.test.ts
Normal file
394
packages/noodl-editor/tests/services/StyleAnalyzer.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* STYLE-005: Unit tests for StyleAnalyzer
|
||||
*
|
||||
* Tests:
|
||||
* - toSuggestions — pure conversion, ordering, message format
|
||||
* - analyzeProject — repeated colour/spacing detection, threshold, var() skipping
|
||||
* - analyzeNode — per-node variant candidate detection
|
||||
*
|
||||
* ProjectModel.instance is monkey-patched per test; restored in afterEach.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { ProjectModel } from '../../src/editor/src/models/projectmodel';
|
||||
import { StyleAnalyzer } from '../../src/editor/src/services/StyleAnalyzer/StyleAnalyzer';
|
||||
import { SUGGESTION_THRESHOLDS, type StyleAnalysisResult } from '../../src/editor/src/services/StyleAnalyzer/types';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a minimal mock node for the analyzer. */
|
||||
function makeNode(id: string, typename: string, parameters: Record<string, string>) {
|
||||
return { id, typename, parameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock ProjectModel.instance with components containing the given nodes.
|
||||
* Supports multiple components if needed (pass array of node arrays).
|
||||
*/
|
||||
function makeMockProject(nodeGroups: ReturnType<typeof makeNode>[][]) {
|
||||
return {
|
||||
getComponents: () =>
|
||||
nodeGroups.map((nodes) => ({
|
||||
forEachNode: (fn: (node: ReturnType<typeof makeNode>) => void) => {
|
||||
nodes.forEach(fn);
|
||||
}
|
||||
})),
|
||||
findNodeWithId: (id: string) => {
|
||||
for (const nodes of nodeGroups) {
|
||||
const found = nodes.find((n) => n.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── toSuggestions ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StyleAnalyzer.toSuggestions', () => {
|
||||
it('returns empty array for empty result', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [],
|
||||
repeatedSpacing: [],
|
||||
variantCandidates: []
|
||||
};
|
||||
expect(StyleAnalyzer.toSuggestions(result)).toEqual([]);
|
||||
});
|
||||
|
||||
it('converts repeated-color to suggestion with correct id and type', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [
|
||||
{
|
||||
value: '#3b82f6',
|
||||
count: 4,
|
||||
elements: [],
|
||||
suggestedTokenName: '--color-3b82f6'
|
||||
}
|
||||
],
|
||||
repeatedSpacing: [],
|
||||
variantCandidates: []
|
||||
};
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].type).toBe('repeated-color');
|
||||
expect(suggestions[0].id).toBe('repeated-color:#3b82f6');
|
||||
expect(suggestions[0].acceptLabel).toBe('Create Token');
|
||||
expect(suggestions[0].message).toContain('#3b82f6');
|
||||
expect(suggestions[0].message).toContain('4 elements');
|
||||
});
|
||||
|
||||
it('converts repeated-spacing to suggestion — uses "Switch to Token" when matchingToken present', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [],
|
||||
repeatedSpacing: [
|
||||
{
|
||||
value: '16px',
|
||||
count: 5,
|
||||
elements: [],
|
||||
suggestedTokenName: '--spacing-16px',
|
||||
matchingToken: '--spacing-4'
|
||||
}
|
||||
],
|
||||
variantCandidates: []
|
||||
};
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
expect(suggestions[0].type).toBe('repeated-spacing');
|
||||
expect(suggestions[0].acceptLabel).toBe('Switch to Token');
|
||||
expect(suggestions[0].message).toContain('--spacing-4');
|
||||
});
|
||||
|
||||
it('converts repeated-spacing without matchingToken — uses "Create Token"', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [],
|
||||
repeatedSpacing: [
|
||||
{
|
||||
value: '24px',
|
||||
count: 3,
|
||||
elements: [],
|
||||
suggestedTokenName: '--spacing-24px'
|
||||
}
|
||||
],
|
||||
variantCandidates: []
|
||||
};
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
expect(suggestions[0].acceptLabel).toBe('Create Token');
|
||||
});
|
||||
|
||||
it('converts variant-candidate to suggestion with correct type and id', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [],
|
||||
repeatedSpacing: [],
|
||||
variantCandidates: [
|
||||
{
|
||||
nodeId: 'node-1',
|
||||
nodeLabel: 'Button',
|
||||
nodeType: 'net.noodl.controls.button',
|
||||
overrideCount: 4,
|
||||
overrides: { backgroundColor: '#22c55e', color: '#fff', borderRadius: '9999px', padding: '12px' },
|
||||
suggestedVariantName: 'custom'
|
||||
}
|
||||
]
|
||||
};
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
expect(suggestions[0].type).toBe('variant-candidate');
|
||||
expect(suggestions[0].id).toBe('variant-candidate:node-1');
|
||||
expect(suggestions[0].acceptLabel).toBe('Save as Variant');
|
||||
expect(suggestions[0].message).toContain('4 custom values');
|
||||
});
|
||||
|
||||
it('orders repeated-color suggestions by count descending', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [
|
||||
{ value: '#aaaaaa', count: 3, elements: [], suggestedTokenName: '--color-aaa' },
|
||||
{ value: '#bbbbbb', count: 7, elements: [], suggestedTokenName: '--color-bbb' },
|
||||
{ value: '#cccccc', count: 5, elements: [], suggestedTokenName: '--color-ccc' }
|
||||
],
|
||||
repeatedSpacing: [],
|
||||
variantCandidates: []
|
||||
};
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
const counts = suggestions.map((s) => s.repeatedValue!.count);
|
||||
expect(counts).toEqual([7, 5, 3]);
|
||||
});
|
||||
|
||||
it('orders variant candidates by override count descending', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [],
|
||||
repeatedSpacing: [],
|
||||
variantCandidates: [
|
||||
{ nodeId: 'a', nodeLabel: 'A', nodeType: 'Group', overrideCount: 3, overrides: {}, suggestedVariantName: 'c' },
|
||||
{ nodeId: 'b', nodeLabel: 'B', nodeType: 'Group', overrideCount: 8, overrides: {}, suggestedVariantName: 'c' },
|
||||
{ nodeId: 'c', nodeLabel: 'C', nodeType: 'Group', overrideCount: 5, overrides: {}, suggestedVariantName: 'c' }
|
||||
]
|
||||
};
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
const counts = suggestions.map((s) => s.variantCandidate!.overrideCount);
|
||||
expect(counts).toEqual([8, 5, 3]);
|
||||
});
|
||||
|
||||
it('colors come before spacing come before variants in output order', () => {
|
||||
const result: StyleAnalysisResult = {
|
||||
repeatedColors: [{ value: '#ff0000', count: 3, elements: [], suggestedTokenName: '--color-ff0000' }],
|
||||
repeatedSpacing: [{ value: '8px', count: 3, elements: [], suggestedTokenName: '--spacing-8px' }],
|
||||
variantCandidates: [
|
||||
{ nodeId: 'x', nodeLabel: 'X', nodeType: 'Group', overrideCount: 3, overrides: {}, suggestedVariantName: 'c' }
|
||||
]
|
||||
};
|
||||
const suggestions = StyleAnalyzer.toSuggestions(result);
|
||||
expect(suggestions[0].type).toBe('repeated-color');
|
||||
expect(suggestions[1].type).toBe('repeated-spacing');
|
||||
expect(suggestions[2].type).toBe('variant-candidate');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── analyzeProject ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('StyleAnalyzer.analyzeProject', () => {
|
||||
let originalInstance: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
originalInstance = (ProjectModel as unknown as { instance: unknown }).instance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = originalInstance;
|
||||
});
|
||||
|
||||
it('returns empty result when ProjectModel.instance is null', () => {
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = null;
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(0);
|
||||
expect(result.repeatedSpacing).toHaveLength(0);
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects repeated colour above threshold (3+)', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(1);
|
||||
expect(result.repeatedColors[0].value).toBe('#3b82f6');
|
||||
expect(result.repeatedColors[0].count).toBe(3);
|
||||
expect(result.repeatedColors[0].elements).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('does NOT report repeated colour below threshold (<3)', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' })
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips CSS var() token references', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'Group', { backgroundColor: 'var(--primary)' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: 'var(--primary)' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: 'var(--primary)' })
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
// var() references must never appear in repeated values
|
||||
expect(result.repeatedColors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects repeated spacing value above threshold', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'Group', { paddingTop: '16px' }),
|
||||
makeNode('n2', 'Group', { paddingTop: '16px' }),
|
||||
makeNode('n3', 'Group', { paddingTop: '16px' })
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedSpacing).toHaveLength(1);
|
||||
expect(result.repeatedSpacing[0].value).toBe('16px');
|
||||
expect(result.repeatedSpacing[0].count).toBe(3);
|
||||
});
|
||||
|
||||
it('detects variant candidate when node has 3+ non-token overrides', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'net.noodl.controls.button', {
|
||||
backgroundColor: '#22c55e',
|
||||
color: '#ffffff',
|
||||
borderRadius: '9999px'
|
||||
// exactly SUGGESTION_THRESHOLDS.variantCandidateMinOverrides
|
||||
})
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.variantCandidates).toHaveLength(1);
|
||||
expect(result.variantCandidates[0].nodeId).toBe('n1');
|
||||
expect(result.variantCandidates[0].overrideCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does NOT report variant candidate below threshold', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'net.noodl.controls.button', {
|
||||
backgroundColor: '#22c55e',
|
||||
color: '#ffffff'
|
||||
// only 2 overrides — below threshold of 3
|
||||
})
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('counts each occurrence across multiple nodes', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'Group', { backgroundColor: '#ff0000' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#ff0000' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: '#ff0000' }),
|
||||
makeNode('n4', 'Group', { backgroundColor: '#ff0000' }),
|
||||
makeNode('n5', 'Group', { backgroundColor: '#ff0000' })
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject();
|
||||
expect(result.repeatedColors[0].count).toBe(5);
|
||||
});
|
||||
|
||||
it('matches repeated value to existing token via tokenModel', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
|
||||
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
|
||||
];
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
|
||||
|
||||
const mockTokenModel = {
|
||||
getTokens: () => [{ name: '--brand-primary' }],
|
||||
resolveToken: (name: string) => (name === '--brand-primary' ? '#3b82f6' : undefined)
|
||||
};
|
||||
|
||||
const result = StyleAnalyzer.analyzeProject({ tokenModel: mockTokenModel });
|
||||
expect(result.repeatedColors[0].matchingToken).toBe('--brand-primary');
|
||||
});
|
||||
|
||||
it('reports SUGGESTION_THRESHOLDS values as expected', () => {
|
||||
expect(SUGGESTION_THRESHOLDS.repeatedValueMinCount).toBe(3);
|
||||
expect(SUGGESTION_THRESHOLDS.variantCandidateMinOverrides).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── analyzeNode ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StyleAnalyzer.analyzeNode', () => {
|
||||
let originalInstance: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
originalInstance = (ProjectModel as unknown as { instance: unknown }).instance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = originalInstance;
|
||||
});
|
||||
|
||||
it('returns empty when ProjectModel.instance is null', () => {
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = null;
|
||||
const result = StyleAnalyzer.analyzeNode('any-id');
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty when node not found', () => {
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[]]);
|
||||
const result = StyleAnalyzer.analyzeNode('nonexistent');
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns variant candidate for node with 3+ non-token overrides', () => {
|
||||
const node = makeNode('btn-1', 'net.noodl.controls.button', {
|
||||
backgroundColor: '#22c55e',
|
||||
color: '#ffffff',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '14px'
|
||||
});
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[node]]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeNode('btn-1');
|
||||
expect(result.variantCandidates).toHaveLength(1);
|
||||
expect(result.variantCandidates[0].nodeId).toBe('btn-1');
|
||||
expect(result.variantCandidates[0].overrideCount).toBe(4);
|
||||
});
|
||||
|
||||
it('returns empty for node with fewer than threshold overrides', () => {
|
||||
const node = makeNode('btn-2', 'net.noodl.controls.button', {
|
||||
backgroundColor: '#22c55e',
|
||||
color: '#ffffff'
|
||||
// 2 overrides — below threshold
|
||||
});
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[node]]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeNode('btn-2');
|
||||
expect(result.variantCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores var() token references when counting overrides', () => {
|
||||
const node = makeNode('btn-3', 'net.noodl.controls.button', {
|
||||
backgroundColor: 'var(--primary)', // token — not counted
|
||||
color: 'var(--text-on-primary)', // token — not counted
|
||||
borderRadius: '9999px', // raw
|
||||
fontSize: '14px', // raw
|
||||
paddingTop: '12px' // raw
|
||||
});
|
||||
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[node]]);
|
||||
|
||||
const result = StyleAnalyzer.analyzeNode('btn-3');
|
||||
// Only 3 raw overrides count — should hit threshold exactly
|
||||
expect(result.variantCandidates).toHaveLength(1);
|
||||
expect(result.variantCandidates[0].overrideCount).toBe(3);
|
||||
});
|
||||
});
|
||||
4
packages/noodl-editor/tests/services/index.ts
Normal file
4
packages/noodl-editor/tests/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// NOTE: StyleAnalyzer.test uses @jest/globals and is a Jest-only test.
|
||||
// It runs via `npm run test:editor`.
|
||||
// Do NOT re-add it here - the Electron Jasmine runner will crash on import.
|
||||
export {};
|
||||
@@ -6,6 +6,7 @@
|
||||
"module": "es2020",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@noodl/git": ["../noodl-git/src/index.ts"],
|
||||
"@noodl-core-ui/*": ["../noodl-core-ui/src/*"],
|
||||
"@noodl-hooks/*": ["./src/editor/src/hooks/*"],
|
||||
"@noodl-utils/*": ["./src/editor/src/utils/*"],
|
||||
|
||||
@@ -265,6 +265,15 @@ export class Git {
|
||||
// this will also checkout the branch
|
||||
await popStashEntryToBranch(this.baseDir, stash.name, stashBranchName);
|
||||
|
||||
// Commit the stash contents to the stash branch to clean the working tree.
|
||||
// Without this, git refuses to merge when both branches have modifications to the
|
||||
// same file (e.g. .gitignore added by appendGitIgnore in _setupRepository).
|
||||
const stashBranchStatus = await this.status();
|
||||
if (stashBranchStatus.length > 0) {
|
||||
await addAll(this.baseDir);
|
||||
await createCommit(this.baseDir, 'Stash contents');
|
||||
}
|
||||
|
||||
// Merge our working branch into the stash branch
|
||||
await this._merge({
|
||||
theirsBranchName: previousBranch,
|
||||
@@ -377,6 +386,35 @@ export class Git {
|
||||
await cleanUntrackedFiles(this.baseDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch remote changes and merge them into the current branch using Noodl's
|
||||
* custom merge strategy (handles project.json conflicts).
|
||||
*
|
||||
* Equivalent to `git fetch` + `git merge origin/<currentBranch>`.
|
||||
*/
|
||||
async pull(options: PullOptions = {}): Promise<void> {
|
||||
// 1. Fetch latest remote state
|
||||
await this.fetch({ onProgress: options.onProgress });
|
||||
|
||||
// 2. Nothing to merge if remote has no commits yet
|
||||
const remoteHeadId = await this.getRemoteHeadCommitId();
|
||||
if (!remoteHeadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Merge origin/<currentBranch> into current branch
|
||||
const currentBranch = await this.getCurrentBranchName();
|
||||
const remoteName = await this.getRemoteName();
|
||||
const remoteRef = `${remoteName}/${currentBranch}`;
|
||||
|
||||
await this._mergeToCurrentBranch({
|
||||
theirsBranchName: remoteRef,
|
||||
squash: false,
|
||||
message: `Merge ${remoteRef} into ${currentBranch}`,
|
||||
allowFastForward: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated This is only used in old git panel
|
||||
@@ -621,8 +659,6 @@ export class Git {
|
||||
|
||||
try {
|
||||
await this.checkoutBranch(branchName);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
if (needsStash) {
|
||||
await this.stashPopChanges(currentBranchName);
|
||||
|
||||
Reference in New Issue
Block a user