feat(canvas): fullscreen Railway-style workspace canvas

Rebuild the project canvas as a full-viewport graph instead of a boxed
66vh panel. Slim workspace top bar now holds the view tabs, project meta
and actions; the canvas fills all remaining space.

- Floating glass controls: env filter + group pill (top-left), zoom out /
  live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap
  (bottom-right), hint chip, demo badge.
- Inspector panel: selecting a node opens a right-side glass panel with
  status, image, address, inferred connections incl. variable reasons,
  and deploy/restart/stop/delete actions. Esc or pane click closes.
- Auto-layout: dependency-depth layered layout (consumers left,
  datastores right) for first-visit placement and the Tidy action.
  Unpositioned services park right of existing bounds, not at (0,0).
- Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type
  icons/colors incl. cron, spinner icon while deploying, hover lift.
- Edges: lime bezier strokes, animated dashes while either endpoint is
  deploying, hover glow updated from stale purple to accent.
- Groups: off-brand crimson replaced with accent tokens, double-click
  title to rename inline.
- Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select
  on Shift-drag. Double-click zoom disabled, dead connection drags off.
- Mobile: icon-only tabs, minimap/hint hidden under md.
- Retake docs/screenshots/canvas.png + site copy (1600x900 demo).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-23 17:00:50 +02:00
1 parent 84b102a2c6
commit e57f0470eb
9 files changed
+950 -456

No files matched your search

@@ -0,0 +1,109 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { inferAutoConnections } from '../auto-connections';
export const NODE_WIDTH = 240;
export const NODE_HEIGHT = 104;
export const COL_GAP = 340;
export const ROW_GAP = 172;
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
// Layered left-to-right layout: a service's depth is the longest chain of
// dependencies beneath it, so consumers sit left and datastores right.
// Unconnected services get their own trailing column separated by a gap.
export function computeLayeredLayout(
services: ServiceEntity[],
edges: LinkEdge[],
): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
if (services.length === 0) {
return positions;
}
const ids = new Set(services.map((service) => service.id));
const outgoing = new Map<string, string[]>();
const connected = new Set<string>();
for (const edge of edges) {
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
continue;
}
connected.add(edge.sourceServiceId);
connected.add(edge.targetServiceId);
const list = outgoing.get(edge.sourceServiceId) ?? [];
list.push(edge.targetServiceId);
outgoing.set(edge.sourceServiceId, list);
}
const depthMemo = new Map<string, number>();
const depthOf = (id: string, trail: Set<string>): number => {
const memoized = depthMemo.get(id);
if (memoized !== undefined) {
return memoized;
}
if (trail.has(id)) {
return 0; // cycle guard
}
trail.add(id);
let best = 0;
for (const target of outgoing.get(id) ?? []) {
best = Math.max(best, depthOf(target, trail) + 1);
}
trail.delete(id);
depthMemo.set(id, best);
return best;
};
const connectedServices = services
.filter((service) => connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const looseServices = services
.filter((service) => !connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
const maxDepth = Math.max(0, ...depthById.values());
// Column = maxDepth - depth so deeper consumers render further left.
const columns = new Map<number, ServiceEntity[]>();
for (const service of connectedServices) {
const col = maxDepth - (depthById.get(service.id) ?? 0);
const list = columns.get(col) ?? [];
list.push(service);
columns.set(col, list);
}
const connectedColCount = columns.size;
const maxRows = Math.max(
1,
...Array.from(columns.values()).map((list) => list.length),
looseServices.length,
);
for (const [col, list] of columns) {
const offsetY = ((maxRows - list.length) * ROW_GAP) / 2;
list.forEach((service, row) => {
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
}
looseServices.forEach((service, row) => {
const col = connectedColCount > 0 ? connectedColCount : 0;
const offsetY = ((maxRows - looseServices.length) * ROW_GAP) / 2;
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
return positions;
}
export function layoutFromVariables(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): Map<string, { x: number; y: number }> {
const links = inferAutoConnections(services, variablesByService);
return computeLayeredLayout(
services,
links.map((link) => link.edge),
);
}