feat: Implement collapsible left and right panels with dynamic column spanning, updated styling, and integrated a bottom status bar in the DesignerRoot.

This commit is contained in:
2026-02-03 13:58:47 -05:00
parent 0ec63b3c97
commit 388897c70e
17 changed files with 1147 additions and 719 deletions

Submodule robot-plugins updated: c6310d3144...d554891dab

65
scripts/check-db.ts Normal file
View File

@@ -0,0 +1,65 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
import { eq } from "drizzle-orm";
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
console.log("🔍 Checking Database State...");
// 1. Check Plugin
const plugins = await db.query.plugins.findMany();
console.log(`\nFound ${plugins.length} plugins.`);
const expectedKeys = new Set<string>();
for (const p of plugins) {
const meta = p.metadata as any;
const defs = p.actionDefinitions as any[];
console.log(`Plugin [${p.name}] (ID: ${p.id}):`);
console.log(` - Robot ID (Column): ${p.robotId}`);
console.log(` - Metadata.robotId: ${meta?.robotId}`);
console.log(` - Action Definitions: ${defs?.length ?? 0} found.`);
if (defs && meta?.robotId) {
defs.forEach(d => {
const key = `${meta.robotId}.${d.id}`;
expectedKeys.add(key);
// console.log(` -> Registers: ${key}`);
});
}
}
// 2. Check Actions
const actions = await db.query.actions.findMany();
console.log(`\nFound ${actions.length} actions.`);
let errorCount = 0;
for (const a of actions) {
// Only check plugin actions
if (a.sourceKind === 'plugin' || a.type.includes(".")) {
const isRegistered = expectedKeys.has(a.type);
const pluginIdMatch = a.pluginId === 'nao6-ros2';
console.log(`Action [${a.name}] (Type: ${a.type}):`);
console.log(` - PluginId: ${a.pluginId} ${pluginIdMatch ? '✅' : '❌'}`);
console.log(` - In Registry: ${isRegistered ? '✅' : '❌'}`);
if (!isRegistered || !pluginIdMatch) errorCount++;
}
}
if (errorCount > 0) {
console.log(`\n❌ Found ${errorCount} actions with issues.`);
} else {
console.log("\n✅ All plugin actions validated successfully against registry definitions.");
}
await connection.end();
}
main().catch(console.error);

View File

