For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0

Generation Canvas

A dotted plane for generative work. Nodes that hold a prompt, its settings or a result, edges that carry light while the result is on its way, and a dock that floats above the plane — three parts in one file, built on the flow edge and the generation overlay.

Prompt
Settings
Aspect ratio
Resolution
Strength
Image
Generated result

seed 1 · 16:9 · 2K

"use client";

import { RotateCcw, Sparkles } from "lucide-react";

Installation

$ pnpm dlx shadcn@latest add https://motif.sweetretry.com/r/generation-canvas.json

It writes one file, components/ui/motif/generation-canvas.tsx, and brings two registry items along with it: Flow Edge and Generation Overlay. It adds no page and no route, and the only package it needs is motion.

What it is made of

The block is the plane and nothing on it. What a node holds is yours, and the controls in the demo above are pieces of this registry that you add only if you use them:

PartWhat it does here
GenerationCanvasThe frame and the plane: a dotted ground that scrolls, and a dock slot
CanvasNodeA card at a pixel position, with ports where edges attach
CanvasEdgeA curve between two ports that carries light while work moves along it
CanvasMediaA frame in the requested ratio that flows while it waits

The demo's settings node is Aspect Ratio Picker, Resolution Segmented and Strength Select, and its dock is a Toolbar. Add them one at a time, with the same command and their own name, when a node of yours needs them.

Usage

A node is a box at a position, and an edge is two points. portOf turns a node into the point an edge should leave from or arrive at, so moving a node moves its lines with it.

import {
  CanvasEdge,
  CanvasMedia,
  CanvasNode,
  GenerationCanvas,
  portOf,
} from "@/components/ui/motif/generation-canvas";
 
const prompt = { x: 24, y: 24, width: 272 };
const image = { x: 368, y: 24, width: 336 };
 
export function Canvas({ pending, src }: { pending: boolean; src?: string }) {
  return (
    <GenerationCanvas className="h-[34rem]" width={744} height={400}>
      <CanvasEdge
        active={pending}
        from={portOf(prompt, "out")}
        to={portOf(image, "in")}
      />
 
      <CanvasNode {...prompt} ports={["out"]} title="Prompt">
        {/* anything */}
      </CanvasNode>
 
      <CanvasNode {...image} ports={["in"]} title="Image">
        <div className="px-3 pb-3">
          <CanvasMedia aspect="16:9" pending={pending} src={src} />
        </div>
      </CanvasNode>
    </GenerationCanvas>
  );
}
PartPropWhat it is
GenerationCanvaswidth, heightThe plane's size, when the nodes need more room than the frame. It scrolls
GenerationCanvasdockFloats at the bottom centre and stays put while the plane scrolls. Omit it and nothing is drawn
CanvasNodex, y, widthPixel position on the plane. Height is the node's content
CanvasNodeports"in", "out" or both. A side with no edge has no port
CanvasNodetitleA 40px header row. Omit it and the node is only its content
CanvasEdgefrom, toTwo points, normally portOf(source, "out") and portOf(target, "in")
CanvasEdgeactiveLight is travelling. Off leaves the line at rest
CanvasMediaaspect"16:9" — the string AspectRatioPicker hands back
CanvasMediapending, srcWhether a generation is in flight, and the result once there is one

The three states of a node

A node waits, finishes or fails, and the line feeding it says which. While work is on its way the edge carries light into the node and the media flows behind it. When the result decodes the flow gives way to a blur-to-focus reveal and the line goes quiet. If the source never loads, the flow gives way to a failure line instead.

Waiting

a raven on a payphone at dusk, 35mm

Done
Generated result
Failed

CanvasMedia leaves status copy and actions to you. Pass overlay for a retry or a progress line, and failureLabel for the words along the bottom when the source fails.

Edges run left to right

The light travels in x, so an edge wants its source on the left of its target. A target above or below the source is fine — the curve leaves and arrives level, and bends in the middle — but a target to the left of the source makes the light run backwards through the curve. Lay a canvas out in columns, and the lines take care of themselves.

