diff --git a/frontend/src/app/BootGate.jsx b/frontend/src/app/BootGate.jsx new file mode 100644 index 00000000..e4f653e2 --- /dev/null +++ b/frontend/src/app/BootGate.jsx @@ -0,0 +1,49 @@ +'use client'; + +// InGen Studio — boot / persistence guard +// +// Runs once per full page load, on the client, before any child effect reads the store. Saved +// pipelines persist ACROSS reloads (autosave writes them to localStorage); this gate's only job is +// a one-time migration: if the stored data version differs from the code's DATA_VERSION, clear our +// namespace once so we never try to load models written against an incompatible schema. + +import { STORAGE_NAMESPACE, DATA_VERSION } from '../models/constants.js'; + +const NS = STORAGE_NAMESPACE; +const VERSION_KEY = `${NS}:version`; + +let booted = false; + +function migrateIfStale() { + if (localStorage.getItem(VERSION_KEY) === DATA_VERSION) return; // up to date — keep saved work + + const oldVersion = localStorage.getItem(VERSION_KEY) ?? 'none'; + const staleKeys = Object.keys(localStorage).filter((k) => k.startsWith(NS + ':')); + + // Snapshot old data into a backup key before wiping so a future recovery path is possible. + if (staleKeys.length > 0) { + const backup = {}; + staleKeys.forEach((k) => { backup[k] = localStorage.getItem(k); }); + try { + localStorage.setItem(`${NS}:backup:${oldVersion}`, JSON.stringify(backup)); + } catch { + // Storage full — skip backup silently, still need to migrate. + } + console.warn( + `[InGen] Data schema changed (${oldVersion} → ${DATA_VERSION}). ` + + `${staleKeys.length} key(s) cleared. Backup saved to "${NS}:backup:${oldVersion}".` + ); + } + + staleKeys.forEach((k) => localStorage.removeItem(k)); + localStorage.setItem(VERSION_KEY, DATA_VERSION); +} + +export default function BootGate({ children }) { + // localStorage is client-only; guard so this is inert during server rendering. + if (typeof window !== 'undefined' && !booted) { + booted = true; + migrateIfStale(); + } + return children; +} diff --git a/frontend/src/app/configs/[configId]/history/page.jsx b/frontend/src/app/configs/[configId]/history/page.jsx new file mode 100644 index 00000000..220a6804 --- /dev/null +++ b/frontend/src/app/configs/[configId]/history/page.jsx @@ -0,0 +1,7 @@ +'use client'; + +import HistoryView from '../../../../components/run/HistoryView.jsx'; + +export default function HistoryPage() { + return ; +} diff --git a/frontend/src/app/configs/[configId]/interfaces/[interfaceName]/page.jsx b/frontend/src/app/configs/[configId]/interfaces/[interfaceName]/page.jsx new file mode 100644 index 00000000..9b1a9d57 --- /dev/null +++ b/frontend/src/app/configs/[configId]/interfaces/[interfaceName]/page.jsx @@ -0,0 +1,7 @@ +'use client'; + +import InterfaceEditor from '../../../../../components/editor/InterfaceEditor.jsx'; + +export default function InterfaceEditorPage() { + return ; +} diff --git a/frontend/src/app/configs/[configId]/layout.jsx b/frontend/src/app/configs/[configId]/layout.jsx new file mode 100644 index 00000000..465651f7 --- /dev/null +++ b/frontend/src/app/configs/[configId]/layout.jsx @@ -0,0 +1,25 @@ +'use client'; + +// InGen Studio — config workspace layout (segment: /configs/[configId]) +// +// Composes the providers (catalog + the config document) around the WorkspaceLayout. Every nested +// route renders into WorkspaceLayout's content slot and shares this config context. This is the +// Next App Router equivalent of the old react-router element. + +import { useParams } from 'next/navigation'; + +import { CatalogProvider } from '../../../state/CatalogContext.jsx'; +import { ConfigProvider } from '../../../state/ConfigContext.jsx'; +import WorkspaceLayout from '../../../components/layout/WorkspaceLayout.jsx'; + +export default function ConfigLayout({ children }) { + const { configId } = useParams(); + return ( + + {/* key={configId} remounts the document store on config switch — clean load state per id. */} + + {children} + + + ); +} diff --git a/frontend/src/app/configs/[configId]/page.jsx b/frontend/src/app/configs/[configId]/page.jsx new file mode 100644 index 00000000..003e314a --- /dev/null +++ b/frontend/src/app/configs/[configId]/page.jsx @@ -0,0 +1,28 @@ +'use client'; + +// Workspace index. New flow: if the first interface has no sources yet, show the source-first +// Start screen (pick a source → choose inFlow/inChat). Once it has a source, redirect into the +// interface editor so reloads land where you left off. + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +import { useConfig } from '../../../state/ConfigContext.jsx'; +import Start from '../../../components/start/Start.jsx'; + +export default function ConfigIndex() { + const { configId } = useParams(); + const router = useRouter(); + const { model, status } = useConfig(); + + const first = model?.interfaceOrder?.[0]; + const firstHasSources = first && (model.interfacesByName[first]?.sources?.length ?? 0) > 0; + + useEffect(() => { + if (firstHasSources) router.replace(`/configs/${configId}/interfaces/${first}`); + }, [configId, first, firstHasSources, router]); + + if (status === 'loading' || !model) return
Loading config…
; + if (firstHasSources) return
Opening editor…
; + return ; +} diff --git a/frontend/src/app/configs/[configId]/run/page.jsx b/frontend/src/app/configs/[configId]/run/page.jsx new file mode 100644 index 00000000..76a07f59 --- /dev/null +++ b/frontend/src/app/configs/[configId]/run/page.jsx @@ -0,0 +1,7 @@ +'use client'; + +import RunConsole from '../../../../components/run/RunConsole.jsx'; + +export default function RunPage() { + return ; +} diff --git a/frontend/src/app/layout.jsx b/frontend/src/app/layout.jsx new file mode 100644 index 00000000..e4c6e86a --- /dev/null +++ b/frontend/src/app/layout.jsx @@ -0,0 +1,32 @@ +// InGen Studio — root layout +// +// Server component that owns the document shell. Global styles (which also pull in the Inter +// webfont via @import) are imported here. BootGate seeds localStorage on the client; AppShell is +// the persistent brand-bar frame that used to be the top-level react-router layout route. + +import '../index.css'; +import BootGate from './BootGate.jsx'; +import AppShell from '../components/layout/AppShell.jsx'; + +export const metadata = { + title: 'InGen Data Transformation', + description: 'YAML interface authoring for the InGen data transformation pipeline.', + icons: { icon: '/favicon.svg' }, +}; + +export const viewport = { + width: 'device-width', + initialScale: 1, +}; + +export default function RootLayout({ children }) { + return ( + + + + {children} + + + + ); +} diff --git a/frontend/src/app/not-found.jsx b/frontend/src/app/not-found.jsx new file mode 100644 index 00000000..9df6af9f --- /dev/null +++ b/frontend/src/app/not-found.jsx @@ -0,0 +1,10 @@ +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+

Page not found.

+ Back to editor +
+ ); +} diff --git a/frontend/src/app/page.jsx b/frontend/src/app/page.jsx new file mode 100644 index 00000000..edac3f92 --- /dev/null +++ b/frontend/src/app/page.jsx @@ -0,0 +1,275 @@ +'use client'; + +// Index route — the pipelines ledger. Lists every saved pipeline (localStorage, via ConfigService) +// so you can resume past work or start a new one. Replaces the old straight-to-draft redirect. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Plus, Trash2, Layers, Pencil, Check, X, Copy, Upload } from 'lucide-react'; + +import { getServices } from '../services/index.js'; +import { makeId } from '../utils/id.js'; +import { + createEmptyConfig, + createEmptyInterface, + upsertInterface, +} from '../models/configModel.js'; +import { yamlToModel } from '../serializers/index.js'; +import ConfirmDialog from '../components/common/ConfirmDialog.jsx'; +import { useDocTitle } from '../hooks/useDocTitle.js'; + +const fmtDate = (iso) => { + if (!iso) return '—'; + const d = new Date(iso); + // Number.isNaN does not coerce, so it must be given the timestamp — Number.isNaN(dateObject) + // is always false and would let an invalid date render as "Invalid Date". + return Number.isNaN(d.getTime()) + ? '—' + : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +}; + +const errMessage = (err, fallback) => err?.message || fallback; + +export default function Home() { + const router = useRouter(); + const [items, setItems] = useState(null); // null = loading, [] = empty + const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); // { id, name } | null + const [editingId, setEditingId] = useState(null); // id of item being renamed + const [editName, setEditName] = useState(''); + const [error, setError] = useState(null); // { label, message } | null + const fileInputRef = useRef(null); + + useDocTitle('Your pipelines'); + const refresh = useCallback(() => { + getServices().config.list() + .then(setItems) + .catch((err) => { + setItems([]); + setError({ label: 'Could not load pipelines', message: errMessage(err, 'Storage unavailable') }); + }); + }, []); + useEffect(() => { refresh(); }, [refresh]); + + const createPipeline = () => { + if (busy) return; + setBusy(true); + const id = makeId('cfg'); + let model = createEmptyConfig({ id, name: 'Untitled pipeline' }); + model = upsertInterface(model, 'interface_1', createEmptyInterface()); + // Navigate optimistically, persist in the background. + getServices().config.create(model).catch(() => {}).finally(() => setBusy(false)); + router.push(`/configs/${id}`); + }; + + const requestDelete = (e, id, name) => { + e.preventDefault(); + e.stopPropagation(); + setConfirmDelete({ id, name }); + }; + + const confirmDeletePipeline = async () => { + if (!confirmDelete) return; + try { + await getServices().config.remove(confirmDelete.id); + } catch (err) { + setError({ label: 'Delete failed', message: errMessage(err, 'Could not delete this pipeline') }); + } finally { + setConfirmDelete(null); + refresh(); + } + }; + + const startRename = (e, id, name) => { + e.preventDefault(); + e.stopPropagation(); + setEditingId(id); + setEditName(name || ''); + }; + + const commitRename = async () => { + const name = editName.trim(); + if (!name || !editingId) { setEditingId(null); return; } + try { + // svc.get throws (rather than returning null) when the id is gone — e.g. the pipeline was + // deleted in another tab while the rename box was open. + const svc = getServices().config; + const m = await svc.get(editingId); + await svc.update({ ...m, meta: { ...m.meta, name } }); + } catch (err) { + setError({ label: 'Rename failed', message: errMessage(err, 'Could not rename this pipeline') }); + } finally { + setEditingId(null); + refresh(); + } + }; + + const cancelRename = () => setEditingId(null); + + // ── Duplicate pipeline ──────────────────────────────────────────────────── + const duplicatePipeline = async (e, id, name) => { + e.preventDefault(); + e.stopPropagation(); + if (busy) return; + setBusy(true); + try { + const svc = getServices().config; + const original = await svc.get(id); // throws if the id no longer exists + const newId = makeId('cfg'); + const copy = { + ...original, + meta: { ...original.meta, id: newId, name: `${name || 'Untitled'} (copy)` }, + }; + await svc.create(copy); + refresh(); + } catch (err) { + setError({ label: 'Duplicate failed', message: errMessage(err, 'Could not duplicate this pipeline') }); + } finally { + setBusy(false); + } + }; + + // ── Import YAML ─────────────────────────────────────────────────────────── + const triggerImport = () => { + setError(null); + fileInputRef.current?.click(); + }; + + const handleImportFile = async (e) => { + const file = e.target.files?.[0]; + if (!fileInputRef.current) return; + fileInputRef.current.value = ''; + if (!file) return; + setBusy(true); + setError(null); + try { + const text = await file.text(); + const newId = makeId('cfg'); + const parsed = yamlToModel(text, { id: newId, name: file.name.replace(/\.ya?ml$/i, '') }); + await getServices().config.create(parsed); + router.push(`/configs/${newId}`); + } catch (err) { + setError({ label: 'Import failed', message: errMessage(err, 'Failed to parse YAML') }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+ ⌗ InGen Studio +

Your pipelines

+

Pick up where you left off, or start a new one. Everything is saved locally in this browser.

+
+
+ + +
+ +
+ {error && ( +
+ {error.label}: {error.message} + +
+ )} + + {items === null ? ( +

Loading…

+ ) : items.length === 0 ? ( + + ) : ( +
    + {items.map((it, i) => ( +
  1. + {editingId === it.id ? ( +
    e.preventDefault()}> + {String(i + 1).padStart(2, '0')} + setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') cancelRename(); + }} + onBlur={commitRename} + /> + + +
    + ) : ( + + {String(i + 1).padStart(2, '0')} + + {it.name || 'Untitled pipeline'} + + {it.interfaceCount} interface{it.interfaceCount === 1 ? '' : 's'} · edited {fmtDate(it.updatedAt)} + + + + + + + )} +
  2. + ))} +
+ )} + + setConfirmDelete(null)} + /> +
+ ); +} diff --git a/frontend/src/components/common/ConfirmDialog.jsx b/frontend/src/components/common/ConfirmDialog.jsx new file mode 100644 index 00000000..dc6d3591 --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.jsx @@ -0,0 +1,61 @@ +// InGen Studio — ConfirmDialog +// +// Drop-in replacement for window.confirm(). Renders a modal overlay with a title, message, +// and Cancel / Confirm buttons. Confirm button can be styled as danger (red) or default. + +import { useEffect, useRef } from 'react'; + +/** + * @param {{ + * open: boolean, + * title: string, + * message: string, + * confirmLabel?: string, + * danger?: boolean, + * onConfirm: () => void, + * onCancel: () => void, + * }} props + */ +export default function ConfirmDialog({ open, title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }) { + const cancelRef = useRef(null); + + // Focus the cancel button when dialog opens (safe default). + useEffect(() => { + if (open) cancelRef.current?.focus(); + }, [open]); + + // Close on Escape key. + useEffect(() => { + if (!open) return; + const handler = (e) => { if (e.key === 'Escape') onCancel(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onCancel]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/common/ErrorBoundary.jsx b/frontend/src/components/common/ErrorBoundary.jsx new file mode 100644 index 00000000..b57ca801 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary.jsx @@ -0,0 +1,43 @@ +// InGen Studio — ErrorBoundary +// +// Catches render/lifecycle exceptions anywhere in the tree below it so a single bad record or a +// thrown component never blanks the whole SPA. React only supports class components as error +// boundaries (no hook equivalent), hence the class. Offers a reset that re-renders the children and +// a link back to the configs list. + +import { Component } from 'react'; +import Link from 'next/link'; + +export default class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidCatch(error, info) { + // Keep a breadcrumb in the console for debugging; no external logging in scope. + console.error('Unhandled UI error:', error, info?.componentStack); + } + + reset = () => this.setState({ error: null }); + + render() { + if (this.state.error) { + return ( +
+

Something went wrong.

+

{this.state.error.message || String(this.state.error)}

