Added sprint protocol

This commit is contained in:
Richard Osborne
2026-02-18 15:59:52 +01:00
parent bf07f1cb4a
commit 297dfe0269
249 changed files with 638915 additions and 250 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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}

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

@@ -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)'
}
}
};

View File

@@ -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)'
}
}
}
}
};

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export { ModernPreset } from './ModernPreset';
export { MinimalPreset } from './MinimalPreset';
export { PlayfulPreset } from './PlayfulPreset';
export { EnterprisePreset } from './EnterprisePreset';
export { SoftPreset } from './SoftPreset';

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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[];
}

View File

@@ -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}`;
}
}

View File

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

View File

@@ -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}
/>
</>
);

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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 />
}
]}
/>

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { DesignTokensTab } from './DesignTokensTab';

View File

@@ -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;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1 @@
export { TokenCategorySection } from './TokenCategorySection';

View File

@@ -49,8 +49,6 @@ export function ProjectSettingsPanel({}: ProjectSettingsPanelProps) {
};
}, []);
propertyView?.render();
return (
<BasePanel title="Project Settings" hasContentScroll>
<Section hasGutter hasVisibleOverflow>

View File

@@ -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();

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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