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

View File

@@ -0,0 +1,108 @@
.PresetCard {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px;
border-radius: 8px;
border: 2px solid transparent;
background: transparent;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease;
flex: 1;
min-width: 0;
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary, #3b82f6);
outline-offset: 2px;
}
&--selected {
border-color: var(--theme-color-primary, #3b82f6);
background: rgba(59, 130, 246, 0.08);
&:hover {
background: rgba(59, 130, 246, 0.12);
}
}
}
.PresetCard-mockup {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 6px;
border: 1px solid transparent;
padding: 6px;
display: flex;
flex-direction: column;
gap: 5px;
box-sizing: border-box;
overflow: hidden;
}
.PresetCard-btn {
height: 10px;
width: 60%;
flex-shrink: 0;
}
.PresetCard-lines {
display: flex;
flex-direction: column;
gap: 3px;
flex-shrink: 0;
}
.PresetCard-line {
height: 3px;
border-radius: 2px;
opacity: 0.6;
}
.PresetCard-surface {
flex: 1;
border: 1px solid transparent;
border-radius: 4px;
min-height: 0;
}
.PresetCard-name {
font-size: 11px;
font-weight: 500;
color: var(--theme-color-fg-default, #e4e4e7);
text-align: center;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.PresetCard-check {
position: absolute;
top: 4px;
right: 4px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-color-primary, #3b82f6);
&::after {
content: '';
position: absolute;
left: 4px;
top: 2px;
width: 5px;
height: 8px;
border: 2px solid #fff;
border-top: none;
border-left: none;
transform: rotate(45deg);
}
}

View File

@@ -0,0 +1,87 @@
/**
* STYLE-003: PresetCard
*
* Individual card showing a visual mini-preview of a style preset.
* Renders a small mockup using the preset's preview colors/radius
* so users can see what their UI will look like before choosing.
*/
import React from 'react';
import css from './PresetCard.module.scss';
export interface PresetDisplayInfo {
/** Unique slug id (e.g. 'modern', 'minimal'). */
id: string;
/** Human-readable name. */
name: string;
/** Short description shown below the card grid. */
description: string;
/** Raw CSS values for the visual preview. No token references. */
preview: {
primaryColor: string;
backgroundColor: string;
surfaceColor: string;
borderColor: string;
textColor: string;
mutedTextColor: string;
radiusMd: string;
};
}
export interface PresetCardProps {
preset: PresetDisplayInfo;
isSelected: boolean;
onSelect: (id: string) => void;
}
export function PresetCard({ preset, isSelected, onSelect }: PresetCardProps) {
const { preview } = preset;
return (
<button
type="button"
className={[css['PresetCard'], isSelected ? css['PresetCard--selected'] : ''].filter(Boolean).join(' ')}
onClick={() => onSelect(preset.id)}
aria-pressed={isSelected}
title={preset.name}
>
{/* Mini UI mockup */}
<div
className={css['PresetCard-mockup']}
style={{ backgroundColor: preview.backgroundColor, borderColor: preview.borderColor }}
>
{/* Primary button row */}
<div
className={css['PresetCard-btn']}
style={{
backgroundColor: preview.primaryColor,
borderRadius: preview.radiusMd
}}
/>
{/* Text lines */}
<div className={css['PresetCard-lines']}>
<div className={css['PresetCard-line']} style={{ backgroundColor: preview.textColor, width: '70%' }} />
<div className={css['PresetCard-line']} style={{ backgroundColor: preview.mutedTextColor, width: '50%' }} />
</div>
{/* Surface card strip */}
<div
className={css['PresetCard-surface']}
style={{
backgroundColor: preview.surfaceColor,
borderColor: preview.borderColor,
borderRadius: `calc(${preview.radiusMd} * 0.6)`
}}
/>
</div>
{/* Preset name */}
<span className={css['PresetCard-name']}>{preset.name}</span>
{/* Selected indicator */}
{isSelected && <span className={css['PresetCard-check']} aria-hidden />}
</button>
);
}

View File

@@ -0,0 +1,27 @@
.PresetSelector {
display: flex;
flex-direction: column;
gap: 8px;
}
.PresetSelector-label {
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default, #e4e4e7);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.PresetSelector-grid {
display: flex;
flex-direction: row;
gap: 4px;
}
.PresetSelector-description {
font-size: 11px;
color: var(--theme-color-fg-default-shy, #71717a);
margin: 0;
line-height: 1.4;
min-height: 1.4em; /* reserve height to prevent layout shift */
}

View File

@@ -0,0 +1,45 @@
/**
* STYLE-003: PresetSelector
*
* Horizontal grid of PresetCards with a description below for the
* currently-selected preset.
*
* Usage:
* <PresetSelector
* presets={getAllPresets()}
* selectedId="modern"
* onChange={(id) => setPreset(id)}
* />
*/
import React from 'react';
import { PresetCard, PresetDisplayInfo } from './PresetCard';
import css from './PresetSelector.module.scss';
export interface PresetSelectorProps {
/** All available presets to display. */
presets: PresetDisplayInfo[];
/** Currently selected preset id. */
selectedId: string;
/** Called when the user picks a different preset. */
onChange: (id: string) => void;
}
export function PresetSelector({ presets, selectedId, onChange }: PresetSelectorProps) {
const selected = presets.find((p) => p.id === selectedId);
return (
<div className={css['PresetSelector']}>
<span className={css['PresetSelector-label']}>Style Preset</span>
<div className={css['PresetSelector-grid']} role="group" aria-label="Style presets">
{presets.map((preset) => (
<PresetCard key={preset.id} preset={preset} isSelected={preset.id === selectedId} onSelect={onChange} />
))}
</div>
{selected && <p className={css['PresetSelector-description']}>{selected.description}</p>}
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { PresetCard } from './PresetCard';
export type { PresetDisplayInfo, PresetCardProps } from './PresetCard';
export { PresetSelector } from './PresetSelector';
export type { PresetSelectorProps } from './PresetSelector';

View File

@@ -0,0 +1,132 @@
/**
* STYLE-002: VariantSelector styles
* All colors use design tokens — no hardcoded values.
*/
.VariantSelector {
position: relative;
display: flex;
flex-direction: column;
gap: var(--spacing-1, 4px);
width: 100%;
}
// Label above the trigger
.VariantSelector-label {
font-size: var(--font-size-xsmall, 11px);
font-weight: 500;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
// Dropdown trigger button
.VariantSelector-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-1, 4px);
width: 100%;
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--border-radius-small, 4px);
font-size: var(--font-size-small, 12px);
cursor: pointer;
text-align: left;
transition: border-color 100ms ease, background 100ms ease;
&:hover:not(:disabled) {
background: var(--theme-color-bg-2);
border-color: var(--theme-color-primary);
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary);
outline-offset: 1px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.VariantSelector-triggerText {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.VariantSelector-chevron {
flex-shrink: 0;
font-size: 10px;
color: var(--theme-color-fg-default-shy);
line-height: 1;
pointer-events: none;
}
// Dropdown list
.VariantSelector-dropdown {
position: absolute;
top: calc(100% + var(--spacing-1, 4px));
left: 0;
right: 0;
z-index: 200;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--border-radius-small, 4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 240px;
overflow-y: auto;
}
// Individual option
.VariantSelector-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2, 8px);
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
background: transparent;
color: var(--theme-color-fg-default);
border: none;
font-size: var(--font-size-small, 12px);
cursor: pointer;
text-align: left;
width: 100%;
transition: background 80ms ease;
&:hover {
background: var(--theme-color-bg-3);
}
&--active {
color: var(--theme-color-primary);
background: color-mix(in srgb, var(--theme-color-primary) 10%, transparent);
&:hover {
background: color-mix(in srgb, var(--theme-color-primary) 15%, transparent);
}
}
}
.VariantSelector-optionLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.VariantSelector-checkmark {
flex-shrink: 0;
font-size: 10px;
color: var(--theme-color-primary);
line-height: 1;
}

View File

@@ -0,0 +1,142 @@
/**
* STYLE-002: VariantSelector
*
* Dropdown component for selecting a node style variant.
* Displays the current variant name and opens a list of available variants.
*
* Usage:
* <VariantSelector
* variants={['primary', 'secondary', 'outline', 'ghost']}
* currentVariant="primary"
* onVariantChange={(name) => applyVariant(node, nodeType, name)}
* />
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import css from './VariantSelector.module.scss';
export interface VariantSelectorProps {
/** List of available variant names to display. */
variants: string[];
/** Currently active variant name. */
currentVariant: string | undefined;
/** Called when the user picks a different variant. */
onVariantChange: (variantName: string) => void;
/** Disable the selector (read-only). */
disabled?: boolean;
/** Optional label shown above the selector. Defaults to 'Variant'. */
label?: string;
}
/**
* Format a variant name for display: 'heading-1' → 'Heading 1', 'flex-row' → 'Flex Row'.
*/
function formatVariantLabel(name: string): string {
return name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
export function VariantSelector({
variants,
currentVariant,
onVariantChange,
disabled = false,
label = 'Variant'
}: VariantSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const toggle = useCallback(() => {
if (!disabled) setIsOpen((v) => !v);
}, [disabled]);
const handleSelect = useCallback(
(name: string) => {
setIsOpen(false);
if (name !== currentVariant) {
onVariantChange(name);
}
},
[currentVariant, onVariantChange]
);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
function onPointerDown(e: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setIsOpen(false);
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen]);
return (
<div className={css['VariantSelector']} ref={containerRef}>
<span className={css['VariantSelector-label']}>{label}</span>
<button
type="button"
className={css['VariantSelector-trigger']}
onClick={toggle}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
title={currentVariant ? formatVariantLabel(currentVariant) : 'No variant'}
>
<span className={css['VariantSelector-triggerText']}>
{currentVariant ? formatVariantLabel(currentVariant) : 'None'}
</span>
<span className={css['VariantSelector-chevron']} aria-hidden>
</span>
</button>
{isOpen && (
<div className={css['VariantSelector-dropdown']} role="listbox">
{variants.map((name) => (
<button
key={name}
type="button"
role="option"
aria-selected={name === currentVariant}
className={[
css['VariantSelector-option'],
name === currentVariant ? css['VariantSelector-option--active'] : ''
]
.filter(Boolean)
.join(' ')}
onClick={() => handleSelect(name)}
>
<span className={css['VariantSelector-optionLabel']}>{formatVariantLabel(name)}</span>
{name === currentVariant && (
<span className={css['VariantSelector-checkmark']} aria-hidden>
</span>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { VariantSelector } from './VariantSelector';
export type { VariantSelectorProps } from './VariantSelector';

View File

@@ -0,0 +1,30 @@
/**
* STYLE-004: ElementStyleSection styles
* Matches the visual rhythm of the existing property editor sections.
* All colors via design tokens — no hardcoded values.
*/
.ElementStyleSection {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--theme-color-border-default);
margin-bottom: 0;
}
.ElementStyleSection-header {
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
font-size: var(--font-size-xsmall, 11px);
font-weight: 600;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0.08em;
user-select: none;
background: var(--theme-color-bg-2);
}
.ElementStyleSection-body {
display: flex;
flex-direction: column;
gap: var(--spacing-2, 8px);
padding: var(--spacing-2, 8px);
}

View File

@@ -0,0 +1,70 @@
/**
* STYLE-004: ElementStyleSection
*
* Property panel section that surfaces the ElementConfig variant and size system.
* Rendered at the top of the property panel for any node that has an ElementConfig
* registered (Button, Text, Group, TextInput, Checkbox).
*
* The component is intentionally dumb — it receives callbacks for variant and size
* changes so that the legacy propertyeditor.ts can manage undo grouping via
* UndoActionGroup + UndoQueue without this component needing to import them.
*/
import React from 'react';
import { VariantSelector } from '../../inputs/VariantSelector';
import { SizePicker } from '../SizePicker';
import css from './ElementStyleSection.module.scss';
export interface ElementStyleSectionProps {
/** Available variant names from the ElementConfig (e.g. ['primary', 'secondary', 'outline']). */
variants: string[];
/** Currently active variant name (from node.parameters._variant). */
currentVariant: string | undefined;
/** Called when the user selects a different variant. Parent handles undo. */
onVariantChange: (variantName: string) => void;
/**
* Available size names from the ElementConfig sizes map (e.g. ['sm', 'md', 'lg', 'xl']).
* Pass an empty array or omit to hide the size picker.
*/
sizes?: string[];
/** Currently active size name (from node.parameters._size). */
currentSize?: string | undefined;
/** Called when the user selects a different size. Parent handles undo. */
onSizeChange?: (sizeName: string) => void;
}
export function ElementStyleSection({
variants,
currentVariant,
onVariantChange,
sizes = [],
currentSize,
onSizeChange
}: ElementStyleSectionProps) {
const hasSizes = sizes.length > 0 && onSizeChange !== undefined;
return (
<div className={css['ElementStyleSection']}>
<div className={css['ElementStyleSection-header']}>Style</div>
<div className={css['ElementStyleSection-body']}>
{variants.length > 0 && (
<VariantSelector
variants={variants}
currentVariant={currentVariant}
onVariantChange={onVariantChange}
label="Variant"
/>
)}
{hasSizes && <SizePicker sizes={sizes} currentSize={currentSize} onSizeChange={onSizeChange} label="Size" />}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { ElementStyleSection } from './ElementStyleSection';
export type { ElementStyleSectionProps } from './ElementStyleSection';

View File

@@ -0,0 +1,70 @@
/**
* STYLE-004: SizePicker styles
* All colors via design tokens.
*/
.SizePicker {
display: flex;
flex-direction: column;
gap: var(--spacing-1, 4px);
width: 100%;
}
.SizePicker-label {
font-size: var(--font-size-xsmall, 11px);
font-weight: 500;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
// Segmented control group
.SizePicker-group {
display: flex;
gap: 2px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--border-radius-small, 4px);
padding: 2px;
}
.SizePicker-option {
flex: 1;
padding: var(--spacing-1, 4px) 0;
background: transparent;
color: var(--theme-color-fg-default-shy);
border: none;
border-radius: 2px;
font-size: var(--font-size-xsmall, 11px);
font-weight: 500;
cursor: pointer;
text-transform: lowercase;
letter-spacing: 0.02em;
transition: background 80ms ease, color 80ms ease;
&:hover:not(:disabled) {
background: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary);
outline-offset: -1px;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&--active {
background: var(--theme-color-bg-2);
color: var(--theme-color-fg-highlight);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
&:hover {
background: var(--theme-color-bg-2);
}
}
}

View File

@@ -0,0 +1,68 @@
/**
* STYLE-004: SizePicker
*
* Segmented control for selecting an element size preset (sm / md / lg / xl).
* Only renders the sizes that are defined in the node's ElementConfig.
*
* Usage:
* <SizePicker
* sizes={['sm', 'md', 'lg', 'xl']}
* currentSize="md"
* onSizeChange={(size) => applySize(node, nodeType, size)}
* />
*/
import React, { useCallback } from 'react';
import css from './SizePicker.module.scss';
export interface SizePickerProps {
/** Available size names in order (e.g. ['sm', 'md', 'lg', 'xl']). */
sizes: string[];
/** Currently active size name. */
currentSize: string | undefined;
/** Called when the user picks a different size. */
onSizeChange: (sizeName: string) => void;
/** Disable all size buttons. */
disabled?: boolean;
/** Optional label. Defaults to 'Size'. */
label?: string;
}
export function SizePicker({ sizes, currentSize, onSizeChange, disabled = false, label = 'Size' }: SizePickerProps) {
const handleClick = useCallback(
(size: string) => {
if (!disabled && size !== currentSize) {
onSizeChange(size);
}
},
[currentSize, disabled, onSizeChange]
);
return (
<div className={css['SizePicker']}>
<span className={css['SizePicker-label']}>{label}</span>
<div className={css['SizePicker-group']} role="group" aria-label={label}>
{sizes.map((size) => (
<button
key={size}
type="button"
className={[css['SizePicker-option'], size === currentSize ? css['SizePicker-option--active'] : '']
.filter(Boolean)
.join(' ')}
onClick={() => handleClick(size)}
disabled={disabled}
aria-pressed={size === currentSize}
title={size.toUpperCase()}
>
{size}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { SizePicker } from './SizePicker';
export type { SizePickerProps } from './SizePicker';

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { PresetDisplayInfo, PresetSelector } from '@noodl-core-ui/components/StylePresets';
import { Label } from '@noodl-core-ui/components/typography/Label';
import css from './CreateProjectModal.module.scss';
@@ -9,20 +10,42 @@ import css from './CreateProjectModal.module.scss';
export interface CreateProjectModalProps {
isVisible: boolean;
onClose: () => void;
onConfirm: (name: string, location: string) => void;
onChooseLocation?: () => Promise<string | null>; // For folder picker
/**
* Called when the user confirms project creation.
* @param name Project name entered by the user.
* @param location Directory path chosen by the user.
* @param presetId Id of the selected style preset (e.g. 'modern', 'minimal').
*/
onConfirm: (name: string, location: string, presetId: string) => void;
onChooseLocation?: () => Promise<string | null>;
/**
* Optional list of style presets to show in the modal.
* When provided a PresetSelector is rendered; when omitted the selector is hidden.
* Pass the result of `getAllPresets()` from noodl-editor's StylePresetsModel.
*/
presets?: PresetDisplayInfo[];
}
export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLocation }: CreateProjectModalProps) {
const DEFAULT_PRESET_ID = 'modern';
export function CreateProjectModal({
isVisible,
onClose,
onConfirm,
onChooseLocation,
presets
}: CreateProjectModalProps) {
const [projectName, setProjectName] = useState('');
const [location, setLocation] = useState('');
const [isChoosingLocation, setIsChoosingLocation] = useState(false);
const [selectedPresetId, setSelectedPresetId] = useState(DEFAULT_PRESET_ID);
// Reset state when modal opens
useEffect(() => {
if (isVisible) {
setProjectName('');
setLocation('');
setSelectedPresetId(DEFAULT_PRESET_ID);
}
}, [isVisible]);
@@ -42,7 +65,7 @@ export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLoca
const handleCreate = () => {
if (!projectName.trim() || !location) return;
onConfirm(projectName.trim(), location);
onConfirm(projectName.trim(), location, selectedPresetId);
};
const isValid = projectName.trim().length > 0 && location.length > 0;
@@ -91,6 +114,13 @@ export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLoca
</div>
</div>
{/* Style Preset Selector */}
{presets && presets.length > 0 && (
<div className={css['Field']}>
<PresetSelector presets={presets} selectedId={selectedPresetId} onChange={setSelectedPresetId} />
</div>
)}
{/* Preview full path */}
{projectName && location && (
<div className={css['PathPreview']}>