Initial commit: img-infinite-canvas AI design workbench MVP

Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 23:15:37 +08:00
commit 44406b72db
349 changed files with 73265 additions and 0 deletions
@@ -0,0 +1,500 @@
.artboard-image,
.artboard-preview {
width: 100%;
height: 100%;
}
.artboard-image {
display: block;
object-fit: contain;
object-position: center center;
user-select: none;
-webkit-user-drag: none;
}
.transparent-artboard-preview {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
.adjusted-artboard-image {
--adjust-warm-opacity: 0;
--adjust-cool-opacity: 0;
--adjust-magenta-opacity: 0;
--adjust-green-opacity: 0;
--adjust-highlight-lift: 0;
--adjust-highlight-recover: 0;
--adjust-shadow-lift: 0;
--adjust-shadow-crush: 0;
--adjust-vignette-opacity: 0;
--adjust-vignette-color: 0, 0, 0;
--adjust-grain-opacity: 0;
--adjust-bloom-opacity: 0;
--adjust-bloom-blur: 0;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.adjusted-artboard-image .artboard-image,
.adjustment-bloom-image,
.adjusted-artboard-image span {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.adjustment-bloom-image {
object-fit: contain;
object-position: center center;
opacity: var(--adjust-bloom-opacity);
filter: blur(var(--adjust-bloom-blur)) brightness(1.18) saturate(1.08);
mix-blend-mode: screen;
pointer-events: none;
}
.adjusted-artboard-image span {
pointer-events: none;
}
.adjustment-light-overlay {
background:
linear-gradient(rgba(255, 255, 255, var(--adjust-highlight-lift)), rgba(255, 255, 255, var(--adjust-highlight-lift))),
linear-gradient(rgba(0, 0, 0, var(--adjust-highlight-recover)), rgba(0, 0, 0, var(--adjust-highlight-recover))),
linear-gradient(rgba(255, 255, 255, var(--adjust-shadow-lift)), rgba(255, 255, 255, var(--adjust-shadow-lift))),
linear-gradient(rgba(0, 0, 0, var(--adjust-shadow-crush)), rgba(0, 0, 0, var(--adjust-shadow-crush)));
mix-blend-mode: soft-light;
}
.adjustment-temperature-overlay {
background:
linear-gradient(rgba(255, 190, 74, var(--adjust-warm-opacity)), rgba(255, 190, 74, var(--adjust-warm-opacity))),
linear-gradient(rgba(72, 150, 255, var(--adjust-cool-opacity)), rgba(72, 150, 255, var(--adjust-cool-opacity))),
linear-gradient(rgba(255, 78, 205, var(--adjust-magenta-opacity)), rgba(255, 78, 205, var(--adjust-magenta-opacity))),
linear-gradient(rgba(42, 190, 104, var(--adjust-green-opacity)), rgba(42, 190, 104, var(--adjust-green-opacity)));
mix-blend-mode: overlay;
}
.adjustment-vignette-overlay {
background: radial-gradient(circle at center, transparent 50%, rgba(var(--adjust-vignette-color), var(--adjust-vignette-opacity)) 100%);
mix-blend-mode: multiply;
}
.adjustment-grain-overlay {
opacity: var(--adjust-grain-opacity);
background-image:
radial-gradient(circle at 25% 30%, rgba(255, 255, 255, 0.75) 0 0.6px, transparent 0.8px),
radial-gradient(circle at 72% 64%, rgba(0, 0, 0, 0.68) 0 0.55px, transparent 0.75px);
background-size: 4px 4px, 5px 5px;
mix-blend-mode: overlay;
}
.pen-stroke-node .artboard-image {
object-fit: fill;
}
.manual-frame-preview {
width: 100%;
height: 100%;
box-sizing: border-box;
}
.image-generator-preview {
position: relative;
width: 100%;
height: 100%;
overflow: visible;
}
.image-generator-label {
position: absolute;
left: 0;
top: -28px;
display: inline-flex;
align-items: center;
gap: 6px;
color: #2f80ff;
font-size: 15px;
font-weight: 500;
line-height: 22px;
white-space: nowrap;
}
.image-generator-label svg {
width: 15px;
height: 15px;
fill: #2f80ff;
stroke: #2f80ff;
}
.image-generator-frame {
width: 100%;
height: 100%;
display: grid;
place-items: center;
border: 2px solid #2f80ff;
background: #eeeeed;
}
.image-generator-frame svg {
color: #cfd0d2;
fill: #cfd0d2;
stroke: #cfd0d2;
}
.generation-skeleton {
--canvas-work-ui-scale: 1;
--canvas-work-label-font: 18px;
--canvas-work-label-pad-x: 18px;
--canvas-work-label-pad-y: 8px;
--canvas-work-label-bottom: 18px;
position: relative;
width: 100%;
height: 100%;
display: grid;
align-content: end;
gap: 7%;
padding: 9%;
background: #f5f6f8;
container-type: inline-size;
}
.generation-skeleton-image,
.generation-skeleton-lines span {
position: relative;
overflow: hidden;
background: #e6e9ee;
}
.generation-skeleton-image::after,
.generation-skeleton-lines span::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
animation: skeleton-shimmer 1.35s infinite;
}
.generation-skeleton-image {
min-height: 0;
aspect-ratio: 1;
border-radius: 10px;
}
.generation-skeleton-lines {
display: grid;
gap: 10px;
}
.generation-skeleton-lines span {
height: 14px;
border-radius: 999px;
}
.generation-skeleton-lines span:nth-child(2) {
width: 72%;
}
.generation-skeleton-lines span:nth-child(3) {
width: 48%;
}
.generation-skeleton-label {
position: absolute;
left: 50%;
bottom: calc(var(--canvas-work-label-bottom) * var(--canvas-work-ui-scale));
z-index: 2;
max-width: calc(100% - calc(56px * var(--canvas-work-ui-scale)));
min-width: calc(64px * var(--canvas-work-ui-scale));
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: calc(var(--canvas-work-label-pad-y) * var(--canvas-work-ui-scale)) calc(var(--canvas-work-label-pad-x) * var(--canvas-work-ui-scale));
border-radius: 999px;
color: rgba(255, 255, 255, 0.94);
background: rgba(87, 94, 94, 0.5);
backdrop-filter: blur(12px);
font-size: calc(var(--canvas-work-label-font) * var(--canvas-work-ui-scale));
font-weight: 520;
line-height: 1.1;
letter-spacing: 0;
white-space: nowrap;
text-overflow: ellipsis;
transform: translateX(-50%);
will-change: transform, background-color;
animation: generation-label-float 2.4s ease-in-out infinite;
}
.generation-skeleton-label::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.2) 38%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0.16) 62%, transparent 100%);
transform: translateX(-115%) skewX(-16deg);
animation: generation-label-sheen 1.9s ease-in-out infinite;
pointer-events: none;
}
@keyframes skeleton-shimmer {
100% {
transform: translateX(100%);
}
}
@keyframes generation-label-float {
0%,
100% {
background: rgba(87, 94, 94, 0.48);
transform: translateX(-50%) translateY(0) scale(1);
}
50% {
background: rgba(87, 94, 94, 0.56);
transform: translateX(-50%) translateY(calc(-1px * var(--canvas-work-ui-scale))) scale(1.012);
}
}
@keyframes generation-label-sheen {
0% {
transform: translateX(-115%) skewX(-16deg);
}
58%,
100% {
transform: translateX(115%) skewX(-16deg);
}
}
.artboard-preview {
overflow: hidden;
color: #171717;
background: #fff;
container-type: inline-size;
}
.artboard-nav {
height: 9%;
min-height: 22px;
display: flex;
align-items: center;
gap: 4%;
padding: 0 4%;
color: #18191b;
background: #fff9f2;
font-size: clamp(7px, 1.6%, 11px);
letter-spacing: 0;
}
.artboard-nav b {
margin-right: auto;
}
.wide-hero {
position: relative;
height: 91%;
display: grid;
grid-template-columns: 0.9fr 1fr 0.8fr;
overflow: hidden;
background:
radial-gradient(circle at 76% 22%, #ffce26 0 28%, transparent 29%),
radial-gradient(circle at 63% -18%, #0067d8 0 30%, transparent 31%),
linear-gradient(135deg, #ff4d3f 0 43%, #ffe9e2 43% 62%, #ffc928 62% 100%);
}
.hero-copyline {
z-index: 1;
align-self: center;
padding-left: 12%;
}
.hero-copyline strong {
display: block;
color: #111;
font-size: clamp(28px, 14.5cqw, 78px);
line-height: 0.84;
letter-spacing: 0;
}
.hero-copyline small {
display: block;
margin-top: 7%;
font-size: clamp(7px, 2cqw, 12px);
font-weight: 850;
}
.hero-model {
position: absolute;
left: 48%;
bottom: -8%;
width: 26%;
height: 93%;
transform: translateX(-50%);
border-radius: 46% 46% 12% 12%;
background:
radial-gradient(circle at 50% 12%, #9a6a50 0 9%, transparent 10%),
linear-gradient(75deg, transparent 0 27%, #17285b 28% 70%, transparent 71%),
linear-gradient(95deg, transparent 0 38%, #0f214d 39% 62%, transparent 63%);
filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.18));
}
.hero-note {
z-index: 1;
align-self: center;
max-width: 74%;
display: grid;
gap: 7px;
color: #1b1b1b;
font-size: clamp(8px, 2.2cqw, 13px);
}
.hero-note span {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.artboard-tall {
display: grid;
grid-template-rows: 28% 32% 27% 13%;
}
.artboard-tall .artboard-wide {
min-height: 0;
}
.mood-section {
display: grid;
grid-template-rows: 26% 1fr;
background: #fff;
}
.mood-section header {
display: flex;
align-items: center;
gap: 8%;
padding: 0 6%;
font-size: clamp(8px, 2.4cqw, 14px);
}
.mood-section header b {
margin-right: auto;
font-size: clamp(18px, 6cqw, 34px);
line-height: 1;
}
.mood-section header span {
color: #868686;
}
.color-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.color-cards div {
position: relative;
display: grid;
align-content: end;
justify-items: center;
overflow: hidden;
padding-bottom: 8%;
}
.color-cards div:nth-child(1) {
background: #ff5545;
}
.color-cards div:nth-child(2) {
background: #ffd72f;
}
.color-cards div:nth-child(3) {
background: #77c47c;
}
.color-cards div:nth-child(4) {
background: #2382dc;
}
.color-cards i {
width: 48%;
height: 54%;
margin-bottom: 6%;
border-radius: 42% 42% 18% 18%;
background:
radial-gradient(circle at 50% 10%, rgba(255, 255, 255, 0.36) 0 11%, transparent 12%),
linear-gradient(145deg, rgba(255, 255, 255, 0.36), rgba(0, 0, 0, 0.12));
box-shadow: 0 10px 12px rgba(17, 24, 39, 0.18);
}
.color-cards strong {
color: #111;
font-size: clamp(10px, 3.6cqw, 24px);
line-height: 0.9;
}
.product-section {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4%;
padding: 6%;
background: #fbfaf7;
}
.product-section span,
.neutral-grid i {
border-radius: 2px;
background:
radial-gradient(circle at 50% 22%, #f2eee6 0 25%, transparent 26%),
linear-gradient(#d7cec2, #f8f4eb);
}
.artboard-tall footer,
.artboard-square footer {
display: grid;
place-items: center;
color: #fff;
background: #111;
font-size: clamp(10px, 3.1cqw, 18px);
font-weight: 850;
}
.artboard-square {
display: grid;
grid-template-rows: 32% 53% 15%;
background: #f8f5ee;
}
.neutral-hero {
display: grid;
align-content: center;
gap: 6px;
padding: 0 9%;
background: #eee8dc;
}
.neutral-hero strong {
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(20px, 8cqw, 44px);
line-height: 0.95;
font-weight: 500;
}
.neutral-hero span {
color: #6e665d;
font-size: clamp(8px, 2.6cqw, 14px);
}
.neutral-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5%;
padding: 6%;
}
@@ -0,0 +1,231 @@
"use client";
import { Circle, Image as ImageIcon } from "lucide-react";
import type { CSSProperties } from "react";
import type { CanvasNode } from "@/domain/design";
import { useI18n } from "@/i18n/i18n";
import { CanvasWorkOverlay } from "@/ui/components/CanvasWorkOverlay";
import { LayerMergeSkeleton } from "@/ui/components/LayerMergeSkeleton";
import { imageAdjustmentImageStyle, imageAdjustmentLayerStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments";
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { framePreviewStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle";
import "./index.css";
export function ArtboardPreview({ node, viewportScale = 1 }: { node: CanvasNode; viewportScale?: number }) {
const { t } = useI18n();
const overlayUiStyle = workOverlayUiStyle(viewportScale, node);
if (node.status === "generating" && node.layerRole === "merge-layer") {
return (
<div className="layer-merge-preview">
{isImageUrl(node.content) && <img className="layer-merge-preview-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} />}
<LayerMergeSkeleton title={node.title} style={overlayUiStyle} />
</div>
);
}
if (node.status === "generating" && node.type === "image" && isImageUrl(node.content)) {
return <WorkingImagePreview node={node} label={node.title} style={overlayUiStyle} />;
}
if (node.status === "generating") {
return <GeneratingArtboardSkeleton title={node.title} style={overlayUiStyle} />;
}
if (node.status === "text-extracting" && node.type === "image" && isImageUrl(node.content)) {
return <WorkingImagePreview node={node} label={t("extractingText")} style={overlayUiStyle} />;
}
if (node.status === "mockup-modeling" && node.type === "image" && isImageUrl(node.content)) {
return <WorkingImagePreview node={node} label={t("mockupPreparing")} style={overlayUiStyle} />;
}
if (node.status === "uploading" && node.type === "image" && isImageUrl(node.content)) {
return <WorkingImagePreview node={node} label={t("uploadingImage")} style={overlayUiStyle} />;
}
if (node.type === "image" && isImageUrl(node.content)) {
const adjustments = parseImageAdjustments(node.imageAdjustments);
if (!isDefaultImageAdjustments(adjustments)) {
return <AdjustedImagePreview node={node} />;
}
if (node.layerRole === "background-removed" || node.tone === "transparent-image") {
return <TransparentImagePreview node={node} />;
}
return <img className="artboard-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} />;
}
if (node.type === "image" && node.layerRole === "image-generator") {
return <ImageGeneratorPreview node={node} />;
}
if (node.type === "frame" && node.layerRole === "manual-frame") {
return <div className="manual-frame-preview" style={framePreviewStyle(node)} />;
}
const kind = artboardKind(node);
if (kind === "wide") return <WideCommerceBoard node={node} />;
if (kind === "tall") return <TallCommerceBoard node={node} />;
return <SquareCommerceBoard node={node} />;
}
function TransparentImagePreview({ node }: { node: CanvasNode }) {
return (
<div className="transparent-artboard-preview">
<img className="artboard-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} />
</div>
);
}
function AdjustedImagePreview({ node }: { node: CanvasNode }) {
const adjustments = parseImageAdjustments(node.imageAdjustments);
const source = canvasImageUrl(node.content);
return (
<div className="adjusted-artboard-image" style={imageAdjustmentLayerStyle(adjustments)}>
<img className="artboard-image" src={source} alt={node.title} draggable={false} style={imageAdjustmentImageStyle(adjustments)} />
<img className="adjustment-bloom-image" src={source} alt="" draggable={false} aria-hidden="true" />
<span className="adjustment-light-overlay" />
<span className="adjustment-temperature-overlay" />
<span className="adjustment-vignette-overlay" />
<span className="adjustment-grain-overlay" />
</div>
);
}
function workOverlayUiStyle(viewportScale: number, node: CanvasNode) {
const scale = Math.max(viewportScale, 0.01);
const screenMin = Math.max(1, Math.min(node.width * scale, node.height * scale));
const labelFont = clampNumber(screenMin * 0.038, 10, 15);
return {
"--canvas-work-ui-scale": `${1 / scale}`,
"--canvas-work-label-font": `${labelFont}px`,
"--canvas-work-label-pad-y": `${clampNumber(labelFont * 0.34, 4, 6)}px`,
"--canvas-work-label-pad-x": `${clampNumber(labelFont * 0.82, 9, 14)}px`,
"--canvas-work-label-bottom": `${clampNumber(screenMin * 0.055, 10, 18)}px`
} as CSSProperties;
}
function clampNumber(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function artboardKind(node: CanvasNode) {
if (node.height > node.width * 1.24) return "tall";
if (node.width > node.height * 1.35) return "wide";
return "square";
}
function ImageGeneratorPreview({ node }: { node: CanvasNode }) {
return (
<div className="image-generator-preview">
<div className="image-generator-label">
<ImageIcon size={14} />
<span>{node.title}</span>
</div>
<div className="image-generator-frame">
<ImageIcon size={34} />
</div>
</div>
);
}
function WorkingImagePreview({ node, label, style }: { node: CanvasNode; label: string; style?: CSSProperties }) {
return (
<CanvasWorkOverlay label={label} ariaLabel={node.title} style={style}>
<img className="artboard-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} />
</CanvasWorkOverlay>
);
}
function GeneratingArtboardSkeleton({ title, style }: { title: string; style?: CSSProperties }) {
return (
<div className="generation-skeleton" aria-label={title} style={style}>
<div className="generation-skeleton-image" />
<div className="generation-skeleton-lines">
<span />
<span />
<span />
</div>
<span className="generation-skeleton-label">{title}</span>
</div>
);
}
function WideCommerceBoard({ node }: { node: CanvasNode }) {
return (
<div className="artboard-preview artboard-wide">
<div className="artboard-nav">
<b>still day</b>
<span>HOME</span>
<span>SHOP</span>
<span>COLLECTION</span>
<Circle size={8} />
</div>
<div className="wide-hero">
<div className="hero-copyline">
<strong>
Wear
<br />
Your
<br />
Mood.
</strong>
<small>{node.title}</small>
</div>
<div className="hero-model" />
<div className="hero-note">
<b>Every color tells a story.</b>
<span>{node.content.slice(0, 52)}</span>
</div>
</div>
</div>
);
}
function TallCommerceBoard({ node }: { node: CanvasNode }) {
return (
<div className="artboard-preview artboard-tall">
<WideCommerceBoard node={node} />
<section className="mood-section">
<header>
<b>MOOD × COLOR</b>
<span>drop 04</span>
</header>
<div className="color-cards">
{["ENERGETIC", "HAPPY", "CALM", "BOLD"].map((label) => (
<div key={label}>
<i />
<strong>{label}</strong>
</div>
))}
</div>
</section>
<section className="product-section">
{Array.from({ length: 8 }).map((_, index) => (
<span key={index} />
))}
</section>
<footer>
<b>Born from every mood.</b>
</footer>
</div>
);
}
function SquareCommerceBoard({ node }: { node: CanvasNode }) {
return (
<div className="artboard-preview artboard-square">
<div className="neutral-hero">
<strong>Wear Your Mood.</strong>
<span>{node.title}</span>
</div>
<div className="neutral-grid">
{Array.from({ length: 8 }).map((_, index) => (
<i key={index} />
))}
</div>
<footer>Born from every mood.</footer>
</div>
);
}