+
+ + Back to configs +
+
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/common/ListControls.jsx b/frontend/src/components/common/ListControls.jsx new file mode 100644 index 00000000..31fa5f0c --- /dev/null +++ b/frontend/src/components/common/ListControls.jsx @@ -0,0 +1,12 @@ +// Reusable reorder/delete controls for list rows (no drag-and-drop dependency — up/down satisfies +// the reorder requirement and keeps React Flow reserved for multi-interface visualization only). + +export default function ListControls({ index, count, onMove, onRemove }) { + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/components/common/MiniMarkdown.jsx b/frontend/src/components/common/MiniMarkdown.jsx new file mode 100644 index 00000000..6d9b8499 --- /dev/null +++ b/frontend/src/components/common/MiniMarkdown.jsx @@ -0,0 +1,123 @@ +// MiniMarkdown — zero-dependency inline markdown renderer for chat bubbles. +// Supports: **bold**, `code`, [link](url), bullet lists (- / *), numbered lists, and +// paragraphs separated by blank lines. Intentionally minimal — text is never interpreted as +// HTML, and link targets are restricted to http/https/mailto (see safeHref). + +// Link hrefs come from assistant-generated replies, so they are untrusted input: a +// `javascript:` or `data:` URL would otherwise execute on click. Anything not on the allowlist +// renders as plain text instead of a link. +function safeHref(url) { + try { + const { protocol } = new URL(url, 'https://example.invalid'); + return ['http:', 'https:', 'mailto:'].includes(protocol) ? url : null; + } catch { + return null; + } +} + +/** Render inline spans: `code`, **bold**, *italic*, [text](url). */ +function renderInline(text) { + // Split on backtick code spans, bold (**), and links. + const parts = []; + const re = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g; + let last = 0; + let m; + while ((m = re.exec(text)) !== null) { + if (m.index > last) parts.push(text.slice(last, m.index)); + const token = m[0]; + if (token.startsWith('`') && token.endsWith('`')) { + parts.push({token.slice(1, -1)}); + } else if (token.startsWith('**')) { + parts.push({token.slice(2, -2)}); + } else if (token.startsWith('*')) { + parts.push({token.slice(1, -1)}); + } else { + // link — unsafe schemes degrade to the link text, never an anchor + const href = safeHref(m[3]); + parts.push(href + ? {m[2]} + : {m[2]}); + } + last = m.index + token.length; + } + if (last < text.length) parts.push(text.slice(last)); + return parts; +} + +/** Parse a block of text into structured nodes. */ +function parse(md) { + const lines = md.split('\n'); + const blocks = []; + let listType = null; // 'ul' | 'ol' | null + let listItems = []; + + const flushList = () => { + if (listItems.length) { + blocks.push({ type: listType, items: listItems }); + listItems = []; + listType = null; + } + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const ulMatch = line.match(/^[-*]\s+(.*)/); + const olMatch = line.match(/^\d+\.\s+(.*)/); + const h3Match = line.match(/^###\s+(.*)/); + const h2Match = line.match(/^##\s+(.*)/); + const h1Match = line.match(/^#\s+(.*)/); + const hrMatch = line.match(/^---+$/); + + if (ulMatch) { + if (listType === 'ol') flushList(); + listType = 'ul'; + listItems.push(ulMatch[1]); + } else if (olMatch) { + if (listType === 'ul') flushList(); + listType = 'ol'; + listItems.push(olMatch[1]); + } else { + flushList(); + if (h3Match) blocks.push({ type: 'h3', text: h3Match[1] }); + else if (h2Match) blocks.push({ type: 'h2', text: h2Match[1] }); + else if (h1Match) blocks.push({ type: 'h1', text: h1Match[1] }); + else if (hrMatch) blocks.push({ type: 'hr' }); + else if (line.trim() === '') blocks.push({ type: 'br' }); + else blocks.push({ type: 'p', text: line }); + } + } + flushList(); + return blocks; +} + +export default function MiniMarkdown({ text, className }) { + if (!text) return null; + const blocks = parse(text); + + return ( +
+ {blocks.map((b, i) => { + switch (b.type) { + case 'h1': return

{renderInline(b.text)}

; + case 'h2': return

{renderInline(b.text)}

; + case 'h3': return

{renderInline(b.text)}

; + case 'hr': return
; + case 'br': return
; + case 'ul': return ( +
    + {b.items.map((it, j) =>
  • {renderInline(it)}
  • )} +
+ ); + case 'ol': return ( +
    + {b.items.map((it, j) =>
  1. {renderInline(it)}
  2. )} +
+ ); + case 'p': + default: + return

{renderInline(b.text)}

; + } + })} +
+ ); +} diff --git a/frontend/src/components/common/SourceActionDialog.jsx b/frontend/src/components/common/SourceActionDialog.jsx new file mode 100644 index 00000000..9cdd09bb --- /dev/null +++ b/frontend/src/components/common/SourceActionDialog.jsx @@ -0,0 +1,78 @@ +// SourceActionDialog — shown when the user connects/adds a source to an interface +// that already has a base source. Offers two choices: +// A) Create a new interface (independent pipeline with its own columns/output) +// B) Merge into the current pipeline (auto-create a merge/union transform) + +import { useEffect, useRef } from 'react'; +import { GitMerge, Layers } from 'lucide-react'; + +/** + * @param {{ + * open: boolean, + * sourceId: string, + * onNewInterface: () => void, + * onMerge: () => void, + * onCancel: () => void, + * }} props + */ +export default function SourceActionDialog({ open, sourceId, onNewInterface, onMerge, onCancel }) { + const cancelRef = useRef(null); + + useEffect(() => { + if (open) cancelRef.current?.focus(); + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e) => { if (e.key === 'Escape') onCancel(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onCancel]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +

+ How should "{sourceId}" connect? +

+

+ This interface already has a base source. Choose how to wire the new source: +

+ +
+ + + +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/layout/AppShell.jsx b/frontend/src/components/layout/AppShell.jsx new file mode 100644 index 00000000..8008a5b8 --- /dev/null +++ b/frontend/src/components/layout/AppShell.jsx @@ -0,0 +1,121 @@ +'use client'; + +// InGen Studio — AppShell +// +// Outermost frame: brand bar with the Graph/Chat view switcher at center, config name + +// status on the right. The view mode is shared via ViewModeContext so the editor and sidebar +// can read it. Workspace-specific controls (YAML toggle, Run) are also here. +// +// Under Next this is the persistent client frame the root layout wraps around every route; the +// routed page renders into {children} where the old react-router used to be. + +import { useSyncExternalStore } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import ErrorBoundary from '../common/ErrorBoundary.jsx'; +import { ViewModeProvider, useViewMode } from '../../state/ViewModeContext.jsx'; +import { ChatSessionProvider } from '../../state/ChatSessionContext.jsx'; +import { GraphSelectionProvider } from '../../state/GraphSelectionContext.jsx'; +import { ChevronLeft, GitBranch, MessageSquare } from 'lucide-react'; +import { parentPath } from './navPaths.js'; + +function BrandBar() { + const { viewMode, setViewMode } = useViewMode(); + const pathname = usePathname(); + const back = parentPath(pathname); + const isWorkspace = pathname?.startsWith('/configs/'); + + return ( +
+
+ {back && ( + + + + )} + +
+ + InGenStudio +
+ YAML Interface Authoring + +
+ + {isWorkspace && ( +
+
+ + +
+
+ )} + +
+ {/* WorkspaceLayout injects config name, save pill, YAML toggle here via portal */} +
+
+ ); +} + +// This is a pure client SPA (localStorage-backed store, reactflow, portals). Gate the routed +// content so it mounts only on the client: server render and the first client render both show the +// same fallback (no hydration mismatch), then the real tree mounts. The brand bar uses no browser +// APIs, so it renders on the server for an instant, branded first paint. +// +// IMPORTANT: this boundary is NOT keyed on the route. ConfigProvider lives below here (in the +// config layout), so keying on pathname would remount the provider on every navigation — which +// reloads the persisted model and discards any not-yet-autosaved edit (e.g. a just-added +// interface). Per-route error reset is handled lower down, below the provider, in WorkspaceLayout. + +// The "is the client live" store never changes after hydration, so there is nothing to subscribe +// to — this returns a no-op unsubscribe. Module-level so the reference stays stable across renders. +const subscribeNoop = () => () => {}; + +function AppBody({ children }) { + // useSyncExternalStore is the hydration-safe way to ask "are we on the client yet?": the server + // snapshot is false and the client snapshot is true, so server render and first client render + // agree (no hydration mismatch) and the real tree mounts on the next pass. Doing this with + // useState + useEffect would set state synchronously in an effect on every mount. + const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false); + + return ( +
+ + {mounted ? children :
Loading…
} +
+
+ ); +} + +export default function AppShell({ children }) { + return ( + + + +
+ + {children} +
+
+
+
+ ); +} diff --git a/frontend/src/components/layout/NavRail.jsx b/frontend/src/components/layout/NavRail.jsx new file mode 100644 index 00000000..98800b5a --- /dev/null +++ b/frontend/src/components/layout/NavRail.jsx @@ -0,0 +1,323 @@ +// InGen Studio — NavRail (view-specific sidebar) +// +// Left rail changes content based on the active view mode: +// - Graph (inFlow): Node palette for drag-and-drop onto the canvas +// - Chat (inChat): Conversation history panel with session list +// Collapsible via a toggle button at the bottom. + +'use client'; + +import { useState } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { + Database, FileOutput, Filter, ArrowRightLeft, ShieldCheck, + Columns, Layers, Copy, GitMerge, Scissors, Table2, ListFilter, Plug, + Plus, ChevronLeft, ChevronRight, MessageSquare, ChevronDown, X, Search +} from 'lucide-react'; +import { useConfig } from '../../state/ConfigContext.jsx'; +import { useSourceActions } from '../../hooks/useSourceActions.js'; +import { useViewMode } from '../../state/ViewModeContext.jsx'; +import { useChatSession } from '../../state/ChatSessionContext.jsx'; +import { useGraphSelection } from '../../state/GraphSelectionContext.jsx'; +import { getSessions } from '../../services/chatHistoryService.js'; +import { buildPalette } from './navPalette.js'; +import { upsertSource } from '../../models/configModel.js'; +import { listAdd, setField } from '../../models/interfaceOps.js'; +import { setColumns } from '../../lib/columnStore.js'; +import InterfaceManager from '../editor/InterfaceManager.jsx'; +import SourceActionDialog from '../common/SourceActionDialog.jsx'; + +// ─── inFlow Mode: Node Palette (draw.io-inspired) ─── + +// Icons are resolved here (navPalette.js is JSX-free so it can be unit-tested). Keyed by leaf +// subtype (sources/transforms/output) or action (columns/validations), with a per-group fallback. +const TRANSFORM_ICONS = { + merge: , outer_join: , + union: , aggregate: , + mask: , melt: , + filter: , not_equals_filter: , + drop_duplicates: , json_array_expander: , +}; +const SOURCE_ICONS = { + file: , mysql: , api: , + json: , +}; +const ACTION_ICONS = { + add_column: , add_validation: , +}; + +function leafIcon(group, node) { + if (group === 'Sources') return SOURCE_ICONS[node.subtype] || ; + if (group === 'Transforms') return TRANSFORM_ICONS[node.subtype] || ; + if (group === 'Output') return ; + return ACTION_ICONS[node.action] || ; +} + +// Auto-id a new source: source_1, source_2, … avoiding collisions with existing ids. +function nextSourceId(model) { + const taken = new Set(model.sourceOrder ?? []); + let n = 1; + while (taken.has(`source_${n}`)) n += 1; + return `source_${n}`; +} + +// Sensible defaults per source type — mirrors models/applyIntent.js so click-add matches chat-add. +const SOURCE_DEFAULTS = { + file: (id) => ({ id, type: 'file', file_type: 'delimited_file', file_path: `data/${id}.csv` }), + mysql: (id) => ({ id, type: 'mysql', database: '', query: 'SELECT * FROM table' }), + api: (id) => ({ id, type: 'api', url: '', method: 'GET' }), + json: (id) => ({ id, type: 'json' }), +}; + +function GraphPalette() { + const { model, updateModel, updateInterface } = useConfig(); + const { setSelectedNodeId } = useGraphSelection(); + const { interfaceName } = useParams(); + const [query, setQuery] = useState(''); + const [collapsed, setCollapsed] = useState({}); + const [tip, setTip] = useState(null); // { node, note, x, y } + const { pendingSourceAction, setPendingSourceAction, handleNewInterface, handleMergeSource } = + useSourceActions(interfaceName, { onMerged: () => setSelectedNodeId(`src-${pendingSourceAction?.sid}`) }); + + const onDragStart = (event, nodeData) => { + event.dataTransfer.setData('application/reactflow', JSON.stringify(nodeData)); + event.dataTransfer.effectAllowed = 'move'; + }; + + // Each handler mutates the model AND selects the resulting node, so the canvas opens its config + // drawer — the same flow transforms already use. Click is the dependable, keyboard-accessible path + // (drag works too for transforms but isn't reliable across browsers). + const addTransform = (subtype) => { + if (!interfaceName || !subtype) return; + const newIdx = model.interfacesByName?.[interfaceName]?.pre_processing?.length ?? 0; + updateInterface(interfaceName, (i) => ({ + ...i, + pre_processing: [...(i.pre_processing ?? []), { type: subtype }], + })); + setSelectedNodeId(`pre-${newIdx}`); + }; + + const addSourceNode = (subtype) => { + if (!interfaceName || !subtype) return; + const id = nextSourceId(model); + updateModel((m) => upsertSource(m, SOURCE_DEFAULTS[subtype](id))); + setColumns(id, []); + const currentSources = model.interfacesByName?.[interfaceName]?.sources ?? []; + if (currentSources.length > 0) { + setPendingSourceAction({ sid: id }); + } else { + updateInterface(interfaceName, (it) => { + const cur = it.sources ?? []; + return cur.includes(id) ? it : { ...it, sources: [...cur, id] }; + }); + setSelectedNodeId(`src-${id}`); + } + }; + + + + const addOutputNode = (subtype) => { + if (!interfaceName || !subtype) return; + updateInterface(interfaceName, (it) => setField(it, 'output', { type: subtype, props: it.output?.props ?? {} })); + setSelectedNodeId('output-node'); + }; + + const addColumnNode = () => { + if (!interfaceName) return; + updateInterface(interfaceName, (it) => listAdd(it, 'columns', { src_col_name: '', dest_col_name: '' })); + setSelectedNodeId('columns-node'); + }; + + // Validations are optional and attach per column; the existing drawer handles column + expectation + // + severity, so we just open it. + const openValidations = () => { + if (!interfaceName) return; + setSelectedNodeId('validation-node'); + }; + + const runLeaf = (group, node) => { + if (group === 'Sources') return addSourceNode(node.subtype); + if (group === 'Transforms') return addTransform(node.subtype); + if (group === 'Output') return addOutputNode(node.subtype); + if (node.action === 'add_column') return addColumnNode(); + if (node.action === 'add_validation') return openValidations(); + }; + + const q = query.trim().toLowerCase(); + const matches = (n) => !q || n.label.toLowerCase().includes(q) || (n.description || '').toLowerCase().includes(q); + + const showTip = (e, node, note) => { + const r = e.currentTarget.getBoundingClientRect(); + setTip({ node, note, x: r.right + 10, y: r.top }); + }; + const hideTip = () => setTip(null); + + return ( +
+
+
+ + {buildPalette().map((group) => { + const nodes = group.nodes.filter(matches); + if (nodes.length === 0) return null; + const isCollapsed = !q && collapsed[group.group]; + return ( +
+ + + {!isCollapsed && ( +
+ {nodes.map((node) => ( +
onDragStart(e, { type: 'transformNode', subtype: node.subtype }) : undefined} + role="button" + tabIndex={0} + onClick={() => runLeaf(group.group, node)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); runLeaf(group.group, node); } }} + onMouseEnter={(e) => showTip(e, node, group.note)} + onFocus={(e) => showTip(e, node, group.note)} + onMouseLeave={hideTip} + onBlur={hideTip} + > + {leafIcon(group.group, node)} + + {node.label} + {node.writes && {node.writes}} + +
+ ))} +
+ )} +
+ ); + })} + + {tip && ( +
+
{tip.node.label}
+ {tip.node.description &&
{tip.node.description}
} + {tip.node.writes && ( +
writes {tip.node.writes}
+ )} + {tip.node.subtype ? ( + tip.node.hint &&
{tip.node.hint}
+ ) : ( + tip.note &&
{tip.note}
+ )} +
+ )} + + setPendingSourceAction(null)} + /> +
+ ); +} + +// ─── Chat Mode: History Panel ─── + +function ChatHistory({ configId }) { + const { model } = useConfig(); + const router = useRouter(); + const { interfaceName: activeInterface } = useParams(); + const { activeSessionId, requestNew, requestLoad } = useChatSession(); + const interfaces = model?.interfaceOrder ?? []; + const base = `/configs/${configId}`; + + // Aggregate sessions from all interfaces, newest first. + const allSessions = interfaces.flatMap((name) => + getSessions(name).map((s) => ({ ...s, interfaceName: name })) + ); + allSessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + const openSession = (s) => { + // Switching to a conversation from another interface navigates there first; the editor then + // honors the load command for its own interface. + if (s.interfaceName !== activeInterface) router.push(`${base}/interfaces/${s.interfaceName}`); + requestLoad(s.interfaceName, s.id); + }; + + return ( +
+
Chat History
+ +
    + {allSessions.map((s) => ( +
  • + +
  • + ))} + {allSessions.length === 0 && ( +
  • + + No conversations yet +
  • + )} +
