( ′∀`)σ≡σ☆))Д′)レ(゚∀゚;)ヘ=З=З=Зε≡(ノ´_ゝ`)ノ
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Atelier — a canvas studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,300;1,9..144,400;1,9..144,600&display=swap" rel="stylesheet">
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600,700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<style>
:root {
--ink: #0E0E14;
--ink-2: #1A1A23;
--paper: #F7F5F0;
--paper-2: #EFECE4;
--paper-3: #E5E1D6;
--line: rgba(14, 14, 20, 0.08);
--line-2: rgba(14, 14, 20, 0.14);
--accent: #FF5B2E;
--accent-2: #E8C547;
--accent-3: #5B8DEF;
--accent-4: #7AC74F;
--accent-5: #D946EF;
--serif: 'Fraunces', Georgia, serif;
--sans: 'General Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--bar-h: 60px;
--safe-top: 40px;
--safe-bottom: 22px;
--tap: 44px;
--sheet-h: 360px;
--radius-lg: 24px;
--radius-md: 14px;
--radius-sm: 10px;
--shadow-sm: 0 1px 2px rgba(14,14,20,0.06), 0 1px 3px rgba(14,14,20,0.04);
--shadow-md: 0 4px 12px rgba(14,14,20,0.08), 0 2px 4px rgba(14,14,20,0.04);
--shadow-lg: 0 12px 40px rgba(14,14,20,0.14), 0 4px 12px rgba(14,14,20,0.06);
--shadow-xl: 0 24px 60px rgba(14,14,20,0.20), 0 8px 20px rgba(14,14,20,0.08);
--spring: cubic-bezier(0.34, 1.4, 0.5, 1);
--ease-out: cubic-bezier(0.2, 0.8, 0.3, 1);
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--dur: 260ms;
--dur-fast: 160ms;
--z-canvas: 1;
--z-floating: 50;
--z-bar: 100;
--z-scrim: 150;
--z-sheet: 200;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; margin: 0; padding: 0; }
html, body {
font-family: var(--sans);
background: #0a0a0f;
color: var(--ink);
height: 100%;
overflow: hidden;
-webkit-font-smoothing: antialiased;
touch-action: manipulation;
letter-spacing: -0.003em;
}
button { font-family: inherit; border: none; background: none; color: inherit; cursor: pointer; }
input { font-family: inherit; }
/* ============ STAGE & DEVICE ============ */
.stage {
width: 100vw; height: 100vh; height: 100dvh;
display: flex; align-items: center; justify-content: center;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, #1a1a28 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 100%, #241621 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 20% 100%, #16202a 0%, transparent 50%),
#06060a;
overflow: hidden;
position: relative;
}
.stage::before {
content: '';
position: absolute; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.08 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
pointer-events: none;
opacity: 0.5;
}
.device {
width: 390px; height: 844px;
max-width: 100vw; max-height: 100vh; max-height: 100dvh;
background: var(--paper);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 48px;
box-shadow:
0 0 0 2px #0a0a0f,
0 0 0 12px #1a1a24,
0 0 0 13px #2a2a38,
0 40px 80px -20px rgba(0,0,0,0.7),
0 20px 40px -10px rgba(0,0,0,0.5);
}
.notch {
position: absolute; top: 10px; left: 50%;
transform: translateX(-50%);
width: 110px; height: 28px;
background: #000;
border-radius: 20px;
z-index: 500;
pointer-events: none;
}
.notch::after {
content: '';
position: absolute; right: 14px; top: 50%;
transform: translateY(-50%);
width: 8px; height: 8px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #2a3540, #0a0f15);
}
.home-bar {
position: absolute; bottom: 8px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 5px;
background: rgba(14,14,20,0.85);
border-radius: 3px;
z-index: 500;
pointer-events: none;
}
@media (max-width: 430px) {
.stage { background: var(--paper); }
.stage::before { display: none; }
.device {
width: 100vw; height: 100vh; height: 100dvh;
border-radius: 0;
box-shadow: none;
}
.notch, .home-bar { display: none; }
}
/* ============ TOP BAR ============ */
.top-bar {
height: calc(var(--bar-h) + var(--safe-top));
padding: var(--safe-top) 10px 0;
display: flex;
align-items: center;
gap: 2px;
z-index: var(--z-bar);
flex-shrink: 0;
background: linear-gradient(to bottom, var(--paper) 0%, var(--paper) 70%, transparent 100%);
position: relative;
}
.top-bar::after {
content: '';
position: absolute;
left: 16px; right: 16px; bottom: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--line-2), transparent);
}
.icon-btn {
width: var(--tap); height: var(--tap);
display: flex; align-items: center; justify-content: center;
color: var(--ink);
border-radius: 12px;
position: relative;
transition: background var(--dur) var(--ease), transform var(--dur-fast) var(--ease-out);
}
.icon-btn svg { width: 20px; height: 20px; stroke-width: 1.75; }
.icon-btn:hover { background: rgba(14,14,20,0.05); }
.icon-btn:active { background: rgba(14,14,20,0.09); transform: scale(0.92); }
.icon-btn:disabled { opacity: 0.25; cursor: not-allowed; }
.icon-btn:disabled:hover { background: none; }
.doc-title-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
min-width: 0;
}
.doc-title {
background: transparent;
border: none;
color: var(--ink);
font-family: var(--serif);
font-size: 19px;
font-weight: 400;
font-style: italic;
text-align: center;
padding: 6px 12px;
border-radius: 8px;
min-width: 0;
max-width: 100%;
outline: none;
letter-spacing: -0.01em;
transition: background var(--dur) var(--ease);
}
.doc-title:focus { background: rgba(14,14,20,0.05); }
.doc-title:hover { background: rgba(14,14,20,0.03); }
.share-btn {
background: var(--ink);
color: var(--paper);
padding: 0 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
height: 36px;
display: flex; align-items: center;
margin-right: 4px;
letter-spacing: -0.01em;
transition: transform var(--dur-fast) var(--spring);
position: relative;
overflow: hidden;
}
.share-btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-5) 100%);
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.share-btn span { position: relative; z-index: 1; }
.share-btn:hover::before { opacity: 1; }
.share-btn:active { transform: scale(0.95); }
/* ============ CANVAS ZONE ============ */
.canvas-zone {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
background:
radial-gradient(ellipse 100% 70% at 50% 50%, rgba(255,255,255,0.4) 0%, transparent 70%),
var(--paper);
}
.canvas-zone::before {
content: '';
position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(14,14,20,0.08) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
mask-image: radial-gradient(ellipse 100% 70% at 50% 50%, black 0%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 100% 70% at 50% 50%, black 0%, transparent 80%);
}
.canvas-host {
position: relative;
border-radius: 6px;
background: #fff;
box-shadow:
0 0 0 1px rgba(14,14,20,0.06),
0 2px 4px rgba(14,14,20,0.04),
0 12px 40px rgba(14,14,20,0.12),
0 24px 60px rgba(14,14,20,0.08);
}
.canvas-host canvas { display: block; border-radius: 6px; }
/* Zoom pill */
.zoom-pill {
position: absolute;
bottom: 16px; right: 16px;
background: rgba(14,14,20,0.92);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
color: var(--paper);
border-radius: 999px;
display: flex; align-items: center;
padding: 3px;
z-index: var(--z-floating);
box-shadow: var(--shadow-lg), inset 0 0 0 1px rgba(255,255,255,0.08);
}
.zoom-pill button {
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,0.7);
border-radius: 50%;
transition: all var(--dur-fast) var(--ease-out);
}
.zoom-pill button:hover { color: white; background: rgba(255,255,255,0.12); }
.zoom-pill button:active { transform: scale(0.9); }
.zoom-pill .val {
font-size: 11px;
font-weight: 600;
min-width: 38px;
text-align: center;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
/* Floating object toolbar */
.obj-toolbar {
position: absolute;
background: rgba(14,14,20,0.92);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border-radius: 14px;
padding: 5px;
display: none;
align-items: center;
gap: 1px;
z-index: var(--z-floating);
box-shadow: var(--shadow-xl), inset 0 0 0 1px rgba(255,255,255,0.1);
will-change: transform;
transform: translate(-50%, 0);
}
.obj-toolbar.visible {
display: flex;
animation: toolbar-in 320ms var(--spring);
}
@keyframes toolbar-in {
from { opacity: 0; transform: translate(-50%, 8px) scale(0.94); }
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
}
.obj-toolbar button {
width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,0.75);
border-radius: 8px;
transition: all var(--dur-fast) var(--ease-out);
}
.obj-toolbar button:hover { color: white; background: rgba(255,255,255,0.12); }
.obj-toolbar button:active { transform: scale(0.92); }
.obj-toolbar button svg { width: 17px; height: 17px; stroke-width: 1.75; }
.obj-toolbar .sep { width: 1px; height: 18px; background: rgba(255,255,255,0.12); margin: 0 3px; }
.obj-toolbar .fill-swatch {
width: 22px; height: 22px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.9);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.2);
transition: transform var(--dur-fast) var(--spring);
}
.obj-toolbar .fill-swatch:hover { transform: scale(1.1); }
.obj-toolbar .opacity-wrap {
display: flex; align-items: center;
padding: 0 10px 0 6px;
gap: 8px;
color: rgba(255,255,255,0.7);
}
.obj-toolbar input[type=range] {
width: 68px;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: rgba(255,255,255,0.2);
border-radius: 2px;
outline: none;
}
.obj-toolbar input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
cursor: pointer;
}
.obj-toolbar input[type=range]::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: white;
border: none;
cursor: pointer;
}
.hidden-color { position: absolute; opacity: 0; pointer-events: none; width: 0; height: 0; }
/* ============ BOTTOM TOOLBAR ============ */
.bottom-bar {
height: calc(var(--bar-h) + var(--safe-bottom));
padding: 0 6px var(--safe-bottom);
display: flex;
align-items: center;
justify-content: space-around;
z-index: var(--z-bar);
flex-shrink: 0;
background: linear-gradient(to top, var(--paper) 0%, var(--paper) 70%, transparent 100%);
position: relative;
}
.bottom-bar::before {
content: '';
position: absolute;
left: 16px; right: 16px; top: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--line-2), transparent);
}
.tab-btn {
flex: 1;
height: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: rgba(14,14,20,0.45);
position: relative;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.01em;
transition: color var(--dur) var(--ease);
}
.tab-btn svg { width: 20px; height: 20px; stroke-width: 1.75; transition: transform var(--dur) var(--spring); }
.tab-btn:active svg { transform: scale(0.88); }
.tab-btn.active { color: var(--ink); }
.tab-btn.active svg { transform: scale(1.05); }
.tab-btn.active::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--accent);
transform: translateX(-50%);
animation: dot-in 400ms var(--spring);
}
@keyframes dot-in {
from { transform: translateX(-50%) scale(0); }
to { transform: translateX(-50%) scale(1); }
}
/* ============ BOTTOM SHEET ============ */
.scrim {
position: absolute;
top: 0; left: 0; right: 0;
bottom: calc(var(--bar-h) + var(--safe-bottom) + var(--sheet-h) - 20px);
background: rgba(14,14,20,0.25);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
transition: opacity var(--dur) var(--ease);
z-index: var(--z-scrim);
}
.scrim.open { opacity: 1; pointer-events: auto; }
.sheet {
position: absolute;
left: 0; right: 0;
bottom: calc(var(--bar-h) + var(--safe-bottom));
height: var(--sheet-h);
max-height: calc(100% - var(--bar-h) - var(--safe-bottom) - var(--safe-top) - 40px);
background: var(--paper);
border-radius: 28px 28px 0 0;
transform: translateY(calc(100% + 100px));
transition: transform 480ms var(--spring);
z-index: var(--z-sheet);
display: flex;
flex-direction: column;
will-change: transform;
box-shadow:
0 -20px 60px rgba(14,14,20,0.2),
0 -4px 12px rgba(14,14,20,0.06),
inset 0 1px 0 rgba(255,255,255,0.8);
overflow: hidden;
}
.sheet.open { transform: translateY(0); }
.sheet::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 140px;
background: linear-gradient(to bottom, rgba(255,255,255,0.5), transparent);
pointer-events: none;
}
.sheet-handle {
width: 40px; height: 4px;
background: var(--line-2);
border-radius: 2px;
margin: 10px auto 4px;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.sheet-header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 10px 22px 14px;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.sheet-title {
font-family: var(--serif);
font-size: 26px;
font-weight: 500;
letter-spacing: -0.02em;
color: var(--ink);
}
.sheet-title em {
font-style: italic;
font-weight: 300;
color: rgba(14,14,20,0.45);
font-size: 14px;
margin-left: 8px;
letter-spacing: 0;
}
.sheet-close {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
color: rgba(14,14,20,0.5);
border-radius: 50%;
transition: all var(--dur-fast) var(--ease-out);
}
.sheet-close:hover { background: rgba(14,14,20,0.06); color: var(--ink); }
.sheet-close:active { transform: scale(0.9); }
.sheet-body {
flex: 1;
overflow-y: auto;
padding: 4px 22px 28px;
-webkit-overflow-scrolling: touch;
position: relative;
z-index: 1;
scrollbar-width: thin;
scrollbar-color: var(--line-2) transparent;
}
.sheet-body::-webkit-scrollbar { width: 4px; }
.sheet-body::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 2px; }
/* ============ PANELS ============ */
.panel { display: none; }
.panel.active {
display: block;
animation: panel-in 400ms var(--ease-out);
}
@keyframes panel-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.tile {
aspect-ratio: 1;
background: #fff;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--line), var(--shadow-sm);
transition: all var(--dur) var(--spring);
position: relative;
overflow: hidden;
}
.tile::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 40%, rgba(255,91,46,0.06) 100%);
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.tile:hover {
transform: translateY(-2px);
box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-md);
}
.tile:hover::before { opacity: 1; }
.tile:active { transform: translateY(0) scale(0.96); }
.tile svg { width: 42px; height: 42px; position: relative; z-index: 1; }
/* Staggered panel entrance for tiles */
.panel.active .grid-3 > *:nth-child(1),
.panel.active > *:nth-child(1) { animation-delay: 40ms; }
.panel.active .grid-3 > *:nth-child(2),
.panel.active > *:nth-child(2) { animation-delay: 80ms; }
.panel.active .grid-3 > *:nth-child(3),
.panel.active > *:nth-child(3) { animation-delay: 120ms; }
.panel.active .grid-3 > *:nth-child(4),
.panel.active > *:nth-child(4) { animation-delay: 160ms; }
.panel.active .grid-3 > *:nth-child(5),
.panel.active > *:nth-child(5) { animation-delay: 200ms; }
.panel.active .grid-3 > *:nth-child(6),
.panel.active > *:nth-child(6) { animation-delay: 240ms; }
.panel.active .grid-3 > *,
.panel.active > .text-preset {
animation: item-in 500ms var(--spring) both;
}
@keyframes item-in {
from { opacity: 0; transform: translateY(14px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Text panel */
.text-preset {
display: block;
width: 100%;
padding: 22px 22px;
background: #fff;
border-radius: var(--radius-md);
margin-bottom: 12px;
text-align: left;
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--line), var(--shadow-sm);
transition: all var(--dur) var(--spring);
font-family: var(--serif);
line-height: 1;
}
.text-preset:hover {
transform: translateY(-2px);
box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-md);
}
.text-preset:active { transform: translateY(0) scale(0.99); }
.text-preset.h { font-size: 30px; font-weight: 600; letter-spacing: -0.03em; }
.text-preset.sh { font-size: 22px; font-weight: 400; font-style: italic; letter-spacing: -0.02em; }
.text-preset.body { font-size: 15px; font-weight: 400; font-family: var(--sans); letter-spacing: -0.01em; color: rgba(14,14,20,0.7); line-height: 1.4; }
/* Draw panel */
.draw-toggle {
width: 100%;
padding: 18px;
background: #fff;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
margin-bottom: 20px;
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--line), var(--shadow-sm);
transition: all var(--dur) var(--spring);
display: flex;
align-items: center;
justify-content: space-between;
letter-spacing: -0.01em;
}
.draw-toggle:hover { transform: translateY(-1px); box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-md); }
.draw-toggle .pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 999px;
background: var(--paper-2);
font-size: 11px;
font-weight: 600;
color: rgba(14,14,20,0.6);
text-transform: uppercase;
letter-spacing: 0.08em;
transition: all var(--dur) var(--ease);
}
.draw-toggle .pill::before {
content: '';
width: 6px; height: 6px; border-radius: 50%;
background: rgba(14,14,20,0.3);
transition: all var(--dur) var(--ease);
}
.draw-toggle.on { background: var(--ink); color: var(--paper); box-shadow: var(--shadow-lg); }
.draw-toggle.on .pill { background: rgba(255,255,255,0.12); color: var(--accent); }
.draw-toggle.on .pill::before { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.draw-label {
font-family: var(--serif);
font-style: italic;
font-size: 13px;
color: rgba(14,14,20,0.55);
margin-bottom: 10px;
margin-top: 4px;
}
.draw-label b { font-style: normal; font-weight: 500; color: var(--ink); font-family: var(--sans); }
.color-row {
display: flex;
gap: 10px;
margin-bottom: 22px;
flex-wrap: wrap;
}
.color-swatch {
width: 34px; height: 34px;
border-radius: 50%;
box-shadow: inset 0 0 0 2px #fff, inset 0 0 0 3px var(--line-2), 0 1px 3px rgba(0,0,0,0.08);
transition: transform var(--dur) var(--spring);
position: relative;
}
.color-swatch:hover { transform: scale(1.12) rotate(8deg); }
.color-swatch.selected {
transform: scale(1.15);
box-shadow: inset 0 0 0 2px #fff, inset 0 0 0 3px var(--ink), 0 4px 12px rgba(0,0,0,0.15);
}
.width-slider {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--paper-2);
border-radius: 3px;
outline: none;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.06);
}
.width-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 22px; height: 22px;
border-radius: 50%;
background: var(--ink);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
border: 2px solid #fff;
transition: transform var(--dur-fast) var(--spring);
}
.width-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
.width-slider::-moz-range-thumb {
width: 22px; height: 22px;
border-radius: 50%;
background: var(--ink);
cursor: pointer;
border: 2px solid #fff;
}
/* Layers */
.layer-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-md);
background: #fff;
margin-bottom: 8px;
transition: all var(--dur) var(--spring);
box-shadow: inset 0 0 0 1px var(--line), var(--shadow-sm);
cursor: pointer;
}
.layer-row:hover { transform: translateX(2px); box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-md); }
.layer-row.active {
box-shadow: inset 0 0 0 1.5px var(--ink), var(--shadow-md);
background: var(--paper);
}
.drag-handle {
color: rgba(14,14,20,0.25);
display: flex; align-items: center;
cursor: grab;
}
.drag-handle svg { width: 14px; height: 14px; }
.layer-thumb {
width: 36px; height: 36px;
border-radius: 8px;
background: var(--paper-2);
border: 1px solid var(--line);
flex-shrink: 0;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
.layer-thumb img { max-width: 80%; max-height: 80%; }
.layer-label {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: -0.01em;
}
.layer-label .type {
font-family: var(--serif);
font-style: italic;
font-size: 11px;
color: rgba(14,14,20,0.4);
font-weight: 400;
display: block;
margin-top: 1px;
}
.layer-row .mini-btn {
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
color: rgba(14,14,20,0.45);
border-radius: 8px;
transition: all var(--dur-fast) var(--ease-out);
}
.layer-row .mini-btn:hover { background: var(--paper-2); color: var(--ink); }
.layer-row .mini-btn:active { transform: scale(0.9); }
.layer-row .mini-btn svg { width: 15px; height: 15px; stroke-width: 1.75; }
.layer-empty {
text-align: center;
padding: 50px 20px;
color: rgba(14,14,20,0.4);
}
.layer-empty .mark {
font-family: var(--serif);
font-style: italic;
font-size: 56px;
color: var(--line-2);
line-height: 1;
margin-bottom: 14px;
}
.layer-empty .msg {
font-family: var(--serif);
font-size: 15px;
font-style: italic;
}
/* Photos */
.photo-tile {
aspect-ratio: 1;
border-radius: var(--radius-md);
display: flex;
align-items: flex-end;
padding: 12px;
color: rgba(255,255,255,0.95);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
transition: all var(--dur) var(--spring);
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.photo-tile:hover { transform: translateY(-2px) scale(1.02); box-shadow: var(--shadow-lg); }
.photo-tile:active { transform: translateY(0) scale(0.97); }
/* Templates */
.template-tile {
aspect-ratio: 3/4;
background: #fff;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 0 10px;
color: rgba(14,14,20,0.5);
box-shadow: inset 0 0 0 1px var(--line), var(--shadow-sm);
transition: all var(--dur) var(--spring);
position: relative;
overflow: hidden;
}
.template-tile:hover { transform: translateY(-2px); box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-md); }
.template-tile:active { transform: translateY(0) scale(0.97); }
.template-tile .preview {
width: 100%;
flex: 1;
border-radius: var(--radius-md) var(--radius-md) 0 0;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 10px;
}
.template-tile .num {
font-family: var(--serif);
font-style: italic;
font-size: 13px;
color: var(--ink);
margin-top: 2px;
}
</style>
</head>
<body>
<div class="stage">
<div class="device" id="device">
<div class="notch"></div>
<!-- TOP BAR -->
<div class="top-bar" role="toolbar" aria-label="Document toolbar">
<button class="icon-btn" aria-label="Back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<div class="doc-title-wrap">
<input class="doc-title" id="docTitle" value="Untitled composition" aria-label="Document title" />
</div>
<button class="icon-btn" id="undoBtn" aria-label="Undo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 015.5 5.5v0a5.5 5.5 0 01-5.5 5.5H11"/></svg>
</button>
<button class="icon-btn" id="redoBtn" aria-label="Redo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14l5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 004 14.5v0A5.5 5.5 0 009.5 20H13"/></svg>
</button>
<button class="share-btn" aria-label="Share"><span>Share</span></button>
</div>
<!-- CANVAS ZONE -->
<div class="canvas-zone" id="canvasZone">
<div class="canvas-host" id="canvasHost">
<canvas id="fabricCanvas"></canvas>
</div>
<div class="obj-toolbar" id="objToolbar" role="toolbar" aria-label="Object actions">
<button id="btnFront" aria-label="Bring to front">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V6a2 2 0 012-2h10"/></svg>
</button>
<button id="btnDup" aria-label="Duplicate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<button id="btnDel" aria-label="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg>
</button>
<div class="sep"></div>
<button id="btnFill" aria-label="Fill color" style="padding: 0 8px;">
<div class="fill-swatch" id="fillSwatch" style="background:#FF5B2E;"></div>
</button>
<input type="color" id="fillPicker" class="hidden-color" aria-label="Color picker" />
<div class="sep"></div>
<div class="opacity-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18"/></svg>
<input type="range" id="opacitySlider" min="0" max="100" value="100" aria-label="Opacity" />
</div>
</div>
<div class="zoom-pill" role="group" aria-label="Zoom">
<button id="zoomOut" aria-label="Zoom out">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round"><path d="M5 12h14"/></svg>
</button>
<div class="val" id="zoomVal">100%</div>
<button id="zoomIn" aria-label="Zoom in">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
<!-- BOTTOM TOOLBAR -->
<div class="bottom-bar" role="tablist" aria-label="Tools">
<button class="tab-btn" data-tab="elements" role="tab" aria-label="Shapes">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><circle cx="17" cy="6.5" r="3.5"/><path d="M3 14h7v7H3z"/><path d="M17 13.5l4.5 7.5h-9z"/></svg>
<span>Shapes</span>
</button>
<button class="tab-btn" data-tab="text" role="tab" aria-label="Type">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6V4h14v2"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
<span>Type</span>
</button>
<button class="tab-btn" data-tab="photos" role="tab" aria-label="Photos">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="1.5"/><path d="M21 16l-5-5L5 21"/></svg>
<span>Photos</span>
</button>
<button class="tab-btn" data-tab="templates" role="tab" aria-label="Layouts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
<span>Layouts</span>
</button>
<button class="tab-btn" data-tab="draw" role="tab" aria-label="Draw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21l3.5-1 13-13-2.5-2.5-13 13z"/><path d="M14 6l2.5 2.5"/><path d="M18 2l4 4"/></svg>
<span>Draw</span>
</button>
<button class="tab-btn" data-tab="layers" role="tab" aria-label="Layers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 8l10 6 10-6-10-6z"/><path d="M2 16l10 6 10-6"/><path d="M2 12l10 6 10-6"/></svg>
<span>Layers</span>
</button>
</div>
<div class="scrim" id="scrim"></div>
<!-- BOTTOM SHEET -->
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="sheetTitle">
<div class="sheet-handle"></div>
<div class="sheet-header">
<div class="sheet-title" id="sheetTitle">Shapes<em>— pick & place</em></div>
<button class="sheet-close" id="sheetClose" aria-label="Close panel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="sheet-body">
<div class="panel" data-panel="elements">
<div class="grid-3" id="elementsGrid"></div>
</div>
<div class="panel" data-panel="text">
<button class="text-preset h" data-size="96" data-weight="600" data-family="serif" data-italic="false">Add a headline</button>
<button class="text-preset sh" data-size="48" data-weight="400" data-family="serif" data-italic="true">a subtitle, softly</button>
<button class="text-preset body" data-size="28" data-weight="400" data-family="sans" data-italic="false">Body copy for the rest of your story.</button>
</div>
<div class="panel" data-panel="photos">
<div class="grid-3" id="photosGrid"></div>
</div>
<div class="panel" data-panel="templates">
<div class="grid-3" id="templatesGrid"></div>
</div>
<div class="panel" data-panel="draw">
<button class="draw-toggle" id="drawToggle">
<span>Free-hand pencil</span>
<span class="pill">Off</span>
</button>
<div class="draw-label">Ink <b>color</b></div>
<div class="color-row" id="colorRow"></div>
<div class="draw-label">Brush <b>weight</b> · <span id="widthVal">8</span>px</div>
<input type="range" class="width-slider" id="widthSlider" min="4" max="32" value="8" aria-label="Brush width" />
</div>
<div class="panel" data-panel="layers">
<div id="layersList"></div>
</div>
</div>
</div>
<div class="home-bar"></div>
</div>
</div>
<script>
(() => {
'use strict';
const PALETTE = ['#FF5B2E','#E8C547','#5B8DEF','#7AC74F','#D946EF','#0E0E14','#FF8FA3','#2DD4BF','#A78BFA','#FB923C','#14B8A6','#F472B6'];
const ARTBOARD_W = 1080;
const ARTBOARD_H = 1080;
const state = {
history: [], cursor: -1, suppressHistory: false,
zoom: 1, baseZoom: 1,
activeTab: null,
drawColor: '#FF5B2E', drawWidth: 8, drawOn: false,
};
const canvasEl = document.getElementById('fabricCanvas');
const canvas = new fabric.Canvas(canvasEl, {
preserveObjectStacking: true,
stopContextMenu: true,
fireRightClick: false,
enableRetinaScaling: true,
backgroundColor: '#FFFFFF',
selection: true,
});
fabric.Object.prototype.set({
borderColor: '#0E0E14',
cornerColor: '#FFFFFF',
cornerStrokeColor: '#0E0E14',
cornerSize: 10,
cornerStyle: 'circle',
transparentCorners: false,
borderScaleFactor: 1.5,
padding: 4,
});
const artboard = new fabric.Rect({
left: 0, top: 0, width: ARTBOARD_W, height: ARTBOARD_H,
fill: '#FFFFFF', selectable: false, evented: false, hoverCursor: 'default',
});
artboard.isArtboard = true;
canvas.add(artboard);
function resizeCanvas() {
const zone = document.getElementById('canvasZone');
const host = document.getElementById('canvasHost');
const pad = 32;
const maxW = zone.clientWidth - pad * 2;
const maxH = zone.clientHeight - pad * 2;
const size = Math.max(140, Math.min(maxW, maxH));
host.style.width = size + 'px';
host.style.height = size + 'px';
canvas.setDimensions({ width: size, height: size });
const zoom = size / ARTBOARD_W;
state.baseZoom = zoom;
state.zoom = zoom;
canvas.setZoom(zoom);
canvas.absolutePan({ x: 0, y: 0 });
updateZoomLabel();
updateFloatingToolbar();
}
window.addEventListener('resize', resizeCanvas);
function updateZoomLabel() {
const rel = state.baseZoom > 0 ? state.zoom / state.baseZoom : 1;
document.getElementById('zoomVal').textContent = Math.round(rel * 100) + '%';
}
function snapshot() {
if (state.suppressHistory) return;
const json = canvas.toJSON(['isArtboard']);
state.history = state.history.slice(0, state.cursor + 1);
state.history.push(json);
if (state.history.length > 30) state.history.shift();
state.cursor = state.history.length - 1;
updateUndoRedo();
}
function updateUndoRedo() {
document.getElementById('undoBtn').disabled = state.cursor <= 0;
document.getElementById('redoBtn').disabled = state.cursor >= state.history.length - 1;
}
function undo() {
if (state.cursor <= 0) return;
state.cursor--;
loadSnapshot(state.history[state.cursor]);
}
function redo() {
if (state.cursor >= state.history.length - 1) return;
state.cursor++;
loadSnapshot(state.history[state.cursor]);
}
function loadSnapshot(json) {
state.suppressHistory = true;
canvas.loadFromJSON(json, () => {
canvas.renderAll();
state.suppressHistory = false;
updateUndoRedo();
renderLayers();
});
}
canvas.on('object:added', snapshot);
canvas.on('object:modified', snapshot);
canvas.on('object:removed', snapshot);
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
const objToolbar = document.getElementById('objToolbar');
function updateFloatingToolbar() {
const obj = canvas.getActiveObject();
if (!obj || obj.isArtboard) { objToolbar.classList.remove('visible'); return; }
const rect = obj.getBoundingRect(true, true);
const host = document.getElementById('canvasHost');
const hostRect = host.getBoundingClientRect();
const zoneRect = document.getElementById('canvasZone').getBoundingClientRect();
const centerX = hostRect.left - zoneRect.left + rect.left + rect.width / 2;
let topY = hostRect.top - zoneRect.top + rect.top - 54;
if (topY < 8) topY = hostRect.top - zoneRect.top + rect.top + rect.height + 12;
const tbHalfW = 180;
const clampedX = Math.max(tbHalfW + 10, Math.min(zoneRect.width - tbHalfW - 10, centerX));
objToolbar.style.left = clampedX + 'px';
objToolbar.style.top = topY + 'px';
objToolbar.classList.add('visible');
const fill = (typeof obj.fill === 'string' && obj.fill) || obj.stroke || '#FF5B2E';
if (typeof fill === 'string') {
document.getElementById('fillSwatch').style.background = fill;
document.getElementById('fillPicker').value = toHex(fill);
}
document.getElementById('opacitySlider').value = Math.round((obj.opacity ?? 1) * 100);
}
function toHex(c) {
if (!c) return '#000000';
if (c.startsWith('#')) return c.length === 7 ? c : '#000000';
const m = c.match(/\d+/g);
if (!m) return '#000000';
return '#' + m.slice(0,3).map(n => (+n).toString(16).padStart(2,'0')).join('');
}
canvas.on('selection:created', updateFloatingToolbar);
canvas.on('selection:updated', updateFloatingToolbar);
canvas.on('selection:cleared', () => objToolbar.classList.remove('visible'));
canvas.on('object:moving', updateFloatingToolbar);
canvas.on('object:scaling', updateFloatingToolbar);
canvas.on('object:rotating', updateFloatingToolbar);
canvas.on('after:render', () => { if (canvas.getActiveObject()) updateFloatingToolbar(); });
document.getElementById('btnDel').addEventListener('click', () => {
const obj = canvas.getActiveObject();
if (obj && !obj.isArtboard) { canvas.remove(obj); canvas.discardActiveObject(); canvas.renderAll(); renderLayers(); }
});
document.getElementById('btnDup').addEventListener('click', () => {
const obj = canvas.getActiveObject();
if (!obj || obj.isArtboard) return;
obj.clone((clone) => {
clone.set({ left: (obj.left || 0) + 20, top: (obj.top || 0) + 20 });
canvas.add(clone);
canvas.setActiveObject(clone);
canvas.renderAll();
renderLayers();
});
});
document.getElementById('btnFront').addEventListener('click', () => {
const obj = canvas.getActiveObject();
if (obj && !obj.isArtboard) { canvas.bringToFront(obj); canvas.renderAll(); renderLayers(); snapshot(); }
});
document.getElementById('btnFill').addEventListener('click', () => document.getElementById('fillPicker').click());
document.getElementById('fillPicker').addEventListener('input', (e) => {
const obj = canvas.getActiveObject();
if (obj && !obj.isArtboard) {
if (obj.type === 'line' || obj.type === 'path') obj.set('stroke', e.target.value);
else obj.set('fill', e.target.value);
document.getElementById('fillSwatch').style.background = e.target.value;
canvas.renderAll();
}
});
document.getElementById('fillPicker').addEventListener('change', snapshot);
const opSlider = document.getElementById('opacitySlider');
opSlider.addEventListener('input', (e) => {
const obj = canvas.getActiveObject();
if (obj && !obj.isArtboard) { obj.set('opacity', e.target.value / 100); canvas.renderAll(); }
});
opSlider.addEventListener('change', snapshot);
const sheet = document.getElementById('sheet');
const scrim = document.getElementById('scrim');
const tabs = document.querySelectorAll('.tab-btn');
const TAB_META = {
elements: { title: 'Shapes', sub: '— pick & place' },
text: { title: 'Type', sub: '— set the tone' },
photos: { title: 'Photos', sub: '— drop them in' },
templates:{ title: 'Layouts', sub: '— start strong' },
draw: { title: 'Draw', sub: '— make your mark' },
layers: { title: 'Layers', sub: '— arrange & edit' },
};
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const name = tab.dataset.tab;
if (state.activeTab === name) { closeSheet(); return; }
openSheet(name);
});
});
function openSheet(name) {
state.activeTab = name;
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.panel').forEach(p => {
p.classList.remove('active');
if (p.dataset.panel === name) {
void p.offsetWidth;
p.classList.add('active');
}
});
const meta = TAB_META[name];
document.getElementById('sheetTitle').innerHTML = meta.title + '<em>' + meta.sub + '</em>';
sheet.classList.add('open');
scrim.classList.add('open');
if (name === 'layers') renderLayers();
}
function closeSheet() {
state.activeTab = null;
tabs.forEach(t => t.classList.remove('active'));
sheet.classList.remove('open');
scrim.classList.remove('open');
}
document.getElementById('sheetClose').addEventListener('click', closeSheet);
scrim.addEventListener('click', closeSheet);
// ============ ELEMENTS ============
const SHAPES = [
{ name: 'Rectangle', svg: '<rect x="6" y="12" width="32" height="20" rx="3" fill="#FF5B2E"/>' },
{ name: 'Circle', svg: '<circle cx="22" cy="22" r="14" fill="#5B8DEF"/>' },
{ name: 'Triangle', svg: '<path d="M22 7 L38 36 L6 36 Z" fill="#E8C547"/>' },
{ name: 'Star', svg: '<path d="M22 5 L26.5 17 L39 17.5 L29 25.5 L32.5 38 L22 31 L11.5 38 L15 25.5 L5 17.5 L17.5 17 Z" fill="#D946EF"/>' },
{ name: 'Line', svg: '<rect x="6" y="20" width="32" height="4" rx="2" fill="#0E0E14"/>' },
{ name: 'Arrow', svg: '<path d="M6 22 L32 22 M24 13 L34 22 L24 31" stroke="#7AC74F" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>' },
];
const elGrid = document.getElementById('elementsGrid');
SHAPES.forEach(s => {
const btn = document.createElement('button');
btn.className = 'tile';
btn.setAttribute('aria-label', `Add ${s.name}`);
btn.innerHTML = `<svg viewBox="0 0 44 44">${s.svg}</svg>`;
btn.addEventListener('click', () => addShape(s.name));
elGrid.appendChild(btn);
});
function randomColor() { return PALETTE[Math.floor(Math.random() * PALETTE.length)]; }
function addShape(type) {
const color = randomColor();
const center = { left: ARTBOARD_W/2, top: ARTBOARD_H/2 };
const common = { originX: 'center', originY: 'center', fill: color, ...center };
let obj;
switch(type) {
case 'Rectangle': obj = new fabric.Rect({ ...common, width: 340, height: 240, rx: 8, ry: 8 }); break;
case 'Circle': obj = new fabric.Circle({ ...common, radius: 140 }); break;
case 'Triangle': obj = new fabric.Triangle({ ...common, width: 300, height: 280 }); break;
case 'Star': {
const pts = []; const spikes = 5; const outer = 160, inner = 70;
for (let i = 0; i < spikes * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = (Math.PI / spikes) * i - Math.PI / 2;
pts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r });
}
obj = new fabric.Polygon(pts, { ...common });
break;
}
case 'Line':
obj = new fabric.Line([-170, 0, 170, 0], { ...common, stroke: color, strokeWidth: 12, strokeLineCap: 'round', fill: undefined });
break;
case 'Arrow': {
const path = 'M 0 0 L 220 0 M 180 -34 L 220 0 L 180 34';
obj = new fabric.Path(path, { ...common, fill: '', stroke: color, strokeWidth: 16, strokeLineCap: 'round', strokeLineJoin: 'round' });
break;
}
}
if (obj) {
canvas.add(obj);
canvas.setActiveObject(obj);
canvas.renderAll();
renderLayers();
}
}
// ============ TEXT ============
document.querySelectorAll('.text-preset').forEach(btn => {
btn.addEventListener('click', () => {
const size = parseInt(btn.dataset.size, 10);
const weight = btn.dataset.weight;
const family = btn.dataset.family === 'serif'
? '"Fraunces", Georgia, serif'
: '"General Sans", -apple-system, sans-serif';
const italic = btn.dataset.italic === 'true';
const text = btn.textContent.trim();
const it = new fabric.IText(text, {
left: ARTBOARD_W/2, top: ARTBOARD_H/2,
originX: 'center', originY: 'center',
fontFamily: family,
fontSize: size,
fontWeight: weight,
fontStyle: italic ? 'italic' : 'normal',
fill: '#0E0E14',
charSpacing: -20,
});
canvas.add(it);
canvas.setActiveObject(it);
it.enterEditing();
it.selectAll();
canvas.renderAll();
renderLayers();
closeSheet();
});
});
// ============ PHOTOS ============
const photosGrid = document.getElementById('photosGrid');
const photoGradients = [
{ from: '#FF5B2E', to: '#FFB088', label: 'Ember' },
{ from: '#5B8DEF', to: '#A5C8FF', label: 'Cerulean' },
{ from: '#0E0E14', to: '#4A4A5C', label: 'Obsidian' },
{ from: '#E8C547', to: '#F4E08A', label: 'Saffron' },
{ from: '#7AC74F', to: '#B9E89B', label: 'Moss' },
{ from: '#D946EF', to: '#F0A5FA', label: 'Orchid' },
];
photoGradients.forEach((p) => {
const b = document.createElement('button');
b.className = 'photo-tile';
b.style.background = `linear-gradient(135deg, ${p.from} 0%, ${p.to} 100%)`;
b.innerHTML = `<span>${p.label}</span>`;
b.setAttribute('aria-label', `Add ${p.label}`);
b.addEventListener('click', () => {
const rect = new fabric.Rect({
left: ARTBOARD_W/2, top: ARTBOARD_H/2,
originX: 'center', originY: 'center',
width: 500, height: 500, rx: 16, ry: 16,
});
rect.set('fill', new fabric.Gradient({
type: 'linear',
coords: { x1: 0, y1: 0, x2: 500, y2: 500 },
colorStops: [{ offset: 0, color: p.from }, { offset: 1, color: p.to }],
}));
canvas.add(rect); canvas.setActiveObject(rect); canvas.renderAll(); renderLayers();
closeSheet();
});
photosGrid.appendChild(b);
});
// ============ TEMPLATES ============
const tplGrid = document.getElementById('templatesGrid');
const TEMPLATES = [
{ n: 'i', bg: '#FF5B2E', accent: '#0E0E14' },
{ n: 'ii', bg: '#0E0E14', accent: '#E8C547' },
{ n: 'iii', bg: '#F7F5F0', accent: '#FF5B2E' },
{ n: 'iv', bg: '#5B8DEF', accent: '#F7F5F0' },
{ n: 'v', bg: '#E8C547', accent: '#0E0E14' },
{ n: 'vi', bg: '#D946EF', accent: '#F7F5F0' },
];
TEMPLATES.forEach(t => {
const b = document.createElement('button');
b.className = 'template-tile';
b.setAttribute('aria-label', `Template ${t.n}`);
b.innerHTML = `
<div class="preview" style="background:${t.bg};">
<div style="height:3px;width:60%;background:${t.accent};border-radius:2px;margin-bottom:4px;opacity:0.9;"></div>
<div style="height:2px;width:40%;background:${t.accent};border-radius:2px;opacity:0.6;"></div>
</div>
<div class="num">${t.n}</div>
`;
b.addEventListener('click', () => applyTemplate(t));
tplGrid.appendChild(b);
});
function applyTemplate(t) {
canvas.getObjects().slice().forEach(o => { if (!o.isArtboard) canvas.remove(o); });
artboard.set('fill', t.bg);
const title = new fabric.IText('Composition', {
left: ARTBOARD_W/2, top: ARTBOARD_H/2 - 60,
originX: 'center', originY: 'center',
fontFamily: '"Fraunces", Georgia, serif',
fontSize: 130, fontWeight: '600', fontStyle: 'italic',
fill: t.accent, charSpacing: -40,
});
const bar = new fabric.Rect({
left: ARTBOARD_W/2, top: ARTBOARD_H/2 + 20,
originX: 'center', originY: 'center',
width: 80, height: 3, fill: t.accent, opacity: 0.8,
});
const sub = new fabric.IText('NO. ' + t.n.toUpperCase(), {
left: ARTBOARD_W/2, top: ARTBOARD_H/2 + 70,
originX: 'center', originY: 'center',
fontFamily: '"General Sans", sans-serif',
fontSize: 28, fontWeight: '500',
fill: t.accent, charSpacing: 400, opacity: 0.7,
});
canvas.add(title, bar, sub);
canvas.renderAll();
renderLayers();
closeSheet();
}
// ============ DRAW ============
const drawToggle = document.getElementById('drawToggle');
drawToggle.addEventListener('click', () => {
state.drawOn = !state.drawOn;
canvas.isDrawingMode = state.drawOn;
if (state.drawOn) {
canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
canvas.freeDrawingBrush.color = state.drawColor;
canvas.freeDrawingBrush.width = state.drawWidth;
}
drawToggle.querySelector('.pill').textContent = state.drawOn ? 'On' : 'Off';
drawToggle.classList.toggle('on', state.drawOn);
});
const colorRow = document.getElementById('colorRow');
const DRAW_COLORS = ['#0E0E14','#FF5B2E','#E8C547','#5B8DEF','#7AC74F','#D946EF','#FB923C','#FFFFFF'];
DRAW_COLORS.forEach((c, idx) => {
const sw = document.createElement('button');
sw.className = 'color-swatch' + (idx === 1 ? ' selected' : '');
sw.style.background = c;
sw.setAttribute('aria-label', `Color ${c}`);
sw.addEventListener('click', () => {
state.drawColor = c;
document.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
sw.classList.add('selected');
if (canvas.freeDrawingBrush) canvas.freeDrawingBrush.color = c;
});
colorRow.appendChild(sw);
});
const widthSlider = document.getElementById('widthSlider');
widthSlider.addEventListener('input', (e) => {
state.drawWidth = +e.target.value;
document.getElementById('widthVal').textContent = state.drawWidth;
if (canvas.freeDrawingBrush) canvas.freeDrawingBrush.width = state.drawWidth;
});
// ============ LAYERS ============
function renderLayers() {
const list = document.getElementById('layersList');
list.innerHTML = '';
const objs = canvas.getObjects().filter(o => !o.isArtboard).slice().reverse();
if (!objs.length) {
list.innerHTML = `<div class="layer-empty"><div class="mark">∅</div><div class="msg">No layers — yet</div></div>`;
return;
}
const active = canvas.getActiveObject();
objs.forEach((obj) => {
const row = document.createElement('div');
row.className = 'layer-row' + (obj === active ? ' active' : '');
const drag = document.createElement('div');
drag.className = 'drag-handle';
drag.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.2"/><circle cx="9" cy="12" r="1.2"/><circle cx="9" cy="18" r="1.2"/><circle cx="15" cy="6" r="1.2"/><circle cx="15" cy="12" r="1.2"/><circle cx="15" cy="18" r="1.2"/></svg>';
const thumb = document.createElement('div');
thumb.className = 'layer-thumb';
try {
const data = obj.toDataURL({ format: 'png', multiplier: 0.1 });
const img = document.createElement('img');
img.src = data;
thumb.appendChild(img);
} catch(e) {}
const label = document.createElement('div');
label.className = 'layer-label';
label.innerHTML = `${escapeHtml(labelFor(obj))}<span class="type">${typeLabel(obj)}</span>`;
const vis = document.createElement('button');
vis.className = 'mini-btn';
vis.setAttribute('aria-label', 'Toggle visibility');
vis.innerHTML = obj.visible !== false
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/><path d="M1 1l22 22"/></svg>';
vis.addEventListener('click', (e) => {
e.stopPropagation();
obj.visible = obj.visible === false ? true : false;
canvas.renderAll();
renderLayers();
});
const del = document.createElement('button');
del.className = 'mini-btn';
del.setAttribute('aria-label', 'Delete layer');
del.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg>';
del.addEventListener('click', (e) => {
e.stopPropagation();
canvas.remove(obj); canvas.renderAll(); renderLayers();
});
row.appendChild(drag);
row.appendChild(thumb);
row.appendChild(label);
row.appendChild(vis);
row.appendChild(del);
row.addEventListener('click', () => {
canvas.setActiveObject(obj); canvas.renderAll(); renderLayers();
});
list.appendChild(row);
});
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function labelFor(obj) {
if (obj.type === 'i-text' || obj.type === 'text' || obj.type === 'textbox') {
return (obj.text || 'Text').substring(0, 22);
}
return typeLabel(obj);
}
function typeLabel(obj) {
const map = { rect: 'Rectangle', circle: 'Circle', triangle: 'Triangle', polygon: 'Polygon', line: 'Line', path: 'Drawing', image: 'Image', group: 'Group', 'i-text': 'Type', text: 'Type', textbox: 'Type' };
return map[obj.type] || obj.type;
}
// ============ ZOOM ============
document.getElementById('zoomIn').addEventListener('click', () => zoomBy(1.2));
document.getElementById('zoomOut').addEventListener('click', () => zoomBy(1/1.2));
function zoomBy(factor) {
const host = document.getElementById('canvasHost');
const w = host.clientWidth, h = host.clientHeight;
const point = new fabric.Point(w/2, h/2);
let newZoom = state.zoom * factor;
newZoom = Math.max(state.baseZoom * 0.5, Math.min(state.baseZoom * 5, newZoom));
canvas.zoomToPoint(point, newZoom);
state.zoom = newZoom;
updateZoomLabel();
updateFloatingToolbar();
}
// ============ PINCH / PAN ============
const zone = document.getElementById('canvasZone');
const pointers = new Map();
let pinchStart = null;
let panLast = null;
let rafPending = false;
let pendingAction = null;
zone.addEventListener('pointerdown', (e) => {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const pts = [...pointers.values()];
pinchStart = {
dist: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y),
zoom: state.zoom,
};
canvas.discardActiveObject();
canvas.renderAll();
}
}, { passive: true });
zone.addEventListener('pointermove', (e) => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2 && pinchStart) {
const pts = [...pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
const factor = dist / pinchStart.dist;
const host = document.getElementById('canvasHost');
const hostRect = host.getBoundingClientRect();
const centerX = (pts[0].x + pts[1].x)/2 - hostRect.left;
const centerY = (pts[0].y + pts[1].y)/2 - hostRect.top;
pendingAction = () => {
let newZoom = pinchStart.zoom * factor;
newZoom = Math.max(state.baseZoom * 0.5, Math.min(state.baseZoom * 5, newZoom));
canvas.zoomToPoint(new fabric.Point(centerX, centerY), newZoom);
state.zoom = newZoom;
updateZoomLabel();
};
scheduleRaf();
} else if (pointers.size === 1 && !canvas.getActiveObject() && !canvas.isDrawingMode) {
if (!panLast) { panLast = { x: e.clientX, y: e.clientY }; return; }
const dx = e.clientX - panLast.x;
const dy = e.clientY - panLast.y;
panLast = { x: e.clientX, y: e.clientY };
pendingAction = () => { canvas.relativePan(new fabric.Point(dx, dy)); };
scheduleRaf();
}
}, { passive: true });
function endPointer(e) {
pointers.delete(e.pointerId);
if (pointers.size < 2) pinchStart = null;
if (pointers.size === 0) panLast = null;
}
zone.addEventListener('pointerup', endPointer);
zone.addEventListener('pointercancel', endPointer);
zone.addEventListener('pointerleave', endPointer);
function scheduleRaf() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (pendingAction) { pendingAction(); pendingAction = null; }
});
}
// ============ SEED ============
function seed() {
state.suppressHistory = true;
const kicker = new fabric.IText('ISSUE Nº 01', {
left: ARTBOARD_W/2, top: 280,
originX: 'center', originY: 'center',
fontFamily: '"General Sans", sans-serif',
fontSize: 26, fontWeight: '600',
fill: '#FF5B2E', charSpacing: 600,
});
const heading = new fabric.IText('Design,\nslowly.', {
left: ARTBOARD_W/2, top: 500,
originX: 'center', originY: 'center',
fontFamily: '"Fraunces", Georgia, serif',
fontSize: 180, fontWeight: '600', fontStyle: 'italic',
fill: '#0E0E14', charSpacing: -60, textAlign: 'center', lineHeight: 0.9,
});
const rule = new fabric.Rect({
left: ARTBOARD_W/2, top: 720,
originX: 'center', originY: 'center',
width: 60, height: 2, fill: '#0E0E14',
});
const sub = new fabric.IText('a canvas for considered work', {
left: ARTBOARD_W/2, top: 780,
originX: 'center', originY: 'center',
fontFamily: '"Fraunces", Georgia, serif',
fontSize: 36, fontWeight: '400', fontStyle: 'italic',
fill: 'rgba(14,14,20,0.6)', charSpacing: 20,
});
const dot = new fabric.Circle({
left: ARTBOARD_W - 200, top: 200,
originX: 'center', originY: 'center',
radius: 60, fill: '#FF5B2E',
});
canvas.add(kicker, heading, rule, sub, dot);
canvas.renderAll();
state.suppressHistory = false;
snapshot();
}
resizeCanvas();
seed();
updateUndoRedo();
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
else if ((e.metaKey || e.ctrlKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); }
else if (e.key === 'Delete' || e.key === 'Backspace') {
const obj = canvas.getActiveObject();
if (obj && !obj.isArtboard && !obj.isEditing) { canvas.remove(obj); canvas.renderAll(); renderLayers(); }
} else if (e.key === 'Escape' && sheet.classList.contains('open')) {
closeSheet();
}
});
})();
</script>
</body>
</html>