Ports are measured from the top

portOf puts a port 20px below a node's top edge, the middle of the header row, whether or not a header is drawn. Nodes can therefore be any height without their edges being told: an edge only ever needs a node's position and width.

Not in the box

Nothing pans, zooms or drags. A node is a box at a position, and moving it is a change to x and y in whatever state you keep. If you need a viewport or free dragging, keep these parts and put your own around them — they do not know what is moving them. And CanvasEdge is a thin wrapper over Flow Edge, so a graph library that draws its own edges can use that directly.

Colours

Every colour is a theme token. The plane's dots are border, a node is card, the light is primary over an idle line in muted-foreground, and a failed source reads as destructive through the overlay. Restyle the theme and the canvas follows.

Component source

generation-canvas.tsx
"use client";

import type { CSSProperties, ReactNode } from "react";

import { FlowEdge } from "@/components/ui/motif/flow-edge";
import { GenerationOverlay } from "@/components/ui/motif/generation-overlay";
import { cn } from "@/lib/utils";

/* -- The plane ---------------------------------------------------------------
 * A generation canvas is three things on a dotted plane: nodes that hold work,
 * edges that say which work feeds which, and a dock that floats above the plane
 * rather than riding on it. Each is a part of its own, so a caller can lay out
 * two nodes or twenty, leave the dock out, or draw the edges from their own data.
 *
 * Nothing here pans, zooms or drags. A node is a box at a pixel position, an edge
 * is a curve between two points, and the plane scrolls when the nodes outgrow
 * it. A graph editor that needs more can keep these parts and put its own
 * viewport around them — the parts do not know what is moving them.
 * --------------------------------------------------------------------------- */

/** Where a port sits from the top of its node: the middle of the 40px header, so the
 *  line meets the node at the same height whether or not the header is drawn. */
export const PORT_OFFSET = 20;

export interface CanvasPoint {
  x: number;
  y: number;
}

export interface CanvasBox {
  /** Left edge of the node, in plane pixels. */
  x: number;
  /** Top edge of the node, in plane pixels. */
  y: number;
  width: number;
}

/** The point an edge should leave from (`"out"`) or arrive at (`"in"`) on a node. */
export const portOf = (node: CanvasBox, side: "in" | "out"): CanvasPoint => ({
  x: side === "out" ? node.x + node.width : node.x,
  y: node.y + PORT_OFFSET,
});

const DOTS: CSSProperties = {
  backgroundImage: "radial-gradient(var(--border) 1px, transparent 1px)",
  backgroundSize: "20px 20px",
};

export interface GenerationCanvasProps {
  children?: ReactNode;
  className?: string;
  /** Floats at the bottom centre, above the plane — a `Toolbar`, usually. It stays
   *  put while the plane scrolls, and draws nothing when omitted. */
  dock?: ReactNode;
  /** Height of the plane, when the nodes need more room than the frame gives them. */
  height?: number | string;
  /** Width of the plane, when the nodes need more room than the frame gives them. */
  width?: number | string;
}

/**
 * The frame and the plane. The frame takes the size its parent gives it; the plane
 * inside is at least that big and scrolls when `width` or `height` ask for more.
 */
export const GenerationCanvas = ({
  children,
  className,
  dock,
  height = "100%",
  width = "100%",
}: GenerationCanvasProps) => (
  <div
    className={cn(
      "relative isolate overflow-hidden bg-background text-foreground",
      className
    )}
  >
    <div className="absolute inset-0 overflow-auto">
      <div
        className="relative min-h-full min-w-full"
        style={{ ...DOTS, height, width }}
      >
        {children}
      </div>
    </div>
    {dock ? (
      <div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
        {dock}
      </div>
    ) : null}
  </div>
);

export interface CanvasNodeProps extends CanvasBox {
  children?: ReactNode;
  className?: string;
  /** Which ports to draw. A node with no edge on a side has none on it. */
  ports?: readonly ("in" | "out")[];
  /** Names the node in a header row. Omit it and the node is only its content. */
  title?: ReactNode;
}

