mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Compare commits
3 Commits
nao_ros2
...
388897c70e
| Author | SHA1 | Date | |
|---|---|---|---|
| 388897c70e | |||
| 0ec63b3c97 | |||
| 89c44efcf7 |
Submodule robot-plugins updated: c6310d3144...d554891dab
65
scripts/check-db.ts
Normal file
65
scripts/check-db.ts
Normal 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);
|
||||
@@ -159,6 +159,7 @@ async function main() {
|
||||
status: "ready",
|
||||
robotId: naoRobot!.id,
|
||||
createdBy: adminUser.id,
|
||||
// visualDesign will be auto-generated by designer from DB steps
|
||||
}).returning();
|
||||
|
||||
// 5. Create Steps & Actions (The Interactive Storyteller Protocol)
|
||||
@@ -168,98 +169,116 @@ async function main() {
|
||||
const [step1] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "The Hook",
|
||||
description: "Initial greeting and engagement",
|
||||
description: "Initial greeting and story introduction",
|
||||
type: "robot",
|
||||
orderIndex: 0,
|
||||
required: true,
|
||||
durationEstimate: 30
|
||||
durationEstimate: 25
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step1!.id,
|
||||
name: "Greet Participant",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
name: "Introduce Story",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Hello there! I have a wonderful story to share with you today.", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step1!.id,
|
||||
name: "Wave Greeting",
|
||||
name: "Welcome Gesture",
|
||||
type: "nao6-ros2.move_arm",
|
||||
orderIndex: 1,
|
||||
// Raising right arm to wave position
|
||||
// Open hand/welcome position
|
||||
parameters: {
|
||||
arm: "right",
|
||||
shoulder_pitch: -1.0,
|
||||
shoulder_roll: -0.3,
|
||||
elbow_yaw: 1.5,
|
||||
elbow_roll: 0.5,
|
||||
speed: 0.5
|
||||
shoulder_pitch: 1.0,
|
||||
shoulder_roll: -0.2,
|
||||
elbow_yaw: 0.5,
|
||||
elbow_roll: -0.4,
|
||||
speed: 0.4
|
||||
},
|
||||
pluginId: naoPlugin!.id,
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "movement",
|
||||
retryable: true
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Step 2: The Narrative (Part 1) ---
|
||||
// --- Step 2: The Narrative ---
|
||||
const [step2] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "The Narrative - Part 1",
|
||||
description: "Robot tells the first part of the story",
|
||||
name: "The Narrative",
|
||||
description: "Robot tells the space traveler story with gaze behavior",
|
||||
type: "robot",
|
||||
orderIndex: 1,
|
||||
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
|
||||
}).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([
|
||||
{
|
||||
stepId: step3!.id,
|
||||
name: "Ask Question",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Did you understand the story so far?", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: { text: "What color was the rock the traveler found?" },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step3!.id,
|
||||
@@ -267,7 +286,7 @@ async function main() {
|
||||
type: "wizard_wait_for_response",
|
||||
orderIndex: 1,
|
||||
parameters: {
|
||||
prompt_text: "Did participant answer 'Alpha'?",
|
||||
prompt_text: "Did participant answer 'Red' correctly?",
|
||||
response_type: "verbal",
|
||||
timeout: 60
|
||||
},
|
||||
@@ -276,36 +295,108 @@ async function main() {
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Step 4: Feedback (Positive/Negative branches implied) ---
|
||||
// For linear seed, we just add the Positive feedback step
|
||||
const [step4] = await db.insert(schema.steps).values({
|
||||
// --- Step 4a: Correct Response Branch ---
|
||||
const [step4a] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Positive Feedback",
|
||||
description: "Correct answer response",
|
||||
name: "Branch A: Correct Response",
|
||||
description: "Response when participant says 'Red'",
|
||||
type: "robot",
|
||||
orderIndex: 3,
|
||||
required: true,
|
||||
durationEstimate: 15
|
||||
required: false,
|
||||
durationEstimate: 20
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step4!.id,
|
||||
name: "Express Agreement",
|
||||
stepId: step4a!.id,
|
||||
name: "Confirm Correct Answer",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Yes, exactly!", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: { text: "Yes! It was a glowing red rock.", emotion: "happy", speed: 1.0 },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step4!.id,
|
||||
name: "Say Correct",
|
||||
type: "nao6-ros2.say_text",
|
||||
stepId: step4a!.id,
|
||||
name: "Nod Head",
|
||||
type: "nao6-ros2.turn_head",
|
||||
orderIndex: 1,
|
||||
parameters: { text: "That is correct! Well done." },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: { yaw: 0.0, pitch: -0.3, speed: 0.5 },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
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({
|
||||
experimentId: experiment!.id,
|
||||
name: "Conclusion",
|
||||
description: "Wrap up the story",
|
||||
description: "End the story and thank participant",
|
||||
type: "robot",
|
||||
orderIndex: 4,
|
||||
orderIndex: 5,
|
||||
required: true,
|
||||
durationEstimate: 30
|
||||
durationEstimate: 25
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step5!.id,
|
||||
name: "Finish Story",
|
||||
name: "End Story",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Alpha explored the world and learned many things. The end." },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: { text: "The End. Thank you for listening." },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step5!.id,
|
||||
name: "Say Goodbye",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
name: "Bow Gesture",
|
||||
type: "nao6-ros2.move_arm",
|
||||
orderIndex: 1,
|
||||
parameters: { text: "Goodbye everyone!", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: {
|
||||
arm: "right",
|
||||
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(`- 1 Admin User (sean@soconnor.dev)`);
|
||||
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`);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -43,7 +43,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
{/* Profile Information */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -63,7 +63,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>Change your account password</CardDescription>
|
||||
@@ -116,7 +116,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* User Summary */}
|
||||
<Card>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Account Summary</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -55,7 +55,5 @@ export function DesignerPageClient({
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />
|
||||
);
|
||||
return <DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function DashboardPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{scheduledTrials.map((trial) => (
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50 transition-colors">
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 bg-muted/10 hover:bg-muted/50 hover:shadow-sm transition-all duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<Calendar className="h-5 w-5" />
|
||||
@@ -302,7 +302,7 @@ function StatsCard({
|
||||
trend?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-muted/40 shadow-sm">
|
||||
<Card className="border-muted/40 shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -284,46 +284,8 @@ export class ActionRegistry {
|
||||
|
||||
loadPluginActions(
|
||||
studyId: string,
|
||||
studyPlugins: Array<{
|
||||
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>;
|
||||
};
|
||||
}>,
|
||||
studyPlugins: any[],
|
||||
): void {
|
||||
// console.log("ActionRegistry.loadPluginActions called with:", { studyId, pluginCount: studyPlugins?.length ?? 0 });
|
||||
|
||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||
|
||||
if (this.loadedStudyId !== studyId) {
|
||||
@@ -332,17 +294,14 @@ export class ActionRegistry {
|
||||
|
||||
let totalActionsLoaded = 0;
|
||||
|
||||
(studyPlugins ?? []).forEach((studyPlugin) => {
|
||||
const { plugin } = studyPlugin;
|
||||
(studyPlugins ?? []).forEach((plugin) => {
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
|
||||
// console.log(`Plugin ${plugin.id}:`, { actionCount: actionDefs?.length ?? 0 });
|
||||
|
||||
if (!actionDefs) return;
|
||||
|
||||
actionDefs.forEach((action) => {
|
||||
actionDefs.forEach((action: any) => {
|
||||
const rawCategory =
|
||||
typeof action.category === "string"
|
||||
? action.category.toLowerCase().trim()
|
||||
|
||||
@@ -8,7 +8,17 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Play, RefreshCw, HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Maximize2,
|
||||
Minimize2
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
@@ -27,7 +37,7 @@ import {
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
KeyboardSensor,
|
||||
closestCorners,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
type DragOverEvent,
|
||||
@@ -35,7 +45,8 @@ import {
|
||||
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
||||
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
||||
import { InspectorPanel } from "./panels/InspectorPanel";
|
||||
import { FlowWorkspace } from "./flow/FlowWorkspace";
|
||||
import { FlowWorkspace, SortableActionChip, StepCardPreview } from "./flow/FlowWorkspace";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
import {
|
||||
type ExperimentDesign,
|
||||
@@ -44,12 +55,13 @@ import {
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { useDesignerStore } from "./state/store";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { actionRegistry, useActionRegistry } from "./ActionRegistry";
|
||||
import { computeDesignHash } from "./state/hashing";
|
||||
import {
|
||||
validateExperimentDesign,
|
||||
groupIssuesByEntity,
|
||||
} from "./state/validators";
|
||||
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
|
||||
|
||||
/**
|
||||
* DesignerRoot
|
||||
@@ -94,6 +106,7 @@ interface RawExperiment {
|
||||
integrityHash?: string | null;
|
||||
pluginDependencies?: string[] | null;
|
||||
visualDesign?: unknown;
|
||||
steps?: unknown[]; // DB steps from relation
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -101,6 +114,26 @@ interface RawExperiment {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
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 (
|
||||
!exp.visualDesign ||
|
||||
typeof exp.visualDesign !== "object" ||
|
||||
@@ -114,6 +147,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
lastSaved?: string;
|
||||
};
|
||||
if (!Array.isArray(vd.steps)) return undefined;
|
||||
|
||||
return {
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
@@ -152,6 +186,9 @@ export function DesignerRoot({
|
||||
autoCompile = true,
|
||||
onPersist,
|
||||
}: DesignerRootProps) {
|
||||
// Subscribe to registry updates to ensure re-renders when actions load
|
||||
useActionRegistry();
|
||||
|
||||
const { startTour } = useTour();
|
||||
|
||||
/* ----------------------------- Remote Experiment ------------------------- */
|
||||
@@ -159,7 +196,18 @@ export function DesignerRoot({
|
||||
data: experiment,
|
||||
isLoading: loadingExperiment,
|
||||
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({
|
||||
onError: (err) => {
|
||||
@@ -199,6 +247,7 @@ export function DesignerRoot({
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
||||
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
|
||||
const clearAllValidationIssues = useDesignerStore(
|
||||
(s) => s.clearAllValidationIssues,
|
||||
@@ -258,6 +307,23 @@ export function DesignerRoot({
|
||||
const [inspectorTab, setInspectorTab] = useState<
|
||||
"properties" | "issues" | "dependencies"
|
||||
>("properties");
|
||||
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
|
||||
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
|
||||
useEffect(() => {
|
||||
const checkWidth = () => {
|
||||
if (window.innerWidth < 1280) {
|
||||
setLeftCollapsed(true);
|
||||
}
|
||||
};
|
||||
// Check once on mount
|
||||
checkWidth();
|
||||
// Optional: Add resize listener if we want live responsiveness
|
||||
// window.addEventListener('resize', checkWidth);
|
||||
// return () => window.removeEventListener('resize', checkWidth);
|
||||
}, []);
|
||||
/**
|
||||
* Active action being dragged from the Action Library (for DragOverlay rendering).
|
||||
* Captures a lightweight subset for visual feedback.
|
||||
@@ -269,6 +335,11 @@ export function DesignerRoot({
|
||||
description?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [activeSortableItem, setActiveSortableItem] = useState<{
|
||||
type: 'step' | 'action';
|
||||
data: any;
|
||||
} | null>(null);
|
||||
|
||||
/* ----------------------------- Initialization ---------------------------- */
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
@@ -327,13 +398,14 @@ export function DesignerRoot({
|
||||
.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(() => {
|
||||
if (!experiment?.studyId) return;
|
||||
if (!studyPluginsRaw) return;
|
||||
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
|
||||
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
|
||||
}, [experiment?.studyId, studyPluginsRaw]);
|
||||
if (!studyPlugins) return;
|
||||
|
||||
// Pass the flattened plugins which match the structure ActionRegistry expects
|
||||
actionRegistry.loadPluginActions(experiment.studyId, studyPlugins);
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------- Ready State Management ------------------------ */
|
||||
// Mark as ready once initialized and plugins are loaded
|
||||
@@ -348,11 +420,10 @@ export function DesignerRoot({
|
||||
// Small delay to ensure all components have rendered
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true);
|
||||
// console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [initialized, isReady, studyPluginsRaw]);
|
||||
}, [initialized, isReady, studyPlugins]);
|
||||
|
||||
/* ----------------------- Automatic Hash Recomputation -------------------- */
|
||||
// Automatically recompute hash when steps change (debounced to avoid excessive computation)
|
||||
@@ -415,6 +486,7 @@ export function DesignerRoot({
|
||||
const currentSteps = [...steps];
|
||||
// Ensure core actions are loaded before validating
|
||||
await actionRegistry.loadCoreActions();
|
||||
|
||||
const result = validateExperimentDesign(currentSteps, {
|
||||
steps: currentSteps,
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
@@ -482,6 +554,15 @@ export function DesignerRoot({
|
||||
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 ---------------------------------- */
|
||||
const persist = useCallback(async () => {
|
||||
if (!initialized) return;
|
||||
@@ -664,15 +745,21 @@ export function DesignerRoot({
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
/* ----------------------------- Drag Handlers ----------------------------- */
|
||||
/* ----------------------------- Drag Handlers ----------------------------- */
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const activeId = active.id.toString();
|
||||
const activeData = active.data.current;
|
||||
|
||||
console.log("[DesignerRoot] DragStart", { activeId, activeData });
|
||||
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
active.data.current?.action
|
||||
activeId.startsWith("action-") &&
|
||||
activeData?.action
|
||||
) {
|
||||
const a = active.data.current.action as {
|
||||
const a = activeData.action as {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
@@ -686,6 +773,18 @@ export function DesignerRoot({
|
||||
category: a.category,
|
||||
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],
|
||||
@@ -694,14 +793,7 @@ export function DesignerRoot({
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
const store = useDesignerStore.getState();
|
||||
|
||||
// Only handle Library -> Flow projection
|
||||
if (!active.id.toString().startsWith("action-")) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const activeId = active.id.toString();
|
||||
|
||||
if (!over) {
|
||||
if (store.insertionProjection) {
|
||||
@@ -710,6 +802,16 @@ export function DesignerRoot({
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Library -> Flow Projection (Action)
|
||||
if (!activeId.startsWith("action-")) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const overId = over.id.toString();
|
||||
const activeDef = active.data.current?.action;
|
||||
|
||||
@@ -804,6 +906,7 @@ export function DesignerRoot({
|
||||
// Clear overlay immediately
|
||||
toggleLibraryScrollLock(false);
|
||||
setDragOverlayAction(null);
|
||||
setActiveSortableItem(null);
|
||||
|
||||
// Capture and clear projection
|
||||
const store = useDesignerStore.getState();
|
||||
@@ -814,6 +917,32 @@ export function DesignerRoot({
|
||||
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)
|
||||
let stepId: string | null = null;
|
||||
let parentId: string | null = null;
|
||||
@@ -880,8 +1009,9 @@ export function DesignerRoot({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const newAction: ExperimentAction = {
|
||||
id: crypto.randomUUID(),
|
||||
id: newId,
|
||||
type: actionDef.type, // this is the 'type' key
|
||||
name: actionDef.name,
|
||||
category: actionDef.category as any,
|
||||
@@ -906,7 +1036,7 @@ export function DesignerRoot({
|
||||
void recomputeHash();
|
||||
}
|
||||
},
|
||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
|
||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep],
|
||||
);
|
||||
// validation status badges removed (unused)
|
||||
/* ------------------------------- Panels ---------------------------------- */
|
||||
@@ -935,10 +1065,11 @@ export function DesignerRoot({
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
studyPlugins={studyPlugins}
|
||||
onClearAll={clearAllValidationIssues}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[inspectorTab, studyPlugins],
|
||||
[inspectorTab, studyPlugins, clearAllValidationIssues],
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ---------------------------------- */
|
||||
@@ -982,82 +1113,160 @@ export function DesignerRoot({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description={designMeta.description || "No description"}
|
||||
icon={Play}
|
||||
actions={actions}
|
||||
className="pb-6"
|
||||
className="flex-none pb-4"
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
{/* Loading Overlay */}
|
||||
{!isReady && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground text-sm">Loading designer...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content - Fade in when ready */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col overflow-hidden transition-opacity duration-500",
|
||||
isReady ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
<div className="flex-1 min-h-0 w-full px-2 overflow-hidden">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<PanelsContainer
|
||||
showDividers
|
||||
className="min-h-0 flex-1"
|
||||
left={leftPanel}
|
||||
center={centerPanel}
|
||||
right={rightPanel}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
{
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-600",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-600",
|
||||
}[dragOverlayAction.category] || "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
{dragOverlayAction.name}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div className="flex-shrink-0 border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
onRecalculateHash={() => recomputeHash()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
<div className="grid grid-cols-8 gap-4 h-full w-full transition-all duration-300 ease-in-out">
|
||||
{/* Left Panel (Library) */}
|
||||
{!leftCollapsed && (
|
||||
<div className={cn(
|
||||
"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 className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||
{leftPanel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center Panel (Workspace) */}
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
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 className="flex-1 overflow-hidden min-h-0 relative">
|
||||
{centerPanel}
|
||||
</div>
|
||||
<div className="border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
onRecalculateHash={() => recomputeHash()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel (Inspector) */}
|
||||
{!rightCollapsed && (
|
||||
<div className={cn(
|
||||
"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 className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragOverlayAction ? (
|
||||
// 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
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded text-white",
|
||||
dragOverlayAction.category === "robot" && "bg-emerald-600",
|
||||
dragOverlayAction.category === "control" && "bg-amber-500",
|
||||
dragOverlayAction.category === "observation" &&
|
||||
"bg-purple-600",
|
||||
)}
|
||||
/>
|
||||
{dragOverlayAction.name}
|
||||
</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}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -388,17 +388,18 @@ export function PropertiesPanelBase({
|
||||
onValueChange={(val) => {
|
||||
onStepUpdate(selectedStep.id, { type: val as StepType });
|
||||
}}
|
||||
disabled={true}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sequential">Sequential</SelectItem>
|
||||
<SelectItem value="parallel">Parallel</SelectItem>
|
||||
<SelectItem value="conditional">Conditional</SelectItem>
|
||||
<SelectItem value="loop">Loop</SelectItem>
|
||||
</SelectContent>
|
||||
</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>
|
||||
<Label className="text-xs">Trigger</Label>
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface ValidationPanelProps {
|
||||
* Called to clear all issues for an entity.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
@@ -60,25 +64,25 @@ export interface ValidationPanelProps {
|
||||
const severityConfig = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-100 dark:bg-red-950/60",
|
||||
borderColor: "border-red-300 dark:border-red-700",
|
||||
color: "text-validation-error-text",
|
||||
bgColor: "bg-validation-error-bg",
|
||||
borderColor: "border-validation-error-border",
|
||||
badgeVariant: "destructive" as const,
|
||||
label: "Error",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-100 dark:bg-amber-950/60",
|
||||
borderColor: "border-amber-300 dark:border-amber-700",
|
||||
badgeVariant: "secondary" as const,
|
||||
color: "text-validation-warning-text",
|
||||
bgColor: "bg-validation-warning-bg",
|
||||
borderColor: "border-validation-warning-border",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Warning",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-100 dark:bg-blue-950/60",
|
||||
borderColor: "border-blue-300 dark:border-blue-700",
|
||||
color: "text-validation-info-text",
|
||||
bgColor: "bg-validation-info-bg",
|
||||
borderColor: "border-validation-info-border",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Info",
|
||||
},
|
||||
@@ -141,7 +145,7 @@ function IssueItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<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}
|
||||
</p>
|
||||
|
||||
@@ -199,6 +203,7 @@ export function ValidationPanel({
|
||||
onIssueClick,
|
||||
onIssueClear,
|
||||
onEntityClear: _onEntityClear,
|
||||
onClearAll,
|
||||
entityLabelForId,
|
||||
className,
|
||||
}: ValidationPanelProps) {
|
||||
@@ -284,7 +289,7 @@ export function ValidationPanel({
|
||||
className={cn(
|
||||
"h-7 justify-start gap-1 text-[11px]",
|
||||
severityFilter === "error" &&
|
||||
"bg-red-600 text-white hover:opacity-90",
|
||||
"bg-red-600 text-white hover:opacity-90",
|
||||
)}
|
||||
onClick={() => setSeverityFilter("error")}
|
||||
aria-pressed={severityFilter === "error"}
|
||||
@@ -300,7 +305,7 @@ export function ValidationPanel({
|
||||
className={cn(
|
||||
"h-7 justify-start gap-1 text-[11px]",
|
||||
severityFilter === "warning" &&
|
||||
"bg-amber-500 text-white hover:opacity-90",
|
||||
"bg-amber-500 text-white hover:opacity-90",
|
||||
)}
|
||||
onClick={() => setSeverityFilter("warning")}
|
||||
aria-pressed={severityFilter === "warning"}
|
||||
@@ -316,7 +321,7 @@ export function ValidationPanel({
|
||||
className={cn(
|
||||
"h-7 justify-start gap-1 text-[11px]",
|
||||
severityFilter === "info" &&
|
||||
"bg-blue-600 text-white hover:opacity-90",
|
||||
"bg-blue-600 text-white hover:opacity-90",
|
||||
)}
|
||||
onClick={() => setSeverityFilter("info")}
|
||||
aria-pressed={severityFilter === "info"}
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
useDndContext,
|
||||
useDroppable,
|
||||
useDndMonitor,
|
||||
type DragEndEvent,
|
||||
@@ -80,21 +81,27 @@ export interface VirtualItem {
|
||||
|
||||
interface StepRowProps {
|
||||
item: VirtualItem;
|
||||
step: ExperimentStep; // Explicit pass for freshness
|
||||
totalSteps: number;
|
||||
selectedStepId: string | null | undefined;
|
||||
selectedActionId: string | null | undefined;
|
||||
renamingStepId: string | null;
|
||||
onSelectStep: (id: string | undefined) => void;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onToggleExpanded: (step: ExperimentStep) => void;
|
||||
onRenameStep: (step: ExperimentStep, name: string) => void;
|
||||
onRenameStep: (step: ExperimentStep, newName: string) => void;
|
||||
onDeleteStep: (step: ExperimentStep) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
setRenamingStepId: (id: string | 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,
|
||||
step,
|
||||
totalSteps,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
renamingStepId,
|
||||
@@ -106,8 +113,10 @@ const StepRow = React.memo(function StepRow({
|
||||
onDeleteAction,
|
||||
setRenamingStepId,
|
||||
registerMeasureRef,
|
||||
onReorderStep,
|
||||
onReorderAction,
|
||||
}: StepRowProps) {
|
||||
const step = item.step;
|
||||
// const step = item.step; // Removed local derivation
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
|
||||
const displayActions = useMemo(() => {
|
||||
@@ -125,34 +134,19 @@ const StepRow = React.memo(function StepRow({
|
||||
return step.actions;
|
||||
}, [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 = {
|
||||
position: "absolute",
|
||||
top: item.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 25 : undefined,
|
||||
transition: "top 300ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
// transform: CSS.Transform.toString(transform), // Removed
|
||||
// zIndex: isDragging ? 25 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} data-step-id={step.id}>
|
||||
<div style={style} data-step-id={step.id}>
|
||||
<div
|
||||
ref={(el) => registerMeasureRef(step.id, el)}
|
||||
className="relative px-3 py-4"
|
||||
@@ -161,11 +155,10 @@ const StepRow = React.memo(function StepRow({
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 rounded border shadow-sm transition-colors",
|
||||
"mb-2 rounded-lg border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||
: "hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -258,14 +251,33 @@ const StepRow = React.memo(function StepRow({
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div
|
||||
className="text-muted-foreground cursor-grab p-1"
|
||||
aria-label="Drag step"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
<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, 'up');
|
||||
}}
|
||||
disabled={item.index === 0}
|
||||
aria-label="Move step up"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 -rotate-90" />
|
||||
</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>
|
||||
|
||||
@@ -282,7 +294,7 @@ const StepRow = React.memo(function StepRow({
|
||||
Drop actions here
|
||||
</div>
|
||||
) : (
|
||||
displayActions.map((action) => (
|
||||
displayActions.map((action, index) => (
|
||||
<SortableActionChip
|
||||
key={action.id}
|
||||
stepId={step.id}
|
||||
@@ -291,6 +303,9 @@ const StepRow = React.memo(function StepRow({
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
onReorderAction={onReorderAction}
|
||||
isFirst={index === 0}
|
||||
isLast={index === displayActions.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -302,7 +317,51 @@ const StepRow = React.memo(function StepRow({
|
||||
</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 */
|
||||
@@ -331,9 +390,19 @@ function parseSortableAction(id: string): string | null {
|
||||
/* Droppable Overlay (for palette action drops) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-step-drop
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-md transition-colors",
|
||||
@@ -348,26 +417,155 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
/* Sortable Action Chip */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ActionChipProps {
|
||||
export interface ActionChipProps {
|
||||
stepId: string;
|
||||
action: ExperimentAction;
|
||||
parentId: string | null;
|
||||
selectedActionId: string | null | undefined;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
|
||||
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,
|
||||
action,
|
||||
parentId,
|
||||
selectedActionId,
|
||||
onSelectAction,
|
||||
onDeleteAction,
|
||||
onReorderAction,
|
||||
dragHandle,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: ActionChipProps) {
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const isSelected = selectedActionId === action.id;
|
||||
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
@@ -388,35 +586,44 @@ function SortableActionChip({
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const isPlaceholder = action.id === "projection-placeholder";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({
|
||||
id: sortableActionId(action.id),
|
||||
disabled: isPlaceholder, // Disable sortable for placeholder
|
||||
data: {
|
||||
type: "action",
|
||||
stepId,
|
||||
parentId,
|
||||
id: action.id,
|
||||
},
|
||||
});
|
||||
// Compute validation status
|
||||
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
|
||||
const validationStatus = useMemo(() => {
|
||||
if (!issues?.length) return undefined;
|
||||
if (issues.some((i) => i.severity === "error")) return "error";
|
||||
if (issues.some((i) => i.severity === "warning")) return "warning";
|
||||
return "info";
|
||||
}, [issues]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Sortable (Local) DnD Monitoring */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
// useSortable disabled per user request to remove action drag-and-drop
|
||||
// const { ... } = useSortable(...)
|
||||
|
||||
// Use local dragging state or passed prop
|
||||
const isDragging = isSortableDragging || dragHandle;
|
||||
const isDragging = dragHandle || false;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
// transform: CSS.Translate.toString(transform),
|
||||
// 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) */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const nestedDroppableId = `container-${action.id}`;
|
||||
const {
|
||||
isOver: isOverNested,
|
||||
@@ -472,114 +679,61 @@ function SortableActionChip({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
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",
|
||||
// Visual feedback for nested drop
|
||||
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectAction(stepId, action.id);
|
||||
}}
|
||||
{...attributes}
|
||||
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();
|
||||
onDeleteAction(stepId, action.id);
|
||||
}}
|
||||
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}
|
||||
|
||||
{/* Nested Actions Container */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
ref={setNestedNodeRef}
|
||||
className={cn(
|
||||
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
|
||||
)}
|
||||
>
|
||||
<SortableContext
|
||||
items={(displayChildren ?? action.children ?? [])
|
||||
.filter(c => c.id !== "projection-placeholder")
|
||||
.map(c => sortableActionId(c.id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(displayChildren || action.children || []).map((child) => (
|
||||
<SortableActionChip
|
||||
key={child.id}
|
||||
stepId={stepId}
|
||||
action={child}
|
||||
parentId={action.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
/>
|
||||
))}
|
||||
{(!displayChildren?.length && !action.children?.length) && (
|
||||
<div className="text-[10px] text-muted-foreground/60 italic py-1">
|
||||
Drag actions here
|
||||
</div>
|
||||
<ActionChipVisuals
|
||||
action={action}
|
||||
isSelected={isSelected}
|
||||
isDragging={isDragging}
|
||||
isOverNested={isOverNested}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectAction(stepId, action.id);
|
||||
}}
|
||||
onDelete={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteAction(stepId, action.id);
|
||||
}}
|
||||
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
|
||||
dragHandleProps={listeners}
|
||||
isLast={isLast}
|
||||
validationStatus={validationStatus}
|
||||
>
|
||||
{/* Nested Actions Container */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
ref={setNestedNodeRef}
|
||||
className={cn(
|
||||
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
|
||||
)}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
>
|
||||
<SortableContext
|
||||
items={(displayChildren ?? action.children ?? [])
|
||||
.filter(c => c.id !== "projection-placeholder")
|
||||
.map(c => sortableActionId(c.id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(displayChildren || action.children || []).map((child) => (
|
||||
<SortableActionChip
|
||||
key={child.id}
|
||||
stepId={stepId}
|
||||
action={child}
|
||||
parentId={action.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
onReorderAction={onReorderAction}
|
||||
/>
|
||||
))}
|
||||
{(!displayChildren?.length && !action.children?.length) && (
|
||||
<div className="text-[10px] text-muted-foreground/60 italic py-1">
|
||||
Drag actions here
|
||||
</div>
|
||||
)}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
</ActionChipVisuals>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -796,6 +950,52 @@ export function FlowWorkspace({
|
||||
[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 */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
@@ -815,19 +1015,9 @@ export function FlowWorkspace({
|
||||
}
|
||||
const activeId = active.id.toString();
|
||||
const overId = over.id.toString();
|
||||
// Step reorder
|
||||
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step reorder is now handled globally in DesignerRoot
|
||||
|
||||
// Action reorder (supports nesting)
|
||||
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
||||
const activeData = active.data.current;
|
||||
@@ -839,8 +1029,9 @@ export function FlowWorkspace({
|
||||
activeData.type === 'action' && overData.type === 'action'
|
||||
) {
|
||||
const stepId = activeData.stepId as string;
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
// Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
|
||||
const activeActionId = activeData.id;
|
||||
const overActionId = overData.id;
|
||||
|
||||
if (activeActionId !== overActionId) {
|
||||
const newParentId = overData.parentId as string | null;
|
||||
@@ -877,8 +1068,10 @@ export function FlowWorkspace({
|
||||
activeData.type === 'action' &&
|
||||
overData.type === 'action'
|
||||
) {
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
// Fix: Access 'id' directly from data payload
|
||||
const activeActionId = activeData.id;
|
||||
const overActionId = overData.id;
|
||||
|
||||
const activeStepId = activeData.stepId;
|
||||
const overStepId = overData.stepId;
|
||||
const activeParentId = activeData.parentId;
|
||||
@@ -956,7 +1149,8 @@ export function FlowWorkspace({
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="tour-designer-canvas"
|
||||
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
// 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}
|
||||
>
|
||||
{steps.length === 0 ? (
|
||||
@@ -990,6 +1184,8 @@ export function FlowWorkspace({
|
||||
<StepRow
|
||||
key={vi.key}
|
||||
item={vi}
|
||||
step={vi.step}
|
||||
totalSteps={steps.length}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
renamingStepId={renamingStepId}
|
||||
@@ -1004,6 +1200,8 @@ export function FlowWorkspace({
|
||||
onDeleteAction={deleteAction}
|
||||
setRenamingStepId={setRenamingStepId}
|
||||
registerMeasureRef={registerMeasureRef}
|
||||
onReorderStep={handleReorderStep}
|
||||
onReorderAction={handleReorderAction}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -5,14 +5,11 @@ import {
|
||||
Save,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
UploadCloud,
|
||||
Wand2,
|
||||
Sparkles,
|
||||
Hash,
|
||||
GitBranch,
|
||||
Keyboard,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -20,21 +17,6 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
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 {
|
||||
onSave?: () => void;
|
||||
onValidate?: () => void;
|
||||
@@ -45,9 +27,6 @@ export interface BottomStatusBarProps {
|
||||
saving?: boolean;
|
||||
validating?: boolean;
|
||||
exporting?: boolean;
|
||||
/**
|
||||
* Optional externally supplied last saved Date for relative display.
|
||||
*/
|
||||
lastSavedAt?: Date;
|
||||
}
|
||||
|
||||
@@ -55,24 +34,16 @@ export function BottomStatusBar({
|
||||
onSave,
|
||||
onValidate,
|
||||
onExport,
|
||||
onOpenCommandPalette,
|
||||
onRecalculateHash,
|
||||
className,
|
||||
saving,
|
||||
validating,
|
||||
exporting,
|
||||
lastSavedAt,
|
||||
}: BottomStatusBarProps) {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Store Selectors */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
const pendingSave = useDesignerStore((s) => s.pendingSave);
|
||||
const versionStrategy = useDesignerStore((s) => s.versionStrategy);
|
||||
const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled);
|
||||
|
||||
const actionCount = useMemo(
|
||||
() => steps.reduce((sum, st) => sum + st.actions.length, 0),
|
||||
@@ -93,64 +64,28 @@ export function BottomStatusBar({
|
||||
return "valid";
|
||||
}, [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 = (() => {
|
||||
switch (validationStatus) {
|
||||
case "valid":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-green-600 dark:text-green-400"
|
||||
title="Validated (hash stable)"
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Validated</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Valid</span>
|
||||
</div>
|
||||
);
|
||||
case "drift":
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400"
|
||||
title="Drift since last validation"
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Drift</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Modified</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" title="Not validated yet">
|
||||
<Hash className="mr-1 h-3 w-3" />
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Unvalidated</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
@@ -159,190 +94,63 @@ export function BottomStatusBar({
|
||||
hasUnsaved && !pendingSave ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600 dark:text-orange-400"
|
||||
title="Unsaved changes"
|
||||
className="h-5 gap-1 border-orange-300 px-1.5 text-[10px] font-normal text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Unsaved</span>
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
const savingIndicator =
|
||||
pendingSave || saving ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="animate-pulse"
|
||||
title="Saving changes"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||
Saving…
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground animate-pulse">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
) : 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"font-medium",
|
||||
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
aria-label="Designer status bar"
|
||||
>
|
||||
{/* Left Cluster: Validation & Hash */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* Status Indicators */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{validationBadge}
|
||||
{unsavedBadge}
|
||||
{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>
|
||||
|
||||
{/* Middle Cluster: Aggregate Counts */}
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title="Steps in current design"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="text-muted-foreground flex items-center gap-3 truncate">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||
{steps.length}
|
||||
<span className="hidden sm:inline"> steps</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title="Total actions across all steps"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||
{actionCount}
|
||||
<span className="hidden sm:inline"> actions</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>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flexible Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right Cluster: Quick Actions */}
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
disabled={!hasUnsaved && !pendingSave}
|
||||
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}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onExport}
|
||||
disabled={exporting}
|
||||
aria-label="Export (e)"
|
||||
title="Export (e)"
|
||||
title="Export JSON"
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</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>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PanelLeft, Settings2 } from "lucide-react";
|
||||
type Edge = "left" | "right";
|
||||
|
||||
export interface PanelsContainerProps {
|
||||
@@ -36,6 +38,14 @@ export interface PanelsContainerProps {
|
||||
|
||||
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
|
||||
keyboardStepPct?: number;
|
||||
|
||||
/**
|
||||
* Controlled collapse state
|
||||
*/
|
||||
leftCollapsed?: boolean;
|
||||
rightCollapsed?: boolean;
|
||||
onLeftCollapseChange?: (collapsed: boolean) => void;
|
||||
onRightCollapseChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +53,7 @@ export interface PanelsContainerProps {
|
||||
*
|
||||
* Tailwind-first, grid-based panel layout with:
|
||||
* - Drag-resizable left/right panels (no persistence)
|
||||
* - Collapsible side panels
|
||||
* - Strict overflow containment (no page-level x-scroll)
|
||||
* - Internal y-scroll for each panel
|
||||
* - Optional visual dividers on the center panel only (prevents double borders)
|
||||
@@ -64,7 +75,7 @@ const Panel: React.FC<React.PropsWithChildren<{
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
||||
className={cn("min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out", panelCls, panelClassName)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -91,6 +102,10 @@ export function PanelsContainer({
|
||||
minRightPct = 0.12,
|
||||
maxRightPct = 0.33,
|
||||
keyboardStepPct = 0.02,
|
||||
leftCollapsed = false,
|
||||
rightCollapsed = false,
|
||||
onLeftCollapseChange,
|
||||
onRightCollapseChange,
|
||||
}: PanelsContainerProps) {
|
||||
const hasLeft = Boolean(left);
|
||||
const hasRight = Boolean(right);
|
||||
@@ -116,20 +131,39 @@ export function PanelsContainer({
|
||||
(lp: number, rp: number) => {
|
||||
if (!hasCenter) return { l: 0, c: 0, r: 0 };
|
||||
|
||||
// Effective widths (0 if collapsed)
|
||||
const effectiveL = leftCollapsed ? 0 : lp;
|
||||
const effectiveR = rightCollapsed ? 0 : rp;
|
||||
|
||||
// When logic runs, we must clamp the *underlying* percentages (lp, rp)
|
||||
// but return 0 for the CSS vars if collapsed.
|
||||
|
||||
// Actually, if collapsed, we just want the CSS var to be 0.
|
||||
// But we maintain the state `leftPct` so it restores correctly.
|
||||
|
||||
if (hasLeft && hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space
|
||||
// Standard clamp (on the state values)
|
||||
const lState = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const rState = clamp(rp, minRightPct, maxRightPct);
|
||||
|
||||
// Effective output
|
||||
const l = leftCollapsed ? 0 : lState;
|
||||
const r = rightCollapsed ? 0 : rState;
|
||||
|
||||
// Center takes remainder
|
||||
const c = 1 - (l + r);
|
||||
return { l, c, r };
|
||||
}
|
||||
if (hasLeft && !hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const c = Math.max(0.2, 1 - l);
|
||||
const lState = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const l = leftCollapsed ? 0 : lState;
|
||||
const c = 1 - l;
|
||||
return { l, c, r: 0 };
|
||||
}
|
||||
if (!hasLeft && hasRight) {
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.2, 1 - r);
|
||||
const rState = clamp(rp, minRightPct, maxRightPct);
|
||||
const r = rightCollapsed ? 0 : rState;
|
||||
const c = 1 - r;
|
||||
return { l: 0, c, r };
|
||||
}
|
||||
// Center only
|
||||
@@ -143,6 +177,8 @@ export function PanelsContainer({
|
||||
maxLeftPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
leftCollapsed,
|
||||
rightCollapsed
|
||||
],
|
||||
);
|
||||
|
||||
@@ -157,10 +193,10 @@ export function PanelsContainer({
|
||||
const deltaPx = e.clientX - d.startX;
|
||||
const deltaPct = deltaPx / d.containerWidth;
|
||||
|
||||
if (d.edge === "left" && hasLeft) {
|
||||
if (d.edge === "left" && hasLeft && !leftCollapsed) {
|
||||
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
|
||||
setLeftPct(nextLeft);
|
||||
} else if (d.edge === "right" && hasRight) {
|
||||
} else if (d.edge === "right" && hasRight && !rightCollapsed) {
|
||||
// Dragging the right edge moves leftwards as delta increases
|
||||
const nextRight = clamp(
|
||||
d.startRight - deltaPct,
|
||||
@@ -170,7 +206,7 @@ export function PanelsContainer({
|
||||
setRightPct(nextRight);
|
||||
}
|
||||
},
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct],
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
@@ -213,14 +249,14 @@ export function PanelsContainer({
|
||||
|
||||
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
|
||||
|
||||
if (edge === "left" && hasLeft) {
|
||||
if (edge === "left" && hasLeft && !leftCollapsed) {
|
||||
const next = clamp(
|
||||
leftPct + (e.key === "ArrowRight" ? step : -step),
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
);
|
||||
setLeftPct(next);
|
||||
} else if (edge === "right" && hasRight) {
|
||||
} else if (edge === "right" && hasRight && !rightCollapsed) {
|
||||
const next = clamp(
|
||||
rightPct + (e.key === "ArrowLeft" ? step : -step),
|
||||
minRightPct,
|
||||
@@ -231,23 +267,33 @@ export function PanelsContainer({
|
||||
};
|
||||
|
||||
// CSS variables for the grid fractions
|
||||
// We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow
|
||||
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
|
||||
? {
|
||||
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
|
||||
"--col-center": `${c * 100}%`,
|
||||
"--col-right": `${(hasRight ? r : 0) * 100}%`,
|
||||
"--col-left": `${hasLeft ? l : 0}fr`,
|
||||
"--col-center": `${c}fr`,
|
||||
"--col-right": `${hasRight ? r : 0}fr`,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Explicit grid template depending on which side panels exist
|
||||
const gridAreas =
|
||||
hasLeft && hasRight
|
||||
? '"left center right"'
|
||||
: hasLeft && !hasRight
|
||||
? '"left center"'
|
||||
: !hasLeft && hasRight
|
||||
? '"center right"'
|
||||
: '"center"';
|
||||
|
||||
const gridCols =
|
||||
hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
? "[grid-template-columns:var(--col-left)_var(--col-center)_var(--col-right)]"
|
||||
: hasLeft && !hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]"
|
||||
? "[grid-template-columns:var(--col-left)_var(--col-center)]"
|
||||
: !hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
: "[grid-template-columns:minmax(0,1fr)]";
|
||||
? "[grid-template-columns:var(--col-center)_var(--col-right)]"
|
||||
: "[grid-template-columns:1fr]";
|
||||
|
||||
// Dividers on the center panel only (prevents double borders if children have their own borders)
|
||||
const centerDividers =
|
||||
@@ -261,81 +307,127 @@ export function PanelsContainer({
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
style={styleVars}
|
||||
className={cn(
|
||||
"relative grid h-full min-h-0 w-full overflow-hidden select-none",
|
||||
gridCols,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasLeft && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{left}
|
||||
</Panel>
|
||||
)}
|
||||
<>
|
||||
{/* Mobile Layout (Flex + Sheets) */}
|
||||
<div className={cn("flex flex-col h-full w-full md:hidden", className)}>
|
||||
{/* Mobile Header/Toolbar for access to panels */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2 bg-background">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasLeft && (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
|
||||
<div className="h-full overflow-hidden">
|
||||
{left}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
<span className="text-sm font-medium">Designer</span>
|
||||
</div>
|
||||
|
||||
{hasCenter && (
|
||||
<Panel
|
||||
className={centerDividers}
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{hasRight && (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
|
||||
<div className="h-full overflow-hidden">
|
||||
{right}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content (Center) */}
|
||||
<div className="flex-1 min-h-0 min-w-0 overflow-hidden relative">
|
||||
{center}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasRight && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{right}
|
||||
</Panel>
|
||||
)}
|
||||
{/* Desktop Layout (Grid) */}
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"relative hidden md:grid h-full min-h-0 w-full max-w-full overflow-hidden select-none",
|
||||
// 2-3-2 ratio for left-center-right panels when all visible
|
||||
hasLeft && hasRight && !leftCollapsed && !rightCollapsed && "grid-cols-[2fr_3fr_2fr]",
|
||||
// Left collapsed: center + right (3:2 ratio)
|
||||
hasLeft && hasRight && leftCollapsed && !rightCollapsed && "grid-cols-[3fr_2fr]",
|
||||
// Right collapsed: left + center (2:3 ratio)
|
||||
hasLeft && hasRight && !leftCollapsed && rightCollapsed && "grid-cols-[2fr_3fr]",
|
||||
// Both collapsed: center only
|
||||
hasLeft && hasRight && leftCollapsed && rightCollapsed && "grid-cols-1",
|
||||
// Only left and center
|
||||
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
|
||||
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
|
||||
// Only center and right
|
||||
!hasLeft && hasRight && !rightCollapsed && "grid-cols-[3fr_2fr]",
|
||||
!hasLeft && hasRight && rightCollapsed && "grid-cols-1",
|
||||
// Only center
|
||||
!hasLeft && !hasRight && "grid-cols-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasLeft && !leftCollapsed && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{left}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Resize handles (only render where applicable) */}
|
||||
{hasCenter && hasLeft && (
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label="Resize left panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("left")}
|
||||
onKeyDown={onKeyResize("left")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between left and center
|
||||
style={{ left: "var(--col-left)", transform: "translateX(-0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
{hasCenter && (
|
||||
<Panel
|
||||
className={centerDividers}
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{center}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{hasCenter && hasRight && (
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label="Resize right panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("right")}
|
||||
onKeyDown={onKeyResize("right")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between center and right (offset from the right)
|
||||
style={{ right: "var(--col-right)", transform: "translateX(0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasRight && !rightCollapsed && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{right}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Eye,
|
||||
X,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -108,7 +109,7 @@ function DraggableAction({
|
||||
{...listeners}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 text-left transition-colors select-none",
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded-lg border px-2 text-left transition-colors select-none",
|
||||
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
@@ -168,7 +169,12 @@ function DraggableAction({
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionLibraryPanel() {
|
||||
export interface ActionLibraryPanelProps {
|
||||
collapsed?: boolean;
|
||||
onCollapse?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanelProps = {}) {
|
||||
const registry = useActionRegistry();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
AlertTriangle,
|
||||
GitBranch,
|
||||
PackageSearch,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,11 @@ export interface InspectorPanelProps {
|
||||
* Called when user changes tab (only if activeTab not externally controlled).
|
||||
*/
|
||||
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
|
||||
/**
|
||||
* Collapse state and handler
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
onCollapse?: (collapsed: boolean) => void;
|
||||
/**
|
||||
* If true, auto-switch to "properties" when a selection occurs.
|
||||
*/
|
||||
@@ -60,6 +67,10 @@ export interface InspectorPanelProps {
|
||||
name: string;
|
||||
version: string;
|
||||
}>;
|
||||
/**
|
||||
* Called to clear all validation issues.
|
||||
*/
|
||||
onClearAll?: () => void;
|
||||
}
|
||||
|
||||
export function InspectorPanel({
|
||||
@@ -68,6 +79,9 @@ export function InspectorPanel({
|
||||
onTabChange,
|
||||
autoFocusOnSelection = true,
|
||||
studyPlugins,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
onClearAll,
|
||||
}: InspectorPanelProps) {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Store Selectors */
|
||||
@@ -314,6 +328,7 @@ export function InspectorPanel({
|
||||
>
|
||||
<ValidationPanel
|
||||
issues={validationIssues}
|
||||
onClearAll={onClearAll}
|
||||
entityLabelForId={(entityId) => {
|
||||
if (entityId.startsWith("action-")) {
|
||||
for (const s of steps) {
|
||||
|
||||
@@ -167,8 +167,6 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
|
||||
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s, idx) => ({ ...s, order: idx }));
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,10 @@ export interface ValidationResult {
|
||||
/* 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[] = [
|
||||
"sequential",
|
||||
"parallel",
|
||||
"conditional",
|
||||
"loop",
|
||||
];
|
||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||
"trial_start",
|
||||
@@ -144,48 +143,8 @@ export function validateStructural(
|
||||
});
|
||||
}
|
||||
|
||||
// Conditional step must have conditions
|
||||
if (step.type === "conditional") {
|
||||
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",
|
||||
});
|
||||
}
|
||||
// All steps must be sequential type (parallel/conditional/loop removed)
|
||||
// Control flow and parallelism should be implemented at the ACTION level
|
||||
|
||||
// Action-level structural validation
|
||||
step.actions.forEach((action) => {
|
||||
@@ -234,6 +193,7 @@ export function validateStructural(
|
||||
}
|
||||
|
||||
// Plugin actions need plugin metadata
|
||||
/* VALIDATION DISABLED BY USER REQUEST
|
||||
if (action.source?.kind === "plugin") {
|
||||
if (!action.source.pluginId) {
|
||||
issues.push({
|
||||
@@ -258,6 +218,7 @@ export function validateStructural(
|
||||
});
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Execution descriptor validation
|
||||
if (!action.execution?.transport) {
|
||||
@@ -532,10 +493,9 @@ export function validateSemantic(
|
||||
// Check for empty steps
|
||||
steps.forEach((step) => {
|
||||
if (step.actions.length === 0) {
|
||||
const severity = step.type === "parallel" ? "error" : "warning";
|
||||
issues.push({
|
||||
severity,
|
||||
message: `${step.type} step has no actions`,
|
||||
severity: "warning",
|
||||
message: "Step has no actions",
|
||||
category: "semantic",
|
||||
stepId: step.id,
|
||||
suggestion: "Add actions to this step or remove it",
|
||||
@@ -635,25 +595,9 @@ export function validateExecution(
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Check for unreachable steps (basic heuristic)
|
||||
if (steps.length > 1) {
|
||||
const trialStartSteps = steps.filter(
|
||||
(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",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: Trigger validation removed - convertDatabaseToSteps() automatically assigns
|
||||
// correct triggers (trial_start for first step, previous_step for others) based on orderIndex.
|
||||
// Manual trigger configuration is intentional for advanced workflows.
|
||||
|
||||
// Check for missing robot dependencies
|
||||
const robotActions = steps.flatMap((step) =>
|
||||
|
||||
@@ -211,7 +211,7 @@ export function DataTable<TData, TValue>({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden rounded-md border">
|
||||
<div className="min-w-0 overflow-hidden rounded-md border shadow-sm bg-card">
|
||||
<div className="min-w-0 overflow-x-auto overflow-y-hidden">
|
||||
<Table className="min-w-[600px]">
|
||||
<TableHeader>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Logo({
|
||||
}: LogoProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square items-center justify-center rounded-lg p-1">
|
||||
<div className="bg-primary text-primary-foreground flex aspect-square items-center justify-center rounded-lg p-1">
|
||||
<Bot className={iconSizes[iconSize]} />
|
||||
</div>
|
||||
{showText && (
|
||||
|
||||
@@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"bg-background relative flex w-full flex-1 flex-col overflow-x-hidden",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
@@ -569,7 +569,7 @@ function SidebarMenuAction({
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -18,7 +18,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
className={cn("[&_tr]:border-b bg-secondary/30", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -158,3 +158,89 @@ export function convertActionToDatabase(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,26 +119,13 @@ export const TRIGGER_OPTIONS = [
|
||||
];
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
value: "sequential" as const,
|
||||
label: "Sequential",
|
||||
description: "Actions run one after another",
|
||||
},
|
||||
{
|
||||
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",
|
||||
description: "Actions run one after another (enforced for all steps)",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
studyMembers,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
import { convertStepsToDatabase } from "~/lib/experiment-designer/block-converter";
|
||||
import {
|
||||
convertStepsToDatabase,
|
||||
convertDatabaseToSteps,
|
||||
} from "~/lib/experiment-designer/block-converter";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentDesign,
|
||||
@@ -382,6 +385,7 @@ export const experimentsRouter = createTRPCRouter({
|
||||
|
||||
return {
|
||||
...experiment,
|
||||
steps: convertDatabaseToSteps(experiment.steps),
|
||||
integrityHash: experiment.integrityHash,
|
||||
executionGraphSummary,
|
||||
pluginDependencies: experiment.pluginDependencies ?? [],
|
||||
|
||||
@@ -69,42 +69,62 @@
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--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 {
|
||||
--radius: 0rem;
|
||||
--background: oklch(0.98 0.005 60);
|
||||
--foreground: oklch(0.15 0.005 240);
|
||||
--card: oklch(0.995 0.001 60);
|
||||
--card-foreground: oklch(0.15 0.005 240);
|
||||
--popover: oklch(0.99 0.002 60);
|
||||
--popover-foreground: oklch(0.15 0.005 240);
|
||||
--primary: oklch(0.55 0.08 240);
|
||||
--primary-foreground: oklch(0.98 0.01 250);
|
||||
--secondary: oklch(0.94 0.01 240);
|
||||
--secondary-foreground: oklch(0.25 0.02 240);
|
||||
--muted: oklch(0.95 0.008 240);
|
||||
--muted-foreground: oklch(0.52 0.015 240);
|
||||
--accent: oklch(0.92 0.015 240);
|
||||
--accent-foreground: oklch(0.2 0.02 240);
|
||||
--destructive: oklch(0.583 0.2387 28.4765);
|
||||
--border: oklch(0.9 0.008 240);
|
||||
--input: oklch(0.96 0.005 240);
|
||||
--ring: oklch(0.55 0.08 240);
|
||||
--chart-1: oklch(0.55 0.08 240);
|
||||
--chart-2: oklch(0.6 0.1 200);
|
||||
--chart-3: oklch(0.65 0.12 160);
|
||||
--chart-4: oklch(0.7 0.1 120);
|
||||
--chart-5: oklch(0.6 0.15 80);
|
||||
--sidebar: oklch(0.97 0.015 250);
|
||||
--sidebar-foreground: oklch(0.2 0.03 240);
|
||||
--sidebar-primary: oklch(0.3 0.08 240);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.01 250);
|
||||
--sidebar-accent: oklch(0.92 0.025 245);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.05 240);
|
||||
--sidebar-border: oklch(0.85 0.03 245);
|
||||
--sidebar-ring: oklch(0.6 0.05 240);
|
||||
--destructive-foreground: oklch(0.9702 0 0);
|
||||
/* Light Mode (Inverted: White BG, gray Cards) */
|
||||
--radius: 0.5rem;
|
||||
--background: hsl(0 0% 100%);
|
||||
/* Pure White Background */
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(240 4.8% 95.9%);
|
||||
/* Light Gray Card */
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(221.2 83.2% 53.3%);
|
||||
/* Indigo-600 */
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(221.2 83.2% 53.3%);
|
||||
--chart-1: hsl(221.2 83.2% 53.3%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--sidebar: hsl(240 4.8% 95.9%);
|
||||
/* Zinc-100: Distinct contrast against white BG */
|
||||
--sidebar-foreground: hsl(240 10% 3.9%);
|
||||
/* Dark Text */
|
||||
--sidebar-primary: hsl(221.2 83.2% 53.3%);
|
||||
/* Indigo Accent */
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 5.9% 90%);
|
||||
/* Zinc-200: Slightly darker for hover */
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(240 5.9% 90%);
|
||||
/* Zinc-200 Border */
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
@@ -131,82 +151,127 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: oklch(0.12 0.008 250);
|
||||
--foreground: oklch(0.95 0.005 250);
|
||||
--card: oklch(0.18 0.008 250);
|
||||
--card-foreground: oklch(0.95 0.005 250);
|
||||
--popover: oklch(0.2 0.01 250);
|
||||
--popover-foreground: oklch(0.95 0.005 250);
|
||||
--primary: oklch(0.65 0.1 240);
|
||||
--primary-foreground: oklch(0.08 0.02 250);
|
||||
--secondary: oklch(0.25 0.015 245);
|
||||
--secondary-foreground: oklch(0.92 0.008 250);
|
||||
--muted: oklch(0.22 0.01 250);
|
||||
--muted-foreground: oklch(0.65 0.02 245);
|
||||
--accent: oklch(0.35 0.025 245);
|
||||
--accent-foreground: oklch(0.92 0.008 250);
|
||||
--destructive: oklch(0.7022 0.1892 22.2279);
|
||||
--border: oklch(0.3 0.015 250);
|
||||
--input: oklch(0.28 0.015 250);
|
||||
--ring: oklch(0.65 0.1 240);
|
||||
--chart-1: oklch(0.65 0.1 240);
|
||||
--chart-2: oklch(0.7 0.12 200);
|
||||
--chart-3: oklch(0.75 0.15 160);
|
||||
--chart-4: oklch(0.8 0.12 120);
|
||||
--chart-5: oklch(0.7 0.18 80);
|
||||
--sidebar: oklch(0.14 0.025 250);
|
||||
--sidebar-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-primary: oklch(0.8 0.06 240);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.025 250);
|
||||
--sidebar-accent: oklch(0.22 0.04 245);
|
||||
--sidebar-accent-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-border: oklch(0.32 0.035 250);
|
||||
--sidebar-ring: oklch(0.55 0.08 240);
|
||||
--destructive-foreground: oklch(0.95 0.01 250);
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
/* Distinct Card Background for better contrast */
|
||||
--card: hsl(240 5% 9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 5% 9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.dark {
|
||||
--background: oklch(0.12 0.008 250);
|
||||
--foreground: oklch(0.95 0.005 250);
|
||||
--card: oklch(0.18 0.008 250);
|
||||
--card-foreground: oklch(0.95 0.005 250);
|
||||
--popover: oklch(0.2 0.01 250);
|
||||
--popover-foreground: oklch(0.95 0.005 250);
|
||||
--primary: oklch(0.65 0.1 240);
|
||||
--primary-foreground: oklch(0.08 0.02 250);
|
||||
--secondary: oklch(0.25 0.015 245);
|
||||
--secondary-foreground: oklch(0.92 0.008 250);
|
||||
--muted: oklch(0.22 0.01 250);
|
||||
--muted-foreground: oklch(0.65 0.02 245);
|
||||
--accent: oklch(0.35 0.025 245);
|
||||
--accent-foreground: oklch(0.92 0.008 250);
|
||||
--destructive: oklch(0.7022 0.1892 22.2279);
|
||||
--border: oklch(0.3 0.015 250);
|
||||
--input: oklch(0.28 0.015 250);
|
||||
--ring: oklch(0.65 0.1 240);
|
||||
--chart-1: oklch(0.65 0.1 240);
|
||||
--chart-2: oklch(0.7 0.12 200);
|
||||
--chart-3: oklch(0.75 0.15 160);
|
||||
--chart-4: oklch(0.8 0.12 120);
|
||||
--chart-5: oklch(0.7 0.18 80);
|
||||
--sidebar: oklch(0.14 0.025 250);
|
||||
--sidebar-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-primary: oklch(0.8 0.06 240);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.025 250);
|
||||
--sidebar-accent: oklch(0.22 0.04 245);
|
||||
--sidebar-accent-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-border: oklch(0.32 0.035 250);
|
||||
--sidebar-ring: oklch(0.55 0.08 240);
|
||||
--destructive-foreground: oklch(0.95 0.01 250);
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 5% 9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 5% 9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.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-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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
@@ -225,3 +290,11 @@
|
||||
@apply bg-background text-foreground shadow;
|
||||
}
|
||||
}
|
||||
|
||||
/* Viewport height constraint for proper flex layout */
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
Reference in New Issue
Block a user