+
+ ); +} + +// ─── Main Component ─── + +export default function NavRail({ configId, collapsed, onToggleCollapse }) { + const { viewMode } = useViewMode(); + + return ( + + ); +} diff --git a/frontend/src/components/layout/WorkspaceLayout.jsx b/frontend/src/components/layout/WorkspaceLayout.jsx new file mode 100644 index 00000000..9a32d21c --- /dev/null +++ b/frontend/src/components/layout/WorkspaceLayout.jsx @@ -0,0 +1,139 @@ +// InGen Studio — WorkspaceLayout +// +// The persistent 3-pane workspace shell: NavRail · editor (router Outlet) · YAML preview. +// No more WorkspaceTopbar — config info is in the brand bar. Left sidebar is collapsible. +// Injects config name + save status into the brand bar's right portal via a useEffect. + +import { useState, useRef, useCallback, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; +import Link from 'next/link'; +import { usePathname, useParams } from 'next/navigation'; +import { Pencil } from 'lucide-react'; + +import NavRail from './NavRail.jsx'; +import YamlPreviewPanel from '../yaml/YamlPreviewPanel.jsx'; +import ErrorBoundary from '../common/ErrorBoundary.jsx'; +import { useConfig } from '../../state/ConfigContext.jsx'; +import { useWorkspaceShortcuts } from '../../hooks/useWorkspaceShortcuts.js'; +import { useDocTitle } from '../../hooks/useDocTitle.js'; + +const PILL = { + loading: { label: 'Loading…', cls: 'pill--muted' }, + saving: { label: 'Saving…', cls: 'pill--muted' }, + dirty: { label: 'Unsaved', cls: 'pill--warn' }, + saved: { label: 'Saved', cls: 'pill--ok' }, + error: { label: 'Error', cls: 'pill--err' }, +}; + +function BrandBarPortal({ children }) { + // Guard for server rendering — the portal target only exists in the live DOM. + if (typeof document === 'undefined') return null; + const el = document.getElementById('brandbar-right-portal'); + if (!el) return null; + return createPortal(children, el); +} + +export default function WorkspaceLayout({ configId, children }) { + const [yamlCollapsed, setYamlCollapsed] = useState(false); + const [railCollapsed, setRailCollapsed] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(''); + const nameInputRef = useRef(null); + const { model, status, issues, updateModel, undo, redo, saveNow } = useConfig(); + useWorkspaceShortcuts({ onSave: saveNow, onUndo: undo, onRedo: redo }); + const pathname = usePathname(); + const { interfaceName } = useParams() || {}; + + useDocTitle(interfaceName ? decodeURIComponent(interfaceName) : null, model?.meta.name); + const pill = PILL[status] ?? PILL.loading; + const errorCount = issues.filter((i) => i.level === 'error').length; + + const startNameEdit = useCallback(() => { + setNameValue(model?.meta.name ?? ''); + setEditingName(true); + }, [model]); + + useLayoutEffect(() => { + if (editingName) nameInputRef.current?.select(); + }, [editingName]); + + const commitNameEdit = useCallback(() => { + const name = nameValue.trim(); + if (name && model) updateModel((m) => ({ ...m, meta: { ...m.meta, name } })); + setEditingName(false); + }, [nameValue, model, updateModel]); + + const cancelNameEdit = useCallback(() => setEditingName(false), []); + + let panesCls = 'workspace__panes'; + if (yamlCollapsed) panesCls += ' workspace__panes--noyaml'; + + if (!interfaceName) { + panesCls += ' workspace__panes--hiddenrail'; + } else if (railCollapsed) { + panesCls += ' workspace__panes--norail'; + } + + return ( +
+ {/* Inject config info into brand bar right side */} + + {editingName ? ( + setNameValue(e.target.value)} + onBlur={commitNameEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') commitNameEdit(); + if (e.key === 'Escape') cancelNameEdit(); + }} + /> + ) : ( + + )} + {pill.label} + {errorCount > 0 && ( + + {errorCount} issue{errorCount > 1 ? 's' : ''} + + )} + + + Run ▸ + + + +
+ {interfaceName && ( + setRailCollapsed((v) => !v)} + /> + )} +
+ {status === 'loading' ? ( +
Loading config…
+ ) : status === 'error' ? ( +
+

Config not found

+

This config could not be loaded. It may have been deleted.

+ Back to configs +
+ ) : ( + // Keyed per-route so a crash on one page clears when you navigate — placed BELOW + // ConfigProvider so the reset never remounts the document store. + {children} + )} +
+ {!yamlCollapsed && } +
+
+ ); +} diff --git a/frontend/src/components/layout/navPalette.js b/frontend/src/components/layout/navPalette.js new file mode 100644 index 00000000..2ee894a7 --- /dev/null +++ b/frontend/src/components/layout/navPalette.js @@ -0,0 +1,76 @@ +// InGen Studio — inFlow palette config (pure, JSX-free so it is unit-testable under node --test). +// +// Every leaf is actionable: a `subtype` leaf adds a node of that type; an `action` leaf runs a +// named handler (columns/validations are singletons — append a row / open the existing drawer). +// Icons are resolved in NavRail by subtype/action — keeping this module free of JSX. +// +// Leaf shape: { subtype?, action?, label, color, draggable?, description?, hint?, writes? } +// The lists are derived from the form schemas, so the menu never drifts from backend support. + +import { PRE_PROCESSOR_SCHEMAS, PRE_PROCESSOR_ORDER } from '../../forms/schemas/preProcessorSchemas.js'; +import { OUTPUT_SCHEMAS, OUTPUT_TYPE_OPTIONS } from '../../forms/schemas/outputSchemas.js'; + +const SOURCE_COLOR = '#3b82f6'; +const TRANSFORM_COLOR = '#f59e0b'; +const COLUMNS_COLOR = '#7853EC'; +const VALIDATION_COLOR = '#10b981'; +const OUTPUT_COLOR = '#ef4444'; + +// Source order mirrors how often you reach for each (file first); `file` fans out to its file_type +// variants inside the drawer rather than as separate leaves. +const SOURCE_ORDER = ['file', 'mysql', 'api', 'json']; +const SOURCE_LABELS = { + file: 'File', + mysql: 'Database (MySQL)', + api: 'API', + json: 'JSON payload', +}; + +export function buildPalette() { + return [ + { + group: 'Sources', + note: 'A pipeline can read many sources; the first is the base input, the rest feed transforms.', + nodes: SOURCE_ORDER.map((subtype) => ({ + subtype, + label: SOURCE_LABELS[subtype], + color: SOURCE_COLOR, + })), + }, + { + group: 'Transforms', + note: 'Merge, union, filter and reshape steps run in order on the base input.', + nodes: PRE_PROCESSOR_ORDER.map((subtype) => { + const s = PRE_PROCESSOR_SCHEMAS[subtype]; + return { + subtype, + label: s.label, + color: TRANSFORM_COLOR, + description: s.description, + hint: s.hint, + writes: (s.yamlFields || []).join(', '), + draggable: true, // transforms also support drag-to-canvas + }; + }), + }, + { + group: 'Columns', + note: 'Columns map source fields to output fields. Add one, then edit the mapping in the drawer.', + nodes: [{ action: 'add_column', label: 'Add column', color: COLUMNS_COLOR }], + }, + { + group: 'Validations', + note: 'Optional great_expectations checks, attached per column. Opens the validations editor.', + nodes: [{ action: 'add_validation', label: 'Add validation', color: VALIDATION_COLOR }], + }, + { + group: 'Output', + note: 'One destination per interface. Use Splitted file or JSON writer for multi-shape output.', + nodes: OUTPUT_TYPE_OPTIONS.map((subtype) => ({ + subtype, + label: OUTPUT_SCHEMAS[subtype].label, + color: OUTPUT_COLOR, + })), + }, + ]; +} diff --git a/frontend/src/components/layout/navPalette.test.js b/frontend/src/components/layout/navPalette.test.js new file mode 100644 index 00000000..5cb6de34 --- /dev/null +++ b/frontend/src/components/layout/navPalette.test.js @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { buildPalette } from './navPalette.js'; +import { PRE_PROCESSOR_ORDER } from '../../forms/schemas/preProcessorSchemas.js'; +import { OUTPUT_TYPE_OPTIONS } from '../../forms/schemas/outputSchemas.js'; + +test('palette enumerates exactly the schema-supported types', () => { + const groups = Object.fromEntries(buildPalette().map((g) => [g.group, g])); + + assert.deepEqual( + groups.Sources.nodes.map((n) => n.subtype), + ['file', 'mysql', 'api', 'json'], + ); + assert.deepEqual(groups.Transforms.nodes.map((n) => n.subtype), PRE_PROCESSOR_ORDER); + assert.deepEqual(groups.Output.nodes.map((n) => n.subtype), OUTPUT_TYPE_OPTIONS); + assert.deepEqual(groups.Columns.nodes.map((n) => n.action), ['add_column']); + assert.deepEqual(groups.Validations.nodes.map((n) => n.action), ['add_validation']); +}); + +test('every leaf is actionable — no dead info-only chips', () => { + for (const g of buildPalette()) { + for (const n of g.nodes) { + assert.ok(n.subtype || n.action, `stale leaf in ${g.group}: ${n.label}`); + } + } +}); diff --git a/frontend/src/components/layout/navPaths.js b/frontend/src/components/layout/navPaths.js new file mode 100644 index 00000000..804f57f2 --- /dev/null +++ b/frontend/src/components/layout/navPaths.js @@ -0,0 +1,14 @@ +// Hierarchical "up" navigation. Browser history (router.back) is unreliable here — the index route +// redirects, so back can bounce or leave the app. Instead we compute the parent route from the +// current path: deep pages → their config, a config → the pipelines index, the index → nowhere. + +/** + * @param {string|null|undefined} pathname + * @returns {string|null} the parent route, or null when there is no "up" (the index). + */ +export function parentPath(pathname) { + if (!pathname || pathname === '/') return null; + const m = pathname.match(/^\/configs\/([^/]+)(\/.+)?$/); + if (!m) return '/'; // unknown route → home + return m[2] ? `/configs/${m[1]}` : '/'; // sub-page → its config; config → home +} diff --git a/frontend/src/components/layout/navPaths.test.js b/frontend/src/components/layout/navPaths.test.js new file mode 100644 index 00000000..29df227d --- /dev/null +++ b/frontend/src/components/layout/navPaths.test.js @@ -0,0 +1,13 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parentPath } from './navPaths.js'; + +test('parentPath walks one level up the route hierarchy', () => { + assert.equal(parentPath('/'), null); // index has no up + assert.equal(parentPath('/configs/cfg_1'), '/'); // config → index + assert.equal(parentPath('/configs/cfg_1/run'), '/configs/cfg_1'); // run → config + assert.equal(parentPath('/configs/cfg_1/history'), '/configs/cfg_1'); // history → config + assert.equal(parentPath('/configs/cfg_1/interfaces/iface_a'), '/configs/cfg_1'); // editor → config + assert.equal(parentPath('/something/else'), '/'); // unknown → home + assert.equal(parentPath(null), null); +}); diff --git a/frontend/src/components/run/HistoryView.jsx b/frontend/src/components/run/HistoryView.jsx new file mode 100644 index 00000000..2e99e570 --- /dev/null +++ b/frontend/src/components/run/HistoryView.jsx @@ -0,0 +1,127 @@ +// Execution History (route element for /configs/:configId/history) +// +// Lists past RunRecords for this config from HistoryService (localStorage-backed). Expand a run to +// see its validation results and per-stage outcome. Read-only audit surface. + +import { useEffect, useState } from 'react'; +import { useConfig } from '../../state/ConfigContext.jsx'; +import { getServices, getAdapterMode } from '../../services/index.js'; +import { ADAPTER_MODE } from '../../models/constants.js'; +import ValidationResults from './ValidationResults.jsx'; +import ConfirmDialog from '../common/ConfirmDialog.jsx'; + +const STATUS_PILL = { success: 'pill--ok', partial: 'pill--warn', failed: 'pill--err' }; +const fmt = (iso) => (iso ? iso.replace('T', ' ').slice(0, 19) : ''); + +export default function HistoryView() { + const { model } = useConfig(); + const configId = model?.meta.id; + const [runs, setRuns] = useState(null); + const [openId, setOpenId] = useState(null); + const [confirmClear, setConfirmClear] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + const [error, setError] = useState(null); + + // In http mode run history is server-authoritative and there is no delete endpoint, so clearing + // would silently do nothing. Don't offer the action rather than confirm it and no-op. + const canClear = getAdapterMode() !== ADAPTER_MODE.HTTP; + + useEffect(() => { + let alive = true; + if (configId) { + getServices().history.list(configId) + .then((r) => { if (alive) setRuns(r); }) + .catch((err) => { + if (!alive) return; + setRuns([]); + setError(err?.message || 'Could not load run history'); + }); + } + return () => { alive = false; }; + }, [configId, refreshKey]); + + const refresh = () => setRefreshKey((k) => k + 1); + + const doClear = async () => { + try { + await getServices().history.clear(configId); + } catch (err) { + setError(err?.message || 'Could not clear run history'); + } finally { + setOpenId(null); + setConfirmClear(false); + refresh(); + } + }; + + return ( +
+
+
+

Execution history

+

+ {canClear ? 'Past simulated runs for this config (stored locally).' : 'Past runs for this config, recorded by the backend.'} +

+
+
+ + {canClear && ( + + )} +
+
+ {error && ( +
+ History error: {error} + +
+ )} + + {runs === null ? ( +
Loading…
+ ) : runs.length === 0 ? ( +
No runs yet. Start one from the Run console.
+ ) : ( +
+ {runs.map((r) => { + const open = openId === r.runId; + const ifaces = r.overrides?.interfaces?.length ?? 0; + return ( +
+ + {open && ( +
+
+ {r.stages.map((s) => ( + + {s.interface}/{s.stage} + + ))} +
+ +
+ )} +
+ ); + })} +
+ )} + + setConfirmClear(false)} + /> +
+ ); +} diff --git a/frontend/src/components/run/LogStream.jsx b/frontend/src/components/run/LogStream.jsx new file mode 100644 index 00000000..74b4e773 --- /dev/null +++ b/frontend/src/components/run/LogStream.jsx @@ -0,0 +1,29 @@ +// Log stream — timestamped, levelled log lines from the run event stream, auto-scrolled to the +// latest line. Mirrors the kind of output `python -m ingen` writes to its logger. + +import { useEffect, useRef } from 'react'; + +const timeOf = (iso) => (iso ? iso.slice(11, 19) : ''); + +export default function LogStream({ events }) { + const endRef = useRef(null); + const logs = events.filter((e) => e.type === 'log'); + + useEffect(() => { endRef.current?.scrollIntoView({ block: 'end' }); }, [logs.length]); + + if (logs.length === 0) return
Logs will appear here during a run.
; + + return ( +
+ {logs.map((e, i) => ( +
+ {timeOf(e.ts)} + {(e.level ?? 'info').toUpperCase()} + {e.interface && {e.interface}{e.stage ? `/${e.stage}` : ''}} + {e.message} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/run/OverridesForm.jsx b/frontend/src/components/run/OverridesForm.jsx new file mode 100644 index 00000000..06b77be8 --- /dev/null +++ b/frontend/src/components/run/OverridesForm.jsx @@ -0,0 +1,88 @@ +// Runtime overrides form — maps to the CLI args that don't mutate the saved YAML (run_date, +// --interfaces subset, --override_params). Submitting starts a (mock) run. + +import { useState } from 'react'; +import { useConfig } from '../../state/ConfigContext.jsx'; +import { JsonField } from '../../forms/fields/Fields.jsx'; + +export default function OverridesForm({ running, onRun, onCancel }) { + const { model } = useConfig(); + const allInterfaces = model?.interfaceOrder ?? []; + const [runDate, setRunDate] = useState(''); + const [selected, setSelected] = useState(() => new Set(allInterfaces)); + const [overrideParams, setOverrideParams] = useState(undefined); + const [queryParams, setQueryParams] = useState(undefined); + + // The model often loads AFTER this form first mounts (allInterfaces was []), which left every + // interface unchecked and the Run button disabled. Re-seed the selection (default = all) whenever + // the set of interface NAMES changes, using React's render-phase "adjust state on prop change" + // pattern (no effect — avoids a cascading-render lint and an extra commit). Keyed on the names so + // unrelated model edits don't reset a user's manual selection. + const ifaceKey = allInterfaces.join(' '); + const [prevIfaceKey, setPrevIfaceKey] = useState(ifaceKey); + if (prevIfaceKey !== ifaceKey) { + setPrevIfaceKey(ifaceKey); + setSelected(new Set(allInterfaces)); + } + + const toggle = (name) => setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); else next.add(name); + return next; + }); + + const submit = () => { + const interfaces = allInterfaces.filter((n) => selected.has(n)); + onRun({ + ...(runDate ? { run_date: runDate } : {}), + interfaces, + ...(overrideParams ? { override_params: overrideParams } : {}), + ...(queryParams ? { query_params: queryParams } : {}), + }); + }; + + return ( +
+
+ +
+ interfaces +
+ {allInterfaces.map((n) => ( + + ))} +
+
+
+
+ + +
+
+ {running ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/run/RunConsole.jsx b/frontend/src/components/run/RunConsole.jsx new file mode 100644 index 00000000..d6c3d4de --- /dev/null +++ b/frontend/src/components/run/RunConsole.jsx @@ -0,0 +1,69 @@ +// Run Console (route element for /configs/:configId/run) +// +// Composes the RunProvider and lays out the controls + live panels: stage timeline, log stream, +// validation results. In mock mode RunService.simulate drives everything with no backend; in http +// mode the same panels render the results streamed back from the FastAPI wrapper. + +import { RunProvider, useRun } from '../../state/RunContext.jsx'; +import { getAdapterMode } from '../../services/index.js'; +import { ADAPTER_MODE } from '../../models/constants.js'; +import OverridesForm from './OverridesForm.jsx'; +import StageTimeline from './StageTimeline.jsx'; +import LogStream from './LogStream.jsx'; +import ValidationResults from './ValidationResults.jsx'; + +const STATUS_PILL = { + success: 'pill--ok', partial: 'pill--warn', failed: 'pill--err', +}; + +// In HTTP mode the run is real (the wrapper executes `python -m ingen`); only mock mode is simulated. +const SUBTITLE = { + [ADAPTER_MODE.HTTP]: 'Executes the config through the InGen backend. Logs and results are live.', + [ADAPTER_MODE.MOCK]: 'Simulated execution — no backend. Logs and results are mocked.', +}; + +function Console() { + const { status, events, record, start, cancel } = useRun(); + const subtitle = SUBTITLE[getAdapterMode()] ?? SUBTITLE[ADAPTER_MODE.MOCK]; + + return ( +
+
+
+

Run console

+

{subtitle}

+
+ {record && ( + + {record.status} · {(record.durationMs / 1000).toFixed(2)}s + + )} +
+ + + +
+

Stages

+ +
+ +
+

Logs

+ +
+ +
+

Validation results

+ +
+
+ ); +} + +export default function RunConsole() { + return ( + + + + ); +} diff --git a/frontend/src/components/run/StageTimeline.jsx b/frontend/src/components/run/StageTimeline.jsx new file mode 100644 index 00000000..6e291126 --- /dev/null +++ b/frontend/src/components/run/StageTimeline.jsx @@ -0,0 +1,46 @@ +// Stage timeline — per-interface row of pipeline stages (read → pre_process → [post_process] → +// format → validate → write), each tile reflecting the latest stage status streamed from the run. +// Derived purely from the event stream so it updates live as the mock execution progresses. + +const STAGE_LABEL = { + read: 'Read', pre_process: 'Pre', post_process: 'Post', format: 'Format', validate: 'Validate', write: 'Write', +}; +const STATUS_CLASS = { + running: 'stagetile--running', ok: 'stagetile--ok', warning: 'stagetile--warn', + failed: 'stagetile--fail', skipped: 'stagetile--skip', +}; + +/** Fold stage events into { [interface]: { order: stage[], status: {stage:status} } }. */ +function buildMatrix(events) { + const byIface = new Map(); + for (const e of events) { + if (e.type !== 'stage') continue; + if (!byIface.has(e.interface)) byIface.set(e.interface, { order: [], status: {} }); + const entry = byIface.get(e.interface); + if (!entry.order.includes(e.stage)) entry.order.push(e.stage); + entry.status[e.stage] = e.status; + } + return byIface; +} + +export default function StageTimeline({ events }) { + const matrix = buildMatrix(events); + if (matrix.size === 0) return
No stages yet — start a run.
; + + return ( +
+ {[...matrix.entries()].map(([iface, { order, status }]) => ( +
+ {iface} +
+ {order.map((stage) => ( + + {STAGE_LABEL[stage] ?? stage} + + ))} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/run/ValidationResults.jsx b/frontend/src/components/run/ValidationResults.jsx new file mode 100644 index 00000000..da87d1ef --- /dev/null +++ b/frontend/src/components/run/ValidationResults.jsx @@ -0,0 +1,45 @@ +// Validation results view — passed/failed/warning expectations with severity. Reused by the Run +// Console and the History detail. Pure presentation of a ValidationReport. + +const STATUS_CLASS = { passed: 'vres--pass', failed: 'vres--fail', warning: 'vres--warn' }; + +export default function ValidationResults({ report }) { + if (!report) return
Run the pipeline to see validation results.
; + const { results, summary } = report; + + return ( +
+
+ {summary.passed} passed + {summary.warning} warning + {summary.failed} failed + of {summary.total} +
+ + {results.length === 0 ? ( +
No expectations configured on these interfaces.
+ ) : ( + + + + + + {results.map((r, i) => ( + + + + + + + + ))} + +
interfacecolumnexpectationseverityresult
{r.interface}{r.column}{r.expectation}{r.severity} + + {r.status}{r.unexpectedCount ? ` · ${r.unexpectedCount}` : ''} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/start/EntryOverlay.jsx b/frontend/src/components/start/EntryOverlay.jsx new file mode 100644 index 00000000..0924b4f8 --- /dev/null +++ b/frontend/src/components/start/EntryOverlay.jsx @@ -0,0 +1,28 @@ +// EntryOverlay — after a source is described, choose how to build: inFlow (visual board) or +// inChat (assistant). Non-binding — both edit the same config and you can switch anytime. + +import { GitBranch, MessageSquare } from 'lucide-react'; + +export default function EntryOverlay({ onChoose, onBack }) { + return ( +
+
+

How do you want to build it?

+

Both edit the same pipeline — switch anytime from the top bar.

+
+
+ + +
+ {onBack && } +
+ ); +} diff --git a/frontend/src/components/start/SourceLoader.jsx b/frontend/src/components/start/SourceLoader.jsx new file mode 100644 index 00000000..fdceec15 --- /dev/null +++ b/frontend/src/components/start/SourceLoader.jsx @@ -0,0 +1,176 @@ +// SourceLoader — pick a source type, fill the fields that matter, (for files) drag-drop & upload. +// One component, two entry points: the start screen AND the inFlow board's "+ Add source" modal. +// Calls onSubmit(sourceObject, columns) once a valid source is described. + +import { useState } from 'react'; +import { FileText, Database, Globe, Braces, UploadCloud, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; + +import SchemaForm from '../../forms/SchemaForm.jsx'; +import { requiredSourceFields, advancedSourceFields } from '../../forms/schemas/sourceSchemas.js'; +import { SOURCE_TYPES, FILE_TYPES } from '../../models/constants.js'; +import { uploadFile } from '../../services/fileService.js'; + +const TYPE_META = { + file: { icon: FileText, color: '#3b82f6', label: 'File', desc: 'CSV, Excel, XML, or JSON' }, + mysql: { icon: Database, color: '#f59e0b', label: 'MySQL', desc: 'SQL query against a database' }, + api: { icon: Globe, color: '#8b5cf6', label: 'API', desc: 'HTTP endpoint (REST / SOAP)' }, + json: { icon: Braces, color: '#10b981', label: 'JSON', desc: 'Runtime JSON payload' }, +}; +const TYPES = Object.values(SOURCE_TYPES); + +// Map a file extension to InGen's file_type so uploads land pre-configured. +const EXT_TO_FILETYPE = { + csv: FILE_TYPES.DELIMITED_FILE, tsv: FILE_TYPES.DELIMITED_FILE, txt: FILE_TYPES.DELIMITED_FILE, + xlsx: FILE_TYPES.EXCEL, xls: FILE_TYPES.EXCEL, xml: FILE_TYPES.XML, json: FILE_TYPES.JSON, +}; + +export default function SourceLoader({ existingIds = [], onSubmit, onCancel, submitLabel = 'Continue' }) { + const [type, setType] = useState('file'); + const [id, setId] = useState(''); + const [fields, setFields] = useState({}); // type-specific body + const [columns, setColumns] = useState([]); + const [showAdvanced, setShowAdvanced] = useState(false); + const [upload, setUpload] = useState(null); // { name, rows, cached } | null + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const idTaken = existingIds.includes(id.trim()); + const required = requiredSourceFields(type); + const advanced = advancedSourceFields(type); + + const missingRequired = required.some((f) => fields[f.key] == null || fields[f.key] === ''); + const canSubmit = id.trim().length > 0 && !idTaken && !missingRequired && !busy; + + // Reset body when switching type (fields don't carry across types). + const pickType = (t) => { setType(t); setFields({}); setColumns([]); setUpload(null); setError(''); setShowAdvanced(false); }; + + const handleFile = async (file) => { + if (!file) return; + setBusy(true); setError(''); + try { + const res = await uploadFile(file); + const ext = file.name.split('.').pop().toLowerCase(); + setFields((f) => ({ ...f, file_path: res.file_path, file_type: f.file_type || EXT_TO_FILETYPE[ext] })); + setColumns(res.columns || []); + setUpload({ name: file.name, rows: res.preview || [], cached: res.cached }); + if (!id.trim()) setId(file.name.replace(/\.[^.]+$/, '').replace(/\W+/g, '_')); + } catch (e) { + setError(e.message || 'Upload failed. Is the backend reachable?'); + } finally { + setBusy(false); + } + }; + + const submit = () => { + if (!canSubmit) return; + onSubmit?.({ id: id.trim(), type, ...fields }, columns); + }; + + const Meta = TYPE_META[type]; + + return ( +
+
+ {TYPES.map((t) => { + const m = TYPE_META[t]; + const Icon = m.icon; + return ( + + ); + })} +
+ +
+ + + {type === 'file' && ( + + )} + + + + {advanced.length > 0 && ( + <> + + {showAdvanced && } + + )} + + {error &&

{error}

} + {columns.length > 0 && ( +

{columns.length} columns detected: {columns.slice(0, 8).join(', ')}{columns.length > 8 ? '…' : ''}

+ )} + +
+ {onCancel && } + +
+
+
+ ); +} + +function Dropzone({ busy, upload, onFile }) { + const [over, setOver] = useState(false); + return ( +
{ e.preventDefault(); setOver(true); }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { e.preventDefault(); setOver(false); onFile(e.dataTransfer.files?.[0]); }} + > + onFile(e.target.files?.[0])} /> + + {upload && upload.rows.length > 0 && ( +
+ + {upload.rows.length > 1 && ( + + {upload.rows[0].slice(0, 6).map((cell, j) => )} + + )} + + {upload.rows.slice(upload.rows.length > 1 ? 1 : 0, 6).map((row, i) => ( + {row.slice(0, 6).map((cell, j) => )} + ))} + +
{cell}
{cell}
+
+ )} +
+ ); +} diff --git a/frontend/src/components/start/Start.jsx b/frontend/src/components/start/Start.jsx new file mode 100644 index 00000000..e99c5139 --- /dev/null +++ b/frontend/src/components/start/Start.jsx @@ -0,0 +1,133 @@ +// Start — the source-first entry. Shown by the config index when the first interface has no +// sources yet: describe a source → choose inFlow/inChat → the workspace powers up on that source. +// Supports adding MULTIPLE sources before entering the editor. + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Plus, Trash2, FileText, Database, Globe, Braces } from 'lucide-react'; + +import { useConfig } from '../../state/ConfigContext.jsx'; +import { useViewMode } from '../../state/ViewModeContext.jsx'; +import { upsertSource, upsertInterface } from '../../models/configModel.js'; +import { setColumns } from '../../lib/columnStore.js'; +import SourceLoader from './SourceLoader.jsx'; +import EntryOverlay from './EntryOverlay.jsx'; + +const TYPE_META = { + file: { icon: FileText, color: '#3b82f6', label: 'File' }, + mysql: { icon: Database, color: '#f59e0b', label: 'MySQL' }, + api: { icon: Globe, color: '#8b5cf6', label: 'API' }, + json: { icon: Braces, color: '#10b981', label: 'JSON' }, +}; + +export default function Start({ configId }) { + const { model, updateModel } = useConfig(); + const { setViewMode } = useViewMode(); + const router = useRouter(); + const [step, setStep] = useState('source'); // 'source' | 'adding' | 'entry' + const [pendingSources, setPendingSources] = useState([]); // [{ source, columns }, ...] + + const interfaceName = model?.interfaceOrder?.[0] ?? 'interface_1'; + + const existingIds = [ + ...(model?.sourceOrder ?? []), + ...pendingSources.map((p) => p.source.id), + ]; + + const onSource = (source, columns) => { + setPendingSources((prev) => [...prev, { source, columns }]); + setStep('source'); + }; + + const removeSource = (idx) => { + setPendingSources((prev) => prev.filter((_, i) => i !== idx)); + }; + + const onChoose = (view) => { + // Thread all pending sources through a single updateModel call so later sources + // see the model already modified by earlier ones (avoids stale-closure data loss). + updateModel((m) => { + let next = m; + for (const { source } of pendingSources) { + next = upsertSource(next, source); + const it = next.interfacesByName[interfaceName] ?? { sources: [], pre_processing: [], columns: [], post_processing: [], output: {} }; + next = upsertInterface(next, interfaceName, { + ...it, + sources: it.sources.includes(source.id) ? it.sources : [...it.sources, source.id], + }); + } + return next; + }); + // Cache the fetched headers outside the updater — StrictMode double-invokes updaters, and this + // writes to the shared column store rather than deriving the next model. + for (const { source, columns } of pendingSources) setColumns(source.id, columns); + setViewMode(view); + router.push(`/configs/${configId}/interfaces/${interfaceName}`); + }; + + return ( +
+
+ + {step === 'entry' ? 'Step 2 · Choose your tool' : step === 'adding' ? 'Add another source' : 'Step 1 · Choose your data'} + +

+ {step === 'entry' + ? `Build with ${pendingSources.length} source${pendingSources.length > 1 ? 's' : ''}` + : 'Start with your data sources'} +

+
+ + {step === 'entry' ? ( + setStep('source')} /> + ) : step === 'adding' ? ( + setStep('source')} + submitLabel="Add source" + /> + ) : ( +
+ {pendingSources.length > 0 && ( +
+ {pendingSources.map(({ source }, i) => { + const meta = TYPE_META[source.type] || TYPE_META.file; + const Icon = meta.icon; + return ( +
+
+
+ +
+
+
{source.id}
+
{meta.label}
+
+
+ +
+ ); + })} +
+ )} + + {pendingSources.length === 0 ? ( + + ) : ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/yaml/YamlPreviewPanel.jsx b/frontend/src/components/yaml/YamlPreviewPanel.jsx new file mode 100644 index 00000000..f144b5c9 --- /dev/null +++ b/frontend/src/components/yaml/YamlPreviewPanel.jsx @@ -0,0 +1,118 @@ +'use client'; + +// InGen Studio — YamlPreviewPanel +// +// First-class, always-live YAML view. It renders `yaml` straight from ConfigContext, which derives +// it from the REAL serializer (modelToYaml) on every model change — so editing any field updates +// this panel immediately. Copy/download act on the same serialized text. +// +// Lightweight, dependency-free syntax tinting + a line-number gutter. The tokenizer is line-based +// and conservative (keys are simple identifiers in generated output), so it never mangles content — +// worst case a line just renders in the default color. + +import { useMemo, useState } from 'react'; +import { useConfig } from '../../state/ConfigContext.jsx'; + +function download(filename, text) { + const blob = new Blob([text], { type: 'text/yaml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function valueClass(v) { + if (/^-?\d+(\.\d+)?$/.test(v)) return 'tok-num'; + if (v === 'true' || v === 'false' || v === 'null') return 'tok-bool'; + if (v[0] === '"' || v[0] === "'") return 'tok-str'; + return 'tok-val'; +} + +/** Tokenize a single YAML line into colored spans (indentation preserved by white-space: pre). */ +function tokens(line) { + const m = line.match(/^(\s*)(.*)$/); + const indent = m[1]; + let rest = m[2]; + if (rest === '') return indent || ' '; + if (rest.startsWith('#')) return [indent, {rest}]; + + const parts = [indent]; + if (rest.startsWith('- ')) { + parts.push(- ); + rest = rest.slice(2); + } + // A quoted scalar is a single value, even if it contains ": " — don't split it into key:value. + if (rest[0] === '"' || rest[0] === "'") { + parts.push({rest}); + return parts; + } + const kv = rest.match(/^([^:\s][^:]*):(\s.*|)$/); + if (kv) { + parts.push({kv[1]}, :); + if (kv[2].trim()) parts.push({kv[2]}); + } else if (rest) { + parts.push({rest}); + } + return parts; +} + +export default function YamlPreviewPanel() { + const { yaml, model } = useConfig(); + const [copied, setCopied] = useState(false); + const filename = `${model?.meta.id ?? 'config'}.yml`; + + const lines = useMemo(() => yaml.replace(/\n$/, '').split('\n'), [yaml]); + + // Track previous lines for diff highlighting. Held in state rather than a ref and recomputed + // during render (React's "adjust state when a prop changes" pattern) so the comparison stays + // correct under StrictMode double-invocation, which would corrupt a ref written during render. + const [prevLines, setPrevLines] = useState(lines); + const [changedSet, setChangedSet] = useState(() => new Set()); + if (prevLines !== lines) { + const set = new Set(); + lines.forEach((line, i) => { if (line !== prevLines[i]) set.add(i); }); + // Also mark lines beyond old length as new. + if (lines.length > prevLines.length) { + for (let i = prevLines.length; i < lines.length; i++) set.add(i); + } + setPrevLines(lines); + setChangedSet(set); + } + + const copy = async () => { + try { + await navigator.clipboard?.writeText(yaml); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + } catch { + /* clipboard blocked — no-op */ + } + }; + + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..d282032f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,1775 @@ +/* InGen Studio — Flowise-inspired UI theme + * + * Direction: A modern, premium data-pipeline studio with Flowise-inspired aesthetics. + * White/lavender surfaces, purple accent system, soft shadows, and clean typography. + * YAML panel retains a dark theme for code contrast. + */ + +/* Typography intentionally uses locally available faces only — no webfont fetch at runtime. + The stacks below name Inter / Space Grotesk / IBM Plex Mono first so the intended design + is used where those are installed, and fall back to solid system faces everywhere else. + This keeps the app working in air-gapped and CSP-restricted deployments. */ + +:root { + /* ═══ Surfaces — light & airy ═══ */ + --bg-primary: #f8f7ff; + --bg-surface: #ffffff; + --bg-sidebar: #ffffff; + --bg-elevated: #faf9ff; + --bg-brand: #ffffff; + --bg-hover: #f3f0ff; + + /* ═══ Purple accent system ═══ */ + --purple-700: #6439d4; + --purple-600: #7853EC; + --purple-500: #8b6cf0; + --purple-400: #a78bfa; + --purple-300: #c4b5fd; + --purple-200: #ddd6fe; + --purple-100: #ede9fe; + --purple-50: #f5f3ff; + + /* ═══ Borders & lines ═══ */ + --border: #e5e3f1; + --border-light: #f0eef8; + --border-focus: var(--purple-400); + + /* ═══ Text ═══ */ + --text: #1a1a2e; + --text-secondary: #6b7080; + --text-muted: #9ca3af; + --text-on-purple: #ffffff; + + /* ═══ Status ═══ */ + --success: #10b981; + --success-soft: #d1fae5; + --warning: #f59e0b; + --warning-soft: #fef3c7; + --error: #ef4444; + --error-soft: #fee2e2; + + /* ═══ Node colors ═══ */ + --node-source: #3b82f6; + --node-transform: #f59e0b; + --node-columns: #7853EC; + --node-postprocess: #ec4899; + --node-validation: #10b981; + --node-output: #ef4444; + + /* ═══ Type ═══ + * display = Space Grotesk (engineered character — brand, tab labels, panel titles, headings) + * sans = Inter (body / UI controls) + * mono = IBM Plex Mono (data, identifiers, counts, YAML — a first-class texture here) */ + --display: 'Space Grotesk', 'Inter', system-ui, sans-serif; + --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + + /* ═══ Metrics ═══ */ + --rail-w: 260px; + --rail-w-collapsed: 52px; + --yaml-w: 380px; + --radius: 8px; + --radius-sm: 6px; + --brandbar-h: 56px; + + /* ═══ Shadows ═══ */ + --shadow-sm: 0 1px 3px rgba(120, 83, 236, 0.04); + --shadow-md: 0 4px 12px rgba(120, 83, 236, 0.06); + --shadow-lg: 0 8px 24px rgba(120, 83, 236, 0.08); + --shadow-card: 0 2px 8px rgba(120, 83, 236, 0.05); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #root { height: 100%; } + +body { + font-family: var(--sans); + background: var(--bg-primary); + color: var(--text); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; } +input, select, textarea { font-family: inherit; } +a { color: inherit; text-decoration: none; } +:focus-visible { outline: 2px solid var(--purple-400); outline-offset: 2px; } + +.mono { font-family: var(--mono); font-size: 12.5px; } +.muted { color: var(--text-secondary); } + +/* ═══════════════════ App Shell ═══════════════════ */ +.app-shell { display: flex; flex-direction: column; height: 100%; } + +.brandbar { + height: var(--brandbar-h); + flex: 0 0 auto; + background: var(--bg-brand); + color: var(--text); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 4px rgba(120, 83, 236, 0.04); + z-index: 20; + gap: 16px; +} + +.brandbar__left { + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; +} + +.brandbar__mark { + display: flex; + flex-direction: column; + gap: 0; + color: var(--text); +} +.brandbar__mark-top { + display: flex; + align-items: baseline; + gap: 5px; + font-family: var(--display); + font-weight: 700; + font-size: 17px; + letter-spacing: -0.4px; +} +.brandbar__glyph { color: var(--purple-600); font-size: 20px; } +.brandbar__sub { color: var(--purple-500); font-weight: 500; } +.brandbar__tag { + color: var(--text-muted); + font-family: var(--mono); + font-size: 9.5px; + letter-spacing: 0.4px; + text-transform: uppercase; +} + +.brandbar__center { + display: flex; + align-items: center; + justify-content: center; + flex: 1; +} + +.brandbar__right { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; +} +.brandbar__config-name { + font-weight: 600; + font-size: 13px; + color: var(--text); +} + +/* View Switcher (in brand bar) */ +.view-switcher { + display: inline-flex; + background: var(--purple-50); + border: 1px solid var(--purple-200); + padding: 3px; + border-radius: 10px; +} +.view-switcher__btn { + padding: 6px 18px; + font-family: var(--display); + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + border-radius: 8px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + gap: 6px; +} +.view-switcher__btn:hover { + color: var(--purple-600); + background: rgba(120, 83, 236, 0.06); +} +.view-switcher__btn--active { + color: #fff !important; + background: var(--purple-600) !important; + box-shadow: 0 2px 8px rgba(120, 83, 236, 0.3); +} + +.app-shell__body { flex: 1 1 auto; min-height: 0; display: flex; } + +/* ═══════════════════ Workspace ═══════════════════ */ +.workspace { flex: 1; display: flex; flex-direction: column; min-width: 0; } + +.workspace__panes { + flex: 1; min-height: 0; + display: grid; + grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--yaml-w); + transition: grid-template-columns 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} +.workspace__panes--noyaml { grid-template-columns: var(--rail-w) minmax(0, 1fr); } +.workspace__panes--norail { grid-template-columns: var(--rail-w-collapsed) minmax(0, 1fr) var(--yaml-w); } +.workspace__panes--norail.workspace__panes--noyaml { grid-template-columns: var(--rail-w-collapsed) minmax(0, 1fr); } +.workspace__panes--hiddenrail { grid-template-columns: minmax(0, 1fr) var(--yaml-w); } +.workspace__panes--hiddenrail.workspace__panes--noyaml { grid-template-columns: minmax(0, 1fr); } +.workspace__editor { min-width: 0; overflow: auto; background: var(--bg-primary); } + +/* ═══════════════════ Nav Rail / Left Sidebar ═══════════════════ */ +.navrail { + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + padding: 16px 12px; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 4px; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.navrail--collapsed { + width: var(--rail-w-collapsed); + padding: 16px 6px; + align-items: center; +} +.navrail--collapsed .navrail__section, +.navrail--collapsed .navrail__heading, +.navrail--collapsed .navrail__link span, +.navrail--collapsed .navrail__item span, +.navrail--collapsed .navrail__soon, +.navrail--collapsed .navrail__empty, +.navrail--collapsed .navrail-chat__title, +.navrail--collapsed .navrail-chat__session-text { display: none; } + +.navrail__collapse-btn { + position: absolute; + bottom: 12px; + right: 12px; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--purple-50); + color: var(--purple-600); + display: grid; + place-items: center; + font-size: 14px; + border: 1px solid var(--purple-200); + transition: all 0.2s ease; + z-index: 5; +} +.navrail__collapse-btn:hover { + background: var(--purple-100); + box-shadow: var(--shadow-sm); +} +.navrail--collapsed .navrail__collapse-btn { + right: 50%; + transform: translateX(50%); +} + +.navrail__item { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; +} +.navrail__item--disabled { color: var(--text-muted); cursor: default; } +.navrail__soon { + font-size: 9px; text-transform: uppercase; letter-spacing: .5px; + color: var(--text-muted); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; +} +.navrail__section { margin: 12px 0 4px; } +.navrail__heading { + font-size: 10px; text-transform: uppercase; letter-spacing: .8px; + color: var(--text-muted); padding: 4px 12px; font-weight: 600; +} +.navrail__list { list-style: none; display: flex; flex-direction: column; gap: 2px; } +.navrail__link { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border-radius: var(--radius-sm); font-size: 12.5px; color: var(--text-secondary); + font-family: var(--mono); + transition: all 0.15s ease; +} +.navrail__link:hover { background: var(--bg-hover); color: var(--text); } +.navrail__link--active { + background: var(--purple-50); + color: var(--purple-700); + font-weight: 600; + border-left: 2px solid var(--purple-600); + padding-left: 10px; +} +.navrail__dot { width: 5px; height: 5px; border-radius: 50%; background: var(--purple-400); flex: 0 0 auto; } +.navrail__empty { color: var(--text-muted); font-size: 12px; padding: 6px 12px; } + +/* Interface list rows with inline reorder/delete controls */ +.navrail__heading--row { + display: flex; align-items: center; justify-content: space-between; gap: 8px; +} +.navrail__icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 20px; height: 20px; padding: 0; + border: none; background: transparent; border-radius: var(--radius-sm); + color: var(--text-muted); cursor: pointer; transition: all 0.15s ease; +} +.navrail__icon-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } +.navrail__icon-btn:disabled { opacity: 0.3; cursor: default; } +.navrail__icon-btn--del:hover:not(:disabled) { background: var(--danger-50, #fef2f2); color: var(--danger, #dc2626); } + +.navrail__iface-row { display: flex; align-items: center; gap: 2px; } +.navrail__iface-row .navrail__link { flex: 1 1 auto; min-width: 0; } +.navrail__iface-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.navrail__iface-ctrls { display: flex; align-items: center; gap: 1px; flex: 0 0 auto; opacity: 0; transition: opacity 0.15s ease; } +.navrail__iface-row:hover .navrail__iface-ctrls, +.navrail__iface-row:focus-within .navrail__iface-ctrls { opacity: 1; } + +.navrail__add { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 4px 12px 8px; } +.navrail__add .field__input { flex: 1 1 auto; min-width: 0; } +.navrail__add-err { font-size: 11px; color: var(--danger, #dc2626); flex-basis: 100%; } + +/* ── Chat Mode: History Sidebar ── */ +.navrail-chat { display: flex; flex-direction: column; gap: 8px; } +.navrail-chat__title { + font-size: 10px; text-transform: uppercase; letter-spacing: .8px; + color: var(--text-muted); font-weight: 600; padding: 0 4px; +} +.navrail-chat__new-btn { + display: flex; align-items: center; gap: 6px; + padding: 8px 12px; border-radius: var(--radius-sm); + background: var(--purple-600); color: #fff; + font-size: 12.5px; font-weight: 550; + transition: all 0.2s ease; +} +.navrail-chat__new-btn:hover { background: var(--purple-700); box-shadow: var(--shadow-sm); } +.navrail-chat__sessions { display: flex; flex-direction: column; gap: 4px; list-style: none; } +.navrail-chat__session { + display: flex; flex-direction: column; gap: 2px; width: 100%; text-align: left; + padding: 8px 12px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 12.5px; font: inherit; color: inherit; + background: transparent; transition: all 0.15s ease; + border: 1px solid transparent; +} +.navrail-chat__session:hover { background: var(--bg-hover); } +.navrail-chat__session--active { + background: var(--purple-50); border-color: var(--purple-200); color: var(--purple-600); +} +.navrail-chat__session-preview { + color: var(--text-secondary); font-size: 11.5px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.navrail-chat__session-time { color: var(--text-muted); font-size: 10.5px; } +.navrail-chat__empty { color: var(--text-muted); font-size: 12px; padding: 8px 4px; } + +/* ═══════════════════ YAML Panel ═══════════════════ */ +.yamlpanel { + background: #0d1017; color: #cdd6e2; + border-left: 1px solid var(--border); + display: flex; flex-direction: column; min-width: 0; +} +.yamlpanel__head { + flex: 0 0 auto; display: flex; align-items: center; gap: 9px; + padding: 11px 14px; border-bottom: 1px solid rgba(255,255,255,0.07); + background: linear-gradient(180deg, #171c26 0%, #0d1017 100%); +} +.yamlpanel__title { + font-family: var(--mono); font-weight: 500; font-size: 11.5px; + letter-spacing: .2px; color: #c6cfdd; +} +.yamlpanel__live { + font-family: var(--display); + font-size: 8.5px; color: #34d399; background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.4); + border-radius: 4px; padding: 1px 6px; text-transform: uppercase; letter-spacing: .7px; font-weight: 600; +} +.yamlpanel__actions { margin-left: auto; display: flex; gap: 5px; } +.yamlpanel__btn { + font-family: var(--display); font-size: 11px; font-weight: 500; color: #aeb8c6; + padding: 3px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.03); transition: all 0.15s ease; +} +.yamlpanel__btn:hover { color: #fff; background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.2); } +.yamlpanel__btn--ok { color: #34d399; border-color: rgba(52, 211, 153, 0.5); background: rgba(52, 211, 153, 0.12); } + +.yamlpanel__code { + flex: 1; overflow: auto; padding: 12px 0 16px; + font-family: var(--mono); font-size: 12px; line-height: 1.65; + tab-size: 2; counter-reset: none; +} +.yamlpanel__line { display: flex; } +.yamlpanel__line:hover { background: rgba(255,255,255,0.025); } +.yamlpanel__line--changed { animation: yamlFlash 1.2s ease-out; } +@keyframes yamlFlash { + 0% { background: rgba(168,218,120,0.25); } + 100% { background: transparent; } +} +.yamlpanel__ln { + flex: 0 0 auto; width: 38px; padding-right: 12px; text-align: right; + color: #3c4757; user-select: none; -webkit-user-select: none; +} +.yamlpanel__lc { flex: 1; white-space: pre; padding-right: 14px; color: #cdd6e2; } + +/* YAML token colors (dark surface) */ +.yamlpanel .tok-key { color: #7cc7ff; } +.yamlpanel .tok-punct { color: #5b6675; } +.yamlpanel .tok-dash { color: #8b96a8; } +.yamlpanel .tok-str { color: #bce6a0; } +.yamlpanel .tok-num { color: #f0b072; } +.yamlpanel .tok-bool { color: #c9a3ff; } +.yamlpanel .tok-val { color: #d7dee9; } +.yamlpanel .tok-comment { color: #6b7686; font-style: italic; } + +/* ═══════════════════ Editor ═══════════════════ */ +.editor { padding: 24px 28px; max-width: 980px; } +/* Graph & chat are canvases, not documents: drop the readable-width cap and padding so they fill the + * pane edge-to-edge — including when the YAML panel is hidden and the pane widens past 980px. */ +.editor--full { max-width: none; padding: 0; height: 100%; } +.editor--full .editor__panel { height: 100%; } +.editor__head { + margin-bottom: 20px; + padding-left: 14px; + border-left: 3px solid var(--purple-600); +} +.editor__head--row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } + +/* Interface names are YAML keys — monospace signals "this is a config identifier" */ +.editor__title { + font-size: 22px; + font-weight: 600; + color: var(--text); + font-family: var(--display); + letter-spacing: -0.5px; +} +.editor__subtitle { + color: var(--text-muted); + font-size: 11.5px; + margin-top: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +/* Manual tabs = a numbered pipeline index. The 01–06 order is the real stage order, and each tab + * carries its stage color (shared with the graph view) as the active accent. */ +.tabbar { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border); + margin-bottom: 22px; + flex-wrap: wrap; +} +.tabbar__tab { + --tab-accent: var(--purple-600); + display: inline-flex; align-items: center; gap: 8px; + padding: 9px 14px 11px; font-size: 12.5px; color: var(--text-secondary); + font-family: var(--display); font-weight: 500; letter-spacing: -0.1px; + border-bottom: 2px solid transparent; margin-bottom: -1px; + transition: color 0.15s ease, border-color 0.15s ease; +} +.tabbar__ord { + font-family: var(--mono); font-size: 10px; font-weight: 500; + color: var(--text-muted); letter-spacing: 0.5px; + padding: 1px 4px; border-radius: 4px; background: var(--bg-elevated); + transition: all 0.15s ease; +} +.tabbar__tab:hover { color: var(--text); } +.tabbar__tab:hover .tabbar__ord { color: var(--text-secondary); } +.tabbar__tab--active { + color: var(--text); + border-bottom-color: var(--tab-accent); + font-weight: 600; +} +.tabbar__tab--active .tabbar__ord { + color: #fff; background: var(--tab-accent); +} +.tabbar__count { + font-family: var(--mono); + font-size: 10px; + background: var(--bg-elevated); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 9px; + padding: 1px 6px; + min-width: 18px; + text-align: center; + font-weight: 600; +} + +.editor__panel { background: transparent; } +.tabcontent { display: flex; flex-direction: column; gap: 12px; } +.tabcontent__hint { color: var(--text-secondary); font-size: 12.5px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + +/* ═══════════════════ Data Grid / Tables ═══════════════════ */ +.dgrid { + width: 100%; border-collapse: collapse; background: var(--bg-surface); + border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; font-size: 13px; + box-shadow: var(--shadow-sm); +} +.dgrid th { + text-align: left; font-weight: 600; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; + color: var(--text-muted); background: var(--bg-elevated); padding: 10px 14px; border-bottom: 1px solid var(--border); +} +.dgrid td { padding: 10px 14px; border-bottom: 1px solid var(--border-light); vertical-align: middle; } +.dgrid tr:last-child td { border-bottom: none; } +.dgrid tr:hover td { background: var(--purple-50); } +.dgrid__idx { color: var(--text-muted); width: 36px; } +.dgrid__detail { color: var(--text-secondary); } +.dgrid__empty { color: var(--text-muted); text-align: center; padding: 20px; } +.dgrid__input { + width: 100%; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); + font-size: 13px; background: var(--bg-surface); color: var(--text); +} +.dgrid__input:focus { outline: none; border-color: var(--purple-400); box-shadow: 0 0 0 3px rgba(120, 83, 236, 0.1); } + +/* ═══════════════════ Stepper / Lists / Chips ═══════════════════ */ +.stepper { list-style: none; display: flex; flex-direction: column; gap: 8px; } +.stepper__item { + display: flex; gap: 12px; align-items: flex-start; + background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; + box-shadow: var(--shadow-sm); +} +.stepper__num { + flex: 0 0 auto; width: 24px; height: 24px; border-radius: 50%; background: var(--purple-600); color: #fff; + display: grid; place-items: center; font-size: 11px; font-weight: 700; +} +.stepper__body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.stepper__detail { color: var(--text-secondary); word-break: break-word; } +.stepper__empty, .emptyblock { + color: var(--text-muted); background: var(--bg-surface); border: 1px dashed var(--border); + border-radius: var(--radius); padding: 18px; font-size: 13px; +} + +.chip { + display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 10px; + font-size: 11.5px; background: var(--purple-50); color: var(--purple-600); border: 1px solid var(--purple-100); +} +.chip--accent { background: var(--success-soft); color: var(--success); border-color: transparent; } +.chip--warn { background: var(--warning-soft); color: var(--warning); border-color: transparent; } +.chip--mono { font-family: var(--mono); font-size: 11px; margin-right: 4px; } + +.kvlist { display: flex; flex-direction: column; gap: 8px; } +.kvlist__row { display: flex; align-items: center; gap: 10px; } +.kvlist__key { font-size: 12px; color: var(--text-secondary); width: 80px; } +.kvlist__json { + background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 10px 12px; font-size: 12px; color: var(--text); overflow: auto; +} +.kvlist__json--block { white-space: pre; } + +/* ═══════════════════ Pills / Buttons ═══════════════════ */ +.pill { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; } +.pill--ok { background: var(--success-soft); color: var(--success); } +.pill--warn { background: var(--warning-soft); color: var(--warning); } +.pill--err { background: var(--error-soft); color: var(--error); } +.pill--muted { background: var(--purple-50); color: var(--text-muted); } + +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 550; + border: 1px solid transparent; + transition: all 0.15s ease; +} +.btn--ghost { color: var(--text-secondary); border-color: var(--border); background: var(--bg-surface); } +.btn--ghost:hover { border-color: var(--purple-300); color: var(--purple-600); background: var(--purple-50); } +.btn--accent { background: var(--purple-600); color: #fff; } +.btn--accent:hover { background: var(--purple-700); box-shadow: 0 2px 8px rgba(120, 83, 236, 0.3); } +.btn--accent:disabled { opacity: .5; cursor: not-allowed; } +.btn--xs { padding: 3px 8px; font-size: 11.5px; } +.btn--solid { background: var(--purple-600); color: #fff; } +.btn--solid:hover { background: var(--purple-700); } +.btn--solid:disabled { opacity: .45; cursor: not-allowed; } +.btn--ghost-dark { color: var(--text); border: 1px solid var(--border); background: var(--bg-surface); } +.btn--ghost-dark:hover { border-color: var(--purple-400); } +.btn--danger { color: #fff; background: var(--error); } +.btn--danger:hover { background: #dc2626; } +.btn--danger:disabled { opacity: .45; cursor: not-allowed; } + +/* ═══════════════════ Landing (kept minimal for fallback) ═══════════════════ */ +.landing { padding: 28px 32px; } +.landing__head { margin-bottom: 18px; } +.landing__head h1 { font-size: 22px; font-weight: 700; } +.configgrid { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } +.configcard { + display: flex; flex-direction: column; gap: 8px; padding: 16px 18px; + background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow-sm); transition: all 0.2s ease; + border-top: 3px solid var(--purple-200); +} +.configcard:hover { + border-top-color: var(--purple-600); + border-color: var(--purple-300); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} +.configcard__name { + font-weight: 700; + font-family: var(--mono); + font-size: 13.5px; + color: var(--text); + letter-spacing: -0.2px; +} +.configcard__meta { color: var(--text-muted); font-family: var(--mono); font-size: 11.5px; } +.configcard__count { font-size: 11.5px; color: var(--purple-600); font-family: var(--mono); font-weight: 600; } + +/* ═══════════════════ Misc ═══════════════════ */ +.placeholder { padding: 32px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 10px; } + +@media (max-width: 1100px) { + .workspace__panes, .workspace__panes--noyaml { grid-template-columns: var(--rail-w) minmax(0, 1fr); } + .yamlpanel { display: none; } +} +@media (max-width: 720px) { + .workspace__panes, .workspace__panes--noyaml { grid-template-columns: 1fr; } + .navrail { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + * { animation: none !important; transition: none !important; } +} + +/* ═══════════════════ Forms, Editors, Run Console ═══════════════════ */ + +/* Fields */ +.field { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; } +.field__label { font-size: 10.5px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px; } +.field__help { font-size: 11px; color: var(--text-secondary); } +.field__help--err { color: var(--error); } +.field__input { + padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); + font-size: 13px; background: var(--bg-surface); color: var(--text); width: 100%; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.field__input:focus { outline: none; border-color: var(--purple-400); box-shadow: 0 0 0 3px rgba(120, 83, 236, 0.1); } +.field__input--area { resize: vertical; line-height: 1.4; } +.field__input--err { border-color: var(--error); } +.field__input--wide { max-width: 420px; } +.field--toggle { flex-direction: row; align-items: center; gap: 8px; } +.field--toggle .field__label { text-transform: none; } +.field__group { + border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; margin-bottom: 8px; background: var(--bg-elevated); +} +.field__grouplabel { font-size: 10.5px; font-weight: 600; color: var(--text-muted); padding: 0 4px; text-transform: uppercase; } +.schemaform { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 4px 14px; } +.schemaform .field__group { grid-column: 1 / -1; } +.schemaform .field__input--area { min-height: 0; } + +/* Add bars + buttons */ +.addbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } +.addbar--sub { margin: 8px 0 0; } +.addbar--card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; } + +/* List controls */ +.lctrls { display: inline-flex; gap: 2px; margin-left: auto; } +.lctrls__btn { + width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border); + background: var(--bg-surface); color: var(--text-secondary); font-size: 12px; line-height: 1; + transition: all 0.15s ease; +} +.lctrls__btn:hover:not(:disabled) { border-color: var(--purple-400); color: var(--purple-600); } +.lctrls__btn:disabled { opacity: .35; cursor: not-allowed; } +.lctrls__btn--del:hover { border-color: var(--error); color: var(--error); } + +/* Stepper editable */ +.stepper__item--editable { flex-direction: row; } +.stepper__row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } + +/* Validations editor */ +.vlist { display: flex; flex-direction: column; gap: 8px; } +.vrow { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-surface); padding: 12px 14px; box-shadow: var(--shadow-sm); } +.vrow__head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } +.vrow__type { flex: 1; } +.vrow__sev { width: auto; } +.vrow__action { font-size: 12px; } + +/* Sources registry */ +.srclist { display: flex; flex-direction: column; gap: 8px; } +.srccard { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-surface); overflow: hidden; box-shadow: var(--shadow-sm); } +.srccard__head { display: flex; align-items: center; gap: 10px; width: 100%; padding: 12px 14px; text-align: left; } +.srccard__id { font-weight: 600; } +.srccard__caret { margin-left: auto; color: var(--text-muted); } +.srccard__body { padding: 14px; border-top: 1px solid var(--border-light); display: flex; flex-direction: column; gap: 8px; } +.outputform { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; } + +/* Run console */ +.overrides { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; box-shadow: var(--shadow-sm); } +.overrides__row { display: flex; gap: 24px; flex-wrap: wrap; } +.overrides__ifaces { display: flex; flex-wrap: wrap; gap: 10px; } +.overrides__iface { display: flex; align-items: center; gap: 5px; font-size: 13px; } +.overrides__actions { margin-top: 12px; } + +.runpanel { margin-bottom: 18px; } +.runpanel__title { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-muted); margin-bottom: 8px; font-weight: 600; } + +.timeline { display: flex; flex-direction: column; gap: 8px; } +.timeline__row { display: flex; align-items: center; gap: 12px; } +.timeline__iface { min-width: 130px; font-weight: 600; } +.timeline__stages { display: flex; gap: 6px; flex-wrap: wrap; } +.stagetile { + font-size: 11.5px; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border); + background: var(--bg-surface); color: var(--text-secondary); +} +.stagetile--running { border-color: var(--purple-400); color: var(--purple-600); animation: pulse 1s ease-in-out infinite; } +.stagetile--ok { background: var(--success-soft); color: var(--success); border-color: transparent; } +.stagetile--warn, .stagetile--warning { background: var(--warning-soft); color: var(--warning); border-color: transparent; } +.stagetile--fail, .stagetile--failed { background: var(--error-soft); color: var(--error); border-color: transparent; } +.stagetile--skip, .stagetile--skipped { opacity: .5; } +@keyframes pulse { 50% { opacity: .55; } } + +.logstream { + background: #0e1116; color: #cdd6e2; border-radius: var(--radius); padding: 12px 14px; + font-family: var(--mono); font-size: 12px; max-height: 280px; overflow: auto; +} +.logline { display: flex; gap: 8px; padding: 1px 0; align-items: baseline; } +.logline__ts { color: #6b7585; flex: 0 0 auto; } +.logline__lvl { flex: 0 0 auto; font-size: 10px; width: 38px; } +.logline__lvl--info { color: #6b7585; } +.logline__lvl--warn { color: #f0c277; } +.logline__lvl--error { color: #f0a59d; } +.logline__scope { color: #7fb4ab; flex: 0 0 auto; } +.logline__msg { color: #cdd6e2; } + +/* Validation results */ +.vres { display: flex; flex-direction: column; gap: 10px; } +.vres__summary { display: flex; align-items: center; gap: 8px; } +.vbadge { font-size: 11.5px; padding: 2px 8px; border-radius: var(--radius-sm); font-weight: 600; } +.vbadge.vres--pass { background: var(--success-soft); color: var(--success); } +.vbadge.vres--warn { background: var(--warning-soft); color: var(--warning); } +.vbadge.vres--fail { background: var(--error-soft); color: var(--error); } + +/* History */ +.histlist { display: flex; flex-direction: column; gap: 8px; } +.histcard { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-surface); overflow: hidden; box-shadow: var(--shadow-sm); } +.histcard__head { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; text-align: left; } +.histcard__time { color: var(--text); } +.histcard__dur { color: var(--text-secondary); font-size: 13px; } +.histcard__caret { margin-left: auto; color: var(--text-muted); } +.histcard__body { padding: 14px; border-top: 1px solid var(--border-light); display: flex; flex-direction: column; gap: 12px; } +.histcard__stages { display: flex; flex-wrap: wrap; gap: 6px; } + +/* ═══════════════════ Graph Editor ═══════════════════ */ + +.grapheditor { + display: flex; + height: 100%; + position: relative; + background: var(--bg-primary); + overflow: hidden; +} +.grapheditor__canvas { + flex: 1; + height: 100%; + position: relative; +} +.grapheditor__toolbar { + position: absolute; + top: 12px; + left: 12px; + z-index: 10; + display: flex; + gap: 8px; + max-width: 360px; +} + +.grapheditor__hint { + font-size: 12px; + line-height: 1.45; + color: var(--text-muted, #6b7280); + background: rgba(255, 255, 255, 0.85); + border: 1px solid var(--border, #e5e7eb); + border-radius: 8px; + padding: 8px 10px; + backdrop-filter: blur(2px); +} + +/* Flowise-inspired Custom Node Cards */ +.custom-node { + background: #ffffff; + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + min-width: 200px; + box-shadow: var(--shadow-card); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + font-family: var(--sans); + position: relative; +} +.custom-node:hover { + box-shadow: var(--shadow-md); + border-color: var(--purple-300); + transform: translateY(-1px); +} +.custom-node--selected { + border-color: var(--purple-600) !important; + box-shadow: 0 0 0 3px rgba(120, 83, 236, 0.15), var(--shadow-md) !important; +} +.custom-node__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} +.custom-node__icon-box { + width: 30px; + height: 30px; + border-radius: 8px; + display: grid; + place-items: center; + font-size: 14px; +} +.custom-node__title { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.custom-node__type { + font-size: 10px; + color: var(--text-muted); + font-family: var(--mono); + letter-spacing: 0.2px; +} +.custom-node__body { + border-top: 1px solid var(--border-light); + padding-top: 8px; +} +.custom-node__info { + font-size: 11px; + color: var(--text-secondary); + font-family: var(--mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Node property table (key → value rows inside each node body) */ +.custom-node__props { + display: grid; + grid-template-columns: auto 1fr; + gap: 3px 10px; + align-items: baseline; +} +.custom-node__prop-row { display: contents; } +.custom-node__prop-key { + color: var(--text-muted); + font-family: var(--mono); + font-size: 10px; + text-align: right; + white-space: nowrap; + padding: 1px 0; +} +.custom-node__prop-val { + color: var(--text); + font-family: var(--mono); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Columns node — chip list */ +.custom-node__collist { display: flex; flex-wrap: wrap; gap: 4px; } +.custom-node__colchip { + background: var(--purple-50); color: var(--purple-700); + border: 1px solid var(--purple-200); border-radius: 4px; + padding: 1px 6px; font-size: 10.5px; font-family: var(--mono); +} +.custom-node__colmore { + color: var(--text-muted); font-size: 10.5px; font-family: var(--mono); align-self: center; +} + + +/* Secondary source-in port — the signature: a ringed blue patch point on join/mask/union/melt. */ +.rf-handle--feed { + width: 14px !important; + height: 14px !important; + background-color: #fff !important; + border: 2.5px solid #3b82f6 !important; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12); +} +.rf-handle--feed:hover { box-shadow: 0 0 0 5px rgba(59, 130, 246, 0.2); transform: scale(1.15); } + +.custom-node__feedport { + position: absolute; top: -9px; left: 50%; transform: translateX(-50%); + display: inline-flex; align-items: center; gap: 3px; + font-size: 8.5px; font-family: var(--mono); letter-spacing: .3px; + color: #3b82f6; background: #fff; padding: 0 5px; border-radius: 6px; + border: 1px solid rgba(59, 130, 246, 0.3); white-space: nowrap; pointer-events: none; +} +.custom-node__feeds { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } +.custom-node__feedchip { + background: rgba(59, 130, 246, 0.08); color: #2563eb; + border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 4px; + padding: 1px 6px; font-size: 10px; font-family: var(--mono); +} +.custom-node__feedhint { + font-size: 10px; font-family: var(--mono); color: var(--text-muted); + font-style: italic; +} + +/* Source editor header (drawer) */ +.src-editor-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.src-editor-id { font-family: var(--display); font-weight: 600; font-size: 15px; color: var(--text); } +.src-editor-type { font-family: var(--mono); font-size: 12px; font-weight: 500; padding: 2px 8px; border-radius: 4px; background: rgba(59, 130, 246, 0.08); } + +/* Unused source — a real authoring smell, shown rather than hidden. */ +.custom-node--unused { border-style: dashed; opacity: 0.8; } +.custom-node--unused:hover { opacity: 1; } +.custom-node__warn { + display: block; margin-top: 6px; font-size: 10px; line-height: 1.35; + color: #b45309; font-family: var(--mono); +} + +/* Canvas legend */ +.grapheditor__legend { + position: absolute; bottom: 14px; left: 14px; z-index: 10; + display: flex; gap: 14px; align-items: center; + background: rgba(255, 255, 255, 0.88); backdrop-filter: blur(2px); + border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; +} +.grapheditor__legend-item { + display: inline-flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--text-secondary); font-family: var(--mono); +} +.grapheditor__legend .leg { width: 18px; height: 0; border-top-width: 2px; border-top-style: solid; } +.grapheditor__legend .leg--flow { border-top-color: var(--purple-600); } +.grapheditor__legend .leg--feed { border-top-style: dashed; border-top-color: #3b82f6; } + +/* Tidy the reactflow chrome to match the app surface. */ +.react-flow__controls { box-shadow: var(--shadow-card); border-radius: 8px; overflow: hidden; } +.react-flow__controls-button { border-bottom: 1px solid var(--border-light); } +.react-flow__minimap { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); } +.grapheditor__hint code { + font-family: var(--mono); font-size: 11px; background: var(--purple-50); + color: var(--purple-700); padding: 0 4px; border-radius: 4px; +} +.grapheditor__hint strong { color: #2563eb; font-weight: 600; } + +/* Side Drawer (Properties Panel) */ +.grapheditor__drawer { + width: 400px; + height: 100%; + background: #ffffff; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + box-shadow: -4px 0 20px rgba(120, 83, 236, 0.05); + z-index: 12; + position: relative; + animation: slideIn 0.25s cubic-bezier(0, 0, 0.2, 1); +} +@keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} +.grapheditor__drawer-head { + padding: 16px 18px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-elevated); +} +.grapheditor__drawer-title { + font-family: var(--display); + font-size: 14.5px; + font-weight: 600; + letter-spacing: -0.2px; + color: var(--text); +} +.grapheditor__drawer-body { + flex: 1; + overflow-y: auto; + padding: 18px; +} +.grapheditor__drawer-close { + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + transition: all 0.15s ease; +} +.grapheditor__drawer-close:hover { + background: var(--purple-50); + color: var(--purple-600); +} + +/* ═══════════════════ Chat Editor ═══════════════════ */ +.chateditor { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-surface); + border-left: 1px solid var(--border); + overflow: hidden; +} +.chateditor__messages { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 18px; + background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-surface) 100%); +} +.chateditor__inputarea { + padding: 16px 20px; + border-top: 1px solid var(--border); + background: #ffffff; + display: flex; + flex-direction: column; + gap: 10px; +} +.chateditor__form { + display: flex; + gap: 10px; + align-items: center; +} +.chateditor__input { + flex: 1; + padding: 11px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + background: var(--bg-surface); + color: var(--text); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.chateditor__input:focus { + outline: none; + border-color: var(--purple-400); + box-shadow: 0 0 0 3px rgba(120, 83, 236, 0.1); +} + +/* Chat Bubbles */ +.chatbubble { + display: flex; + gap: 12px; + max-width: 80%; + animation: messageFade 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} +@keyframes messageFade { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.chatbubble--user { + align-self: flex-end; + flex-direction: row-reverse; +} +.chatbubble--assistant { + align-self: flex-start; +} +.chatbubble--system { + align-self: center; + max-width: 100%; + color: var(--text-secondary); + font-size: 11.5px; + font-family: var(--mono); + background: var(--purple-50); + border: 1px solid var(--purple-100); + border-radius: 12px; + padding: 4px 14px; + margin: 4px 0; +} +.chatbubble__avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; +} +.chatbubble__avatar--user { + background: var(--purple-600); + color: #fff; +} +.chatbubble__avatar--assistant { + background: linear-gradient(135deg, var(--purple-100) 0%, var(--purple-200) 100%); + color: var(--purple-600); +} +.chatbubble__content { + padding: 12px 16px; + border-radius: 12px; + font-size: 13px; + line-height: 1.6; + box-shadow: var(--shadow-sm); +} +.chatbubble__content--user { + background: var(--purple-600); + color: #fff; + border-top-right-radius: 4px; +} +.chatbubble__content--assistant { + background: #ffffff; + color: var(--text); + border-top-left-radius: 4px; + border: 1px solid var(--border); +} +.chatbubble__content--assistant code { + background: var(--purple-50); + border: 1px solid var(--purple-100); + padding: 2px 5px; + border-radius: 4px; + font-family: var(--mono); + font-size: 11.5px; + color: var(--purple-600); +} + +/* Prompt Chips */ +.prompt-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.prompt-chip { + background: var(--purple-50); + border: 1px solid var(--purple-100); + border-radius: 16px; + padding: 5px 14px; + font-size: 11.5px; + color: var(--purple-600); + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; +} +.prompt-chip:hover { + background: var(--purple-100); + border-color: var(--purple-300); + box-shadow: var(--shadow-sm); +} + +/* Typing Indicator */ +.typing-indicator { + display: flex; + gap: 4px; + padding: 6px 10px; + align-items: center; +} +.typing-indicator span { + width: 6px; + height: 6px; + background-color: var(--purple-400); + border-radius: 50%; + animation: typingBounce 1.4s infinite ease-in-out both; +} +.typing-indicator span:nth-child(1) { animation-delay: -0.32s; } +.typing-indicator span:nth-child(2) { animation-delay: -0.16s; } +@keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0); } + 40% { transform: scale(1); } +} + +/* ═══════════════════ Implementation plan: palette · sources · columns ═══════════════════ */ + +/* ── Shared empty state ── */ +.empty-state { + display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; + padding: 38px 24px; margin-bottom: 14px; + border: 1px dashed var(--border); border-radius: var(--radius); background: var(--bg-surface); +} +.empty-state__icon { + display: grid; place-items: center; width: 54px; height: 54px; border-radius: 14px; + background: var(--purple-50); color: var(--purple-500); +} +.empty-state__text { display: flex; flex-direction: column; gap: 3px; } +.empty-state__text strong { font-family: var(--display); font-size: 15px; font-weight: 600; color: var(--text); } +.empty-state__text span { font-size: 12.5px; color: var(--text-secondary); } + +/* ── Node palette (graph rail, draw.io-inspired) ── */ +.palette { display: flex; flex-direction: column; gap: 4px; } +.palette__search { position: relative; margin-bottom: 8px; } +.palette__search-icon { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--text-muted); } +.palette__search-input { + width: 100%; padding: 7px 26px 7px 30px; font-size: 12.5px; font-family: var(--sans); + border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-surface); color: var(--text); +} +.palette__search-input:focus { outline: none; border-color: var(--purple-400); box-shadow: 0 0 0 3px var(--purple-100); } +.palette__search-clear { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + display: grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; color: var(--text-muted); +} +.palette__search-clear:hover { background: var(--bg-hover); color: var(--text); } + +.palette__group-head { + display: flex; align-items: center; gap: 6px; width: 100%; padding: 7px 4px; color: var(--text-secondary); + font-family: var(--display); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; +} +.palette__group-head:hover { color: var(--text); } +.palette__group-count { + margin-left: auto; font-family: var(--mono); font-size: 9.5px; color: var(--text-muted); + background: var(--bg-elevated); border-radius: 8px; padding: 0 6px; +} +.palette__nodes { display: flex; flex-direction: column; gap: 5px; padding: 2px 0 10px; } + +.palette-node { + position: relative; display: flex; align-items: center; gap: 9px; padding: 8px 10px; + border: 1px solid var(--border-light); border-left: 3px solid var(--node-color, var(--purple-400)); + border-radius: var(--radius-sm); background: var(--bg-surface); transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease; +} +.palette-node__icon { flex: 0 0 auto; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 6px; background: var(--bg-elevated); } +.palette-node__text { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.palette-node__label { font-family: var(--display); font-size: 12.5px; font-weight: 500; color: var(--text); } +.palette-node__sub { font-family: var(--mono); font-size: 9.5px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.palette-node__sub--info { font-style: italic; } +.palette-node--addable { cursor: grab; } +.palette-node--addable:hover { border-color: var(--node-color); box-shadow: var(--shadow-sm); transform: translateX(2px); } +.palette-node--addable:active { cursor: grabbing; } +.palette-node--addable:focus-visible { outline: 2px solid var(--node-color); outline-offset: 2px; } +.palette-node--info { opacity: .62; } +.palette-node--info:hover { opacity: 1; } + +/* Floating tooltip (fixed, positioned in JS) */ +.palette-tip { + position: fixed; z-index: 1000; width: 250px; max-width: 80vw; padding: 11px 13px; pointer-events: none; + background: #14181f; color: #d7dee9; border: 1px solid rgba(255,255,255,.1); border-radius: 9px; + box-shadow: 0 12px 32px rgba(0,0,0,.28); animation: tipIn .12s ease; +} +@keyframes tipIn { from { opacity: 0; transform: translateX(-4px); } to { opacity: 1; transform: none; } } +.palette-tip__title { font-family: var(--display); font-size: 13px; font-weight: 600; margin-bottom: 4px; } +.palette-tip__desc { font-size: 11.5px; line-height: 1.5; color: #aeb8c6; } +.palette-tip__writes { margin-top: 8px; display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; font-family: var(--mono); font-size: 10px; } +.palette-tip__writes span { text-transform: uppercase; letter-spacing: .5px; color: #5b6675; } +.palette-tip__writes code { color: #7cc7ff; } +.palette-tip__hint { margin-top: 8px; padding-top: 7px; border-top: 1px solid rgba(255,255,255,.08); font-size: 11px; line-height: 1.45; color: #9aa6b6; } +.palette-tip__note { margin-top: 8px; font-size: 11px; line-height: 1.45; color: #f0b072; } + +/* ── Sources tab — cards + inline create ── */ +.src-cards { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; } +.src-card { + display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; + border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-surface); transition: border-color .15s ease, box-shadow .15s ease; +} +.src-card:hover { border-color: var(--purple-200); box-shadow: var(--shadow-sm); } +.src-card__left { display: flex; align-items: center; gap: 11px; min-width: 0; } +.src-card__rank { + flex: 0 0 auto; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; + font-family: var(--mono); font-size: 11px; font-weight: 600; color: var(--text-secondary); background: var(--bg-elevated); +} +.src-card__icon { flex: 0 0 auto; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; } +.src-card__info { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.src-card__id { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--text); } +.src-card__detail { display: flex; align-items: center; gap: 8px; min-width: 0; } +.src-card__type-chip { font-family: var(--display); font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 5px; border: 1px solid; } +.src-card__summary { font-family: var(--mono); font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.src-actions { display: flex; flex-direction: column; gap: 10px; } +.src-actions__existing { display: flex; gap: 8px; align-items: center; } +.src-actions__existing .field__input { flex: 1; min-width: 0; } +.src-actions__create-btn { align-self: flex-start; } +.src-create-form { border: 1px solid var(--purple-200); border-radius: var(--radius); background: var(--purple-50); overflow: hidden; } +.src-create-form__header { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; background: var(--purple-100); } +.src-create-form__title { font-family: var(--display); font-size: 12.5px; font-weight: 600; color: var(--purple-700); } +.src-create-form__body { padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.src-create-form__row { display: grid; grid-template-columns: 2fr 1fr; gap: 10px; } +.src-create-form__type-desc { font-size: 11.5px; color: var(--text-secondary); margin: -2px 0 2px; } + +/* ── Columns tab — cards ── */ +.colcard-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.colcard-header .tabcontent__hint { margin: 0; } +.colcard-header__actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; } +.colcard-list { display: flex; flex-direction: column; gap: 8px; } +.colcard-add-btn { margin-top: 12px; } +.colcard-fetch-err { display: flex; align-items: center; gap: 6px; margin: 0 0 10px; font-size: 12px; color: var(--error); } +.colcard-fetch-hint { margin: 0 0 10px; } +/* native caret room so the source-column dropdown reads as a picker, not a text field */ +.colcard__input--select { cursor: pointer; padding-right: 26px; appearance: auto; } +.colcard__colpick-empty { + width: 100%; padding: 6px 9px; font-family: var(--mono); font-size: 12px; text-align: center; cursor: pointer; + border: 1px dashed var(--purple-300); border-radius: 999px; background: var(--purple-50); color: var(--purple-700); +} +.colcard__colpick-empty:hover { border-color: var(--purple-400); background: var(--purple-100); } +.colcard { padding: 8px 10px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-surface); transition: border-color .15s ease, box-shadow .15s ease; } +.colcard:hover { border-color: var(--purple-200); } +.colcard--expanded { border-color: var(--purple-300); box-shadow: var(--shadow-sm); } +/* pill-shaped row: rounded capsule housing rank chip + src/dest pickers + reorder/format/remove */ +.colcard__main { + display: flex; align-items: center; gap: 10px; padding: 6px 8px; + background: var(--bg-elevated); border: 1px solid var(--border-light); border-radius: 999px; +} +.colcard__rank { + flex: 0 0 auto; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; + font-family: var(--mono); font-size: 11px; font-weight: 600; color: var(--text-secondary); + background: var(--bg-surface); border: 1px solid var(--border-light); +} +.colcard__mapping { flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0; } +.colcard__field { display: flex; flex-direction: column; align-items: center; gap: 3px; flex: 1; min-width: 64px; margin: 0; } +.colcard__field-label { font-family: var(--display); font-size: 10.5px; font-weight: 600; letter-spacing: .2px; color: var(--text-secondary); } +.colcard__input { + width: 100%; padding: 6px 14px; font-family: var(--mono); font-size: 12px; text-align: center; + border: 1px solid var(--border); border-radius: 999px; background: var(--bg-surface); color: var(--text); +} +.colcard__input:focus { outline: none; border-color: var(--purple-400); box-shadow: 0 0 0 3px var(--purple-100); background: #fff; } +.colcard__arrow { flex: 0 0 auto; display: flex; align-items: center; color: var(--purple-400); } +.colcard__actions { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: 4px; } +.colcard__actions-row { display: flex; align-items: center; gap: 6px; } +.colcard__actions .lctrls__btn { width: 22px; height: 22px; border-radius: 999px; background: var(--bg-surface); } +.colcard__fmt-badge { + display: inline-flex; align-items: center; gap: 4px; font-family: var(--mono); font-size: 10.5px; color: var(--text-secondary); + padding: 4px 11px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-surface); transition: all .15s ease; +} +.colcard__fmt-badge:hover { border-color: var(--purple-300); color: var(--purple-700); } +.colcard__fmt-badge--has { color: var(--purple-700); border-color: var(--purple-200); background: var(--purple-50); } +.colcard__fmt-badge--open { border-color: var(--purple-400); } +.colcard__fmt-body { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-light); display: flex; flex-direction: column; gap: 10px; } +.colcard__fmt-row { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border-light); border-radius: var(--radius-sm); background: var(--bg-elevated); } +.colcard__fmt-head { display: flex; align-items: center; justify-content: space-between; } +.colcard__fmt-add { display: flex; gap: 8px; align-items: center; } +.colcard__fmt-add .field__input { flex: 1; } + +/* ───────────────────────── Start / source-first entry ───────────────────────── */ +.start { max-width: 860px; margin: 0 auto; padding: 40px 24px 64px; } +.start__brandline { text-align: center; margin-bottom: 28px; } +.start__step { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--purple-600); } +.start__title { font-family: var(--display); font-size: 30px; font-weight: 600; color: var(--text); margin: 6px 0 0; } + +.srcloader { display: flex; flex-direction: column; gap: 22px; } +.srcloader__types { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; } +.srctype { display: flex; flex-direction: column; gap: 4px; padding: 16px 14px; text-align: left; background: var(--bg-surface); + border: 1.5px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color .15s, box-shadow .15s, transform .1s; } +.srctype:hover { border-color: var(--c); box-shadow: var(--shadow-md); transform: translateY(-1px); } +.srctype--active { border-color: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 16%, transparent); } +.srctype__icon { color: var(--c); } +.srctype__label { font-family: var(--display); font-weight: 600; font-size: 14px; color: var(--text); } +.srctype__desc { font-size: 11.5px; color: var(--text-secondary); line-height: 1.35; } + +.srcloader__form { display: flex; flex-direction: column; gap: 14px; background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-card); } +.srcloader__advtoggle { align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; + color: var(--purple-600); background: none; cursor: pointer; padding: 2px 0; } +.srcloader__error { font-size: 12.5px; color: #b91c1c; background: #fef2f2; border: 1px solid #fecaca; border-radius: var(--radius-sm); padding: 8px 10px; margin: 0; } +.srcloader__cols { font-size: 12px; color: var(--text-secondary); margin: 0; } +.srcloader__actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; } + +.dropzone { position: relative; border: 1.5px dashed var(--purple-300); border-radius: var(--radius); background: var(--purple-50); transition: border-color .15s, background .15s; } +.dropzone--over { border-color: var(--purple-600); background: var(--purple-100); } +.dropzone--done { border-style: solid; border-color: var(--border); background: var(--bg-elevated); } +.dropzone__input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } +.dropzone__label { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 22px; text-align: center; + color: var(--text-secondary); font-size: 13px; cursor: pointer; } +.dropzone__label u { color: var(--purple-600); } +.dropzone__preview { padding: 0 14px 14px; overflow-x: auto; } +.dropzone__preview table { border-collapse: collapse; font-family: var(--mono); font-size: 11px; } +.dropzone__preview td { border: 1px solid var(--border-light); padding: 3px 8px; color: var(--text-secondary); white-space: nowrap; } +.spin { animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* Start screen — multi-source cards */ +.start__sources { display: flex; flex-direction: column; gap: 18px; } +.start__source-cards { display: flex; flex-direction: column; gap: 8px; } +.start__source-card { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 12px 14px; background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow-card); +} +.start__source-card-left { display: flex; align-items: center; gap: 10px; } +.start__source-card-icon { width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; } +.start__source-card-id { font-family: var(--display); font-weight: 600; font-size: 14px; color: var(--text); } +.start__source-card-type { font-family: var(--mono); font-size: 11px; } +.start__actions { display: flex; justify-content: center; gap: 12px; margin-top: 8px; } + +/* Entry overlay (inFlow vs inChat) */ +.entry { text-align: center; padding: 12px 0; } +.entry__head { margin-bottom: 24px; } +.entry__title { font-family: var(--display); font-size: 22px; font-weight: 600; color: var(--text); margin: 0; } +.entry__sub { font-size: 13px; color: var(--text-secondary); margin: 6px 0 0; } +.entry__choices { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.entrychoice { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; text-align: left; padding: 24px; + border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--bg-surface); cursor: pointer; + transition: border-color .15s, box-shadow .15s, transform .1s; } +.entrychoice:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); } +.entrychoice--flow:hover { border-color: #3b82f6; } +.entrychoice--chat:hover { border-color: var(--purple-600); } +.entrychoice__icon { color: var(--purple-600); } +.entrychoice--flow .entrychoice__icon { color: #3b82f6; } +.entrychoice__name { font-family: var(--display); font-size: 18px; font-weight: 600; color: var(--text); } +.entrychoice__desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.45; } +.entry__back { margin-top: 20px; } + +/* Generic centered modal (board "Add source") */ +.modal { position: fixed; inset: 0; background: rgba(26, 26, 46, 0.45); display: grid; place-items: center; z-index: 100; padding: 24px; } +.modal__panel { width: min(720px, 100%); max-height: 90vh; overflow-y: auto; background: var(--bg-primary); border-radius: var(--radius); + box-shadow: var(--shadow-lg); } +.modal__head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg-primary); } +.modal__title { font-family: var(--display); font-weight: 600; color: var(--text); } +.modal__body { padding: 18px; } + +@media (max-width: 640px) { .entry__choices { grid-template-columns: 1fr; } } +@media (prefers-reduced-motion: reduce) { .srctype, .entrychoice, .spin { transition: none; animation: none; } } + +/* ── Brand-bar back button — borderless icon, sits flush in the bar ── */ +.brandbar__back { border-color: transparent; background: transparent; padding: 6px; color: var(--text-muted); } +.brandbar__back:hover { color: var(--purple-600); background: var(--purple-50); border-color: transparent; } + +/* ── Pipelines ledger (index route) ── */ +.plx { max-width: 880px; margin: 0 auto; padding: 56px 24px 80px; } +.plx__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; } +.plx__eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: 0.06em; color: var(--purple-600); } +.plx__title { font-family: var(--display); font-size: 34px; font-weight: 600; letter-spacing: -0.02em; color: var(--text); margin: 8px 0 0; } +.plx__sub { color: var(--text-secondary); margin-top: 8px; max-width: 52ch; font-size: 14.5px; } +.plx__new { white-space: nowrap; } +.plx__state { color: var(--text-secondary); padding: 24px 4px; } + +.plx__list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); } +.plx__row { display: flex; align-items: center; gap: 16px; padding: 15px 12px; border-bottom: 1px solid var(--border-light); + text-decoration: none; color: inherit; transition: background 0.12s ease; } +.plx__row:hover { background: var(--bg-hover); } +.plx__rank { font-family: var(--mono); font-size: 13px; color: var(--purple-400); width: 26px; flex-shrink: 0; font-variant-numeric: tabular-nums; transition: color 0.12s ease; } +.plx__row:hover .plx__rank { color: var(--purple-600); } +.plx__info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.plx__name { font-family: var(--display); font-weight: 600; font-size: 15.5px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.plx__meta { font-family: var(--mono); font-size: 12px; color: var(--text-muted); } +.plx__del { opacity: 0; flex-shrink: 0; border: none; background: transparent; color: var(--text-muted); padding: 6px; border-radius: var(--radius-sm); cursor: pointer; transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease; } +.plx__row:hover .plx__del, .plx__del:focus-visible { opacity: 1; } +.plx__del:hover { color: var(--error); background: var(--error-soft); } + +.plx__empty { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 56px 24px; + border: 1.5px dashed var(--border); border-radius: var(--radius); background: var(--bg-surface); color: var(--text-secondary); + cursor: pointer; text-align: center; transition: border-color 0.15s ease, background 0.15s ease; } +.plx__empty:hover { border-color: var(--purple-300); background: var(--purple-50); } +.plx__empty:disabled { cursor: default; } +.plx__empty-glyph { color: var(--purple-500); margin-bottom: 4px; } +.plx__empty strong { font-family: var(--display); font-size: 16px; color: var(--text); } + +.plx__head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.plx__import-err { + display: flex; align-items: center; gap: 12px; justify-content: space-between; + padding: 10px 14px; margin-bottom: 16px; border-radius: var(--radius-sm); + background: var(--error-soft); border: 1px solid var(--error); + font-size: 13px; color: var(--error); +} +.plx__import-err-close { + background: transparent; border: none; cursor: pointer; + color: var(--error); font-size: 14px; line-height: 1; flex-shrink: 0; + padding: 2px 6px; border-radius: 4px; +} +.plx__import-err-close:hover { background: var(--error); color: #fff; } +@media (max-width: 640px) { .plx__head { flex-direction: column; } .plx__head-actions { align-self: stretch; } .plx__new { flex: 1; justify-content: center; } } +@media (prefers-reduced-motion: reduce) { .plx__row, .plx__rank, .plx__del, .plx__empty { transition: none; } } + +/* ═══════════════════ ConfirmDialog ═══════════════════ */ +.cdialog__backdrop { + position: fixed; inset: 0; z-index: 200; + background: rgba(26, 26, 46, 0.5); + backdrop-filter: blur(2px); + display: grid; place-items: center; padding: 24px; + animation: fadeIn 0.15s ease; +} +.cdialog { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + padding: 24px 28px; + width: min(420px, 100%); + display: flex; flex-direction: column; gap: 14px; + animation: slideUp 0.18s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.cdialog__title { + font-family: var(--display); font-size: 16px; font-weight: 600; color: var(--text); +} +.cdialog__msg { + font-size: 14px; color: var(--text-secondary); line-height: 1.55; +} +.cdialog__actions { + display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { opacity: 0; transform: translateY(8px) scale(0.97); } to { opacity: 1; transform: none; } } + +/* Wide dialog variant for choice dialogs */ +.cdialog--wide { width: min(520px, 100%); } + +/* ═══ SourceActionDialog choices ═══ */ +.srcaction__choices { + display: flex; gap: 12px; margin: 4px 0; +} +.srcaction__card { + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; + padding: 20px 16px; border-radius: 12px; + border: 1.5px solid var(--border); background: var(--bg-elevated); + cursor: pointer; text-align: center; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} +.srcaction__card:hover { + border-color: var(--purple-400); + box-shadow: 0 0 0 3px rgba(120, 83, 236, 0.12); + background: var(--purple-50); +} +.srcaction__card-icon { + width: 48px; height: 48px; border-radius: 12px; + display: grid; place-items: center; +} +.srcaction__card-icon--new { background: rgba(16, 185, 129, 0.12); color: #10b981; } +.srcaction__card-icon--merge { background: rgba(59, 130, 246, 0.12); color: #3b82f6; } +.srcaction__card-text { display: flex; flex-direction: column; gap: 4px; } +.srcaction__card-text strong { font-size: 14px; font-weight: 600; color: var(--text); } +.srcaction__card-text span { font-size: 12px; color: var(--text-secondary); line-height: 1.45; } + +/* ═══════════════════ Graph Editor — n8n overrides ═══════════════════ */ + +/* Snap grid on the canvas background */ +.react-flow__background { opacity: 1 !important; } + +/* Selection box (box-select drag) */ +.react-flow__selection { + background: rgba(120, 83, 236, 0.06) !important; + border: 1.5px dashed var(--purple-400) !important; + border-radius: 4px !important; +} + +/* Selected node: animated dashed ring */ +.react-flow__node.selected > .custom-node, +.custom-node--selected { + border-color: var(--purple-600) !important; + box-shadow: 0 0 0 2px rgba(120, 83, 236, 0.2), var(--shadow-md) !important; + animation: nodeSelectedPulse 1.8s ease infinite !important; +} +@keyframes nodeSelectedPulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(120, 83, 236, 0.2), var(--shadow-md); } + 50% { box-shadow: 0 0 0 4px rgba(120, 83, 236, 0.12), var(--shadow-md); } +} + +/* Smooth-step edges */ +.react-flow__edge-path { stroke-width: 2px !important; } +.react-flow__edge.selected .react-flow__edge-path { stroke: var(--purple-600) !important; stroke-width: 2.5px !important; } + +/* Handles — bigger, easier to grab */ +.react-flow__handle { + width: 12px !important; height: 12px !important; + border-radius: 50% !important; + border: 2px solid #fff !important; + cursor: crosshair !important; + transition: transform 0.12s ease, box-shadow 0.12s ease !important; +} +.react-flow__handle:hover { + transform: scale(1.5) !important; + box-shadow: 0 0 0 4px rgba(120, 83, 236, 0.25) !important; +} +/* Target (in) handles — muted color */ +.rf-handle--in { background-color: var(--text-muted) !important; } +/* Source (out) handles — larger purple, most grabbable */ +.rf-handle--out { + width: 14px !important; + height: 14px !important; + background-color: var(--purple-600) !important; +} +/* Pulse all target handles green while dragging */ +.react-flow.connecting .react-flow__handle.target { + background-color: #10b981 !important; + transform: scale(1.35) !important; + box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.25) !important; +} +/* Bright green when hovered as valid drop */ +.react-flow__handle.valid { + background-color: #10b981 !important; + box-shadow: 0 0 0 7px rgba(16, 185, 129, 0.4) !important; + transform: scale(1.6) !important; +} + +/* Graph toolbar */ +.grapheditor__topbar { + display: flex; align-items: center; gap: 4px; + background: #fff; border: 1px solid var(--border); + border-radius: 10px; padding: 5px 8px; + box-shadow: var(--shadow-md); + pointer-events: all; +} +.grapheditor__topbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; } +.grapheditor__topbar-btn { + display: inline-flex; align-items: center; gap: 5px; + padding: 5px 10px; border-radius: 7px; + font-size: 12px; font-weight: 500; color: var(--text-secondary); + background: transparent; border: none; cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; + white-space: nowrap; +} +.grapheditor__topbar-btn:hover { background: var(--bg-hover); color: var(--text); } +.grapheditor__topbar-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.grapheditor__topbar-btn--active { background: var(--purple-50); color: var(--purple-700); } +.grapheditor__zoom-pct { + font-family: var(--mono); font-size: 11.5px; color: var(--text-muted); + min-width: 38px; text-align: center; padding: 0 4px; +} + +/* Context menu */ +.graph-ctxmenu { + position: fixed; z-index: 50; + background: #fff; border: 1px solid var(--border); + border-radius: 9px; padding: 5px; + box-shadow: 0 8px 24px rgba(26, 26, 46, 0.14); + min-width: 170px; + animation: slideUp 0.13s cubic-bezier(0.34, 1.2, 0.64, 1); +} +.graph-ctxmenu__section { padding: 2px 0 2px; border-bottom: 1px solid var(--border-light); margin-bottom: 2px; } +.graph-ctxmenu__section:last-child { border: none; margin: 0; } +.graph-ctxmenu__item { + display: flex; align-items: center; gap: 9px; + padding: 7px 10px; border-radius: 6px; + font-size: 13px; color: var(--text); + cursor: pointer; transition: background 0.1s ease; + background: none; border: none; width: 100%; text-align: left; +} +.graph-ctxmenu__item:hover { background: var(--bg-hover); } +.graph-ctxmenu__item--danger { color: var(--error); } +.graph-ctxmenu__item--danger:hover { background: var(--error-soft); } +.graph-ctxmenu__label { font-size: 10px; color: var(--text-muted); padding: 4px 10px 2px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } + +/* Node — wider, better-padded */ +.custom-node { min-width: 240px; padding: 12px 14px; } + +/* ── Pipeline rename (ledger) ──────────────────────────────────────────── */ +.plx__row--editing { cursor: default; } +.plx__rename-input { + flex: 1; min-width: 0; padding: 6px 10px; + border: 1.5px solid var(--accent); border-radius: 7px; + font-size: 14px; font-weight: 500; color: var(--text); + background: var(--bg); outline: none; +} +.plx__rename-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 30px; height: 30px; border-radius: 7px; + border: 1px solid var(--border); background: var(--bg); + color: var(--text-secondary); cursor: pointer; flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} +.plx__rename-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.plx__rename-btn--cancel:hover { background: var(--error); color: #fff; border-color: var(--error); } +.plx__icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 32px; height: 32px; border-radius: 7px; + border: none; background: transparent; color: var(--text-muted); + cursor: pointer; flex-shrink: 0; opacity: 0; + transition: opacity 0.12s, background 0.12s, color 0.12s; +} +.plx__row:hover .plx__icon-btn { opacity: 1; } +.plx__icon-btn:hover { background: var(--bg-hover); color: var(--text); } + +/* ── Brand bar inline name editing ─────────────────────────────────────── */ +.brandbar__config-name--btn { + display: inline-flex; align-items: center; gap: 4px; + background: transparent; border: none; + font-size: 13px; font-weight: 600; color: var(--text); + cursor: pointer; padding: 4px 8px; border-radius: 6px; + transition: background 0.12s; +} +.brandbar__config-name--btn:hover { background: var(--bg-hover); } +.brandbar__name-input { + font-size: 13px; font-weight: 600; color: var(--text); + border: 1.5px solid var(--accent); border-radius: 6px; + padding: 4px 8px; background: var(--bg); outline: none; + max-width: 240px; +} + +/* ── InterfaceManager sidebar panel ────────────────────────────────────── */ +.ifmgr { + border-bottom: 1px solid var(--border-light); + padding: 10px 10px 12px; + background: var(--bg-surface); +} +.ifmgr__head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 6px; +} +.ifmgr__label { + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.6px; color: var(--text-muted); +} +.ifmgr__add-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: 5px; + border: 1px solid var(--border); background: transparent; + color: var(--text-secondary); cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.ifmgr__add-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.ifmgr__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.ifmgr__item { + display: flex; align-items: center; gap: 4px; + border-radius: 6px; padding: 0 4px; + transition: background 0.1s; +} +.ifmgr__item:hover { background: var(--bg-hover); } +.ifmgr__item--active { background: var(--accent-soft) !important; } +.ifmgr__item--active .ifmgr__name-btn { color: var(--accent); font-weight: 600; } +.ifmgr__name-btn { + flex: 1; min-width: 0; text-align: left; + padding: 5px 4px; font-size: 12px; color: var(--text); + background: none; border: none; cursor: pointer; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.ifmgr__actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.1s; flex-shrink: 0; } +.ifmgr__item:hover .ifmgr__actions { opacity: 1; } +.ifmgr__icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: 4px; + border: none; background: transparent; color: var(--text-muted); + cursor: pointer; transition: background 0.1s, color 0.1s; +} +.ifmgr__icon-btn:hover { background: var(--bg-hover); color: var(--text); } +.ifmgr__icon-btn--danger:hover { background: var(--error-soft); color: var(--error); } +.ifmgr__icon-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.ifmgr__rename { + display: flex; align-items: center; gap: 4px; + flex: 1; padding: 3px 0; +} +.ifmgr__rename-input { + flex: 1; min-width: 0; font-size: 12px; padding: 4px 6px; + border: 1.5px solid var(--accent); border-radius: 5px; + background: var(--bg); color: var(--text); outline: none; +} +.ifmgr__rename-input--err { border-color: var(--error); } +.ifmgr__new { + display: flex; align-items: center; gap: 4px; + margin-top: 6px; +} + +/* ── Source card delete button ─────────────────────────────────────────── */ +.src-card__controls { + display: flex; align-items: center; gap: 4px; +} +.src-card__del-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; border-radius: 6px; + border: 1px solid transparent; background: transparent; + color: var(--text-muted); cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + flex-shrink: 0; +} +.src-card__del-btn:hover { background: var(--error-soft); color: var(--error); border-color: var(--error); } + +/* ── MiniMarkdown styles ───────────────────────────────────────────────── */ +.mm { font-size: inherit; line-height: 1.55; } +.mm-p { margin: 0 0 6px; } +.mm-p:last-child { margin-bottom: 0; } +.mm-gap { height: 6px; } +.mm-h1 { font-size: 1.1em; font-weight: 700; margin: 8px 0 4px; } +.mm-h2 { font-size: 1em; font-weight: 700; margin: 6px 0 4px; } +.mm-h3 { font-size: 0.95em; font-weight: 600; margin: 6px 0 3px; color: var(--text-secondary); } +.mm-hr { border: none; border-top: 1px solid var(--border-light); margin: 8px 0; } +.mm-ul, .mm-ol { margin: 4px 0 6px 18px; padding: 0; } +.mm-ul li, .mm-ol li { margin: 2px 0; } +.mm-code { + font-family: var(--mono); font-size: 0.88em; + background: rgba(120, 83, 236, 0.09); color: #6d42c9; + padding: 1px 5px; border-radius: 4px; +} +.mm-link { color: var(--accent); text-decoration: underline; } + +/* ── Chat bubble body + time ───────────────────────────────────────────── */ +.chatbubble__body { + display: flex; flex-direction: column; gap: 2px; + min-width: 0; +} +.chatbubble__time { + font-size: 10px; color: var(--text-muted); + align-self: flex-end; margin-top: 2px; +} +.chatbubble--user .chatbubble__time { align-self: flex-end; } +.chatbubble__avatar { + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; border-radius: 50%; + flex-shrink: 0; font-size: 12px; +} +.chatbubble__avatar--user { background: var(--accent); color: #fff; } +.chatbubble__avatar--assistant { background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border); }