mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
Added sprint protocol
This commit is contained in:
335009
packages/noodl-editor/src/editor/index.bundle.js
Normal file
335009
packages/noodl-editor/src/editor/index.bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
packages/noodl-editor/src/editor/index.bundle.js.map
Normal file
1
packages/noodl-editor/src/editor/index.bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -1,23 +1,34 @@
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import { useModel } from '@noodl-hooks/useModel';
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { StylesModel } from '@noodl-models/StylesModel';
|
||||
import { StyleTokenRecord, StyleTokensModel } from '@noodl-models/StyleTokensModel';
|
||||
|
||||
import { Slot } from '@noodl-core-ui/types/global';
|
||||
|
||||
import { DesignTokenColor, extractProjectColors } from './extractProjectColors';
|
||||
|
||||
export interface ProjectDesignTokenContext {
|
||||
/** Legacy named color styles (used by old property editor). */
|
||||
staticColors: DesignTokenColor[];
|
||||
/** Raw hex colors used in project (used by old property editor). */
|
||||
dynamicColors: DesignTokenColor[];
|
||||
/** Legacy text styles. */
|
||||
textStyles: TSFixme[];
|
||||
/** New STYLE-001 design tokens — full flat list. */
|
||||
designTokens: StyleTokenRecord[];
|
||||
/** StyleTokensModel instance for direct interaction. */
|
||||
styleTokensModel: StyleTokensModel | null;
|
||||
}
|
||||
|
||||
const ProjectDesignTokenContext = createContext<ProjectDesignTokenContext>({
|
||||
staticColors: [],
|
||||
dynamicColors: [],
|
||||
textStyles: []
|
||||
textStyles: [],
|
||||
designTokens: [],
|
||||
styleTokensModel: null
|
||||
});
|
||||
|
||||
export interface ProjectDesignTokenContextProps {
|
||||
@@ -31,7 +42,10 @@ export function ProjectDesignTokenContextProvider({ children }: ProjectDesignTok
|
||||
const [staticColors, setStaticColors] = useState<DesignTokenColor[]>([]);
|
||||
const [dynamicColors, setDynamicColors] = useState<DesignTokenColor[]>([]);
|
||||
const [textStyles, setTextStyles] = useState<TSFixme[]>([]);
|
||||
const [designTokens, setDesignTokens] = useState<StyleTokenRecord[]>([]);
|
||||
const [styleTokensModel] = useState<StyleTokensModel>(() => new StyleTokensModel());
|
||||
|
||||
// Sync legacy colors/text styles
|
||||
useEffect(() => {
|
||||
const stylesModel = new StylesModel();
|
||||
|
||||
@@ -59,12 +73,30 @@ export function ProjectDesignTokenContextProvider({ children }: ProjectDesignTok
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync design tokens from StyleTokensModel
|
||||
useEffect(() => {
|
||||
setDesignTokens(styleTokensModel.getTokens());
|
||||
}, [styleTokensModel]);
|
||||
|
||||
useEventListener(styleTokensModel, 'tokensChanged', () => {
|
||||
setDesignTokens(styleTokensModel.getTokens());
|
||||
});
|
||||
|
||||
// Cleanup StyleTokensModel on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
styleTokensModel.dispose();
|
||||
};
|
||||
}, [styleTokensModel]);
|
||||
|
||||
return (
|
||||
<ProjectDesignTokenContext.Provider
|
||||
value={{
|
||||
staticColors,
|
||||
dynamicColors,
|
||||
textStyles
|
||||
textStyles,
|
||||
designTokens,
|
||||
styleTokensModel
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* STYLE-002: Element Config Registry
|
||||
*
|
||||
* Singleton registry that maps node types to their ElementConfig definitions.
|
||||
* Used by the node creation hook to apply default styles and by the
|
||||
* VariantSelector UI to enumerate available variants.
|
||||
*
|
||||
* Usage:
|
||||
* ElementConfigRegistry.get('net.noodl.controls.button') // → ElementConfig
|
||||
* ElementConfigRegistry.getVariants('Text') // → string[]
|
||||
* ElementConfigRegistry.applyDefaults(nodeModel) // → stamps defaults
|
||||
* ElementConfigRegistry.applyVariant(nodeModel, 'card') // → stamps variant
|
||||
*/
|
||||
|
||||
import { ButtonConfig } from './configs/ButtonConfig';
|
||||
import { CheckboxConfig } from './configs/CheckboxConfig';
|
||||
import { GroupConfig } from './configs/GroupConfig';
|
||||
import { TextConfig } from './configs/TextConfig';
|
||||
import { TextInputConfig } from './configs/TextInputConfig';
|
||||
import { ElementConfig, ResolvedVariant, VariantConfig } from './ElementConfigTypes';
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Registry map
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const _configs = new Map<string, ElementConfig>();
|
||||
|
||||
function register(config: ElementConfig): void {
|
||||
_configs.set(config.nodeType, config);
|
||||
}
|
||||
|
||||
function get(nodeType: string): ElementConfig | undefined {
|
||||
return _configs.get(nodeType);
|
||||
}
|
||||
|
||||
function has(nodeType: string): boolean {
|
||||
return _configs.has(nodeType);
|
||||
}
|
||||
|
||||
function getAll(): ElementConfig[] {
|
||||
return Array.from(_configs.values());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Variant helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
function getVariantNames(nodeType: string): string[] {
|
||||
const config = _configs.get(nodeType);
|
||||
if (!config) return [];
|
||||
return Object.keys(config.variants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a variant into base styles and interaction state styles.
|
||||
* Strips the `states` key from base properties.
|
||||
*/
|
||||
function resolveVariant(nodeType: string, variantName: string): ResolvedVariant | undefined {
|
||||
const config = _configs.get(nodeType);
|
||||
if (!config) return undefined;
|
||||
|
||||
const variant: VariantConfig | undefined = config.variants[variantName];
|
||||
if (!variant) return undefined;
|
||||
|
||||
const baseStyles: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(variant)) {
|
||||
if (key === 'states') continue;
|
||||
if (typeof value === 'string') {
|
||||
baseStyles[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles,
|
||||
states: variant.states ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Node model integration
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Minimal interface for a Noodl node model that the registry can interact with.
|
||||
* NodeGraphNode exposes a plain `parameters` object for reading/writing node properties.
|
||||
*/
|
||||
export interface NodeModelLike {
|
||||
/** The node's parameters bag — direct object access (camelCase CSS property keys). */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default styles from the element config to a newly-created node.
|
||||
* Only sets properties that haven't already been explicitly set on the node.
|
||||
* Uses `typeName` rather than reading from the node, because the node's type
|
||||
* may not yet be resolved when called from the creation flow.
|
||||
*
|
||||
* @param node - A node model (must have a plain `parameters` object)
|
||||
* @param typeName - The node type identifier string (e.g. 'net.noodl.controls.button')
|
||||
*/
|
||||
function applyDefaults(node: NodeModelLike, typeName: string): void {
|
||||
const config = _configs.get(typeName);
|
||||
if (!config) return;
|
||||
|
||||
const defaults = config.defaults;
|
||||
const variantName = defaults['_variant'];
|
||||
|
||||
// Apply non-variant defaults (skip the _variant marker)
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (key === '_variant') continue;
|
||||
// Only stamp if not already set on this node
|
||||
if (node.parameters[key] === undefined || node.parameters[key] === null) {
|
||||
node.parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply variant styles on top of defaults
|
||||
if (variantName) {
|
||||
applyVariant(node, typeName, variantName);
|
||||
// Persist the active variant name so the UI can show it
|
||||
node.parameters['_variant'] = variantName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a named variant's base styles to a node.
|
||||
* Overwrites existing values — intentional when user switches variants.
|
||||
*
|
||||
* @param node - A node model (must have a plain `parameters` object)
|
||||
* @param typeName - The node type identifier string
|
||||
* @param variantName - e.g. 'primary', 'card', 'heading-1'
|
||||
*/
|
||||
function applyVariant(node: NodeModelLike, typeName: string, variantName: string): void {
|
||||
const resolved = resolveVariant(typeName, variantName);
|
||||
if (!resolved) return;
|
||||
|
||||
for (const [key, value] of Object.entries(resolved.baseStyles)) {
|
||||
node.parameters[key] = value;
|
||||
}
|
||||
|
||||
// Update the active variant marker
|
||||
node.parameters['_variant'] = variantName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a named size preset's style overrides to a node.
|
||||
* Overwrites existing values — intentional when user switches sizes.
|
||||
*
|
||||
* @param node - A node model (must have a plain `parameters` object)
|
||||
* @param typeName - The node type identifier string
|
||||
* @param sizeName - e.g. 'sm', 'md', 'lg', 'xl'
|
||||
*/
|
||||
function applySize(node: NodeModelLike, typeName: string, sizeName: string): void {
|
||||
const config = _configs.get(typeName);
|
||||
if (!config?.sizes) return;
|
||||
|
||||
const sizePreset = config.sizes[sizeName];
|
||||
if (!sizePreset) return;
|
||||
|
||||
for (const [key, value] of Object.entries(sizePreset)) {
|
||||
node.parameters[key] = value;
|
||||
}
|
||||
|
||||
// Track the active size
|
||||
node.parameters['_size'] = sizeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the size names defined in the config, in the order they appear.
|
||||
*/
|
||||
function getSizeNames(nodeType: string): string[] {
|
||||
const config = _configs.get(nodeType);
|
||||
if (!config?.sizes) return [];
|
||||
return Object.keys(config.sizes);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Register all built-in configs
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
register(ButtonConfig);
|
||||
register(CheckboxConfig);
|
||||
register(GroupConfig);
|
||||
register(TextConfig);
|
||||
register(TextInputConfig);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Public API
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
export const ElementConfigRegistry = {
|
||||
register,
|
||||
get,
|
||||
has,
|
||||
getAll,
|
||||
getVariantNames,
|
||||
getSizeNames,
|
||||
resolveVariant,
|
||||
applyDefaults,
|
||||
applyVariant,
|
||||
applySize
|
||||
} as const;
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* STYLE-002: Element Config Types
|
||||
*
|
||||
* TypeScript interfaces for the element configuration and variant system.
|
||||
* Configs define default styles and pre-built variants for Noodl's core visual nodes,
|
||||
* so elements look good immediately on creation and offer quick style switching.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Style properties applied in a specific interaction state.
|
||||
*/
|
||||
export interface StateStyles {
|
||||
hover?: Record<string, string>;
|
||||
active?: Record<string, string>;
|
||||
focus?: Record<string, string>;
|
||||
disabled?: Record<string, string>;
|
||||
placeholder?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named style variant for a node type.
|
||||
* Contains base CSS property overrides and optional interaction state styles.
|
||||
*/
|
||||
export interface VariantConfig {
|
||||
/** Base CSS properties for this variant (camelCase keys). */
|
||||
[property: string]: string | StateStyles | undefined;
|
||||
/** Interaction state style overrides. */
|
||||
states?: StateStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* A size preset — a named set of CSS property overrides (typically spacing + font size).
|
||||
*/
|
||||
export type SizePresets = Record<string, Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Full configuration for a node type.
|
||||
* Describes defaults applied on creation, optional size presets, and named style variants.
|
||||
*/
|
||||
export interface ElementConfig {
|
||||
/** Noodl node type identifier (e.g. 'net.noodl.controls.button'). */
|
||||
nodeType: string;
|
||||
|
||||
/**
|
||||
* Default CSS property values applied when the node is first created.
|
||||
* Use `var(--token-name)` references to link to design tokens.
|
||||
* The special key `_variant` sets the initially-selected variant name.
|
||||
*/
|
||||
defaults: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Optional named size presets (e.g. sm, md, lg, xl).
|
||||
* Each preset is a partial set of CSS overrides applied on top of defaults.
|
||||
*/
|
||||
sizes?: SizePresets;
|
||||
|
||||
/**
|
||||
* Named style variants. Keys are variant names (e.g. 'primary', 'card').
|
||||
* Each variant specifies CSS properties and optional interaction states.
|
||||
*/
|
||||
variants: Record<string, VariantConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved variant styles — base styles with interaction states separated out.
|
||||
*/
|
||||
export interface ResolvedVariant {
|
||||
/** Flat CSS properties for the default (non-interacting) state. */
|
||||
baseStyles: Record<string, string>;
|
||||
/** Interaction state style overrides. */
|
||||
states: StateStyles;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* STYLE-002: Button Element Config
|
||||
*
|
||||
* Defines default styles and pre-built variants for the Button node
|
||||
* (net.noodl.controls.button). All values use design token CSS variables
|
||||
* so changing a token cascades to all buttons.
|
||||
*/
|
||||
|
||||
import { ElementConfig } from '../ElementConfigTypes';
|
||||
|
||||
export const ButtonConfig: ElementConfig = {
|
||||
nodeType: 'net.noodl.controls.button',
|
||||
|
||||
// Applied when a Button node is first dropped onto the canvas
|
||||
defaults: {
|
||||
paddingTop: 'var(--space-2)',
|
||||
paddingBottom: 'var(--space-2)',
|
||||
paddingLeft: 'var(--space-4)',
|
||||
paddingRight: 'var(--space-4)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontWeight: 'var(--font-medium)',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
_variant: 'primary'
|
||||
},
|
||||
|
||||
sizes: {
|
||||
sm: {
|
||||
paddingTop: 'var(--space-1)',
|
||||
paddingBottom: 'var(--space-1)',
|
||||
paddingLeft: 'var(--space-2)',
|
||||
paddingRight: 'var(--space-2)',
|
||||
fontSize: 'var(--text-xs)'
|
||||
},
|
||||
md: {
|
||||
paddingTop: 'var(--space-2)',
|
||||
paddingBottom: 'var(--space-2)',
|
||||
paddingLeft: 'var(--space-4)',
|
||||
paddingRight: 'var(--space-4)',
|
||||
fontSize: 'var(--text-sm)'
|
||||
},
|
||||
lg: {
|
||||
paddingTop: 'var(--space-3)',
|
||||
paddingBottom: 'var(--space-3)',
|
||||
paddingLeft: 'var(--space-6)',
|
||||
paddingRight: 'var(--space-6)',
|
||||
fontSize: 'var(--text-base)'
|
||||
},
|
||||
xl: {
|
||||
paddingTop: 'var(--space-4)',
|
||||
paddingBottom: 'var(--space-4)',
|
||||
paddingLeft: 'var(--space-8)',
|
||||
paddingRight: 'var(--space-8)',
|
||||
fontSize: 'var(--text-lg)'
|
||||
}
|
||||
},
|
||||
|
||||
variants: {
|
||||
primary: {
|
||||
backgroundColor: 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
borderWidth: '0',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
states: {
|
||||
hover: { backgroundColor: 'var(--primary-hover)' },
|
||||
active: { transform: 'scale(0.98)' },
|
||||
disabled: { opacity: '0.5', cursor: 'not-allowed' }
|
||||
}
|
||||
},
|
||||
|
||||
secondary: {
|
||||
backgroundColor: 'var(--secondary)',
|
||||
color: 'var(--secondary-foreground)',
|
||||
borderWidth: '0',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
states: {
|
||||
hover: { backgroundColor: 'var(--secondary-hover)' },
|
||||
active: { transform: 'scale(0.98)' },
|
||||
disabled: { opacity: '0.5', cursor: 'not-allowed' }
|
||||
}
|
||||
},
|
||||
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
borderWidth: 'var(--border-1)',
|
||||
borderColor: 'var(--border)',
|
||||
borderStyle: 'solid',
|
||||
states: {
|
||||
hover: { backgroundColor: 'var(--accent)', color: 'var(--accent-foreground)' },
|
||||
disabled: { opacity: '0.5', cursor: 'not-allowed' }
|
||||
}
|
||||
},
|
||||
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
borderWidth: '0',
|
||||
states: {
|
||||
hover: { backgroundColor: 'var(--accent)', color: 'var(--accent-foreground)' },
|
||||
disabled: { opacity: '0.5', cursor: 'not-allowed' }
|
||||
}
|
||||
},
|
||||
|
||||
destructive: {
|
||||
backgroundColor: 'var(--destructive)',
|
||||
color: 'var(--destructive-foreground)',
|
||||
borderWidth: '0',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
states: {
|
||||
hover: { backgroundColor: 'var(--destructive-hover)' },
|
||||
disabled: { opacity: '0.5', cursor: 'not-allowed' }
|
||||
}
|
||||
},
|
||||
|
||||
link: {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--primary)',
|
||||
borderWidth: '0',
|
||||
textDecoration: 'none',
|
||||
paddingLeft: '0',
|
||||
paddingRight: '0',
|
||||
states: {
|
||||
hover: { textDecoration: 'underline' }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* STYLE-002: Checkbox Element Config
|
||||
*
|
||||
* Defines default styles for the Checkbox node (net.noodl.controls.checkbox).
|
||||
*/
|
||||
|
||||
import { ElementConfig } from '../ElementConfigTypes';
|
||||
|
||||
export const CheckboxConfig: ElementConfig = {
|
||||
nodeType: 'net.noodl.controls.checkbox',
|
||||
|
||||
defaults: {
|
||||
width: 'var(--space-4)',
|
||||
height: 'var(--space-4)',
|
||||
borderWidth: 'var(--border-1)',
|
||||
borderColor: 'var(--border)',
|
||||
borderStyle: 'solid',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
_variant: 'default'
|
||||
},
|
||||
|
||||
variants: {
|
||||
default: {
|
||||
borderColor: 'var(--border)',
|
||||
states: {
|
||||
hover: { borderColor: 'var(--primary)' },
|
||||
disabled: { opacity: '0.5', cursor: 'not-allowed' }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* STYLE-002: Group Element Config
|
||||
*
|
||||
* Defines default styles and pre-built variants for the Group node
|
||||
* (net.noodl.visual.group). Groups start transparent by default but
|
||||
* offer card/section/inset/flex layout variants.
|
||||
*/
|
||||
|
||||
import { ElementConfig } from '../ElementConfigTypes';
|
||||
|
||||
export const GroupConfig: ElementConfig = {
|
||||
nodeType: 'net.noodl.visual.group',
|
||||
|
||||
defaults: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
_variant: 'default'
|
||||
},
|
||||
|
||||
variants: {
|
||||
default: {
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0',
|
||||
borderWidth: '0',
|
||||
borderRadius: '0'
|
||||
},
|
||||
|
||||
card: {
|
||||
backgroundColor: 'var(--surface)',
|
||||
padding: 'var(--space-4)',
|
||||
borderWidth: 'var(--border-1)',
|
||||
borderColor: 'var(--border-subtle)',
|
||||
borderStyle: 'solid',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-md)'
|
||||
},
|
||||
|
||||
section: {
|
||||
padding: 'var(--space-8)'
|
||||
},
|
||||
|
||||
inset: {
|
||||
backgroundColor: 'var(--muted)',
|
||||
padding: 'var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)'
|
||||
},
|
||||
|
||||
'flex-row': {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)'
|
||||
},
|
||||
|
||||
'flex-col': {
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-2)'
|
||||
},
|
||||
|
||||
centered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* STYLE-002: Text Element Config
|
||||
*
|
||||
* Defines default styles and pre-built variants for the Text node.
|
||||
*
|
||||
* BUG FIX: Text elements previously defaulted to width: 100% with no flex-shrink,
|
||||
* causing them to push sibling elements off-screen in row layouts.
|
||||
* Fixed by using width: auto + proper flex participation defaults.
|
||||
*/
|
||||
|
||||
import { ElementConfig } from '../ElementConfigTypes';
|
||||
|
||||
export const TextConfig: ElementConfig = {
|
||||
nodeType: 'Text',
|
||||
|
||||
defaults: {
|
||||
// BUG FIX: Proper flex participation (was: width: '100%' with no shrink)
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
flexShrink: '1',
|
||||
flexGrow: '0',
|
||||
minWidth: '0',
|
||||
|
||||
// Typography
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 'var(--text-base)',
|
||||
fontWeight: 'var(--font-normal)',
|
||||
lineHeight: 'var(--leading-normal)',
|
||||
color: 'var(--foreground)',
|
||||
|
||||
_variant: 'body'
|
||||
},
|
||||
|
||||
variants: {
|
||||
body: {
|
||||
fontSize: 'var(--text-base)',
|
||||
fontWeight: 'var(--font-normal)',
|
||||
lineHeight: 'var(--leading-normal)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
'heading-1': {
|
||||
fontSize: 'var(--text-4xl)',
|
||||
fontWeight: 'var(--font-bold)',
|
||||
lineHeight: 'var(--leading-tight)',
|
||||
letterSpacing: 'var(--tracking-tight)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
'heading-2': {
|
||||
fontSize: 'var(--text-3xl)',
|
||||
fontWeight: 'var(--font-semibold)',
|
||||
lineHeight: 'var(--leading-tight)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
'heading-3': {
|
||||
fontSize: 'var(--text-2xl)',
|
||||
fontWeight: 'var(--font-semibold)',
|
||||
lineHeight: 'var(--leading-snug)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
'heading-4': {
|
||||
fontSize: 'var(--text-xl)',
|
||||
fontWeight: 'var(--font-semibold)',
|
||||
lineHeight: 'var(--leading-snug)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
'heading-5': {
|
||||
fontSize: 'var(--text-lg)',
|
||||
fontWeight: 'var(--font-medium)',
|
||||
lineHeight: 'var(--leading-normal)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
'heading-6': {
|
||||
fontSize: 'var(--text-base)',
|
||||
fontWeight: 'var(--font-medium)',
|
||||
lineHeight: 'var(--leading-normal)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
muted: {
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--muted-foreground)'
|
||||
},
|
||||
|
||||
label: {
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontWeight: 'var(--font-medium)',
|
||||
color: 'var(--foreground)'
|
||||
},
|
||||
|
||||
small: {
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--muted-foreground)'
|
||||
},
|
||||
|
||||
code: {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
backgroundColor: 'var(--muted)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 'var(--radius-sm)'
|
||||
},
|
||||
|
||||
lead: {
|
||||
fontSize: 'var(--text-xl)',
|
||||
color: 'var(--muted-foreground)',
|
||||
lineHeight: 'var(--leading-relaxed)'
|
||||
},
|
||||
|
||||
blockquote: {
|
||||
fontStyle: 'italic',
|
||||
borderLeftWidth: '4px',
|
||||
borderLeftColor: 'var(--border)',
|
||||
borderLeftStyle: 'solid',
|
||||
paddingLeft: 'var(--space-4)',
|
||||
color: 'var(--muted-foreground)'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* STYLE-002: TextInput Element Config
|
||||
*
|
||||
* Defines default styles and pre-built variants for the TextInput node
|
||||
* (net.noodl.controls.textinput). Includes default + error variants
|
||||
* with proper focus ring styling.
|
||||
*/
|
||||
|
||||
import { ElementConfig } from '../ElementConfigTypes';
|
||||
|
||||
export const TextInputConfig: ElementConfig = {
|
||||
nodeType: 'net.noodl.controls.textinput',
|
||||
|
||||
defaults: {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
|
||||
paddingTop: 'var(--space-2)',
|
||||
paddingBottom: 'var(--space-2)',
|
||||
paddingLeft: 'var(--space-3)',
|
||||
paddingRight: 'var(--space-3)',
|
||||
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 'var(--text-base)',
|
||||
color: 'var(--foreground)',
|
||||
|
||||
borderWidth: 'var(--border-1)',
|
||||
borderColor: 'var(--border)',
|
||||
borderStyle: 'solid',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
|
||||
backgroundColor: 'var(--background)',
|
||||
|
||||
_variant: 'default'
|
||||
},
|
||||
|
||||
variants: {
|
||||
default: {
|
||||
borderColor: 'var(--border)',
|
||||
backgroundColor: 'var(--background)',
|
||||
states: {
|
||||
focus: {
|
||||
borderColor: 'var(--ring)',
|
||||
boxShadow: '0 0 0 2px var(--ring)',
|
||||
outline: 'none'
|
||||
},
|
||||
disabled: {
|
||||
backgroundColor: 'var(--muted)',
|
||||
opacity: '0.5',
|
||||
cursor: 'not-allowed'
|
||||
},
|
||||
placeholder: {
|
||||
color: 'var(--muted-foreground)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
error: {
|
||||
borderColor: 'var(--destructive)',
|
||||
states: {
|
||||
focus: {
|
||||
borderColor: 'var(--destructive)',
|
||||
boxShadow: '0 0 0 2px var(--destructive)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* STYLE-002: Element Config barrel export
|
||||
*/
|
||||
|
||||
export { ButtonConfig } from './ButtonConfig';
|
||||
export { CheckboxConfig } from './CheckboxConfig';
|
||||
export { GroupConfig } from './GroupConfig';
|
||||
export { TextConfig } from './TextConfig';
|
||||
export { TextInputConfig } from './TextInputConfig';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* STYLE-002: ElementConfigs public API
|
||||
*/
|
||||
|
||||
export type { ElementConfig, VariantConfig, StateStyles, SizePresets, ResolvedVariant } from './ElementConfigTypes';
|
||||
export { ElementConfigRegistry } from './ElementConfigRegistry';
|
||||
export type { NodeModelLike } from './ElementConfigRegistry';
|
||||
|
||||
// Config objects (useful for testing or direct access)
|
||||
export { ButtonConfig } from './configs/ButtonConfig';
|
||||
export { CheckboxConfig } from './configs/CheckboxConfig';
|
||||
export { GroupConfig } from './configs/GroupConfig';
|
||||
export { TextConfig } from './configs/TextConfig';
|
||||
export { TextInputConfig } from './configs/TextInputConfig';
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* STYLE-003: Style Preset Types
|
||||
*
|
||||
* TypeScript interfaces for the style preset system.
|
||||
* Presets define a complete set of semantic token overrides that give
|
||||
* a project a cohesive visual aesthetic from the moment of creation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal preview metadata used to render preset cards in the selector UI.
|
||||
* All values are raw CSS color strings or px values — no token references.
|
||||
*/
|
||||
export interface PresetPreview {
|
||||
/** Primary action color (button backgrounds, links). */
|
||||
primaryColor: string;
|
||||
/** Page/card background. */
|
||||
backgroundColor: string;
|
||||
/** Card/surface color. */
|
||||
surfaceColor: string;
|
||||
/** Default border color. */
|
||||
borderColor: string;
|
||||
/** Text color. */
|
||||
textColor: string;
|
||||
/** Secondary/muted text color. */
|
||||
mutedTextColor: string;
|
||||
/** Border radius for medium elements (e.g. buttons, cards). */
|
||||
radiusMd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named style preset — a curated set of semantic token overrides.
|
||||
*
|
||||
* Built-in presets are never stored in project metadata; they're applied
|
||||
* as overrides on top of the default token set when a project is created.
|
||||
*
|
||||
* The `tokens` map uses the same CSS custom property names as `DefaultTokens.ts`
|
||||
* (e.g. `--primary`, `--radius-md`). Only the semantic/structural tokens that
|
||||
* vary between presets are included — palette scale tokens stay as-is.
|
||||
*/
|
||||
export interface StylePreset {
|
||||
/** Unique slug identifier (e.g. 'modern', 'minimal'). */
|
||||
id: string;
|
||||
/** Human-readable name shown in the UI. */
|
||||
name: string;
|
||||
/** One-line description shown below the preset cards. */
|
||||
description: string;
|
||||
/** Whether this is a built-in (non-deletable) preset. */
|
||||
isBuiltIn: boolean;
|
||||
/**
|
||||
* Token overrides. Keys are CSS custom property names.
|
||||
* Only needs to contain tokens that DIFFER from the Modern/default preset.
|
||||
* For the Modern preset, this is empty (it IS the defaults).
|
||||
*/
|
||||
tokens: Record<string, string>;
|
||||
/** Simplified preview data for the PresetCard UI. */
|
||||
preview: PresetPreview;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* STYLE-003: StylePresetsModel
|
||||
*
|
||||
* Manages the registry of built-in style presets.
|
||||
* Pure functions — no singleton, no side effects.
|
||||
*
|
||||
* Usage:
|
||||
* import { getAllPresets, getPreset } from './StylePresetsModel';
|
||||
* const presets = getAllPresets(); // all 5 built-ins
|
||||
* const preset = getPreset('minimal'); // by id
|
||||
*/
|
||||
|
||||
import { ModernPreset, MinimalPreset, PlayfulPreset, EnterprisePreset, SoftPreset } from './presets';
|
||||
import { StylePreset } from './StylePresetTypes';
|
||||
|
||||
/** All built-in presets in display order (Modern first = default). */
|
||||
const BUILT_IN_PRESETS: StylePreset[] = [ModernPreset, MinimalPreset, PlayfulPreset, EnterprisePreset, SoftPreset];
|
||||
|
||||
/**
|
||||
* Returns all built-in style presets in display order.
|
||||
*/
|
||||
export function getAllPresets(): StylePreset[] {
|
||||
return BUILT_IN_PRESETS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a built-in preset by its id, or undefined if not found.
|
||||
*/
|
||||
export function getPreset(id: string): StylePreset | undefined {
|
||||
return BUILT_IN_PRESETS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default preset (Modern).
|
||||
*/
|
||||
export function getDefaultPreset(): StylePreset {
|
||||
return ModernPreset;
|
||||
}
|
||||
|
||||
// ─── Pending Preset ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Module-level store for a "pending preset" to be applied the next time
|
||||
// StyleTokensModel initialises (i.e. when the editor first opens after a
|
||||
// new project is created from the launcher).
|
||||
//
|
||||
// Flow:
|
||||
// 1. User picks a preset in CreateProjectModal → launcher calls setPendingPresetId
|
||||
// 2. Project is created and editor route is triggered
|
||||
// 3. StyleTokensModel._buildEffectiveTokens() calls consumePendingPreset()
|
||||
// 4. Preset tokens are applied and persisted to project metadata
|
||||
// 5. Pending preset is cleared — subsequent reloads behave normally
|
||||
|
||||
let _pendingPresetId: string | null = null;
|
||||
|
||||
/**
|
||||
* Mark a preset as "pending" — it will be applied when StyleTokensModel
|
||||
* next builds its effective token map (i.e. on editor startup).
|
||||
* Pass `null` or `'modern'` to cancel / use defaults.
|
||||
*/
|
||||
export function setPendingPresetId(id: string | null): void {
|
||||
// Modern = default values, nothing to apply
|
||||
_pendingPresetId = id === 'modern' || id === null ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume (read + clear) the pending preset.
|
||||
* Returns the pending StylePreset, or null if none is set.
|
||||
* After calling this, _pendingPresetId is cleared.
|
||||
*/
|
||||
export function consumePendingPreset(): StylePreset | null {
|
||||
if (!_pendingPresetId) return null;
|
||||
const preset = getPreset(_pendingPresetId);
|
||||
_pendingPresetId = null;
|
||||
return preset ?? null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { StylePreset, PresetPreview } from './StylePresetTypes';
|
||||
export {
|
||||
getAllPresets,
|
||||
getPreset,
|
||||
getDefaultPreset,
|
||||
setPendingPresetId,
|
||||
consumePendingPreset
|
||||
} from './StylePresetsModel';
|
||||
export { ModernPreset, MinimalPreset, PlayfulPreset, EnterprisePreset, SoftPreset } from './presets';
|
||||
@@ -0,0 +1,69 @@
|
||||
import { StylePreset } from '../StylePresetTypes';
|
||||
|
||||
/**
|
||||
* Enterprise preset — dark navy palette, conservative rounding, subtle shadows.
|
||||
* Professional and trustworthy for business applications.
|
||||
*/
|
||||
export const EnterprisePreset: StylePreset = {
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'Professional and trustworthy for business applications',
|
||||
isBuiltIn: true,
|
||||
|
||||
tokens: {
|
||||
// Primary — dark navy
|
||||
'--primary': '#0f172a',
|
||||
'--primary-hover': '#1e293b',
|
||||
'--primary-foreground': '#ffffff',
|
||||
// Secondary — slate
|
||||
'--secondary': '#475569',
|
||||
'--secondary-hover': '#334155',
|
||||
'--secondary-foreground': '#ffffff',
|
||||
// Destructive — dark red
|
||||
'--destructive': '#b91c1c',
|
||||
'--destructive-hover': '#991b1b',
|
||||
'--destructive-foreground': '#ffffff',
|
||||
// Muted
|
||||
'--muted': '#f1f5f9',
|
||||
'--muted-foreground': '#475569',
|
||||
// Accent
|
||||
'--accent': '#e2e8f0',
|
||||
'--accent-foreground': '#0f172a',
|
||||
// Surfaces
|
||||
'--background': '#ffffff',
|
||||
'--foreground': '#0f172a',
|
||||
'--surface': '#f8fafc',
|
||||
'--surface-raised': '#ffffff',
|
||||
// Borders — slightly stronger than Modern
|
||||
'--border': '#cbd5e1',
|
||||
'--border-subtle': '#e2e8f0',
|
||||
'--border-strong': '#94a3b8',
|
||||
// Focus ring
|
||||
'--ring': '#0f172a',
|
||||
'--ring-offset': '#ffffff',
|
||||
// Font family — professional, slightly traditional
|
||||
'--font-sans': '"Source Sans Pro", "Segoe UI", ui-sans-serif, sans-serif',
|
||||
// Border radius — conservative
|
||||
'--radius-sm': '2px',
|
||||
'--radius-md': '4px',
|
||||
'--radius-lg': '6px',
|
||||
'--radius-xl': '8px',
|
||||
'--radius-2xl': '10px',
|
||||
'--radius-3xl': '12px',
|
||||
// Shadows — very subtle
|
||||
'--shadow-sm': '0 1px 2px rgb(0 0 0 / 0.05)',
|
||||
'--shadow-md': '0 2px 4px rgb(0 0 0 / 0.06), 0 1px 2px rgb(0 0 0 / 0.04)',
|
||||
'--shadow-lg': '0 4px 8px rgb(0 0 0 / 0.06), 0 2px 4px rgb(0 0 0 / 0.04)',
|
||||
'--shadow-xl': '0 8px 16px rgb(0 0 0 / 0.08), 0 4px 6px rgb(0 0 0 / 0.05)'
|
||||
},
|
||||
|
||||
preview: {
|
||||
primaryColor: '#0f172a',
|
||||
backgroundColor: '#ffffff',
|
||||
surfaceColor: '#f8fafc',
|
||||
borderColor: '#cbd5e1',
|
||||
textColor: '#0f172a',
|
||||
mutedTextColor: '#475569',
|
||||
radiusMd: '4px'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { StylePreset } from '../StylePresetTypes';
|
||||
|
||||
/**
|
||||
* Minimal preset — ultra-clean, monochromatic, sharp edges, no shadows.
|
||||
*/
|
||||
export const MinimalPreset: StylePreset = {
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
description: 'Ultra-clean with sharp edges and no shadows',
|
||||
isBuiltIn: true,
|
||||
|
||||
tokens: {
|
||||
// Primary — near black
|
||||
'--primary': '#18181b',
|
||||
'--primary-hover': '#27272a',
|
||||
'--primary-foreground': '#ffffff',
|
||||
// Secondary
|
||||
'--secondary': '#71717a',
|
||||
'--secondary-hover': '#52525b',
|
||||
'--secondary-foreground': '#ffffff',
|
||||
// Destructive
|
||||
'--destructive': '#dc2626',
|
||||
'--destructive-hover': '#b91c1c',
|
||||
// Muted
|
||||
'--muted': '#f4f4f5',
|
||||
'--muted-foreground': '#71717a',
|
||||
// Accent
|
||||
'--accent': '#f4f4f5',
|
||||
'--accent-foreground': '#18181b',
|
||||
// Surfaces
|
||||
'--background': '#ffffff',
|
||||
'--foreground': '#18181b',
|
||||
'--surface': '#fafafa',
|
||||
'--surface-raised': '#ffffff',
|
||||
// Borders
|
||||
'--border': '#e4e4e7',
|
||||
'--border-subtle': '#f4f4f5',
|
||||
'--border-strong': '#d4d4d8',
|
||||
// Focus ring
|
||||
'--ring': '#18181b',
|
||||
'--ring-offset': '#ffffff',
|
||||
// Font family — system stack, no custom font
|
||||
'--font-sans': 'system-ui, -apple-system, sans-serif',
|
||||
// Border radius — sharp
|
||||
'--radius-sm': '2px',
|
||||
'--radius-md': '4px',
|
||||
'--radius-lg': '6px',
|
||||
'--radius-xl': '8px',
|
||||
'--radius-2xl': '10px',
|
||||
'--radius-3xl': '12px',
|
||||
// Shadows — none
|
||||
'--shadow-sm': 'none',
|
||||
'--shadow-md': 'none',
|
||||
'--shadow-lg': 'none',
|
||||
'--shadow-xl': 'none',
|
||||
'--shadow-2xl': 'none'
|
||||
},
|
||||
|
||||
preview: {
|
||||
primaryColor: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
surfaceColor: '#fafafa',
|
||||
borderColor: '#e4e4e7',
|
||||
textColor: '#18181b',
|
||||
mutedTextColor: '#71717a',
|
||||
radiusMd: '4px'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StylePreset } from '../StylePresetTypes';
|
||||
|
||||
/**
|
||||
* Modern preset — clean, professional with subtle depth.
|
||||
* This is the default: its token values match DefaultTokens.ts exactly,
|
||||
* so `tokens` is intentionally empty (no overrides needed).
|
||||
*/
|
||||
export const ModernPreset: StylePreset = {
|
||||
id: 'modern',
|
||||
name: 'Modern',
|
||||
description: 'Clean and professional with subtle depth',
|
||||
isBuiltIn: true,
|
||||
|
||||
// Modern IS the defaults — no token overrides needed.
|
||||
tokens: {},
|
||||
|
||||
preview: {
|
||||
primaryColor: '#3b82f6',
|
||||
backgroundColor: '#ffffff',
|
||||
surfaceColor: '#f8fafc',
|
||||
borderColor: '#e2e8f0',
|
||||
textColor: '#0f172a',
|
||||
mutedTextColor: '#64748b',
|
||||
radiusMd: '8px'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { StylePreset } from '../StylePresetTypes';
|
||||
|
||||
/**
|
||||
* Playful preset — vibrant purple/pink palette, very rounded shapes, soft colored shadows.
|
||||
*/
|
||||
export const PlayfulPreset: StylePreset = {
|
||||
id: 'playful',
|
||||
name: 'Playful',
|
||||
description: 'Vibrant colors and rounded, friendly shapes',
|
||||
isBuiltIn: true,
|
||||
|
||||
tokens: {
|
||||
// Primary — purple
|
||||
'--primary': '#8b5cf6',
|
||||
'--primary-hover': '#7c3aed',
|
||||
'--primary-foreground': '#ffffff',
|
||||
// Secondary — pink
|
||||
'--secondary': '#ec4899',
|
||||
'--secondary-hover': '#db2777',
|
||||
'--secondary-foreground': '#ffffff',
|
||||
// Destructive — rose
|
||||
'--destructive': '#f43f5e',
|
||||
'--destructive-hover': '#e11d48',
|
||||
'--destructive-foreground': '#ffffff',
|
||||
// Muted
|
||||
'--muted': '#faf5ff',
|
||||
'--muted-foreground': '#6b7280',
|
||||
// Accent — soft pink
|
||||
'--accent': '#fce7f3',
|
||||
'--accent-foreground': '#831843',
|
||||
// Surfaces
|
||||
'--background': '#ffffff',
|
||||
'--foreground': '#1e1b4b',
|
||||
'--surface': '#faf5ff',
|
||||
'--surface-raised': '#ffffff',
|
||||
// Borders
|
||||
'--border': '#e9d5ff',
|
||||
'--border-subtle': '#faf5ff',
|
||||
'--border-strong': '#d8b4fe',
|
||||
// Focus ring — purple
|
||||
'--ring': '#8b5cf6',
|
||||
'--ring-offset': '#ffffff',
|
||||
// Font family — friendly rounded font with fallback
|
||||
'--font-sans': '"Nunito", "Quicksand", ui-sans-serif, sans-serif',
|
||||
// Border radius — very rounded
|
||||
'--radius-sm': '8px',
|
||||
'--radius-md': '16px',
|
||||
'--radius-lg': '24px',
|
||||
'--radius-xl': '32px',
|
||||
'--radius-2xl': '40px',
|
||||
'--radius-3xl': '48px',
|
||||
// Shadows — soft purple tinted
|
||||
'--shadow-sm': '0 1px 3px rgb(139 92 246 / 0.1)',
|
||||
'--shadow-md': '0 4px 12px rgb(139 92 246 / 0.15)',
|
||||
'--shadow-lg': '0 10px 20px rgb(139 92 246 / 0.15)',
|
||||
'--shadow-xl': '0 20px 30px rgb(139 92 246 / 0.12)'
|
||||
},
|
||||
|
||||
preview: {
|
||||
primaryColor: '#8b5cf6',
|
||||
backgroundColor: '#ffffff',
|
||||
surfaceColor: '#faf5ff',
|
||||
borderColor: '#e9d5ff',
|
||||
textColor: '#1e1b4b',
|
||||
mutedTextColor: '#6b7280',
|
||||
radiusMd: '16px'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { StylePreset } from '../StylePresetTypes';
|
||||
|
||||
/**
|
||||
* Soft preset — gentle indigo tones, soft rounding, very light shadows.
|
||||
* Calming aesthetic with generous whitespace feel.
|
||||
*/
|
||||
export const SoftPreset: StylePreset = {
|
||||
id: 'soft',
|
||||
name: 'Soft',
|
||||
description: 'Gentle colors and soft shapes for a calming aesthetic',
|
||||
isBuiltIn: true,
|
||||
|
||||
tokens: {
|
||||
// Primary — indigo
|
||||
'--primary': '#6366f1',
|
||||
'--primary-hover': '#4f46e5',
|
||||
'--primary-foreground': '#ffffff',
|
||||
// Secondary — light purple
|
||||
'--secondary': '#a78bfa',
|
||||
'--secondary-hover': '#8b5cf6',
|
||||
'--secondary-foreground': '#ffffff',
|
||||
// Destructive — soft rose
|
||||
'--destructive': '#fb7185',
|
||||
'--destructive-hover': '#f43f5e',
|
||||
'--destructive-foreground': '#ffffff',
|
||||
// Muted
|
||||
'--muted': '#f5f3ff',
|
||||
'--muted-foreground': '#6b7280',
|
||||
// Accent
|
||||
'--accent': '#ede9fe',
|
||||
'--accent-foreground': '#4c1d95',
|
||||
// Surfaces — off-white, warm
|
||||
'--background': '#fefefe',
|
||||
'--foreground': '#374151',
|
||||
'--surface': '#f9fafb',
|
||||
'--surface-raised': '#ffffff',
|
||||
// Borders — soft
|
||||
'--border': '#e5e7eb',
|
||||
'--border-subtle': '#f3f4f6',
|
||||
'--border-strong': '#d1d5db',
|
||||
// Focus ring — indigo
|
||||
'--ring': '#6366f1',
|
||||
'--ring-offset': '#fefefe',
|
||||
// Font family
|
||||
'--font-sans': '"DM Sans", ui-sans-serif, system-ui, sans-serif',
|
||||
// Border radius — soft rounded
|
||||
'--radius-sm': '6px',
|
||||
'--radius-md': '12px',
|
||||
'--radius-lg': '20px',
|
||||
'--radius-xl': '28px',
|
||||
'--radius-2xl': '36px',
|
||||
'--radius-3xl': '44px',
|
||||
// Shadows — very soft, barely there
|
||||
'--shadow-sm': '0 1px 3px rgb(0 0 0 / 0.02)',
|
||||
'--shadow-md': '0 4px 6px rgb(0 0 0 / 0.04)',
|
||||
'--shadow-lg': '0 8px 12px rgb(0 0 0 / 0.05)',
|
||||
'--shadow-xl': '0 16px 24px rgb(0 0 0 / 0.06)'
|
||||
},
|
||||
|
||||
preview: {
|
||||
primaryColor: '#6366f1',
|
||||
backgroundColor: '#fefefe',
|
||||
surfaceColor: '#f9fafb',
|
||||
borderColor: '#e5e7eb',
|
||||
textColor: '#374151',
|
||||
mutedTextColor: '#6b7280',
|
||||
radiusMd: '12px'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ModernPreset } from './ModernPreset';
|
||||
export { MinimalPreset } from './MinimalPreset';
|
||||
export { PlayfulPreset } from './PlayfulPreset';
|
||||
export { EnterprisePreset } from './EnterprisePreset';
|
||||
export { SoftPreset } from './SoftPreset';
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* STYLE-001: Default design tokens for new Noodl projects.
|
||||
*
|
||||
* Tailwind CSS-inspired scales with semantic aliases.
|
||||
* All values are CSS custom properties that get injected as :root { ... }
|
||||
*/
|
||||
|
||||
import { StyleTokenRecord } from './TokenCategories';
|
||||
|
||||
export const DEFAULT_TOKENS: StyleTokenRecord[] = [
|
||||
// ─── Semantic Colors ─────────────────────────────────────────────────────────
|
||||
|
||||
// Primary - Main brand/action color
|
||||
{
|
||||
name: '--primary',
|
||||
value: '#3b82f6',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Main brand and action color'
|
||||
},
|
||||
{
|
||||
name: '--primary-hover',
|
||||
value: '#2563eb',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Primary color on hover'
|
||||
},
|
||||
{
|
||||
name: '--primary-foreground',
|
||||
value: '#ffffff',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Text color on primary background'
|
||||
},
|
||||
|
||||
// Secondary
|
||||
{
|
||||
name: '--secondary',
|
||||
value: '#64748b',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Supporting action color'
|
||||
},
|
||||
{
|
||||
name: '--secondary-hover',
|
||||
value: '#475569',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Secondary color on hover'
|
||||
},
|
||||
{
|
||||
name: '--secondary-foreground',
|
||||
value: '#ffffff',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Text color on secondary background'
|
||||
},
|
||||
|
||||
// Destructive
|
||||
{
|
||||
name: '--destructive',
|
||||
value: '#ef4444',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Dangerous actions and errors'
|
||||
},
|
||||
{
|
||||
name: '--destructive-hover',
|
||||
value: '#dc2626',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Destructive color on hover'
|
||||
},
|
||||
{
|
||||
name: '--destructive-foreground',
|
||||
value: '#ffffff',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Text color on destructive background'
|
||||
},
|
||||
|
||||
// Muted
|
||||
{
|
||||
name: '--muted',
|
||||
value: '#f1f5f9',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Subtle backgrounds, disabled states'
|
||||
},
|
||||
{
|
||||
name: '--muted-foreground',
|
||||
value: '#64748b',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Text color on muted background'
|
||||
},
|
||||
|
||||
// Accent
|
||||
{
|
||||
name: '--accent',
|
||||
value: '#f1f5f9',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Highlights and selections'
|
||||
},
|
||||
{
|
||||
name: '--accent-foreground',
|
||||
value: '#0f172a',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Text color on accent background'
|
||||
},
|
||||
|
||||
// Surface colors
|
||||
{
|
||||
name: '--background',
|
||||
value: '#ffffff',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Page/app background'
|
||||
},
|
||||
{
|
||||
name: '--foreground',
|
||||
value: '#0f172a',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Default text color'
|
||||
},
|
||||
{
|
||||
name: '--surface',
|
||||
value: '#f8fafc',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Card and panel surfaces'
|
||||
},
|
||||
{
|
||||
name: '--surface-raised',
|
||||
value: '#ffffff',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Elevated surface (dialogs, dropdowns)'
|
||||
},
|
||||
|
||||
// Border colors
|
||||
{
|
||||
name: '--border',
|
||||
value: '#e2e8f0',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Default border color'
|
||||
},
|
||||
{
|
||||
name: '--border-subtle',
|
||||
value: '#f1f5f9',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Subtle/light border'
|
||||
},
|
||||
{
|
||||
name: '--border-strong',
|
||||
value: '#cbd5e1',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Strong/dark border'
|
||||
},
|
||||
|
||||
// Focus ring
|
||||
{ name: '--ring', value: '#3b82f6', category: 'color-semantic', isCustom: false, description: 'Focus ring color' },
|
||||
{
|
||||
name: '--ring-offset',
|
||||
value: '#ffffff',
|
||||
category: 'color-semantic',
|
||||
isCustom: false,
|
||||
description: 'Focus ring offset color'
|
||||
},
|
||||
|
||||
// ─── Palette Colors (Gray) ────────────────────────────────────────────────────
|
||||
|
||||
{ name: '--gray-50', value: '#f8fafc', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-100', value: '#f1f5f9', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-200', value: '#e2e8f0', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-300', value: '#cbd5e1', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-400', value: '#94a3b8', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-500', value: '#64748b', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-600', value: '#475569', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-700', value: '#334155', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-800', value: '#1e293b', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-900', value: '#0f172a', category: 'color-palette', isCustom: false },
|
||||
{ name: '--gray-950', value: '#020617', category: 'color-palette', isCustom: false },
|
||||
|
||||
// Blue
|
||||
{ name: '--blue-50', value: '#eff6ff', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-100', value: '#dbeafe', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-200', value: '#bfdbfe', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-300', value: '#93c5fd', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-400', value: '#60a5fa', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-500', value: '#3b82f6', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-600', value: '#2563eb', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-700', value: '#1d4ed8', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-800', value: '#1e40af', category: 'color-palette', isCustom: false },
|
||||
{ name: '--blue-900', value: '#1e3a8a', category: 'color-palette', isCustom: false },
|
||||
|
||||
// Red
|
||||
{ name: '--red-50', value: '#fef2f2', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-100', value: '#fee2e2', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-200', value: '#fecaca', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-300', value: '#fca5a5', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-400', value: '#f87171', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-500', value: '#ef4444', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-600', value: '#dc2626', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-700', value: '#b91c1c', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-800', value: '#991b1b', category: 'color-palette', isCustom: false },
|
||||
{ name: '--red-900', value: '#7f1d1d', category: 'color-palette', isCustom: false },
|
||||
|
||||
// Green
|
||||
{ name: '--green-50', value: '#f0fdf4', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-100', value: '#dcfce7', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-200', value: '#bbf7d0', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-300', value: '#86efac', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-400', value: '#4ade80', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-500', value: '#22c55e', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-600', value: '#16a34a', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-700', value: '#15803d', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-800', value: '#166534', category: 'color-palette', isCustom: false },
|
||||
{ name: '--green-900', value: '#14532d', category: 'color-palette', isCustom: false },
|
||||
|
||||
// Yellow/Amber
|
||||
{ name: '--amber-50', value: '#fffbeb', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-100', value: '#fef3c7', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-200', value: '#fde68a', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-300', value: '#fcd34d', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-400', value: '#fbbf24', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-500', value: '#f59e0b', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-600', value: '#d97706', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-700', value: '#b45309', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-800', value: '#92400e', category: 'color-palette', isCustom: false },
|
||||
{ name: '--amber-900', value: '#78350f', category: 'color-palette', isCustom: false },
|
||||
|
||||
// Purple
|
||||
{ name: '--purple-50', value: '#faf5ff', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-100', value: '#f3e8ff', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-200', value: '#e9d5ff', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-300', value: '#d8b4fe', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-400', value: '#c084fc', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-500', value: '#a855f7', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-600', value: '#9333ea', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-700', value: '#7e22ce', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-800', value: '#6b21a8', category: 'color-palette', isCustom: false },
|
||||
{ name: '--purple-900', value: '#581c87', category: 'color-palette', isCustom: false },
|
||||
|
||||
// ─── Spacing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
{ name: '--space-0', value: '0px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-px', value: '1px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-0-5', value: '2px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-1', value: '4px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-1-5', value: '6px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-2', value: '8px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-2-5', value: '10px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-3', value: '12px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-3-5', value: '14px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-4', value: '16px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-5', value: '20px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-6', value: '24px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-7', value: '28px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-8', value: '32px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-9', value: '36px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-10', value: '40px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-11', value: '44px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-12', value: '48px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-14', value: '56px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-16', value: '64px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-20', value: '80px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-24', value: '96px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-28', value: '112px', category: 'spacing', isCustom: false },
|
||||
{ name: '--space-32', value: '128px', category: 'spacing', isCustom: false },
|
||||
|
||||
// Semantic spacing aliases
|
||||
{
|
||||
name: '--space-xs',
|
||||
value: 'var(--space-1)',
|
||||
category: 'spacing',
|
||||
isCustom: false,
|
||||
description: 'Extra small (4px)'
|
||||
},
|
||||
{ name: '--space-sm', value: 'var(--space-2)', category: 'spacing', isCustom: false, description: 'Small (8px)' },
|
||||
{ name: '--space-md', value: 'var(--space-4)', category: 'spacing', isCustom: false, description: 'Medium (16px)' },
|
||||
{ name: '--space-lg', value: 'var(--space-6)', category: 'spacing', isCustom: false, description: 'Large (24px)' },
|
||||
{
|
||||
name: '--space-xl',
|
||||
value: 'var(--space-8)',
|
||||
category: 'spacing',
|
||||
isCustom: false,
|
||||
description: 'Extra large (32px)'
|
||||
},
|
||||
{
|
||||
name: '--space-2xl',
|
||||
value: 'var(--space-12)',
|
||||
category: 'spacing',
|
||||
isCustom: false,
|
||||
description: '2x large (48px)'
|
||||
},
|
||||
{
|
||||
name: '--space-3xl',
|
||||
value: 'var(--space-16)',
|
||||
category: 'spacing',
|
||||
isCustom: false,
|
||||
description: '3x large (64px)'
|
||||
},
|
||||
|
||||
// ─── Typography: Font Sizes ───────────────────────────────────────────────────
|
||||
|
||||
{ name: '--text-xs', value: '12px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-sm', value: '14px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-base', value: '16px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-lg', value: '18px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-xl', value: '20px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-2xl', value: '24px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-3xl', value: '30px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-4xl', value: '36px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-5xl', value: '48px', category: 'typography-size', isCustom: false },
|
||||
{ name: '--text-6xl', value: '60px', category: 'typography-size', isCustom: false },
|
||||
|
||||
// ─── Typography: Font Weights ─────────────────────────────────────────────────
|
||||
|
||||
{ name: '--font-thin', value: '100', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-extralight', value: '200', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-light', value: '300', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-normal', value: '400', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-medium', value: '500', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-semibold', value: '600', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-bold', value: '700', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-extrabold', value: '800', category: 'typography-weight', isCustom: false },
|
||||
{ name: '--font-black', value: '900', category: 'typography-weight', isCustom: false },
|
||||
|
||||
// ─── Typography: Line Heights ─────────────────────────────────────────────────
|
||||
|
||||
{ name: '--leading-none', value: '1', category: 'typography-leading', isCustom: false },
|
||||
{ name: '--leading-tight', value: '1.25', category: 'typography-leading', isCustom: false },
|
||||
{ name: '--leading-snug', value: '1.375', category: 'typography-leading', isCustom: false },
|
||||
{ name: '--leading-normal', value: '1.5', category: 'typography-leading', isCustom: false },
|
||||
{ name: '--leading-relaxed', value: '1.625', category: 'typography-leading', isCustom: false },
|
||||
{ name: '--leading-loose', value: '2', category: 'typography-leading', isCustom: false },
|
||||
|
||||
// ─── Typography: Letter Spacing ───────────────────────────────────────────────
|
||||
|
||||
{ name: '--tracking-tighter', value: '-0.05em', category: 'typography-tracking', isCustom: false },
|
||||
{ name: '--tracking-tight', value: '-0.025em', category: 'typography-tracking', isCustom: false },
|
||||
{ name: '--tracking-normal', value: '0em', category: 'typography-tracking', isCustom: false },
|
||||
{ name: '--tracking-wide', value: '0.025em', category: 'typography-tracking', isCustom: false },
|
||||
{ name: '--tracking-wider', value: '0.05em', category: 'typography-tracking', isCustom: false },
|
||||
{ name: '--tracking-widest', value: '0.1em', category: 'typography-tracking', isCustom: false },
|
||||
|
||||
// ─── Typography: Font Families ────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
name: '--font-sans',
|
||||
value: "ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'",
|
||||
category: 'typography-family',
|
||||
isCustom: false,
|
||||
description: 'System sans-serif stack'
|
||||
},
|
||||
{
|
||||
name: '--font-serif',
|
||||
value: "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif",
|
||||
category: 'typography-family',
|
||||
isCustom: false,
|
||||
description: 'System serif stack'
|
||||
},
|
||||
{
|
||||
name: '--font-mono',
|
||||
value: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
|
||||
category: 'typography-family',
|
||||
isCustom: false,
|
||||
description: 'System monospace stack'
|
||||
},
|
||||
|
||||
// ─── Border Radius ────────────────────────────────────────────────────────────
|
||||
|
||||
{ name: '--radius-none', value: '0px', category: 'border-radius', isCustom: false },
|
||||
{ name: '--radius-sm', value: '4px', category: 'border-radius', isCustom: false },
|
||||
{ name: '--radius-md', value: '8px', category: 'border-radius', isCustom: false },
|
||||
{ name: '--radius-lg', value: '12px', category: 'border-radius', isCustom: false },
|
||||
{ name: '--radius-xl', value: '16px', category: 'border-radius', isCustom: false },
|
||||
{ name: '--radius-2xl', value: '24px', category: 'border-radius', isCustom: false },
|
||||
{ name: '--radius-3xl', value: '32px', category: 'border-radius', isCustom: false },
|
||||
{ name: '--radius-full', value: '9999px', category: 'border-radius', isCustom: false, description: 'Pill/circle' },
|
||||
|
||||
// ─── Border Width ─────────────────────────────────────────────────────────────
|
||||
|
||||
{ name: '--border-0', value: '0px', category: 'border-width', isCustom: false },
|
||||
{ name: '--border-1', value: '1px', category: 'border-width', isCustom: false },
|
||||
{ name: '--border-2', value: '2px', category: 'border-width', isCustom: false },
|
||||
{ name: '--border-4', value: '4px', category: 'border-width', isCustom: false },
|
||||
{ name: '--border-8', value: '8px', category: 'border-width', isCustom: false },
|
||||
|
||||
// ─── Shadows ──────────────────────────────────────────────────────────────────
|
||||
|
||||
{ name: '--shadow-none', value: 'none', category: 'shadow', isCustom: false },
|
||||
{ name: '--shadow-sm', value: '0 1px 2px 0 rgb(0 0 0 / 0.05)', category: 'shadow', isCustom: false },
|
||||
{
|
||||
name: '--shadow-md',
|
||||
value: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
category: 'shadow',
|
||||
isCustom: false
|
||||
},
|
||||
{
|
||||
name: '--shadow-lg',
|
||||
value: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
category: 'shadow',
|
||||
isCustom: false
|
||||
},
|
||||
{
|
||||
name: '--shadow-xl',
|
||||
value: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
category: 'shadow',
|
||||
isCustom: false
|
||||
},
|
||||
{ name: '--shadow-2xl', value: '0 25px 50px -12px rgb(0 0 0 / 0.25)', category: 'shadow', isCustom: false },
|
||||
{ name: '--shadow-inner', value: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)', category: 'shadow', isCustom: false },
|
||||
|
||||
// ─── Animation: Durations ─────────────────────────────────────────────────────
|
||||
|
||||
{ name: '--duration-75', value: '75ms', category: 'animation-duration', isCustom: false },
|
||||
{ name: '--duration-100', value: '100ms', category: 'animation-duration', isCustom: false },
|
||||
{ name: '--duration-150', value: '150ms', category: 'animation-duration', isCustom: false },
|
||||
{ name: '--duration-200', value: '200ms', category: 'animation-duration', isCustom: false },
|
||||
{ name: '--duration-300', value: '300ms', category: 'animation-duration', isCustom: false },
|
||||
{ name: '--duration-500', value: '500ms', category: 'animation-duration', isCustom: false },
|
||||
{ name: '--duration-700', value: '700ms', category: 'animation-duration', isCustom: false },
|
||||
{ name: '--duration-1000', value: '1000ms', category: 'animation-duration', isCustom: false },
|
||||
|
||||
// ─── Animation: Easing ───────────────────────────────────────────────────────
|
||||
|
||||
{ name: '--ease-linear', value: 'linear', category: 'animation-easing', isCustom: false },
|
||||
{ name: '--ease-in', value: 'cubic-bezier(0.4, 0, 1, 1)', category: 'animation-easing', isCustom: false },
|
||||
{ name: '--ease-out', value: 'cubic-bezier(0, 0, 0.2, 1)', category: 'animation-easing', isCustom: false },
|
||||
{ name: '--ease-in-out', value: 'cubic-bezier(0.4, 0, 0.2, 1)', category: 'animation-easing', isCustom: false },
|
||||
{
|
||||
name: '--ease-bounce',
|
||||
value: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
category: 'animation-easing',
|
||||
isCustom: false,
|
||||
description: 'Bouncy animation curve'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a Map from the default tokens array for fast lookup by name.
|
||||
*/
|
||||
export function buildDefaultTokenMap(): Map<string, StyleTokenRecord> {
|
||||
const map = new Map<string, StyleTokenRecord>();
|
||||
for (const token of DEFAULT_TOKENS) {
|
||||
map.set(token.name, token);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* STYLE-001: StyleTokensModel
|
||||
*
|
||||
* Main model for the Noodl design token system.
|
||||
* Stores Tailwind-inspired CSS custom properties and persists customisations
|
||||
* in project metadata under the key 'designTokens'.
|
||||
*
|
||||
* Architecture:
|
||||
* - Default tokens are loaded from DefaultTokens.ts (never stored in project.json)
|
||||
* - Only user *overrides* are stored as customTokens in project.json
|
||||
* - The effective token map merges defaults + overrides
|
||||
* - Listeners can subscribe to 'tokensChanged' to react to updates
|
||||
*/
|
||||
|
||||
import Model from '../../../../shared/model';
|
||||
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
||||
import { ProjectModel } from '../projectmodel';
|
||||
import { consumePendingPreset } from '../StylePresets';
|
||||
import { UndoActionGroup, UndoQueue } from '../undo-queue-model';
|
||||
import { buildDefaultTokenMap } from './DefaultTokens';
|
||||
import {
|
||||
StyleTokenRecord,
|
||||
StyleTokensData,
|
||||
TokenCategory,
|
||||
TOKEN_CATEGORIES,
|
||||
TokenCategoryGroup,
|
||||
TOKEN_CATEGORY_GROUPS
|
||||
} from './TokenCategories';
|
||||
import { TokenResolver } from './TokenResolver';
|
||||
|
||||
const METADATA_KEY = 'designTokens';
|
||||
const CURRENT_VERSION = 1;
|
||||
|
||||
export class StyleTokensModel extends Model {
|
||||
/** Full merged token map (defaults + overrides). */
|
||||
private _tokens: Map<string, StyleTokenRecord> = new Map();
|
||||
|
||||
/** Resolver instance for CSS var() reference resolution. */
|
||||
readonly resolver: TokenResolver;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._buildEffectiveTokens();
|
||||
this.resolver = new TokenResolver(this._tokens);
|
||||
this._bindListeners();
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns all effective tokens (defaults merged with project overrides).
|
||||
* Ordered as: defaults first (in definition order), then any extra custom tokens.
|
||||
*/
|
||||
getTokens(): StyleTokenRecord[] {
|
||||
return Array.from(this._tokens.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns tokens filtered to a specific category.
|
||||
*/
|
||||
getTokensByCategory(category: TokenCategory): StyleTokenRecord[] {
|
||||
return this.getTokens().filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns tokens for a group of categories (e.g. all 'Colors' tokens).
|
||||
*/
|
||||
getTokensByGroup(group: TokenCategoryGroup): StyleTokenRecord[] {
|
||||
const categories = (Object.keys(TOKEN_CATEGORIES) as TokenCategory[]).filter(
|
||||
(cat) => TOKEN_CATEGORIES[cat].group === group
|
||||
);
|
||||
return this.getTokens().filter((t) => categories.includes(t.category));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns tokens grouped by their display group for rendering the panel.
|
||||
*/
|
||||
getTokensGrouped(): Record<TokenCategoryGroup, StyleTokenRecord[]> {
|
||||
const result = {} as Record<TokenCategoryGroup, StyleTokenRecord[]>;
|
||||
for (const group of TOKEN_CATEGORY_GROUPS) {
|
||||
result[group] = this.getTokensByGroup(group);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a token name to its actual CSS value (follows var() references).
|
||||
*/
|
||||
resolveToken(name: string): string | undefined {
|
||||
return this.resolver.resolve(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single token by CSS custom property name.
|
||||
*/
|
||||
getToken(name: string): StyleTokenRecord | undefined {
|
||||
return this._tokens.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the tokens that have been customised (differ from defaults).
|
||||
*/
|
||||
getCustomTokens(): StyleTokenRecord[] {
|
||||
return this.getTokens().filter((t) => t.isCustom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a token value is overriding its default.
|
||||
*/
|
||||
isOverridden(name: string): boolean {
|
||||
const token = this._tokens.get(name);
|
||||
if (!token?.isCustom) return false;
|
||||
const defaultMap = buildDefaultTokenMap();
|
||||
const def = defaultMap.get(name);
|
||||
return !def || def.value !== token.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set/update a token value. If setting a default token to a new value,
|
||||
* it becomes a custom override. If resetting to the default value, the
|
||||
* isCustom flag is cleared.
|
||||
*
|
||||
* Fires 'tokensChanged' on success.
|
||||
*/
|
||||
setToken(name: string, value: string, args?: { undo?: boolean; label?: string }): void {
|
||||
const existing = this._tokens.get(name);
|
||||
const defaultMap = buildDefaultTokenMap();
|
||||
const defaultToken = defaultMap.get(name);
|
||||
|
||||
if (existing && existing.value === value) return; // No-op
|
||||
|
||||
const prevToken = existing ? { ...existing } : undefined;
|
||||
const isRevertingToDefault = defaultToken && defaultToken.value === value;
|
||||
const category = existing?.category ?? defaultToken?.category ?? ('color-semantic' as TokenCategory);
|
||||
|
||||
const newToken: StyleTokenRecord = {
|
||||
name,
|
||||
value,
|
||||
category,
|
||||
isCustom: !isRevertingToDefault,
|
||||
description: existing?.description ?? defaultToken?.description
|
||||
};
|
||||
|
||||
this._tokens.set(name, newToken);
|
||||
this._store();
|
||||
this.resolver.invalidate(name);
|
||||
this.notifyListeners('tokensChanged', { name, token: newToken });
|
||||
|
||||
if (args?.undo) {
|
||||
UndoQueue.instance.push(
|
||||
new UndoActionGroup({
|
||||
label: args.label ?? 'set design token',
|
||||
do: () => this.setToken(name, value),
|
||||
undo: () => {
|
||||
if (prevToken) {
|
||||
this.setToken(name, prevToken.value);
|
||||
} else {
|
||||
this.deleteCustomToken(name);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a brand-new custom token (not in defaults).
|
||||
*/
|
||||
addCustomToken(token: Omit<StyleTokenRecord, 'isCustom'>, args?: { undo?: boolean; label?: string }): void {
|
||||
if (this._tokens.has(token.name)) {
|
||||
// Already exists — treat as an update
|
||||
this.setToken(token.name, token.value, args);
|
||||
return;
|
||||
}
|
||||
|
||||
const newToken: StyleTokenRecord = { ...token, isCustom: true };
|
||||
this._tokens.set(token.name, newToken);
|
||||
this._store();
|
||||
this.notifyListeners('tokensChanged', { name: token.name, token: newToken });
|
||||
|
||||
if (args?.undo) {
|
||||
UndoQueue.instance.push(
|
||||
new UndoActionGroup({
|
||||
label: args.label ?? 'add design token',
|
||||
do: () => this.addCustomToken(token),
|
||||
undo: () => this.deleteCustomToken(token.name)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom token. For default tokens, this resets them to their
|
||||
* default value. For fully custom tokens, removes them entirely.
|
||||
*/
|
||||
deleteCustomToken(name: string, args?: { undo?: boolean; label?: string }): void {
|
||||
const existing = this._tokens.get(name);
|
||||
if (!existing) return;
|
||||
|
||||
const defaultMap = buildDefaultTokenMap();
|
||||
const defaultToken = defaultMap.get(name);
|
||||
|
||||
if (defaultToken) {
|
||||
// Reset to default rather than delete
|
||||
this.setToken(name, defaultToken.value, args);
|
||||
} else {
|
||||
// Fully custom — remove it
|
||||
this._tokens.delete(name);
|
||||
this._store();
|
||||
this.resolver.invalidate(name);
|
||||
this.notifyListeners('tokensChanged', { name, token: null });
|
||||
|
||||
if (args?.undo) {
|
||||
UndoQueue.instance.push(
|
||||
new UndoActionGroup({
|
||||
label: args.label ?? 'delete design token',
|
||||
do: () => this.deleteCustomToken(name),
|
||||
undo: () => this.addCustomToken(existing)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset ALL customised tokens back to their defaults, and remove any
|
||||
* fully-custom tokens. Clears the customTokens stored in project metadata.
|
||||
*/
|
||||
resetAllToDefaults(args?: { undo?: boolean }): void {
|
||||
const prevCustom = this.getCustomTokens().map((t) => ({ ...t }));
|
||||
this._buildEffectiveTokens();
|
||||
this._clearStore();
|
||||
this.resolver.invalidate();
|
||||
this.notifyListeners('tokensChanged', { name: null, token: null });
|
||||
|
||||
if (args?.undo) {
|
||||
UndoQueue.instance.push(
|
||||
new UndoActionGroup({
|
||||
label: 'reset all design tokens',
|
||||
do: () => this.resetAllToDefaults(),
|
||||
undo: () => {
|
||||
for (const token of prevCustom) {
|
||||
this._tokens.set(token.name, token);
|
||||
}
|
||||
this._store();
|
||||
this.notifyListeners('tokensChanged', { name: null, token: null });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a preset's token overrides to this project.
|
||||
*
|
||||
* Bulk-sets all token values defined in the preset. For the Modern preset
|
||||
* (whose `tokens` map is empty) this is a no-op. For all others, only the
|
||||
* tokens listed in the preset are overridden — palette scale tokens and any
|
||||
* tokens not mentioned by the preset retain their defaults.
|
||||
*
|
||||
* Call this right after creating a new project (while ProjectModel.instance
|
||||
* is set) so the overrides are persisted in project metadata.
|
||||
*/
|
||||
applyPreset(tokens: Record<string, string>): void {
|
||||
const entries = Object.entries(tokens);
|
||||
if (entries.length === 0) return; // Modern = defaults, nothing to write
|
||||
|
||||
for (const [name, value] of entries) {
|
||||
this.setToken(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSS :root { ... } block with all effective tokens.
|
||||
* Used for injection into the preview iframe and deployed projects.
|
||||
*/
|
||||
generateCss(): string {
|
||||
return this.resolver.generateCss(this._tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all custom tokens as JSON (for import/export feature).
|
||||
*/
|
||||
exportCustomTokensJson(): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
version: CURRENT_VERSION,
|
||||
customTokens: this.getCustomTokens()
|
||||
} satisfies StyleTokensData,
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import tokens from a JSON string (previously exported).
|
||||
* Only applies custom overrides — safe to call on any project.
|
||||
*/
|
||||
importCustomTokensJson(json: string): void {
|
||||
try {
|
||||
const data: StyleTokensData = JSON.parse(json);
|
||||
if (!data.customTokens || !Array.isArray(data.customTokens)) return;
|
||||
|
||||
for (const token of data.customTokens) {
|
||||
this.setToken(token.name, token.value);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[StyleTokensModel] Failed to import tokens JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
dispose(): void {
|
||||
this._unbindListeners();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the effective token map by merging defaults with project overrides.
|
||||
*/
|
||||
private _buildEffectiveTokens(): void {
|
||||
// Start with all defaults
|
||||
this._tokens = buildDefaultTokenMap() as Map<string, StyleTokenRecord>;
|
||||
|
||||
// Load stored custom overrides from project metadata
|
||||
const stored = this._loadStored();
|
||||
if (!stored) return;
|
||||
|
||||
for (const customToken of stored.customTokens) {
|
||||
this._tokens.set(customToken.name, customToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist only the custom overrides to project metadata.
|
||||
* We never persist defaults — they're always loaded from DefaultTokens.ts.
|
||||
*/
|
||||
private _store(): void {
|
||||
if (!ProjectModel.instance) return;
|
||||
|
||||
const customTokens: StyleTokenRecord[] = [];
|
||||
for (const [, token] of this._tokens) {
|
||||
if (token.isCustom) {
|
||||
customTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
const data: StyleTokensData = {
|
||||
version: CURRENT_VERSION,
|
||||
customTokens
|
||||
};
|
||||
|
||||
ProjectModel.instance.setMetaData(METADATA_KEY, data);
|
||||
}
|
||||
|
||||
private _clearStore(): void {
|
||||
if (!ProjectModel.instance) return;
|
||||
ProjectModel.instance.setMetaData(METADATA_KEY, null);
|
||||
}
|
||||
|
||||
private _loadStored(): StyleTokensData | null {
|
||||
if (!ProjectModel.instance) return null;
|
||||
const data = ProjectModel.instance.getMetaData(METADATA_KEY);
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return data as StyleTokensData;
|
||||
}
|
||||
|
||||
private _bindListeners(): void {
|
||||
const reload = () => {
|
||||
if (ProjectModel.instance) {
|
||||
this._buildEffectiveTokens();
|
||||
// Apply pending preset (set during new-project creation in the launcher).
|
||||
// This is a one-shot: consumePendingPreset() clears the pending value.
|
||||
this._applyAndClearPendingPreset();
|
||||
this.resolver.updateTokens(this._tokens);
|
||||
this.notifyListeners('tokensChanged', { name: null, token: null });
|
||||
}
|
||||
};
|
||||
|
||||
EventDispatcher.instance.on(['ProjectModel.importComplete', 'ProjectModel.instanceHasChanged'], reload, this);
|
||||
|
||||
EventDispatcher.instance.on(
|
||||
'ProjectModel.metadataChanged',
|
||||
({ key }: { key: string }) => {
|
||||
if (key === METADATA_KEY) {
|
||||
this._buildEffectiveTokens();
|
||||
this.resolver.updateTokens(this._tokens);
|
||||
this.notifyListeners('tokensChanged', { name: null, token: null });
|
||||
}
|
||||
},
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for a pending preset (written by the launcher when a new project
|
||||
* is created) and apply it to the current project. One-shot — the pending
|
||||
* value is consumed/cleared after this call.
|
||||
*/
|
||||
private _applyAndClearPendingPreset(): void {
|
||||
const preset = consumePendingPreset();
|
||||
if (!preset || Object.keys(preset.tokens).length === 0) return;
|
||||
this.applyPreset(preset.tokens);
|
||||
}
|
||||
|
||||
private _unbindListeners(): void {
|
||||
EventDispatcher.instance.off(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* STYLE-001: Token System Enhancement
|
||||
*
|
||||
* TypeScript types for the Noodl design token system.
|
||||
* Inspired by Tailwind CSS - Tailwind-scale defaults with semantic aliases.
|
||||
*/
|
||||
|
||||
// ─── Token Category Types ─────────────────────────────────────────────────────
|
||||
|
||||
export type TokenCategory =
|
||||
| 'color-semantic'
|
||||
| 'color-palette'
|
||||
| 'spacing'
|
||||
| 'typography-size'
|
||||
| 'typography-weight'
|
||||
| 'typography-leading'
|
||||
| 'typography-tracking'
|
||||
| 'typography-family'
|
||||
| 'border-radius'
|
||||
| 'border-width'
|
||||
| 'shadow'
|
||||
| 'animation-duration'
|
||||
| 'animation-easing';
|
||||
|
||||
export const TOKEN_CATEGORIES: Record<
|
||||
TokenCategory,
|
||||
{ label: string; description: string; group: TokenCategoryGroup }
|
||||
> = {
|
||||
'color-semantic': {
|
||||
label: 'Semantic Colors',
|
||||
description: 'Purpose-based color tokens (primary, secondary, etc.)',
|
||||
group: 'Colors'
|
||||
},
|
||||
'color-palette': {
|
||||
label: 'Palette Colors',
|
||||
description: 'Raw color scales (gray, blue, red, etc.)',
|
||||
group: 'Colors'
|
||||
},
|
||||
spacing: {
|
||||
label: 'Spacing',
|
||||
description: 'Margins, paddings, gaps',
|
||||
group: 'Spacing'
|
||||
},
|
||||
'typography-size': {
|
||||
label: 'Font Sizes',
|
||||
description: 'Text size scale',
|
||||
group: 'Typography'
|
||||
},
|
||||
'typography-weight': {
|
||||
label: 'Font Weights',
|
||||
description: 'Thin to black weight scale',
|
||||
group: 'Typography'
|
||||
},
|
||||
'typography-leading': {
|
||||
label: 'Line Heights',
|
||||
description: 'Line height scale',
|
||||
group: 'Typography'
|
||||
},
|
||||
'typography-tracking': {
|
||||
label: 'Letter Spacing',
|
||||
description: 'Letter spacing scale',
|
||||
group: 'Typography'
|
||||
},
|
||||
'typography-family': {
|
||||
label: 'Font Families',
|
||||
description: 'Font family stacks',
|
||||
group: 'Typography'
|
||||
},
|
||||
'border-radius': {
|
||||
label: 'Border Radius',
|
||||
description: 'Corner radius scale',
|
||||
group: 'Borders'
|
||||
},
|
||||
'border-width': {
|
||||
label: 'Border Width',
|
||||
description: 'Border thickness scale',
|
||||
group: 'Borders'
|
||||
},
|
||||
shadow: {
|
||||
label: 'Shadows',
|
||||
description: 'Box shadow scale',
|
||||
group: 'Effects'
|
||||
},
|
||||
'animation-duration': {
|
||||
label: 'Durations',
|
||||
description: 'Transition and animation durations',
|
||||
group: 'Animation'
|
||||
},
|
||||
'animation-easing': {
|
||||
label: 'Easing',
|
||||
description: 'Timing functions',
|
||||
group: 'Animation'
|
||||
}
|
||||
};
|
||||
|
||||
export type TokenCategoryGroup = 'Colors' | 'Spacing' | 'Typography' | 'Borders' | 'Effects' | 'Animation';
|
||||
|
||||
export const TOKEN_CATEGORY_GROUPS: TokenCategoryGroup[] = [
|
||||
'Colors',
|
||||
'Spacing',
|
||||
'Typography',
|
||||
'Borders',
|
||||
'Effects',
|
||||
'Animation'
|
||||
];
|
||||
|
||||
// ─── Token Interface ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface StyleToken {
|
||||
/** CSS custom property name, e.g. "--primary" */
|
||||
name: string;
|
||||
/** Raw value or reference to another token e.g. "#3b82f6" or "var(--blue-500)" */
|
||||
value: string;
|
||||
/** Token category for grouping in the panel */
|
||||
category: TokenCategory;
|
||||
/** User-defined token vs system default */
|
||||
isCustom: boolean;
|
||||
/** Optional human-readable description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ─── Token Map ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Map of CSS custom property name → StyleToken */
|
||||
export type StyleTokenMap = Map<string, StyleToken>;
|
||||
|
||||
// ─── Serializable storage format (for project.json) ─────────────────────────
|
||||
|
||||
export interface StyleTokenRecord {
|
||||
name: string;
|
||||
value: string;
|
||||
category: TokenCategory;
|
||||
isCustom: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface StyleTokensData {
|
||||
/** Version for future migration support */
|
||||
version: number;
|
||||
/** Custom token overrides — only tokens that differ from defaults */
|
||||
customTokens: StyleTokenRecord[];
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* STYLE-001: TokenResolver
|
||||
*
|
||||
* Resolves CSS custom property references to their actual values.
|
||||
* Handles chained references like --space-xs -> var(--space-1) -> 4px
|
||||
* Includes caching to avoid repeated resolution on the same token map.
|
||||
*/
|
||||
|
||||
import { StyleTokenRecord } from './TokenCategories';
|
||||
|
||||
const VAR_REGEX = /^var\((--[\w-]+)\)$/;
|
||||
const MAX_DEPTH = 10; // Prevent infinite loops from circular references
|
||||
|
||||
export class TokenResolver {
|
||||
private cache = new Map<string, string>();
|
||||
private tokens: Map<string, StyleTokenRecord>;
|
||||
|
||||
constructor(tokens: Map<string, StyleTokenRecord>) {
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the token map (e.g. when a token is added/modified).
|
||||
* Clears the cache since resolved values may have changed.
|
||||
*/
|
||||
updateTokens(tokens: Map<string, StyleTokenRecord>): void {
|
||||
this.tokens = tokens;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a token name to its final CSS value, following var() references.
|
||||
*
|
||||
* @example
|
||||
* resolver.resolve('--space-xs') // → "4px" (via var(--space-1) → 4px)
|
||||
* resolver.resolve('--primary') // → "#3b82f6"
|
||||
* resolver.resolve('--missing') // → undefined
|
||||
*/
|
||||
resolve(tokenName: string, depth = 0): string | undefined {
|
||||
if (depth > MAX_DEPTH) {
|
||||
console.warn(`[TokenResolver] Max reference depth exceeded for "${tokenName}" - possible circular reference`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.cache.has(tokenName)) {
|
||||
return this.cache.get(tokenName);
|
||||
}
|
||||
|
||||
const token = this.tokens.get(tokenName);
|
||||
if (!token) return undefined;
|
||||
|
||||
const resolved = this._resolveValue(token.value, depth);
|
||||
if (resolved !== undefined) {
|
||||
this.cache.set(tokenName, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw value — handles both direct values and var() references.
|
||||
*/
|
||||
private _resolveValue(value: string, depth: number): string | undefined {
|
||||
const match = VAR_REGEX.exec(value.trim());
|
||||
if (match) {
|
||||
// It's a reference — resolve it recursively
|
||||
return this.resolve(match[1], depth + 1);
|
||||
}
|
||||
// Direct value (hex, px, etc.)
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a var() reference.
|
||||
*/
|
||||
static isReference(value: string): boolean {
|
||||
return VAR_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the referenced token name from a var() value.
|
||||
* Returns null if not a var() reference.
|
||||
*/
|
||||
static extractReference(value: string): string | null {
|
||||
const match = VAR_REGEX.exec(value.trim());
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache for a specific token (or all tokens).
|
||||
*/
|
||||
invalidate(tokenName?: string): void {
|
||||
if (tokenName) {
|
||||
this.cache.delete(tokenName);
|
||||
// Also invalidate any tokens that reference this one
|
||||
for (const [name, token] of this.tokens) {
|
||||
const ref = TokenResolver.extractReference(token.value);
|
||||
if (ref === tokenName) {
|
||||
this.cache.delete(name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSS :root { ... } block from the full token map.
|
||||
* Resolves semantic tokens that reference palettes inline.
|
||||
*/
|
||||
generateCss(tokens: Map<string, StyleTokenRecord>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [, token] of tokens) {
|
||||
lines.push(` ${token.name}: ${token.value};`);
|
||||
}
|
||||
return `:root {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { StyleTokensModel } from './StyleTokensModel';
|
||||
export { TokenResolver } from './TokenResolver';
|
||||
export { DEFAULT_TOKENS, buildDefaultTokenMap } from './DefaultTokens';
|
||||
export type {
|
||||
StyleToken,
|
||||
StyleTokenMap,
|
||||
StyleTokenRecord,
|
||||
StyleTokensData,
|
||||
TokenCategory,
|
||||
TokenCategoryGroup
|
||||
} from './TokenCategories';
|
||||
export { TOKEN_CATEGORIES, TOKEN_CATEGORY_GROUPS } from './TokenCategories';
|
||||
@@ -25,6 +25,7 @@ import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
|
||||
import { useEventListener } from '../../hooks/useEventListener';
|
||||
import { DialogLayerModel } from '../../models/DialogLayerModel';
|
||||
import { detectRuntimeVersion } from '../../models/migration/ProjectScanner';
|
||||
import { getAllPresets, setPendingPresetId } from '../../models/StylePresets';
|
||||
import { IRouteProps } from '../../pages/AppRoute';
|
||||
import { GitHubOAuthService, GitHubClient } from '../../services/github';
|
||||
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
|
||||
@@ -37,6 +38,9 @@ export interface ProjectsPageProps extends IRouteProps {
|
||||
from: TSFixme;
|
||||
}
|
||||
|
||||
/** Built-in presets computed once at module level — never changes at runtime. */
|
||||
const STYLE_PRESETS = getAllPresets();
|
||||
|
||||
/**
|
||||
* Map LocalProjectsModel ProjectItemWithRuntime to LauncherProjectData format
|
||||
*/
|
||||
@@ -441,9 +445,12 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
}, []);
|
||||
|
||||
const handleCreateProjectConfirm = useCallback(
|
||||
async (name: string, location: string) => {
|
||||
async (name: string, location: string, presetId: string) => {
|
||||
setIsCreateModalVisible(false);
|
||||
|
||||
// Store the chosen preset — StyleTokensModel will consume it on editor startup.
|
||||
setPendingPresetId(presetId);
|
||||
|
||||
try {
|
||||
const path = filesystem.makeUniquePath(filesystem.join(location, name));
|
||||
|
||||
@@ -454,15 +461,18 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
(project) => {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
if (!project) {
|
||||
// Clear pending preset if project creation failed
|
||||
setPendingPresetId(null);
|
||||
ToastLayer.showError('Could not create project');
|
||||
return;
|
||||
}
|
||||
// Navigate to editor with the newly created project
|
||||
// Navigate to editor — StyleTokensModel will apply preset on load
|
||||
props.route.router.route({ to: 'editor', project });
|
||||
},
|
||||
{ name, path, projectTemplate: '' }
|
||||
);
|
||||
} catch (error) {
|
||||
setPendingPresetId(null);
|
||||
console.error('Failed to create project:', error);
|
||||
ToastLayer.showError('Failed to create project');
|
||||
}
|
||||
@@ -937,6 +947,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
onClose={handleCreateModalClose}
|
||||
onConfirm={handleCreateProjectConfirm}
|
||||
onChooseLocation={handleChooseLocation}
|
||||
presets={STYLE_PRESETS}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<div class="sidebar-panel">
|
||||
<div class="sidebar-property-editor">
|
||||
<div class="variants"></div>
|
||||
|
||||
|
||||
<div class="element-style-section"></div>
|
||||
|
||||
<div class="visual-states"></div>
|
||||
|
||||
<div class="groups">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="groups"></div>
|
||||
|
||||
<!-- a padding at the bottom so drop downs can be scrolled to when at the very bottom of the property editor -->
|
||||
<div class="property-drop-down-padding" style="position:relative; height:200px; visibility:hidden;width: 100%; display:none;">
|
||||
</div>
|
||||
<div
|
||||
class="property-drop-down-padding"
|
||||
style="position: relative; height: 200px; visibility: hidden; width: 100%; display: none"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NodeGraphModel, NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
import { INodeIndexCategory } from '@noodl-utils/createnodeindex';
|
||||
import { guid } from '@noodl-utils/utils';
|
||||
|
||||
import { ElementConfigRegistry } from '../../models/ElementConfigs';
|
||||
import { CommentFillStyle } from '../CommentLayer/CommentLayerView';
|
||||
|
||||
export function parseNodeObject(nodeTypes: TSFixme[], model: TSFixme, parentModel: TSFixme) {
|
||||
@@ -73,6 +74,10 @@ export function createNodeFunction(
|
||||
id: guid()
|
||||
});
|
||||
|
||||
// STYLE-002: Apply element config defaults (token-based styles + initial variant)
|
||||
// Only affects nodes with a registered ElementConfig — no-op for all others.
|
||||
ElementConfigRegistry.applyDefaults(node, type.name);
|
||||
|
||||
if (parentModel) {
|
||||
parentModel.addChild(node, { undo: true, label: 'create' });
|
||||
} else if (attachToRoot) {
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
import { useProjectDesignTokenContext } from '@noodl-contexts/ProjectDesignTokenContext';
|
||||
/**
|
||||
* STYLE-001: Enhanced Design Token Panel
|
||||
*
|
||||
* Replaces the basic experimental panel with a proper collapsible category system.
|
||||
* Shows all Tailwind-inspired design tokens grouped by category (Colors, Spacing, etc.)
|
||||
* with visual previews and support for editing token values.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Tabs, TabsVariant } from '@noodl-core-ui/components/layout/Tabs';
|
||||
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
|
||||
import { Section } from '@noodl-core-ui/components/sidebar/Section';
|
||||
import { Label } from '@noodl-core-ui/components/typography/Label';
|
||||
|
||||
import { ColorsTab } from './components/ColorsTab';
|
||||
import { DesignTokensTab } from './components/DesignTokensTab';
|
||||
|
||||
export function DesignTokenPanel() {
|
||||
const { textStyles } = useProjectDesignTokenContext();
|
||||
|
||||
return (
|
||||
<BasePanel title="Design Tokens">
|
||||
<Tabs
|
||||
variant={TabsVariant.Sidebar}
|
||||
tabs={[
|
||||
{
|
||||
label: 'Colors',
|
||||
content: <ColorsTab />
|
||||
label: 'Tokens',
|
||||
content: <DesignTokensTab />
|
||||
},
|
||||
{
|
||||
label: 'Typography',
|
||||
content: (
|
||||
<Section title="Experimental features">
|
||||
<Box hasXSpacing hasTopSpacing>
|
||||
<VStack>
|
||||
{textStyles.map((textStyle) => (
|
||||
<Box key={textStyle.name} hasBottomSpacing={1}>
|
||||
<Label>{JSON.stringify(textStyle)}</Label>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Section>
|
||||
)
|
||||
label: 'Colors',
|
||||
content: <ColorsTab />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* STYLE-001: Design Tokens Tab
|
||||
*
|
||||
* Main tab showing all design tokens grouped by category (Colors, Spacing, etc.).
|
||||
* Each group is collapsible. Token rows show a visual preview and the current value.
|
||||
*/
|
||||
|
||||
import { useProjectDesignTokenContext } from '@noodl-contexts/ProjectDesignTokenContext';
|
||||
import React from 'react';
|
||||
|
||||
import { StyleTokenRecord, TOKEN_CATEGORY_GROUPS, TokenCategoryGroup } from '@noodl-models/StyleTokensModel';
|
||||
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
import { SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
|
||||
|
||||
import { TokenCategorySection } from '../TokenCategorySection';
|
||||
|
||||
export function DesignTokensTab() {
|
||||
const { designTokens, styleTokensModel } = useProjectDesignTokenContext();
|
||||
|
||||
// Group tokens by their display group
|
||||
const grouped = React.useMemo(() => {
|
||||
const map: Partial<Record<TokenCategoryGroup, StyleTokenRecord[]>> = {};
|
||||
for (const group of TOKEN_CATEGORY_GROUPS) {
|
||||
map[group] = [];
|
||||
}
|
||||
for (const token of designTokens) {
|
||||
// Find the group from TOKEN_CATEGORIES
|
||||
// We rely on the model already having them grouped correctly
|
||||
const groupForToken = getGroupForToken(token);
|
||||
if (groupForToken && map[groupForToken]) {
|
||||
map[groupForToken].push(token);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [designTokens]);
|
||||
|
||||
const customCount = designTokens.filter((t) => t.isCustom).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{customCount > 0 && (
|
||||
<div style={{ padding: '8px 12px', fontSize: '11px', color: 'var(--theme-color-fg-default-shy)' }}>
|
||||
{customCount} token{customCount !== 1 ? 's' : ''} overriding defaults
|
||||
<button
|
||||
onClick={() => styleTokensModel?.resetAllToDefaults({ undo: true })}
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--theme-color-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{TOKEN_CATEGORY_GROUPS.map((group) => {
|
||||
const tokens = grouped[group] ?? [];
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CollapsableSection
|
||||
key={group}
|
||||
title={group}
|
||||
variant={SectionVariant.Panel}
|
||||
UNSAFE_style={{ marginTop: group === TOKEN_CATEGORY_GROUPS[0] ? '16px' : '8px' }}
|
||||
>
|
||||
<TokenCategorySection
|
||||
tokens={tokens}
|
||||
onTokenChange={(name, value) => styleTokensModel?.setToken(name, value, { undo: true })}
|
||||
onTokenReset={(name) => styleTokensModel?.deleteCustomToken(name, { undo: true })}
|
||||
/>
|
||||
</CollapsableSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the display group from a token's category.
|
||||
* Mirrors TOKEN_CATEGORIES group mappings without creating circular imports.
|
||||
*/
|
||||
function getGroupForToken(token: StyleTokenRecord): TokenCategoryGroup | null {
|
||||
const cat = token.category;
|
||||
if (cat === 'color-semantic' || cat === 'color-palette') return 'Colors';
|
||||
if (cat === 'spacing') return 'Spacing';
|
||||
if (cat.startsWith('typography')) return 'Typography';
|
||||
if (cat === 'border-radius' || cat === 'border-width') return 'Borders';
|
||||
if (cat === 'shadow') return 'Effects';
|
||||
if (cat.startsWith('animation')) return 'Animation';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DesignTokensTab } from './DesignTokensTab';
|
||||
@@ -0,0 +1,138 @@
|
||||
.TokenList {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.TokenRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
transition: background-color var(--duration-100) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&.isOverridden {
|
||||
.TokenName {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Color swatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
.ColorSwatch {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
// ─── Token info ───────────────────────────────────────────────────────────────
|
||||
|
||||
.TokenInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.TokenName {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.TokenValue {
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// ─── Override reset button ────────────────────────────────────────────────────
|
||||
|
||||
.ResetButton {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-100) var(--ease-out);
|
||||
|
||||
.TokenRow:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Non-color previews ───────────────────────────────────────────────────────
|
||||
|
||||
.SpacingPreview {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.SpacingBar {
|
||||
height: 4px;
|
||||
background-color: var(--theme-color-primary);
|
||||
border-radius: 2px;
|
||||
min-width: 1px;
|
||||
}
|
||||
|
||||
.RadiusPreview {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid var(--theme-color-fg-default-shy);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ShadowPreview {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.FontSizePreview {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-color-fg-default);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.DotPreview {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--theme-color-fg-default-shy);
|
||||
margin: 5px;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* STYLE-001: TokenCategorySection
|
||||
*
|
||||
* Renders a list of token rows within a category section.
|
||||
* Each row shows a color swatch (for colors) or a text preview, the token name,
|
||||
* current value, and a reset button if overridden.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { StyleTokenRecord, TokenResolver } from '@noodl-models/StyleTokensModel';
|
||||
|
||||
import css from './TokenCategorySection.module.scss';
|
||||
|
||||
interface TokenCategorySectionProps {
|
||||
tokens: StyleTokenRecord[];
|
||||
onTokenChange: (name: string, value: string) => void;
|
||||
onTokenReset: (name: string) => void;
|
||||
}
|
||||
|
||||
export function TokenCategorySection({ tokens, onTokenChange, onTokenReset }: TokenCategorySectionProps) {
|
||||
return (
|
||||
<div className={css.TokenList}>
|
||||
{tokens.map((token) => (
|
||||
<TokenRow key={token.name} token={token} onTokenChange={onTokenChange} onTokenReset={onTokenReset} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenRowProps {
|
||||
token: StyleTokenRecord;
|
||||
onTokenChange: (name: string, value: string) => void;
|
||||
onTokenReset: (name: string) => void;
|
||||
}
|
||||
|
||||
// onTokenChange is passed for future inline editing (Phase 3: TokenPicker)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function TokenRow({ token, onTokenChange: _onTokenChange, onTokenReset }: TokenRowProps) {
|
||||
const isColor = token.category === 'color-semantic' || token.category === 'color-palette';
|
||||
const isRef = TokenResolver.isReference(token.value);
|
||||
// Resolved display value — show raw value if it's a reference
|
||||
const displayValue = isRef ? token.value : token.value;
|
||||
|
||||
return (
|
||||
<div className={`${css.TokenRow} ${token.isCustom ? css.isOverridden : ''}`}>
|
||||
{/* Preview swatch for colors */}
|
||||
{isColor && (
|
||||
<div
|
||||
className={css.ColorSwatch}
|
||||
style={{ backgroundColor: isRef ? `var(${token.name})` : token.value }}
|
||||
title={token.value}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Non-color preview (spacing bar, font weight number, etc.) */}
|
||||
{!isColor && <TokenPreview token={token} />}
|
||||
|
||||
{/* Token name + value */}
|
||||
<div className={css.TokenInfo}>
|
||||
<span className={css.TokenName} title={token.description}>
|
||||
{token.name}
|
||||
</span>
|
||||
<span className={css.TokenValue}>{displayValue}</span>
|
||||
</div>
|
||||
|
||||
{/* Override indicator + reset */}
|
||||
{token.isCustom && (
|
||||
<button className={css.ResetButton} onClick={() => onTokenReset(token.name)} title="Reset to default">
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenPreview({ token }: { token: StyleTokenRecord }) {
|
||||
const cat = token.category;
|
||||
|
||||
if (cat === 'spacing') {
|
||||
// Render a bar whose width reflects the spacing value
|
||||
const px = parseInt(token.value, 10);
|
||||
const clampedWidth = Math.min(Math.max(px / 2, 1), 48);
|
||||
return (
|
||||
<div className={css.SpacingPreview}>
|
||||
<div className={css.SpacingBar} style={{ width: `${clampedWidth}px` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cat === 'border-radius') {
|
||||
return <div className={css.RadiusPreview} style={{ borderRadius: token.value }} title={token.value} />;
|
||||
}
|
||||
|
||||
if (cat === 'shadow') {
|
||||
return (
|
||||
<div
|
||||
className={css.ShadowPreview}
|
||||
style={{ boxShadow: token.value === 'none' ? 'none' : token.value }}
|
||||
title={token.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (cat === 'typography-size') {
|
||||
return (
|
||||
<span className={css.FontSizePreview} style={{ fontSize: token.value }}>
|
||||
Aa
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: dot
|
||||
return <div className={css.DotPreview} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TokenCategorySection } from './TokenCategorySection';
|
||||
@@ -49,8 +49,6 @@ export function ProjectSettingsPanel({}: ProjectSettingsPanelProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
propertyView?.render();
|
||||
|
||||
return (
|
||||
<BasePanel title="Project Settings" hasContentScroll>
|
||||
<Section hasGutter hasVisibleOverflow>
|
||||
|
||||
@@ -79,14 +79,6 @@ export class CodeEditorType extends TypeView {
|
||||
const p = args.port;
|
||||
const parent = args.parent;
|
||||
|
||||
// Debug: Log all port properties
|
||||
console.log('[CodeEditorType.fromPort] Port properties:', {
|
||||
name: p.name,
|
||||
readOnly: p.readOnly,
|
||||
type: p.type,
|
||||
allKeys: Object.keys(p)
|
||||
});
|
||||
|
||||
view.port = p;
|
||||
view.displayName = p.displayName ? p.displayName : p.name;
|
||||
view.name = p.name;
|
||||
@@ -102,8 +94,6 @@ export class CodeEditorType extends TypeView {
|
||||
// Try multiple locations for readOnly flag
|
||||
view.readOnly = p.readOnly || p.type?.readOnly || getEditType(p)?.readOnly || false;
|
||||
|
||||
console.log('[CodeEditorType.fromPort] Resolved readOnly:', view.readOnly);
|
||||
|
||||
// HACK: Like most of Property panel,
|
||||
// since the property panel can have many code editors
|
||||
// we want to open the one most likely to be the
|
||||
@@ -312,8 +302,6 @@ export class CodeEditorType extends TypeView {
|
||||
|
||||
// Determine which editor to use
|
||||
if (isJavaScriptEditor) {
|
||||
console.log('✨ Using JavaScriptEditor for:', this.type.codeeditor);
|
||||
|
||||
// Determine validation type based on editor type
|
||||
let validationType: ValidationType = 'function';
|
||||
if (this.type.codeeditor === 'javascript') {
|
||||
@@ -330,13 +318,6 @@ export class CodeEditorType extends TypeView {
|
||||
validationType = 'script';
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[CodeEditorType] Rendering JavaScriptEditor:', {
|
||||
parameterName: scope.name,
|
||||
readOnly: this.readOnly,
|
||||
nodeId: nodeId
|
||||
});
|
||||
|
||||
// Create close handler to trigger popout close
|
||||
const closeHandler = () => {
|
||||
_this.parent.hidePopout();
|
||||
|
||||
@@ -6,7 +6,10 @@ 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 { VariantsEditor } from './components/VariantStates';
|
||||
@@ -29,6 +32,10 @@ export class PropertyEditor extends View {
|
||||
renderPortsViewScheduled: TSFixme;
|
||||
variantsRoot: Root | null = null;
|
||||
visualStatesRoot: Root | null = null;
|
||||
/** React root for the ElementStyleSection (variant + size picker). */
|
||||
elementStyleRoot: Root | null = null;
|
||||
/** Stable group object used to manage undo/redo event subscriptions. */
|
||||
private readonly _elementStyleGroup: Record<string, never> = {};
|
||||
|
||||
constructor(args) {
|
||||
super();
|
||||
@@ -105,6 +112,93 @@ export class PropertyEditor extends View {
|
||||
// Interaction state changed, schedule
|
||||
this.scheduleRenderPortsView();
|
||||
}
|
||||
|
||||
/**
|
||||
* STYLE-004: Render the ElementStyleSection (variant + size picker) for nodes
|
||||
* that have an ElementConfig registered. Safe to call multiple times — reuses
|
||||
* the existing React root.
|
||||
*/
|
||||
renderElementStyleSection() {
|
||||
const typeName: string | undefined = this.model.type?.name;
|
||||
if (!typeName || !ElementConfigRegistry.has(typeName)) return;
|
||||
|
||||
const variants = ElementConfigRegistry.getVariantNames(typeName);
|
||||
const sizes = ElementConfigRegistry.getSizeNames(typeName);
|
||||
const currentVariant = this.model.parameters['_variant'] as string | undefined;
|
||||
const currentSize = this.model.parameters['_size'] as string | undefined;
|
||||
|
||||
const props = {
|
||||
variants,
|
||||
currentVariant,
|
||||
onVariantChange: this.onElementVariantChange.bind(this),
|
||||
sizes,
|
||||
currentSize,
|
||||
onSizeChange: sizes.length > 0 ? this.onElementSizeChange.bind(this) : undefined
|
||||
};
|
||||
|
||||
const container = this.$('.element-style-section')[0];
|
||||
if (!container) return;
|
||||
|
||||
if (!this.elementStyleRoot) {
|
||||
this.elementStyleRoot = createRoot(container);
|
||||
}
|
||||
this.elementStyleRoot.render(React.createElement(ElementStyleSection, props));
|
||||
}
|
||||
|
||||
/**
|
||||
* STYLE-004: Apply a new variant to the node with full undo support.
|
||||
* All property changes are batched into a single UndoActionGroup.
|
||||
*/
|
||||
onElementVariantChange(variantName: string) {
|
||||
const typeName: string | undefined = this.model.type?.name;
|
||||
if (!typeName) return;
|
||||
|
||||
const resolved = ElementConfigRegistry.resolveVariant(typeName, variantName);
|
||||
if (!resolved) return;
|
||||
|
||||
const undo = new UndoActionGroup({ label: 'change variant' });
|
||||
|
||||
for (const [key, value] of Object.entries(resolved.baseStyles)) {
|
||||
this.model.setParameter(key, value, { undo, label: 'change variant' });
|
||||
}
|
||||
// Persist the active variant marker
|
||||
this.model.setParameter('_variant', variantName, { undo, label: 'change variant' });
|
||||
|
||||
UndoQueue.instance.push(undo);
|
||||
|
||||
// Refresh port list (style changes may affect visible ports)
|
||||
this.scheduleRenderPortsView();
|
||||
// Refresh the picker to reflect the new selection
|
||||
this.renderElementStyleSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* STYLE-004: Apply a size preset to the node with full undo support.
|
||||
* Size overrides are batched into a single UndoActionGroup.
|
||||
*/
|
||||
onElementSizeChange(sizeName: string) {
|
||||
const typeName: string | undefined = this.model.type?.name;
|
||||
if (!typeName) return;
|
||||
|
||||
const config = ElementConfigRegistry.get(typeName);
|
||||
if (!config?.sizes) return;
|
||||
|
||||
const sizePreset = config.sizes[sizeName];
|
||||
if (!sizePreset) return;
|
||||
|
||||
const undo = new UndoActionGroup({ label: 'change size' });
|
||||
|
||||
for (const [key, value] of Object.entries(sizePreset)) {
|
||||
this.model.setParameter(key, value, { undo, label: 'change size' });
|
||||
}
|
||||
this.model.setParameter('_size', sizeName, { undo, label: 'change size' });
|
||||
|
||||
UndoQueue.instance.push(undo);
|
||||
|
||||
this.scheduleRenderPortsView();
|
||||
this.renderElementStyleSection();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.el = this.bindView($(PropertyEditorTemplate), this);
|
||||
|
||||
@@ -117,6 +211,18 @@ export class PropertyEditor extends View {
|
||||
|
||||
this.renderVisualStates();
|
||||
|
||||
// STYLE-004: Re-render ElementStyleSection on undo/redo so the picker
|
||||
// reflects the restored parameter values.
|
||||
this.model.off(this._elementStyleGroup);
|
||||
this.model.on(
|
||||
['modelParameterUndo', 'modelParameterRedo'],
|
||||
() => {
|
||||
this.renderElementStyleSection();
|
||||
},
|
||||
this._elementStyleGroup
|
||||
);
|
||||
this.renderElementStyleSection();
|
||||
|
||||
this.parent && this.parent.append(this.el);
|
||||
|
||||
return this.el;
|
||||
|
||||
38421
packages/noodl-editor/src/frames/viewer-frame/index.bundle.js
Normal file
38421
packages/noodl-editor/src/frames/viewer-frame/index.bundle.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
packages/noodl-editor/src/main/main.bundle.js
Normal file
1
packages/noodl-editor/src/main/main.bundle.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,17 +1,13 @@
|
||||
const configDev = require('./config-dev');
|
||||
const configDist = require('./config-dist');
|
||||
module.exports = {
|
||||
type: 'dist',
|
||||
Tracker: {
|
||||
trackExceptions: true
|
||||
},
|
||||
PreviewServer: {
|
||||
port: 8574
|
||||
},
|
||||
|
||||
function getProcess() {
|
||||
try {
|
||||
const remote = require('@electron/remote');
|
||||
return remote ? remote.process : process;
|
||||
} catch (exc) {
|
||||
// Error: "@electron/remote" cannot be required in the browser process. Instead require("@electron/remote/main").
|
||||
return process;
|
||||
}
|
||||
}
|
||||
|
||||
const _process = getProcess();
|
||||
|
||||
if (!_process.env.devMode) _process.env.devMode = (_process.argv || []).indexOf('--dev') !== -1 ? 'yes' : 'no';
|
||||
module.exports = _process.env.devMode === 'yes' ? configDev : configDist;
|
||||
apiEndpoint: 'https://api.noodlcloud.com',
|
||||
domainEndpoint: 'http://domains.noodlcloud.com',
|
||||
aiEndpoint: 'https://p2qsqhrh6xd6relfoaye4tf6nm0bibpm.lambda-url.us-east-1.on.aws'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user