const PORT =
  "absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-card";

/** One box of work on the plane: a card at a pixel position, with ports where edges
 *  attach. It is a `card` however the plane is themed — the plane is the page. */
export const CanvasNode = ({
  children,
  className,
  ports = [],
  title,
  width,
  x,
  y,
}: CanvasNodeProps) => (
  <section
    className={cn(
      "absolute rounded-xl border border-border bg-card text-card-foreground",
      className
    )}
    style={{ left: x, top: y, width }}
  >
    {title ? (
      <header className="flex h-10 items-center px-3 font-medium text-muted-foreground text-xs">
        {title}
      </header>
    ) : null}
    {children}
    {ports.includes("in") ? (
      <span
        aria-hidden="true"
        className={PORT}
        style={{ left: 0, top: PORT_OFFSET }}
      />
    ) : null}
    {ports.includes("out") ? (
      <span
        aria-hidden="true"
        className={PORT}
        style={{ left: "100%", top: PORT_OFFSET }}
      />
    ) : null}
  </section>
);

export interface CanvasEdgeProps {
  /** Whether light is travelling — work is moving along this edge right now. */
  active?: boolean;
  className?: string;
  /** Where the edge leaves, normally `portOf(source, "out")`. */
  from: CanvasPoint;
  /** Where the edge arrives, normally `portOf(target, "in")`. */
  to: CanvasPoint;
}

/** A curve between two ports. It runs left to right — the light travels in x — so
 *  put the source on the left of the target. */
export const CanvasEdge = ({
  active = false,
  className,
  from,
  to,
}: CanvasEdgeProps) => {
  const left = Math.min(from.x, to.x);
  const top = Math.min(from.y, to.y);
  const width = Math.max(Math.abs(to.x - from.x), 1);
  const height = Math.max(Math.abs(to.y - from.y), 1);
  const startY = from.y <= to.y ? 0 : height;
  const endY = from.y <= to.y ? height : 0;

  return (
    <div
      aria-hidden="true"
      className="pointer-events-none absolute"
      style={{ height, left, top, width }}
    >
      <FlowEdge
        active={active}
        className={cn("text-primary", className)}
        d={`M 0 ${startY} C ${width / 2} ${startY} ${width / 2} ${endY} ${width} ${endY}`}
        glow={2}
        strokeWidth={2}
        style={{ "--edge-track": "var(--muted-foreground)" } as CSSProperties}
        viewBox={`0 0 ${width} ${height}`}
      />
    </div>
  );
};

export interface CanvasMediaProps {
  alt?: string;
  /** Aspect ratio as `"16:9"` — the same string `AspectRatioPicker` hands back. */
  aspect?: string;
  className?: string;
  /** Shown along the bottom when `src` never decodes. */
  failureLabel?: string;
  /** Status copy and actions drawn over the media — a retry, a progress line. */
  overlay?: ReactNode;
  /** Whether a generation is in flight. */
  pending: boolean;
  /** Echoed along the bottom while pending. */
  prompt?: string | null;
  /** Varies the flow's motion, so concurrent nodes do not move in step. */
  seed?: string | number;
  /** The result. Omit it while nothing exists yet. */
  src?: string;
}

/** The media inside a node: a frame in the requested ratio that flows while it waits
 *  and reveals the result when it decodes. */
export const CanvasMedia = ({
  alt = "Generated result",
  aspect = "16:9",
  className,
  failureLabel,
  overlay,
  pending,
  prompt,
  seed,
  src,
}: CanvasMediaProps) => (
  <div
    className={cn(
      "relative w-full overflow-hidden rounded-lg border border-border",
      className
    )}
    style={{ aspectRatio: aspect.replace(":", " / ") }}
  >
    <GenerationOverlay
      failureLabel={failureLabel}
      mediaSrc={src}
      overlay={overlay}
      pending={pending}
      prompt={prompt}
      seed={seed}
    >
      {src ? (
        <img alt={alt} className="h-full w-full object-cover" src={src} />
      ) : null}
    </GenerationOverlay>
  </div>
);