@@ -159,6 +159,7 @@ async function main() {
status: "ready", status: "ready",
robotId: naoRobot!.id, robotId: naoRobot!.id,
createdBy: adminUser.id, createdBy: adminUser.id,
// visualDesign will be auto-generated by designer from DB steps
}).returning(); }).returning();
// 5. Create Steps & Actions (The Interactive Storyteller Protocol) // 5. Create Steps & Actions (The Interactive Storyteller Protocol)
@@ -168,98 +169,116 @@ async function main() {
const [step1] = await db.insert(schema.steps).values({ const [step1] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "The Hook", name: "The Hook",
description: "Initial greeting and engagement", description: "Initial greeting and story introduction",
type: "robot", type: "robot",
orderIndex: 0, orderIndex: 0,
required: true, required: true,
durationEstimate: 30 durationEstimate: 25
}).returning(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step1!.id, stepId: step1!.id,
name: "Greet Participant", name: "Introduce Story",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Hello there! I have a wonderful story to share with you today.", emotion: "happy", speed: 1.0 }, parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "interaction", category: "interaction",
retryable: true retryable: true
}, },
{ {
stepId: step1!.id, stepId: step1!.id,
name: "Wave Greeting", name: "Welcome Gesture",
type: "nao6-ros2.move_arm", type: "nao6-ros2.move_arm",
orderIndex: 1, orderIndex: 1,
// Raising right arm to wave position // Open hand/welcome position
parameters: { parameters: {
arm: "right", arm: "right",
shoulder_pitch: -1.0, shoulder_pitch: 1.0,
shoulder_roll: -0.3, shoulder_roll: -0.2,
elbow_yaw: 1.5, elbow_yaw: 0.5,
elbow_roll: 0.5, elbow_roll: -0.4,
speed: 0.5 speed: 0.4
}, },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement", category: "movement",
retryable: true retryable: true
} }
]); ]);
// --- Step 2: The Narrative (Part 1) --- // --- Step 2: The Narrative ---
const [step2] = await db.insert(schema.steps).values({ const [step2] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "The Narrative - Part 1", name: "The Narrative",
description: "Robot tells the first part of the story", description: "Robot tells the space traveler story with gaze behavior",
type: "robot", type: "robot",
orderIndex: 1, orderIndex: 1,
required: true, required: true,
durationEstimate: 60
}).returning();
await db.insert(schema.actions).values([
{
stepId: step2!.id,
name: "Tell Story Part 1",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Once upon a time, in a land far away, there lived a curious robot named Alpha." },
pluginId: naoPlugin!.id,
category: "interaction"
},
{
stepId: step2!.id,
name: "Look at Audience",
type: "nao6-ros2.move_head",
orderIndex: 1,
parameters: { yaw: 0.0, pitch: -0.2, speed: 0.5 },
pluginId: naoPlugin!.id,
category: "movement"
}
]);
// --- Step 3: Comprehension Check (Wizard Decision) ---
// Note: In a real visual designer, this would be a Branch/Conditional.
// Here we model it as a Wizard Step where they explicitly choose the next robot action.
const [step3] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Comprehension Check",
description: "Wizard verifies participant understanding",
type: "wizard",
orderIndex: 2,
required: true,
durationEstimate: 45 durationEstimate: 45
}).returning(); }).returning();
await db.insert(schema.actions).values([
{
stepId: step2!.id,
name: "Tell Story",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "The traveler flew to Mars. He found a red rock that glowed in the dark. He put it in his pocket." },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "interaction",
retryable: true
},
{
stepId: step2!.id,
name: "Look Away (Thinking)",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: 1.5, pitch: 0.0, speed: 0.3 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
},
{
stepId: step2!.id,
name: "Look Back at Participant",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: 0.0, pitch: -0.1, speed: 0.4 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
}
]);
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
const [step3] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Comprehension Check",
description: "Ask participant about rock color and wait for wizard input",
type: "wizard",
orderIndex: 2,
required: true,
durationEstimate: 30
}).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step3!.id, stepId: step3!.id,
name: "Ask Question", name: "Ask Question",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Did you understand the story so far?", emotion: "happy", speed: 1.0 }, parameters: { text: "What color was the rock the traveler found?" },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
category: "interaction" pluginVersion: "2.1.0",
category: "interaction",
retryable: true
}, },
{ {
stepId: step3!.id, stepId: step3!.id,
@@ -267,7 +286,7 @@ async function main() {
type: "wizard_wait_for_response", type: "wizard_wait_for_response",
orderIndex: 1, orderIndex: 1,
parameters: { parameters: {
prompt_text: "Did participant answer 'Alpha'?", prompt_text: "Did participant answer 'Red' correctly?",
response_type: "verbal", response_type: "verbal",
timeout: 60 timeout: 60
}, },
@@ -276,36 +295,108 @@ async function main() {
} }
]); ]);
// --- Step 4: Feedback (Positive/Negative branches implied) --- // --- Step 4a: Correct Response Branch ---
// For linear seed, we just add the Positive feedback step const [step4a] = await db.insert(schema.steps).values({
const [step4] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "Positive Feedback", name: "Branch A: Correct Response",
description: "Correct answer response", description: "Response when participant says 'Red'",
type: "robot", type: "robot",
orderIndex: 3, orderIndex: 3,
required: true, required: false,
durationEstimate: 15 durationEstimate: 20
}).returning(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step4!.id, stepId: step4a!.id,
name: "Express Agreement", name: "Confirm Correct Answer",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.say_with_emotion",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Yes, exactly!", emotion: "happy", speed: 1.0 }, parameters: { text: "Yes! It was a glowing red rock.", emotion: "happy", speed: 1.0 },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
category: "interaction" pluginVersion: "2.1.0",
category: "interaction",
retryable: true
}, },
{ {
stepId: step4!.id, stepId: step4a!.id,
name: "Say Correct", name: "Nod Head",
type: "nao6-ros2.say_text", type: "nao6-ros2.turn_head",
orderIndex: 1, orderIndex: 1,
parameters: { text: "That is correct! Well done." }, parameters: { yaw: 0.0, pitch: -0.3, speed: 0.5 },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
category: "interaction" pluginVersion: "2.1.0",
category: "movement",
retryable: true
},
{
stepId: step4a!.id,
name: "Return to Neutral",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
}
]);
// --- Step 4b: Incorrect Response Branch ---
const [step4b] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Branch B: Incorrect Response",
description: "Response when participant gives wrong answer",
type: "robot",
orderIndex: 4,
required: false,
durationEstimate: 20
}).returning();
await db.insert(schema.actions).values([
{
stepId: step4b!.id,
name: "Correct Participant",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Actually, it was red." },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "interaction",
retryable: true
},
{
stepId: step4b!.id,
name: "Shake Head (Left)",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: -0.5, pitch: 0.0, speed: 0.5 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
},
{
stepId: step4b!.id,
name: "Shake Head (Right)",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: 0.5, pitch: 0.0, speed: 0.5 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
},
{
stepId: step4b!.id,
name: "Return to Center",
type: "nao6-ros2.turn_head",
orderIndex: 3,
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
} }
]); ]);
@@ -313,31 +404,42 @@ async function main() {
const [step5] = await db.insert(schema.steps).values({ const [step5] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "Conclusion", name: "Conclusion",
description: "Wrap up the story", description: "End the story and thank participant",
type: "robot", type: "robot",
orderIndex: 4, orderIndex: 5,
required: true, required: true,
durationEstimate: 30 durationEstimate: 25
}).returning(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step5!.id, stepId: step5!.id,
name: "Finish Story", name: "End Story",
type: "nao6-ros2.say_text", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Alpha explored the world and learned many things. The end." }, parameters: { text: "The End. Thank you for listening." },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
category: "interaction" pluginVersion: "2.1.0",
category: "interaction",
retryable: true
}, },
{ {
stepId: step5!.id, stepId: step5!.id,
name: "Say Goodbye", name: "Bow Gesture",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.move_arm",
orderIndex: 1, orderIndex: 1,
parameters: { text: "Goodbye everyone!", emotion: "happy", speed: 1.0 }, parameters: {
pluginId: naoPlugin!.id, arm: "right",
category: "interaction" shoulder_pitch: 1.8,
shoulder_roll: 0.1,
elbow_yaw: 0.0,
elbow_roll: -0.3,
speed: 0.3
},
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
} }
]); ]);
@@ -360,7 +462,13 @@ async function main() {
console.log(`Summary:`); console.log(`Summary:`);
console.log(`- 1 Admin User (sean@soconnor.dev)`); console.log(`- 1 Admin User (sean@soconnor.dev)`);
console.log(`- Study: 'Comparative WoZ Study'`); console.log(`- Study: 'Comparative WoZ Study'`);
console.log(`- Experiment: 'The Interactive Storyteller' (${5} steps created)`); console.log(`- Experiment: 'The Interactive Storyteller' (6 steps created)`);
console.log(` - Step 1: The Hook (greeting + welcome gesture)`);
console.log(` - Step 2: The Narrative (story + gaze sequence)`);
console.log(` - Step 3: Comprehension Check (question + wizard wait)`);
console.log(` - Step 4a: Branch A - Correct Response (affirmation + nod)`);
console.log(` - Step 4b: Branch B - Incorrect Response (correction + head shake)`);
console.log(` - Step 5: Conclusion (ending + bow)`);
console.log(`- ${insertedParticipants.length} Participants`); console.log(`- ${insertedParticipants.length} Participants`);
} catch (error) { } catch (error) {

View File

@@ -284,46 +284,8 @@ export class ActionRegistry {
loadPluginActions( loadPluginActions(
studyId: string, studyId: string,
studyPlugins: Array<{ studyPlugins: any[],
plugin: {
id: string;
robotId: string | null;
version: string | null;
actionDefinitions?: Array<{
id: string;
name: string;
description?: string;
category?: string;
icon?: string;
timeout?: number;
retryable?: boolean;
aliases?: string[];
parameterSchema?: unknown;
ros2?: {
topic?: string;
messageType?: string;
service?: string;
action?: string;
payloadMapping?: unknown;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
};
rest?: {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
};
}>;
metadata?: Record<string, any>;
};
}>,
): void { ): void {
// console.log("ActionRegistry.loadPluginActions called with:", { studyId, pluginCount: studyPlugins?.length ?? 0 });
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return; if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
if (this.loadedStudyId !== studyId) { if (this.loadedStudyId !== studyId) {
@@ -332,17 +294,14 @@ export class ActionRegistry {
let totalActionsLoaded = 0; let totalActionsLoaded = 0;
(studyPlugins ?? []).forEach((studyPlugin) => { (studyPlugins ?? []).forEach((plugin) => {
const { plugin } = studyPlugin;
const actionDefs = Array.isArray(plugin.actionDefinitions) const actionDefs = Array.isArray(plugin.actionDefinitions)
? plugin.actionDefinitions ? plugin.actionDefinitions
: undefined; : undefined;
// console.log(`Plugin ${plugin.id}:`, { actionCount: actionDefs?.length ?? 0 });
if (!actionDefs) return; if (!actionDefs) return;
actionDefs.forEach((action) => { actionDefs.forEach((action: any) => {
const rawCategory = const rawCategory =
typeof action.category === "string" typeof action.category === "string"
? action.category.toLowerCase().trim() ? action.category.toLowerCase().trim()

View File

@@ -37,7 +37,7 @@ import {
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
KeyboardSensor, KeyboardSensor,
closestCorners, closestCenter,
type DragEndEvent, type DragEndEvent,
type DragStartEvent, type DragStartEvent,
type DragOverEvent, type DragOverEvent,
@@ -45,7 +45,8 @@ import {
import { BottomStatusBar } from "./layout/BottomStatusBar"; import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
import { InspectorPanel } from "./panels/InspectorPanel"; import { InspectorPanel } from "./panels/InspectorPanel";
import { FlowWorkspace } from "./flow/FlowWorkspace"; import { FlowWorkspace, SortableActionChip, StepCardPreview } from "./flow/FlowWorkspace";
import { GripVertical } from "lucide-react";
import { import {
type ExperimentDesign, type ExperimentDesign,
@@ -54,12 +55,13 @@ import {
} from "~/lib/experiment-designer/types"; } from "~/lib/experiment-designer/types";
import { useDesignerStore } from "./state/store"; import { useDesignerStore } from "./state/store";
import { actionRegistry } from "./ActionRegistry"; import { actionRegistry, useActionRegistry } from "./ActionRegistry";
import { computeDesignHash } from "./state/hashing"; import { computeDesignHash } from "./state/hashing";
import { import {
validateExperimentDesign, validateExperimentDesign,
groupIssuesByEntity, groupIssuesByEntity,
} from "./state/validators"; } from "./state/validators";
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
/** /**
* DesignerRoot * DesignerRoot
@@ -104,6 +106,7 @@ interface RawExperiment {
integrityHash?: string | null; integrityHash?: string | null;
pluginDependencies?: string[] | null; pluginDependencies?: string[] | null;
visualDesign?: unknown; visualDesign?: unknown;
steps?: unknown[]; // DB steps from relation
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -111,6 +114,26 @@ interface RawExperiment {
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
// plugin provenance data (which might be missing from stale visualDesign snapshots).
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
try {
// console.log('[DesignerRoot] Hydrating design from Database Steps (Source of Truth)');
const dbSteps = convertDatabaseToSteps(exp.steps);
return {
id: exp.id,
name: exp.name,
description: exp.description ?? "",
steps: dbSteps,
version: 1, // Reset version on re-hydration
lastSaved: new Date(),
};
} catch (err) {
console.warn('[DesignerRoot] Failed to convert DB steps, falling back to visualDesign:', err);
}
}
// 2. Fallback to visualDesign blob if DB steps unavailable or conversion failed
if ( if (
!exp.visualDesign || !exp.visualDesign ||
typeof exp.visualDesign !== "object" || typeof exp.visualDesign !== "object" ||
@@ -124,6 +147,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
lastSaved?: string; lastSaved?: string;
}; };
if (!Array.isArray(vd.steps)) return undefined; if (!Array.isArray(vd.steps)) return undefined;
return { return {
id: exp.id, id: exp.id,
name: exp.name, name: exp.name,
@@ -162,6 +186,9 @@ export function DesignerRoot({
autoCompile = true, autoCompile = true,
onPersist, onPersist,
}: DesignerRootProps) { }: DesignerRootProps) {
// Subscribe to registry updates to ensure re-renders when actions load
useActionRegistry();
const { startTour } = useTour(); const { startTour } = useTour();
/* ----------------------------- Remote Experiment ------------------------- */ /* ----------------------------- Remote Experiment ------------------------- */
@@ -169,7 +196,18 @@ export function DesignerRoot({
data: experiment, data: experiment,
isLoading: loadingExperiment, isLoading: loadingExperiment,
refetch: refetchExperiment, refetch: refetchExperiment,
} = api.experiments.get.useQuery({ id: experimentId }); } = api.experiments.get.useQuery(
{ id: experimentId },
{
// Debug Mode: Disable all caching to ensure fresh data from DB
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: 0,
gcTime: 0, // Garbage collect immediately
}
);
const updateExperiment = api.experiments.update.useMutation({ const updateExperiment = api.experiments.update.useMutation({
onError: (err) => { onError: (err) => {
@@ -209,6 +247,7 @@ export function DesignerRoot({
const upsertAction = useDesignerStore((s) => s.upsertAction); const upsertAction = useDesignerStore((s) => s.upsertAction);
const selectStep = useDesignerStore((s) => s.selectStep); const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction); const selectAction = useDesignerStore((s) => s.selectAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues); const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
const clearAllValidationIssues = useDesignerStore( const clearAllValidationIssues = useDesignerStore(
(s) => s.clearAllValidationIssues, (s) => s.clearAllValidationIssues,
@@ -296,6 +335,11 @@ export function DesignerRoot({
description?: string; description?: string;
} | null>(null); } | null>(null);
const [activeSortableItem, setActiveSortableItem] = useState<{
type: 'step' | 'action';
data: any;
} | null>(null);
/* ----------------------------- Initialization ---------------------------- */ /* ----------------------------- Initialization ---------------------------- */
useEffect(() => { useEffect(() => {
if (initialized) return; if (initialized) return;
@@ -354,13 +398,14 @@ export function DesignerRoot({
.catch((err) => console.error("Core action load failed:", err)); .catch((err) => console.error("Core action load failed:", err));
}, []); }, []);
// Load plugin actions when study plugins available // Load plugin actions only after we have the flattened, processed plugin list
useEffect(() => { useEffect(() => {
if (!experiment?.studyId) return; if (!experiment?.studyId) return;
if (!studyPluginsRaw) return; if (!studyPlugins) return;
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw); // Pass the flattened plugins which match the structure ActionRegistry expects
}, [experiment?.studyId, studyPluginsRaw]); actionRegistry.loadPluginActions(experiment.studyId, studyPlugins);
}, [experiment?.studyId, studyPlugins]);
/* ------------------------- Ready State Management ------------------------ */ /* ------------------------- Ready State Management ------------------------ */
// Mark as ready once initialized and plugins are loaded // Mark as ready once initialized and plugins are loaded
@@ -375,11 +420,10 @@ export function DesignerRoot({
// Small delay to ensure all components have rendered // Small delay to ensure all components have rendered
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsReady(true); setIsReady(true);
// console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
}, 150); }, 150);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [initialized, isReady, studyPluginsRaw]); }, [initialized, isReady, studyPlugins]);
/* ----------------------- Automatic Hash Recomputation -------------------- */ /* ----------------------- Automatic Hash Recomputation -------------------- */
// Automatically recompute hash when steps change (debounced to avoid excessive computation) // Automatically recompute hash when steps change (debounced to avoid excessive computation)
@@ -442,6 +486,7 @@ export function DesignerRoot({
const currentSteps = [...steps]; const currentSteps = [...steps];
// Ensure core actions are loaded before validating // Ensure core actions are loaded before validating
await actionRegistry.loadCoreActions(); await actionRegistry.loadCoreActions();
const result = validateExperimentDesign(currentSteps, { const result = validateExperimentDesign(currentSteps, {
steps: currentSteps, steps: currentSteps,
actionDefinitions: actionRegistry.getAllActions(), actionDefinitions: actionRegistry.getAllActions(),
@@ -509,6 +554,15 @@ export function DesignerRoot({
clearAllValidationIssues, clearAllValidationIssues,
]); ]);
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
// DISABLED: User prefers manual validation to avoid noise on improved sequential architecture
// useEffect(() => {
// if (isReady) {
// void validateDesign();
// }
// }, [isReady, validateDesign]);
/* --------------------------------- Save ---------------------------------- */ /* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => { const persist = useCallback(async () => {
if (!initialized) return; if (!initialized) return;
@@ -691,15 +745,21 @@ export function DesignerRoot({
useSensor(KeyboardSensor), useSensor(KeyboardSensor),
); );
/* ----------------------------- Drag Handlers ----------------------------- */
/* ----------------------------- Drag Handlers ----------------------------- */ /* ----------------------------- Drag Handlers ----------------------------- */
const handleDragStart = useCallback( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
const { active } = event; const { active } = event;
const activeId = active.id.toString();
const activeData = active.data.current;
console.log("[DesignerRoot] DragStart", { activeId, activeData });
if ( if (
active.id.toString().startsWith("action-") && activeId.startsWith("action-") &&
active.data.current?.action activeData?.action
) { ) {
const a = active.data.current.action as { const a = activeData.action as {
id: string; id: string;
name: string; name: string;
category: string; category: string;
@@ -713,6 +773,18 @@ export function DesignerRoot({
category: a.category, category: a.category,
description: a.description, description: a.description,
}); });
} else if (activeId.startsWith("s-step-")) {
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
setActiveSortableItem({
type: 'step',
data: activeData
});
} else if (activeId.startsWith("s-act-")) {
console.log("[DesignerRoot] Setting active sortable ACTION", activeData);
setActiveSortableItem({
type: 'action',
data: activeData
});
} }
}, },
[toggleLibraryScrollLock], [toggleLibraryScrollLock],
@@ -721,14 +793,7 @@ export function DesignerRoot({
const handleDragOver = useCallback((event: DragOverEvent) => { const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event; const { active, over } = event;
const store = useDesignerStore.getState(); const store = useDesignerStore.getState();
const activeId = active.id.toString();
// Only handle Library -> Flow projection
if (!active.id.toString().startsWith("action-")) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
if (!over) { if (!over) {
if (store.insertionProjection) { if (store.insertionProjection) {
@@ -737,6 +802,16 @@ export function DesignerRoot({
return; return;
} }
// 3. Library -> Flow Projection (Action)
if (!activeId.startsWith("action-")) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
const overId = over.id.toString(); const overId = over.id.toString();
const activeDef = active.data.current?.action; const activeDef = active.data.current?.action;
@@ -831,6 +906,7 @@ export function DesignerRoot({
// Clear overlay immediately // Clear overlay immediately
toggleLibraryScrollLock(false); toggleLibraryScrollLock(false);
setDragOverlayAction(null); setDragOverlayAction(null);
setActiveSortableItem(null);
// Capture and clear projection // Capture and clear projection
const store = useDesignerStore.getState(); const store = useDesignerStore.getState();
@@ -841,6 +917,32 @@ export function DesignerRoot({
return; return;
} }
const activeId = active.id.toString();
// Handle Step Reordering (Active is a sortable step)
if (activeId.startsWith("s-step-")) {
const overId = over.id.toString();
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
if (!overId.startsWith("s-step-") && !overId.startsWith("step-")) return;
// Strip prefixes to get raw IDs
const rawActiveId = activeId.replace(/^s-step-/, "");
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
console.log("[DesignerRoot] DragEnd - Step Sort", { activeId, overId, rawActiveId, rawOverId });
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
const newIndex = steps.findIndex((s) => s.id === rawOverId);
console.log("[DesignerRoot] Indices", { oldIndex, newIndex });
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
console.log("[DesignerRoot] Reordering...");
reorderStep(oldIndex, newIndex);
}
return;
}
// 1. Determine Target (Step, Parent, Index) // 1. Determine Target (Step, Parent, Index)
let stepId: string | null = null; let stepId: string | null = null;
let parentId: string | null = null; let parentId: string | null = null;
@@ -907,8 +1009,9 @@ export function DesignerRoot({
} }
: undefined; : undefined;
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
const newAction: ExperimentAction = { const newAction: ExperimentAction = {
id: crypto.randomUUID(), id: newId,
type: actionDef.type, // this is the 'type' key type: actionDef.type, // this is the 'type' key
name: actionDef.name, name: actionDef.name,
category: actionDef.category as any, category: actionDef.category as any,
@@ -933,7 +1036,7 @@ export function DesignerRoot({
void recomputeHash(); void recomputeHash();
} }
}, },
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock], [steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep],
); );
// validation status badges removed (unused) // validation status badges removed (unused)
/* ------------------------------- Panels ---------------------------------- */ /* ------------------------------- Panels ---------------------------------- */
@@ -962,10 +1065,11 @@ export function DesignerRoot({
activeTab={inspectorTab} activeTab={inspectorTab}
onTabChange={setInspectorTab} onTabChange={setInspectorTab}
studyPlugins={studyPlugins} studyPlugins={studyPlugins}
onClearAll={clearAllValidationIssues}
/> />
</div> </div>
), ),
[inspectorTab, studyPlugins], [inspectorTab, studyPlugins, clearAllValidationIssues],
); );
/* ------------------------------- Render ---------------------------------- */ /* ------------------------------- Render ---------------------------------- */
@@ -1020,51 +1124,117 @@ export function DesignerRoot({
{/* Main Grid Container - 2-4-2 Split */} {/* Main Grid Container - 2-4-2 Split */}
{/* Main Grid Container - 2-4-2 Split */} {/* Main Grid Container - 2-4-2 Split */}
<div className="flex-1 min-h-0 w-full px-4 overflow-hidden"> <div className="flex-1 min-h-0 w-full px-2 overflow-hidden">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={closestCenter}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)} onDragCancel={() => toggleLibraryScrollLock(false)}
> >
<div className="grid grid-cols-8 gap-4 h-full w-full"> <div className="grid grid-cols-8 gap-4 h-full w-full transition-all duration-300 ease-in-out">
{/* Left Panel (2/8) */} {/* Left Panel (Library) */}
<div className="col-span-2 flex flex-col overflow-hidden rounded-lg border-2 border-dashed border-red-300 bg-red-50/50 dark:bg-red-900/10"> {!leftCollapsed && (
<div className="flex items-center justify-between border-b border-red-200 bg-red-100/50 px-3 py-2 text-sm font-medium text-red-900 dark:border-red-800 dark:bg-red-900/20 dark:text-red-100"> <div className={cn(
Left Panel (2fr) "flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
rightCollapsed ? "col-span-3" : "col-span-2"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Action Library</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setLeftCollapsed(true)}
>
<PanelLeftClose className="h-4 w-4" />
</Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 min-h-0"> <div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
{leftPanel} {leftPanel}
</div> </div>
</div> </div>
)}
{/* Center Panel (4/8) - The Workspace */} {/* Center Panel (Workspace) */}
<div className="col-span-4 flex flex-col overflow-hidden rounded-lg border-2 border-dashed border-green-300 bg-green-50/50 dark:bg-green-900/10"> <div className={cn(
<div className="flex items-center justify-between border-b border-green-200 bg-green-100/50 px-3 py-2 text-sm font-medium text-green-900 dark:border-green-800 dark:bg-green-900/20 dark:text-green-100"> "flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
Center Workspace (4fr) leftCollapsed && rightCollapsed ? "col-span-8" :
leftCollapsed ? "col-span-6" :
rightCollapsed ? "col-span-5" :
"col-span-4"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
{leftCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 mr-2"
onClick={() => setLeftCollapsed(false)}
title="Open Library"
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
)}
<span className="text-sm font-medium">Flow Workspace</span>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-2"
onClick={() => setRightCollapsed(false)}
title="Open Inspector"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
)}
</div> </div>
<div className="flex-1 overflow-hidden min-h-0 relative"> <div className="flex-1 overflow-hidden min-h-0 relative">
{/* Center content needs to be relative for absolute positioning children if any */}
{centerPanel} {centerPanel}
</div> </div>
<div className="border-t">
<BottomStatusBar
onSave={() => persist()}
onValidate={() => validateDesign()}
onExport={() => handleExport()}
onRecalculateHash={() => recomputeHash()}
lastSavedAt={lastSavedAt}
saving={isSaving}
validating={isValidating}
exporting={isExporting}
/>
</div>
</div> </div>
{/* Right Panel (2/8) */} {/* Right Panel (Inspector) */}
<div className="col-span-2 flex flex-col overflow-hidden rounded-lg border-2 border-dashed border-blue-300 bg-blue-50/50 dark:bg-blue-900/10"> {!rightCollapsed && (
<div className="flex items-center justify-between border-b border-blue-200 bg-blue-100/50 px-3 py-2 text-sm font-medium text-blue-900 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-100"> <div className={cn(
Right Panel (2fr) "flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
leftCollapsed ? "col-span-2" : "col-span-2"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Inspector</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<PanelRightClose className="h-4 w-4" />
</Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 min-h-0"> <div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
{rightPanel} {rightPanel}
</div> </div>
</div> </div>
)}
</div> </div>
<DragOverlay> <DragOverlay dropAnimation={null}>
{dragOverlayAction ? ( {dragOverlayAction ? (
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none"> // Library Item Drag
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none ring-2 ring-blue-500/20">
<div <div
className={cn( className={cn(
"flex h-4 w-4 items-center justify-center rounded text-white", "flex h-4 w-4 items-center justify-center rounded text-white",
@@ -1076,6 +1246,24 @@ export function DesignerRoot({
/> />
{dragOverlayAction.name} {dragOverlayAction.name}
</div> </div>
) : activeSortableItem?.type === 'action' ? (
// Existing Action Sort
<div className="w-[300px] opacity-90 pointer-events-none">
<SortableActionChip
stepId={activeSortableItem.data.stepId}
action={activeSortableItem.data.action}
parentId={activeSortableItem.data.parentId}
selectedActionId={selectedActionId}
onSelectAction={() => { }}
onDeleteAction={() => { }}
dragHandle={true}
/>
</div>
) : activeSortableItem?.type === 'step' ? (
// Existing Step Sort
<div className="w-[400px] pointer-events-none opacity-90">
<StepCardPreview step={activeSortableItem.data.step} dragHandle />
</div>
) : null} ) : null}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>

View File

@@ -388,17 +388,18 @@ export function PropertiesPanelBase({
onValueChange={(val) => { onValueChange={(val) => {
onStepUpdate(selectedStep.id, { type: val as StepType }); onStepUpdate(selectedStep.id, { type: val as StepType });
}} }}
disabled={true}
> >
<SelectTrigger className="mt-1 h-7 w-full text-xs"> <SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="sequential">Sequential</SelectItem> <SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
<SelectItem value="conditional">Conditional</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground mt-1 text-[10px]">
Steps always execute sequentially. Use control flow actions for parallel/conditional logic.
</p>
</div> </div>
<div> <div>
<Label className="text-xs">Trigger</Label> <Label className="text-xs">Trigger</Label>

View File

@@ -46,6 +46,10 @@ export interface ValidationPanelProps {
* Called to clear all issues for an entity. * Called to clear all issues for an entity.
*/ */
onEntityClear?: (entityId: string) => void; onEntityClear?: (entityId: string) => void;
/**
* Called to clear all issues globally.
*/
onClearAll?: () => void;
/** /**
* Optional function to map entity IDs to human-friendly names (e.g., step/action names). * Optional function to map entity IDs to human-friendly names (e.g., step/action names).
*/ */
@@ -60,25 +64,25 @@ export interface ValidationPanelProps {
const severityConfig = { const severityConfig = {
error: { error: {
icon: AlertCircle, icon: AlertCircle,
color: "text-red-600 dark:text-red-400", color: "text-validation-error-text",
bgColor: "bg-red-100 dark:bg-red-950/60", bgColor: "bg-validation-error-bg",
borderColor: "border-red-300 dark:border-red-700", borderColor: "border-validation-error-border",
badgeVariant: "destructive" as const, badgeVariant: "destructive" as const,
label: "Error", label: "Error",
}, },
warning: { warning: {
icon: AlertTriangle, icon: AlertTriangle,
color: "text-amber-600 dark:text-amber-400", color: "text-validation-warning-text",
bgColor: "bg-amber-100 dark:bg-amber-950/60", bgColor: "bg-validation-warning-bg",
borderColor: "border-amber-300 dark:border-amber-700", borderColor: "border-validation-warning-border",
badgeVariant: "secondary" as const, badgeVariant: "outline" as const,
label: "Warning", label: "Warning",
}, },
info: { info: {
icon: Info, icon: Info,
color: "text-blue-600 dark:text-blue-400", color: "text-validation-info-text",
bgColor: "bg-blue-100 dark:bg-blue-950/60", bgColor: "bg-validation-info-bg",
borderColor: "border-blue-300 dark:border-blue-700", borderColor: "border-validation-info-border",
badgeVariant: "outline" as const, badgeVariant: "outline" as const,
label: "Info", label: "Info",
}, },
@@ -141,7 +145,7 @@ function IssueItem({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[12px] leading-snug break-words whitespace-normal"> <p className="text-[12px] leading-snug break-words whitespace-normal text-foreground">
{issue.message} {issue.message}
</p> </p>
@@ -199,6 +203,7 @@ export function ValidationPanel({
onIssueClick, onIssueClick,
onIssueClear, onIssueClear,
onEntityClear: _onEntityClear, onEntityClear: _onEntityClear,
onClearAll,
entityLabelForId, entityLabelForId,
className, className,
}: ValidationPanelProps) { }: ValidationPanelProps) {

View File

@@ -8,6 +8,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { import {
useDndContext,
useDroppable, useDroppable,
useDndMonitor, useDndMonitor,
type DragEndEvent, type DragEndEvent,
@@ -80,21 +81,27 @@ export interface VirtualItem {
interface StepRowProps { interface StepRowProps {
item: VirtualItem; item: VirtualItem;
step: ExperimentStep; // Explicit pass for freshness
totalSteps: number;
selectedStepId: string | null | undefined; selectedStepId: string | null | undefined;
selectedActionId: string | null | undefined; selectedActionId: string | null | undefined;
renamingStepId: string | null; renamingStepId: string | null;
onSelectStep: (id: string | undefined) => void; onSelectStep: (id: string | undefined) => void;
onSelectAction: (stepId: string, actionId: string | undefined) => void; onSelectAction: (stepId: string, actionId: string | undefined) => void;
onToggleExpanded: (step: ExperimentStep) => void; onToggleExpanded: (step: ExperimentStep) => void;
onRenameStep: (step: ExperimentStep, name: string) => void; onRenameStep: (step: ExperimentStep, newName: string) => void;
onDeleteStep: (step: ExperimentStep) => void; onDeleteStep: (step: ExperimentStep) => void;
onDeleteAction: (stepId: string, actionId: string) => void; onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void; setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
} }
const StepRow = React.memo(function StepRow({ function StepRow({
item, item,
step,
totalSteps,
selectedStepId, selectedStepId,
selectedActionId, selectedActionId,
renamingStepId, renamingStepId,
@@ -106,8 +113,10 @@ const StepRow = React.memo(function StepRow({
onDeleteAction, onDeleteAction,
setRenamingStepId, setRenamingStepId,
registerMeasureRef, registerMeasureRef,
onReorderStep,
onReorderAction,
}: StepRowProps) { }: StepRowProps) {
const step = item.step; // const step = item.step; // Removed local derivation
const insertionProjection = useDesignerStore((s) => s.insertionProjection); const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => { const displayActions = useMemo(() => {
@@ -125,34 +134,19 @@ const StepRow = React.memo(function StepRow({
return step.actions; return step.actions;
}, [step.actions, step.id, insertionProjection]); }, [step.actions, step.id, insertionProjection]);
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
data: {
type: "step",
step: step,
},
});
const style: React.CSSProperties = { const style: React.CSSProperties = {
position: "absolute", position: "absolute",
top: item.top, top: item.top,
left: 0, left: 0,
right: 0, right: 0,
width: "100%", width: "100%",
transform: CSS.Transform.toString(transform), transition: "top 300ms cubic-bezier(0.4, 0, 0.2, 1)",
transition, // transform: CSS.Transform.toString(transform), // Removed
zIndex: isDragging ? 25 : undefined, // zIndex: isDragging ? 25 : undefined,
}; };
return ( return (
<div ref={setNodeRef} style={style} data-step-id={step.id}> <div style={style} data-step-id={step.id}>
<div <div
ref={(el) => registerMeasureRef(step.id, el)} ref={(el) => registerMeasureRef(step.id, el)}
className="relative px-3 py-4" className="relative px-3 py-4"
@@ -164,8 +158,7 @@ const StepRow = React.memo(function StepRow({
"mb-2 rounded-lg border shadow-sm transition-colors", "mb-2 rounded-lg border shadow-sm transition-colors",
selectedStepId === step.id selectedStepId === step.id
? "border-border bg-accent/30" ? "border-border bg-accent/30"
: "hover:bg-accent/30", : "hover:bg-accent/30"
isDragging && "opacity-80 ring-1 ring-blue-300",
)} )}
> >
<div <div
@@ -258,14 +251,33 @@ const StepRow = React.memo(function StepRow({
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
<div <Button
className="text-muted-foreground cursor-grab p-1" variant="ghost"
aria-label="Drag step" size="sm"
{...attributes} className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
{...listeners} onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, 'up');
}}
disabled={item.index === 0}
aria-label="Move step up"
> >
<GripVertical className="h-4 w-4" /> <ChevronRight className="h-4 w-4 -rotate-90" />
</div> </Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, 'down');
}}
disabled={item.index === totalSteps - 1}
aria-label="Move step down"
>
<ChevronRight className="h-4 w-4 rotate-90" />
</Button>
</div> </div>
</div> </div>
@@ -282,7 +294,7 @@ const StepRow = React.memo(function StepRow({
Drop actions here Drop actions here
</div> </div>
) : ( ) : (
displayActions.map((action) => ( displayActions.map((action, index) => (
<SortableActionChip <SortableActionChip
key={action.id} key={action.id}
stepId={step.id} stepId={step.id}
@@ -291,6 +303,9 @@ const StepRow = React.memo(function StepRow({
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
onSelectAction={onSelectAction} onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction} onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={index === 0}
isLast={index === displayActions.length - 1}
/> />
)) ))
)} )}
@@ -302,7 +317,51 @@ const StepRow = React.memo(function StepRow({
</div> </div>
</div> </div>
); );
}); }
/* -------------------------------------------------------------------------- */
/* Step Card Preview (for DragOverlay) */
/* -------------------------------------------------------------------------- */
export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dragHandle?: boolean }) {
return (
<div
className={cn(
"rounded-lg border bg-background shadow-xl ring-2 ring-blue-500/20",
dragHandle && "cursor-grabbing"
)}
>
<div className="flex items-center justify-between gap-2 border-b px-2 py-1.5 p-3">
<div className="flex items-center gap-2">
<div className="text-muted-foreground rounded p-1">
<ChevronRight className="h-4 w-4" />
</div>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
</div>
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<GripVertical className="h-4 w-4" />
</div>
</div>
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
<div className="bg-muted/10 p-2 h-12 flex items-center justify-center border-t border-dashed">
<span className="text-[10px] text-muted-foreground">
{step.actions.length} actions hidden while dragging
</span>
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Utility */ /* Utility */
@@ -331,9 +390,19 @@ function parseSortableAction(id: string): string | null {
/* Droppable Overlay (for palette action drops) */ /* Droppable Overlay (for palette action drops) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
function StepDroppableArea({ stepId }: { stepId: string }) { function StepDroppableArea({ stepId }: { stepId: string }) {
const { isOver } = useDroppable({ id: `step-${stepId}` }); const { active } = useDndContext();
const isStepDragging = active?.id.toString().startsWith("s-step-");
const { isOver, setNodeRef } = useDroppable({
id: `step-${stepId}`,
disabled: isStepDragging
});
if (isStepDragging) return null;
return ( return (
<div <div
ref={setNodeRef}
data-step-drop data-step-drop
className={cn( className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors", "pointer-events-none absolute inset-0 rounded-md transition-colors",
@@ -348,26 +417,155 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
/* Sortable Action Chip */ /* Sortable Action Chip */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
interface ActionChipProps { export interface ActionChipProps {
stepId: string; stepId: string;
action: ExperimentAction; action: ExperimentAction;
parentId: string | null; parentId: string | null;
selectedActionId: string | null | undefined; selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void; onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void; onDeleteAction: (stepId: string, actionId: string) => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
dragHandle?: boolean; dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
} }
function SortableActionChip({ /* -------------------------------------------------------------------------- */
/* Action Chip Visuals (Pure Component) */
/* -------------------------------------------------------------------------- */
export interface ActionChipVisualsProps {
action: ExperimentAction;
isSelected?: boolean;
isDragging?: boolean;
isOverNested?: boolean;
onSelect?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
onReorder?: (direction: 'up' | 'down') => void;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
validationStatus?: "error" | "warning" | "info";
}
export function ActionChipVisuals({
action,
isSelected,
isDragging,
isOverNested,
onSelect,
onDelete,
onReorder,
dragHandleProps,
children,
isFirst,
isLast,
validationStatus,
}: ActionChipVisualsProps) {
const def = actionRegistry.getAction(action.type);
return (
<div
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-border bg-accent/30",
isDragging && "opacity-70 shadow-lg",
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<span className="flex-1 leading-snug font-medium break-words flex items-center gap-2">
{action.name}
{validationStatus === "error" && (
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600" aria-label="Error" />
)}
{validationStatus === "warning" && (
<div className="h-2 w-2 rounded-full bg-amber-500 ring-1 ring-amber-600" aria-label="Warning" />
)}
</span>
<div className="flex items-center gap-0.5 mr-1 bg-background/50 rounded-md border border-border/50 shadow-sm px-0.5">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('up');
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('down');
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
{def?.parameters.length ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{def.parameters.slice(0, 4).map((p) => (
<span
key={p.id}
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 4 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{children}
</div>
);
}
export function SortableActionChip({
stepId, stepId,
action, action,
parentId, parentId,
selectedActionId, selectedActionId,
onSelectAction, onSelectAction,
onDeleteAction, onDeleteAction,
onReorderAction,
dragHandle, dragHandle,
isFirst,
isLast,
}: ActionChipProps) { }: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
const isSelected = selectedActionId === action.id; const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection); const insertionProjection = useDesignerStore((s) => s.insertionProjection);
@@ -388,35 +586,44 @@ function SortableActionChip({
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder"; const isPlaceholder = action.id === "projection-placeholder";
const { // Compute validation status
attributes, const issues = useDesignerStore((s) => s.validationIssues[action.id]);
listeners, const validationStatus = useMemo(() => {
setNodeRef, if (!issues?.length) return undefined;
transform, if (issues.some((i) => i.severity === "error")) return "error";
transition, if (issues.some((i) => i.severity === "warning")) return "warning";
isDragging: isSortableDragging, return "info";
} = useSortable({ }, [issues]);
id: sortableActionId(action.id),
disabled: isPlaceholder, // Disable sortable for placeholder /* ------------------------------------------------------------------------ */
data: { /* Sortable (Local) DnD Monitoring */
type: "action", /* ------------------------------------------------------------------------ */
stepId, // useSortable disabled per user request to remove action drag-and-drop
parentId, // const { ... } = useSortable(...)
id: action.id,
},
});
// Use local dragging state or passed prop // Use local dragging state or passed prop
const isDragging = isSortableDragging || dragHandle; const isDragging = dragHandle || false;
const style = { const style = {
transform: CSS.Translate.toString(transform), // transform: CSS.Translate.toString(transform),
transition, // transition,
}; };
// We need a ref for droppable? Droppable is below.
// For the chip itself, if not sortable, we don't need setNodeRef.
// But we might need it for layout?
// Let's keep a simple div ref usage if needed, but useSortable provided setNodeRef.
// We can just use a normal ref or nothing if not measuring.
const setNodeRef = undefined; // No-op
const attributes = {};
const listeners = {};
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */ /* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
const nestedDroppableId = `container-${action.id}`; const nestedDroppableId = `container-${action.id}`;
const { const {
isOver: isOverNested, isOver: isOverNested,
@@ -472,80 +679,26 @@ function SortableActionChip({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( {...attributes}
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]", >
"bg-muted/40 hover:bg-accent/40 cursor-pointer", <ActionChipVisuals
isSelected && "border-border bg-accent/30", action={action}
isDragging && "opacity-70 shadow-lg", isSelected={isSelected}
// Visual feedback for nested drop isDragging={isDragging}
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50" isOverNested={isOverNested}
)} onSelect={(e) => {
onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSelectAction(stepId, action.id); onSelectAction(stepId, action.id);
}} }}
{...attributes} onDelete={(e) => {
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<div
{...listeners}
className="text-muted-foreground/70 hover:text-foreground cursor-grab rounded p-0.5"
aria-label="Drag action"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
def
? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category]
: "bg-slate-400",
)}
/>
<span className="flex-1 leading-snug font-medium break-words">
{action.name}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDeleteAction(stepId, action.id); onDeleteAction(stepId, action.id);
}} }}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100" onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
aria-label="Delete action" dragHandleProps={listeners}
isLast={isLast}
validationStatus={validationStatus}
> >
<Trash2 className="h-3 w-3" />
</button>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
{def?.parameters.length ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{def.parameters.slice(0, 4).map((p) => (
<span
key={p.id}
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 4 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{/* Nested Actions Container */} {/* Nested Actions Container */}
{shouldRenderChildren && ( {shouldRenderChildren && (
<div <div
@@ -569,6 +722,7 @@ function SortableActionChip({
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
onSelectAction={onSelectAction} onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction} onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
/> />
))} ))}
{(!displayChildren?.length && !action.children?.length) && ( {(!displayChildren?.length && !action.children?.length) && (
@@ -579,7 +733,7 @@ function SortableActionChip({
</SortableContext> </SortableContext>
</div> </div>
)} )}
</ActionChipVisuals>
</div> </div>
); );
} }
@@ -796,6 +950,52 @@ export function FlowWorkspace({
[removeAction, selectedActionId, selectAction, recomputeHash], [removeAction, selectedActionId, selectAction, recomputeHash],
); );
const handleReorderStep = useCallback(
(stepId: string, direction: 'up' | 'down') => {
console.log('handleReorderStep', stepId, direction);
const currentIndex = steps.findIndex((s) => s.id === stepId);
console.log('currentIndex', currentIndex, 'total', steps.length);
if (currentIndex === -1) return;
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
console.log('newIndex', newIndex);
if (newIndex < 0 || newIndex >= steps.length) return;
reorderStep(currentIndex, newIndex);
},
[steps, reorderStep]
);
const handleReorderAction = useCallback(
(stepId: string, actionId: string, direction: 'up' | 'down') => {
const step = steps.find(s => s.id === stepId);
if (!step) return;
const findInTree = (list: ExperimentAction[], pId: string | null): { list: ExperimentAction[], parentId: string | null, index: number } | null => {
const idx = list.findIndex(a => a.id === actionId);
if (idx !== -1) return { list, parentId: pId, index: idx };
for (const a of list) {
if (a.children) {
const res = findInTree(a.children, a.id);
if (res) return res;
}
}
return null;
};
const context = findInTree(step.actions, null);
if (!context) return;
const { parentId, index, list } = context;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= list.length) return;
moveAction(stepId, actionId, parentId, newIndex);
},
[steps, moveAction]
);
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */ /* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
@@ -815,19 +1015,9 @@ export function FlowWorkspace({
} }
const activeId = active.id.toString(); const activeId = active.id.toString();
const overId = over.id.toString(); const overId = over.id.toString();
// Step reorder
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) { // Step reorder is now handled globally in DesignerRoot
const fromStepId = parseSortableStep(activeId);
const toStepId = parseSortableStep(overId);
if (fromStepId && toStepId && fromStepId !== toStepId) {
const fromIndex = steps.findIndex((s) => s.id === fromStepId);
const toIndex = steps.findIndex((s) => s.id === toStepId);
if (fromIndex >= 0 && toIndex >= 0) {
reorderStep(fromIndex, toIndex);
void recomputeHash();
}
}
}
// Action reorder (supports nesting) // Action reorder (supports nesting)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) { if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const activeData = active.data.current; const activeData = active.data.current;
@@ -839,8 +1029,9 @@ export function FlowWorkspace({
activeData.type === 'action' && overData.type === 'action' activeData.type === 'action' && overData.type === 'action'
) { ) {
const stepId = activeData.stepId as string; const stepId = activeData.stepId as string;
const activeActionId = activeData.action.id; // Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
const overActionId = overData.action.id; const activeActionId = activeData.id;
const overActionId = overData.id;
if (activeActionId !== overActionId) { if (activeActionId !== overActionId) {
const newParentId = overData.parentId as string | null; const newParentId = overData.parentId as string | null;
@@ -877,8 +1068,10 @@ export function FlowWorkspace({
activeData.type === 'action' && activeData.type === 'action' &&
overData.type === 'action' overData.type === 'action'
) { ) {
const activeActionId = activeData.action.id; // Fix: Access 'id' directly from data payload
const overActionId = overData.action.id; const activeActionId = activeData.id;
const overActionId = overData.id;
const activeStepId = activeData.stepId; const activeStepId = activeData.stepId;
const overStepId = overData.stepId; const overStepId = overData.stepId;
const activeParentId = activeData.parentId; const activeParentId = activeData.parentId;
@@ -956,7 +1149,8 @@ export function FlowWorkspace({
<div <div
ref={containerRef} ref={containerRef}
id="tour-designer-canvas" id="tour-designer-canvas"
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md border" // Removed 'border' class to fix double border issue
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md"
onScroll={onScroll} onScroll={onScroll}
> >
{steps.length === 0 ? ( {steps.length === 0 ? (
@@ -990,6 +1184,8 @@ export function FlowWorkspace({
<StepRow <StepRow
key={vi.key} key={vi.key}
item={vi} item={vi}
step={vi.step}
totalSteps={steps.length}
selectedStepId={selectedStepId} selectedStepId={selectedStepId}
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
renamingStepId={renamingStepId} renamingStepId={renamingStepId}
@@ -1004,6 +1200,8 @@ export function FlowWorkspace({
onDeleteAction={deleteAction} onDeleteAction={deleteAction}
setRenamingStepId={setRenamingStepId} setRenamingStepId={setRenamingStepId}
registerMeasureRef={registerMeasureRef} registerMeasureRef={registerMeasureRef}
onReorderStep={handleReorderStep}
onReorderAction={handleReorderAction}
/> />
), ),
)} )}

View File

@@ -5,14 +5,11 @@ import {
Save, Save,
RefreshCw, RefreshCw,
Download, Download,
Hash,
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
UploadCloud, Hash,
Wand2,
Sparkles,
GitBranch, GitBranch,
Keyboard, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -20,21 +17,6 @@ import { Separator } from "~/components/ui/separator";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useDesignerStore } from "../state/store"; import { useDesignerStore } from "../state/store";
/**
* BottomStatusBar
*
* Compact, persistent status + quick-action bar for the Experiment Designer.
* Shows:
* - Validation / drift / unsaved state
* - Short design hash & version
* - Aggregate counts (steps / actions)
* - Last persisted hash (if available)
* - Quick actions (Save, Validate, Export, Command Palette)
*
* The bar is intentionally UI-only: callback props are used so that higher-level
* orchestration (e.g. DesignerRoot / Shell) controls actual side effects.
*/
export interface BottomStatusBarProps { export interface BottomStatusBarProps {
onSave?: () => void; onSave?: () => void;
onValidate?: () => void; onValidate?: () => void;
@@ -45,9 +27,6 @@ export interface BottomStatusBarProps {
saving?: boolean; saving?: boolean;
validating?: boolean; validating?: boolean;
exporting?: boolean; exporting?: boolean;
/**
* Optional externally supplied last saved Date for relative display.
*/
lastSavedAt?: Date; lastSavedAt?: Date;
} }
@@ -55,24 +34,16 @@ export function BottomStatusBar({
onSave, onSave,
onValidate, onValidate,
onExport, onExport,
onOpenCommandPalette,
onRecalculateHash,
className, className,
saving, saving,
validating, validating,
exporting, exporting,
lastSavedAt,
}: BottomStatusBarProps) { }: BottomStatusBarProps) {
/* ------------------------------------------------------------------------ */
/* Store Selectors */
/* ------------------------------------------------------------------------ */
const steps = useDesignerStore((s) => s.steps); const steps = useDesignerStore((s) => s.steps);
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash); const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash); const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash); const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
const pendingSave = useDesignerStore((s) => s.pendingSave); const pendingSave = useDesignerStore((s) => s.pendingSave);
const versionStrategy = useDesignerStore((s) => s.versionStrategy);
const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled);
const actionCount = useMemo( const actionCount = useMemo(
() => steps.reduce((sum, st) => sum + st.actions.length, 0), () => steps.reduce((sum, st) => sum + st.actions.length, 0),
@@ -93,64 +64,28 @@ export function BottomStatusBar({
return "valid"; return "valid";
}, [currentDesignHash, lastValidatedHash]); }, [currentDesignHash, lastValidatedHash]);
const shortHash = useMemo(
() => (currentDesignHash ? currentDesignHash.slice(0, 8) : "—"),
[currentDesignHash],
);
const lastPersistedShort = useMemo(
() => (lastPersistedHash ? lastPersistedHash.slice(0, 8) : null),
[lastPersistedHash],
);
/* ------------------------------------------------------------------------ */
/* Derived Display Helpers */
/* ------------------------------------------------------------------------ */
function formatRelative(date?: Date): string {
if (!date) return "—";
const now = Date.now();
const diffMs = now - date.getTime();
if (diffMs < 30_000) return "just now";
const mins = Math.floor(diffMs / 60_000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
const relSaved = formatRelative(lastSavedAt);
const validationBadge = (() => { const validationBadge = (() => {
switch (validationStatus) { switch (validationStatus) {
case "valid": case "valid":
return ( return (
<Badge <div className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
variant="outline" <CheckCircle2 className="h-3.5 w-3.5" />
className="border-green-400 text-green-600 dark:text-green-400" <span className="hidden sm:inline">Valid</span>
title="Validated (hash stable)" </div>
>
<CheckCircle2 className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Validated</span>
</Badge>
); );
case "drift": case "drift":
return ( return (
<Badge <div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
variant="destructive" <AlertTriangle className="h-3.5 w-3.5" />
className="border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400" <span className="hidden sm:inline">Modified</span>
title="Drift since last validation" </div>
>
<AlertTriangle className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Drift</span>
</Badge>
); );
default: default:
return ( return (
<Badge variant="outline" title="Not validated yet"> <div className="flex items-center gap-1.5 text-muted-foreground">
<Hash className="mr-1 h-3 w-3" /> <Hash className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Unvalidated</span> <span className="hidden sm:inline">Unvalidated</span>
</Badge> </div>
); );
} }
})(); })();
@@ -159,190 +94,63 @@ export function BottomStatusBar({
hasUnsaved && !pendingSave ? ( hasUnsaved && !pendingSave ? (
<Badge <Badge
variant="outline" variant="outline"
className="border-orange-300 text-orange-600 dark:text-orange-400" className="h-5 gap-1 border-orange-300 px-1.5 text-[10px] font-normal text-orange-600 dark:text-orange-400"
title="Unsaved changes"
> >
<AlertTriangle className="mr-1 h-3 w-3" /> Unsaved
<span className="hidden sm:inline">Unsaved</span>
</Badge> </Badge>
) : null; ) : null;
const savingIndicator = const savingIndicator =
pendingSave || saving ? ( pendingSave || saving ? (
<Badge <div className="flex items-center gap-1.5 text-muted-foreground animate-pulse">
variant="secondary" <RefreshCw className="h-3 w-3 animate-spin" />
className="animate-pulse" <span>Saving...</span>
title="Saving changes" </div>
>
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
Saving
</Badge>
) : null; ) : null;
/* ------------------------------------------------------------------------ */
/* Handlers */
/* ------------------------------------------------------------------------ */
const handleSave = useCallback(() => {
if (onSave) onSave();
}, [onSave]);
const handleValidate = useCallback(() => {
if (onValidate) onValidate();
}, [onValidate]);
const handleExport = useCallback(() => {
if (onExport) onExport();
}, [onExport]);
const handlePalette = useCallback(() => {
if (onOpenCommandPalette) onOpenCommandPalette();
}, [onOpenCommandPalette]);
const handleRecalculateHash = useCallback(() => {
if (onRecalculateHash) onRecalculateHash();
}, [onRecalculateHash]);
/* ------------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------------ */
return ( return (
<div <div
className={cn( className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur", "border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs", "flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
"font-medium",
className, className,
)} )}
aria-label="Designer status bar"
> >
{/* Left Cluster: Validation & Hash */} {/* Status Indicators */}
<div className="flex min-w-0 items-center gap-2"> <div className="flex items-center gap-3 min-w-0">
{validationBadge} {validationBadge}
{unsavedBadge} {unsavedBadge}
{savingIndicator} {savingIndicator}
<Separator orientation="vertical" className="h-4" />
<div
className="flex items-center gap-1 font-mono text-[11px]"
title="Current design hash"
>
<Hash className="text-muted-foreground h-3 w-3" />
{shortHash}
{lastPersistedShort && lastPersistedShort !== shortHash && (
<span
className="text-muted-foreground/70"
title="Last persisted hash"
>
/ {lastPersistedShort}
</span>
)}
</div>
</div> </div>
{/* Middle Cluster: Aggregate Counts */} <Separator orientation="vertical" className="h-4 opacity-50" />
<div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
<div {/* Stats */}
className="flex items-center gap-1" <div className="text-muted-foreground flex items-center gap-3 truncate">
title="Steps in current design" <span className="flex items-center gap-1.5">
> <GitBranch className="h-3.5 w-3.5 opacity-70" />
<GitBranch className="h-3 w-3" />
{steps.length} {steps.length}
<span className="hidden sm:inline"> steps</span> </span>
</div> <span className="flex items-center gap-1.5">
<div <Sparkles className="h-3.5 w-3.5 opacity-70" />
className="flex items-center gap-1"
title="Total actions across all steps"
>
<Sparkles className="h-3 w-3" />
{actionCount} {actionCount}
<span className="hidden sm:inline"> actions</span> </span>
</div>
<div
className="hidden items-center gap-1 sm:flex"
title="Auto-save setting"
>
<UploadCloud className="h-3 w-3" />
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
</div>
<div
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
>
<Hash className="h-3 w-3" />
{currentDesignHash?.slice(0, 16) ?? '—'}
<Button
variant="ghost"
size="sm"
className="h-5 px-1 ml-1"
onClick={handleRecalculateHash}
aria-label="Recalculate hash"
title="Recalculate hash"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
title="Relative time since last save"
>
Saved {relSaved}
</div>
</div> </div>
{/* Flexible Spacer */}
<div className="flex-1" /> <div className="flex-1" />
{/* Right Cluster: Quick Actions */} {/* Actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2" className="h-7 px-2 text-xs"
disabled={!hasUnsaved && !pendingSave} onClick={onExport}
onClick={handleSave}
aria-label="Save (s)"
title="Save (s)"
>
<Save className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Save</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleValidate}
disabled={validating}
aria-label="Validate (v)"
title="Validate (v)"
>
<RefreshCw
className={cn("mr-1 h-3 w-3", validating && "animate-spin")}
/>
<span className="hidden sm:inline">Validate</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExport}
disabled={exporting} disabled={exporting}
aria-label="Export (e)" title="Export JSON"
title="Export (e)"
> >
<Download className="mr-1 h-3 w-3" /> <Download className="mr-1.5 h-3.5 w-3.5" />
<span className="hidden sm:inline">Export</span> Export
</Button>
<Separator orientation="vertical" className="mx-1 h-4" />
<Button
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handlePalette}
aria-label="Command Palette (⌘K)"
title="Command Palette (⌘K)"
>
<Keyboard className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Commands</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -404,6 +404,28 @@ export function PanelsContainer({
{right} {right}
</Panel> </Panel>
)} )}
{/* Resize Handles */}
{hasLeft && !leftCollapsed && (
<button
type="button"
className="absolute top-0 bottom-0 w-1.5 -ml-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
style={{ left: "var(--col-left)" }}
onPointerDown={startDrag("left")}
onKeyDown={onKeyResize("left")}
aria-label="Resize left panel"
/>
)}
{hasRight && !rightCollapsed && (
<button
type="button"
className="absolute top-0 bottom-0 w-1.5 -mr-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
style={{ right: "var(--col-right)" }}
onPointerDown={startDrag("right")}
onKeyDown={onKeyResize("right")}
aria-label="Resize right panel"
/>
)}
</div> </div>
</> </>
); );

View File

@@ -67,6 +67,10 @@ export interface InspectorPanelProps {
name: string; name: string;
version: string; version: string;
}>; }>;
/**
* Called to clear all validation issues.
*/
onClearAll?: () => void;
} }
export function InspectorPanel({ export function InspectorPanel({
@@ -77,6 +81,7 @@ export function InspectorPanel({
studyPlugins, studyPlugins,
collapsed, collapsed,
onCollapse, onCollapse,
onClearAll,
}: InspectorPanelProps) { }: InspectorPanelProps) {
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Store Selectors */ /* Store Selectors */
@@ -323,6 +328,7 @@ export function InspectorPanel({
> >
<ValidationPanel <ValidationPanel
issues={validationIssues} issues={validationIssues}
onClearAll={onClearAll}
entityLabelForId={(entityId) => { entityLabelForId={(entityId) => {
if (entityId.startsWith("action-")) { if (entityId.startsWith("action-")) {
for (const s of steps) { for (const s of steps) {

View File

@@ -167,8 +167,6 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] { function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps return steps
.slice()
.sort((a, b) => a.order - b.order)
.map((s, idx) => ({ ...s, order: idx })); .map((s, idx) => ({ ...s, order: idx }));
} }

View File

@@ -49,11 +49,10 @@ export interface ValidationResult {
/* Validation Rule Sets */ /* Validation Rule Sets */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
// Steps should ALWAYS execute sequentially
// Parallel/conditional/loop execution happens at the ACTION level, not step level
const VALID_STEP_TYPES: StepType[] = [ const VALID_STEP_TYPES: StepType[] = [
"sequential", "sequential",
"parallel",
"conditional",
"loop",
]; ];
const VALID_TRIGGER_TYPES: TriggerType[] = [ const VALID_TRIGGER_TYPES: TriggerType[] = [
"trial_start", "trial_start",
@@ -144,48 +143,8 @@ export function validateStructural(
}); });
} }
// Conditional step must have conditions // All steps must be sequential type (parallel/conditional/loop removed)
if (step.type === "conditional") { // Control flow and parallelism should be implemented at the ACTION level
const conditionKeys = Object.keys(step.trigger.conditions || {});
if (conditionKeys.length === 0) {
issues.push({
severity: "error",
message: "Conditional step must define at least one condition",
category: "structural",
field: "trigger.conditions",
stepId,
suggestion: "Add conditions to define when this step should execute",
});
}
}
// Loop step should have termination conditions
if (step.type === "loop") {
const conditionKeys = Object.keys(step.trigger.conditions || {});
if (conditionKeys.length === 0) {
issues.push({
severity: "warning",
message:
"Loop step should define termination conditions to prevent infinite loops",
category: "structural",
field: "trigger.conditions",
stepId,
suggestion: "Add conditions to control when the loop should exit",
});
}
}
// Parallel step should have multiple actions
if (step.type === "parallel" && step.actions.length < 2) {
issues.push({
severity: "warning",
message:
"Parallel step has fewer than 2 actions - consider using sequential type",
category: "structural",
stepId,
suggestion: "Add more actions or change to sequential execution",
});
}
// Action-level structural validation // Action-level structural validation
step.actions.forEach((action) => { step.actions.forEach((action) => {
@@ -234,6 +193,7 @@ export function validateStructural(
} }
// Plugin actions need plugin metadata // Plugin actions need plugin metadata
/* VALIDATION DISABLED BY USER REQUEST
if (action.source?.kind === "plugin") { if (action.source?.kind === "plugin") {
if (!action.source.pluginId) { if (!action.source.pluginId) {
issues.push({ issues.push({
@@ -258,6 +218,7 @@ export function validateStructural(
}); });
} }
} }
*/
// Execution descriptor validation // Execution descriptor validation
if (!action.execution?.transport) { if (!action.execution?.transport) {
@@ -532,10 +493,9 @@ export function validateSemantic(
// Check for empty steps // Check for empty steps
steps.forEach((step) => { steps.forEach((step) => {
if (step.actions.length === 0) { if (step.actions.length === 0) {
const severity = step.type === "parallel" ? "error" : "warning";
issues.push({ issues.push({
severity, severity: "warning",
message: `${step.type} step has no actions`, message: "Step has no actions",
category: "semantic", category: "semantic",
stepId: step.id, stepId: step.id,
suggestion: "Add actions to this step or remove it", suggestion: "Add actions to this step or remove it",
@@ -635,25 +595,9 @@ export function validateExecution(
): ValidationIssue[] { ): ValidationIssue[] {
const issues: ValidationIssue[] = []; const issues: ValidationIssue[] = [];
// Check for unreachable steps (basic heuristic) // Note: Trigger validation removed - convertDatabaseToSteps() automatically assigns
if (steps.length > 1) { // correct triggers (trial_start for first step, previous_step for others) based on orderIndex.
const trialStartSteps = steps.filter( // Manual trigger configuration is intentional for advanced workflows.
(s) => s.trigger.type === "trial_start",
);
if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => {
issues.push({
severity: "info",
message:
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
category: "execution",
field: "trigger.type",
stepId: step.id,
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
});
});
}
}
// Check for missing robot dependencies // Check for missing robot dependencies
const robotActions = steps.flatMap((step) => const robotActions = steps.flatMap((step) =>

View File

@@ -158,3 +158,89 @@ export function convertActionToDatabase(
category: action.category, category: action.category,
}; };
} }
// Reconstruct designer steps from database records
export function convertDatabaseToSteps(
dbSteps: any[] // Typing as any[] because Drizzle types are complex to import here without circular deps
): ExperimentStep[] {
// Paranoid Sort: Ensure steps are strictly ordered by index before assigning Triggers.
// This safeguards against API returning unsorted data.
const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
return sortedSteps.map((dbStep, idx) => {
// console.log(`[block-converter] Step ${dbStep.name} OrderIndex:`, dbStep.orderIndex, dbStep.order_index);
return {
id: dbStep.id,
name: dbStep.name,
description: dbStep.description ?? undefined,
type: mapDatabaseToStepType(dbStep.type),
order: dbStep.orderIndex ?? idx, // Fallback to array index if missing
trigger: {
// Enforce Sequential Architecture: Validated by user requirement.
// Index 0 is Trial Start, all others are Previous Step.
type: idx === 0 ? "trial_start" : "previous_step",
conditions: (dbStep.conditions as Record<string, unknown>) || {},
},
expanded: true, // Default to expanded in designer
actions: (dbStep.actions || []).map((dbAction: any) =>
convertDatabaseToAction(dbAction)
),
};
});
}
function mapDatabaseToStepType(type: string): ExperimentStep["type"] {
switch (type) {
case "wizard":
return "sequential";
case "parallel":
return "parallel";
case "conditional":
return "conditional"; // Loop is also stored as conditional, distinction lost unless encoded in metadata
default:
return "sequential";
}
}
export function convertDatabaseToAction(dbAction: any): ExperimentAction {
// Reconstruct nested source object
const source: ExperimentAction["source"] = {
kind: (dbAction.sourceKind || dbAction.source_kind || "core") as "core" | "plugin",
pluginId: dbAction.pluginId || dbAction.plugin_id || undefined,
pluginVersion: dbAction.pluginVersion || dbAction.plugin_version || undefined,
robotId: dbAction.robotId || dbAction.robot_id || undefined,
baseActionId: dbAction.baseActionId || dbAction.base_action_id || undefined,
};
// Robust Inference: If properties are missing but Type suggests a plugin (e.g., "nao6-ros2.say_text"),
// assume/infer the pluginId to ensure validation passes.
if (dbAction.type && dbAction.type.includes(".") && !source.pluginId) {
const parts = dbAction.type.split(".");
if (parts.length === 2) {
source.kind = "plugin";
source.pluginId = parts[0];
// Fallback robotId if missing
if (!source.robotId) source.robotId = parts[0];
}
}
// Reconstruct execution object
const execution: ExecutionDescriptor = {
transport: dbAction.transport as ExecutionDescriptor["transport"],
ros2: dbAction.ros2 as ExecutionDescriptor["ros2"],
rest: dbAction.rest as ExecutionDescriptor["rest"],
retryable: dbAction.retryable ?? false,
};
return {
id: dbAction.id,
name: dbAction.name,
description: dbAction.description ?? undefined,
type: dbAction.type,
category: dbAction.category ?? "general",
parameters: (dbAction.parameters as Record<string, unknown>) || {},
source,
execution,
parameterSchemaRaw: dbAction.parameterSchemaRaw,
};
}

View File

@@ -119,26 +119,13 @@ export const TRIGGER_OPTIONS = [
]; ];
// Step type options for UI // Step type options for UI
// IMPORTANT: Steps should ALWAYS execute sequentially
// Parallel execution, conditionals, and loops should be implemented via control flow ACTIONS
export const STEP_TYPE_OPTIONS = [ export const STEP_TYPE_OPTIONS = [
{ {
value: "sequential" as const, value: "sequential" as const,
label: "Sequential", label: "Sequential",
description: "Actions run one after another", description: "Actions run one after another (enforced for all steps)",
},
{
value: "parallel" as const,
label: "Parallel",
description: "Actions run at the same time",
},
{
value: "conditional" as const,
label: "Conditional",
description: "Actions run if condition is met",
},
{
value: "loop" as const,
label: "Loop",
description: "Actions repeat multiple times",
}, },
]; ];

View File

@@ -17,7 +17,10 @@ import {
studyMembers, studyMembers,
userSystemRoles, userSystemRoles,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { convertStepsToDatabase } from "~/lib/experiment-designer/block-converter"; import {
convertStepsToDatabase,
convertDatabaseToSteps,
} from "~/lib/experiment-designer/block-converter";
import type { import type {
ExperimentStep, ExperimentStep,
ExperimentDesign, ExperimentDesign,
@@ -382,6 +385,7 @@ export const experimentsRouter = createTRPCRouter({
return { return {
...experiment, ...experiment,
steps: convertDatabaseToSteps(experiment.steps),
integrityHash: experiment.integrityHash, integrityHash: experiment.integrityHash,
executionGraphSummary, executionGraphSummary,
pluginDependencies: experiment.pluginDependencies ?? [], pluginDependencies: experiment.pluginDependencies ?? [],

View File

@@ -69,6 +69,17 @@
--shadow-opacity: var(--shadow-opacity); --shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color); --color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
/* Validation Colors */
--color-validation-error-bg: var(--validation-error-bg);
--color-validation-error-text: var(--validation-error-text);
--color-validation-error-border: var(--validation-error-border);
--color-validation-warning-bg: var(--validation-warning-bg);
--color-validation-warning-text: var(--validation-warning-text);
--color-validation-warning-border: var(--validation-warning-border);
--color-validation-info-bg: var(--validation-info-bg);
--color-validation-info-text: var(--validation-info-text);
--color-validation-info-border: var(--validation-info-border);
} }
:root { :root {
@@ -140,14 +151,12 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
/* Dark Mode (Inverted: Lighter BG, Black Cards) */ --background: hsl(240 10% 3.9%);
--background: hsl(240 3.7% 15.9%);
/* Lighter Dark BG */
--foreground: hsl(0 0% 98%); --foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%); /* Distinct Card Background for better contrast */
/* Deep Black Card */ --card: hsl(240 5% 9%);
--card-foreground: hsl(0 0% 98%); --card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%); --popover: hsl(240 5% 9%);
--popover-foreground: hsl(0 0% 98%); --popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%); --primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%); --primary-foreground: hsl(240 5.9% 10%);
@@ -180,27 +189,25 @@
@layer base { @layer base {
.dark { .dark {
/* Dark Mode (Zinc) */
--background: hsl(240 10% 3.9%); --background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%); --foreground: hsl(0 0% 98%);
--card: hsl(240 3.7% 15.9%); --card: hsl(240 5% 9%);
--card-foreground: hsl(0 0% 98%); --card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%); --popover: hsl(240 5% 9%);
--popover-foreground: hsl(0 0% 98%); --popover-foreground: hsl(0 0% 98%);
--primary: hsl(217.2 91.2% 59.8%); --primary: hsl(0 0% 98%);
/* Indigo-400 */ --primary-foreground: hsl(240 5.9% 10%);
--primary-foreground: hsl(222.2 47.4% 11.2%); --secondary: hsl(240 3.7% 15.9%);
--secondary: hsl(217.2 32.6% 17.5%); --secondary-foreground: hsl(0 0% 98%);
--secondary-foreground: hsl(210 40% 98%); --muted: hsl(240 3.7% 15.9%);
--muted: hsl(217.2 32.6% 17.5%); --muted-foreground: hsl(240 5% 64.9%);
--muted-foreground: hsl(215 20.2% 65.1%); --accent: hsl(240 3.7% 15.9%);
--accent: hsl(217.2 32.6% 17.5%); --accent-foreground: hsl(0 0% 98%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%); --destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%); --destructive-foreground: hsl(0 0% 98%);
--border: hsl(217.2 32.6% 17.5%); --border: hsl(240 3.7% 15.9%);
--input: hsl(217.2 32.6% 17.5%); --input: hsl(240 3.7% 15.9%);
--ring: hsl(217.2 91.2% 59.8%); --ring: hsl(240 4.9% 83.9%);
--chart-1: hsl(220 70% 50%); --chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%); --chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%); --chart-3: hsl(30 80% 55%);
@@ -213,11 +220,53 @@
--sidebar-accent: hsl(240 3.7% 15.9%); --sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%); --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%); --sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar-ring: hsl(217.2 91.2% 59.8%);
/* Validation Dark Mode */
--validation-error-bg: hsl(0 75% 15%);
/* Red 950-ish */
--validation-error-text: hsl(0 100% 90%);
/* Red 100 */
--validation-error-border: hsl(0 50% 30%);
/* Red 900 */
--validation-warning-bg: hsl(30 90% 10%);
/* Amber 950-ish */
--validation-warning-text: hsl(30 100% 90%);
/* Amber 100 */
--validation-warning-border: hsl(30 60% 30%);
/* Amber 900 */
--validation-info-bg: hsl(210 50% 15%);
/* Blue 950-ish */
--validation-info-text: hsl(210 100% 90%);
/* Blue 100 */
--validation-info-border: hsl(210 40% 30%);
/* Blue 900 */
} }
} }
:root {
/* Validation Light Mode Defaults */
--validation-error-bg: hsl(0 85% 97%);
/* Red 50 */
--validation-error-text: hsl(0 72% 45%);
/* Red 700 */
--validation-error-border: hsl(0 80% 90%);
/* Red 200 */
--validation-warning-bg: hsl(40 85% 97%);
/* Amber 50 */
--validation-warning-text: hsl(35 90% 35%);
/* Amber 700 */
--validation-warning-border: hsl(40 80% 90%);
/* Amber 200 */
--validation-info-bg: hsl(210 85% 97%);
/* Blue 50 */
--validation-info-text: hsl(220 80% 45%);
/* Blue 700 */
--validation-info-border: hsl(210 80% 90%);
/* Blue 200 */
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;