JSON canvas initial

This commit is contained in:
2026-09-17 17:07:04 +02:00
parent b14941b3e0
commit 7797d4a456
22 changed files with 3277 additions and 19 deletions
+327
View File
@@ -26,6 +26,17 @@
--danger-hover: #d03467;
--overlay: rgba(0, 0, 0, 0.55);
/* JSON Canvas preset colours "1".."6" (red, orange, yellow, green, cyan,
purple), pulled toward the app palette so a canvas does not look like a
different program. Hex colours in a .canvas file pass through untouched. */
--canvas-1: #e05555;
--canvas-2: #e08a3c;
--canvas-3: #d9b03c;
--canvas-4: #5aab64;
--canvas-5: #01b6c4;
--canvas-6: #a069c4;
--canvas-edge: #9a9a9a;
--border: 1px solid var(--secondary);
--border-dashed: 1px dashed var(--secondary);
--border-guide: 1px solid var(--bg-panel-hover);
@@ -891,3 +902,319 @@ aside.sidebar:empty { display: none; }
.list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; }
.toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);}
}
/* === Canvas ===
A canvas is an alternative view of a page (see canvas.go). The tab strip is
shared with the normal page template; everything below it only exists in
canvas mode. */
/* --- view switcher (rendered on normal pages too) --- */
.canvas-tabs {
gap: 0;
flex-wrap: wrap;
border-bottom: var(--border-dashed);
margin-bottom: var(--space-4);
}
.canvas-tab {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
color: var(--text-muted);
background: none;
border: 1px solid transparent;
border-bottom: none;
/* Overlap the strip's own border so the active tab joins it. */
margin-bottom: -1px;
font: inherit;
font-size: var(--font-sm);
cursor: pointer;
}
.canvas-tab:hover { color: var(--link-hover); }
.canvas-tab.is-active {
color: var(--text);
border-color: var(--secondary);
background: var(--bg-panel);
}
.canvas-tab-add { color: var(--secondary); }
/* --- full-bleed view mode ---
The canvas owns the viewport instead of the centred reading column: main
loses its max-width and padding, and the centre column stops scrolling so the
surface can handle pan itself. */
body.canvas-mode .center { overflow: hidden; }
body.canvas-mode main {
max-width: none;
padding: 0;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
body.canvas-mode footer { max-width: none; }
body.canvas-mode .canvas-tabs { margin: 0; padding: var(--space-2) var(--space-4) 0; }
.canvas-app {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.canvas-toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
padding: 0.4rem 0.6rem;
background: var(--bg-panel-hover);
border-top: var(--border);
border-bottom: var(--border);
flex: 0 0 auto;
}
.canvas-status { margin-left: auto; padding-left: var(--space-3); }
.canvas-swatch {
display: inline-block;
width: 0.7em;
height: 0.7em;
vertical-align: middle;
margin-right: 0.3em;
border: 1px solid var(--bg);
}
.canvas-swatch[data-swatch="1"] { background: var(--canvas-1); }
.canvas-swatch[data-swatch="2"] { background: var(--canvas-2); }
.canvas-swatch[data-swatch="3"] { background: var(--canvas-3); }
.canvas-swatch[data-swatch="4"] { background: var(--canvas-4); }
.canvas-swatch[data-swatch="5"] { background: var(--canvas-5); }
.canvas-swatch[data-swatch="6"] { background: var(--canvas-6); }
/* --- surface, world, layers ---
touch-action:none hands every touch gesture to the pointer handlers; without
it the browser claims one-finger drags for scrolling and two-finger for page
zoom, and neither reaches the canvas. */
.canvas-surface {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
touch-action: none;
outline: none;
cursor: grab;
background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1px);
background-repeat: repeat;
}
.canvas-surface.is-grid-hidden { background-image: none; }
/* The world is a zero-size anchor: all of its content is absolutely positioned
in world coordinates and the whole thing is moved by one transform, so pan
and zoom never touch a single node's layout. */
.canvas-world {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
transform-origin: 0 0;
will-change: transform;
}
.canvas-layer { position: absolute; top: 0; left: 0; }
/* Drawn in world coordinates with no viewport of its own; overflow:visible is
what lets it paint outside its 1x1 box, including at negative coordinates. */
.canvas-edges {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
overflow: visible;
}
/* --- nodes --- */
.canvas-node {
position: absolute;
display: flex;
flex-direction: column;
border: 1px solid var(--node-accent, var(--secondary));
background: var(--bg-panel);
/* Visible so the connection ports and resize handles, which sit on the
border, are not clipped; the body clips its own content instead. */
overflow: visible;
contain: layout paint;
}
.canvas-node[data-color="1"] { --node-accent: var(--canvas-1); }
.canvas-node[data-color="2"] { --node-accent: var(--canvas-2); }
.canvas-node[data-color="3"] { --node-accent: var(--canvas-3); }
.canvas-node[data-color="4"] { --node-accent: var(--canvas-4); }
.canvas-node[data-color="5"] { --node-accent: var(--canvas-5); }
.canvas-node[data-color="6"] { --node-accent: var(--canvas-6); }
.canvas-node.is-selected { outline: 2px solid var(--primary-hover); outline-offset: 1px; }
.canvas-node.is-edge-target { outline: 2px dashed var(--link); outline-offset: 1px; }
.canvas-node-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--space-3);
font-size: var(--font-sm);
}
.canvas-node-body.content { margin-bottom: 0; }
.canvas-node-body > :first-child { margin-top: 0; }
.canvas-node-body > :last-child { margin-bottom: 0; }
.canvas-node-body h1 { font-size: 1.1rem; }
.canvas-node-body h2 { font-size: 1rem; }
.canvas-node-body h3 { font-size: 0.95rem; }
/* Task checkboxes are rendered enabled for pages, where page/tasks.js writes
the toggle back to index.md. Inside a canvas node there is no such wiring and
the markdown lives in the .canvas file, so a click would be a lie. */
.canvas-node-body .task-checkbox { pointer-events: none; }
.canvas-node-title {
flex: 0 0 auto;
padding: var(--space-1) var(--space-3);
font-size: var(--font-xs);
color: var(--text-muted);
border-bottom: var(--border-dashed);
}
/* A transcluded image or player fills its node edge to edge. */
.canvas-node-body:has(> .canvas-media) { padding: 0; overflow: hidden; }
.canvas-media { display: block; width: 100%; height: 100%; }
.canvas-media img { display: block; width: 100%; height: 100%; object-fit: contain; }
.canvas-broken { color: var(--danger-hover); }
.canvas-file-fallback { padding: var(--space-2) 0; }
.canvas-link-card {
display: flex;
flex-direction: column;
gap: var(--space-1);
align-items: flex-start;
max-width: 100%;
}
.canvas-link-host { color: var(--link); }
.canvas-link-url { font-size: var(--font-xs); color: var(--text-muted); max-width: 100%; }
.link-favicon { vertical-align: middle; margin-right: 3px; }
.canvas-node-group {
background: rgba(255, 255, 255, 0.03);
border: 1px dashed var(--node-accent, var(--bg-panel-hover));
background-position: center;
}
.canvas-node-group[data-bg-style="cover"] { background-size: cover; }
.canvas-node-group[data-bg-style="ratio"] { background-size: contain; background-repeat: no-repeat; }
.canvas-node-group[data-bg-style="repeat"] { background-repeat: repeat; }
.canvas-node-group .canvas-node-body { display: none; }
.canvas-group-label {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
color: var(--text-muted);
}
/* --- ports and handles ---
Both are positioned on the node border (the negative margin centres them on
it) and stay invisible until the node is hovered or selected, so a dense
canvas is not covered in dots. */
.canvas-port {
position: absolute;
width: 12px;
height: 12px;
margin: -6px;
border-radius: 50%;
background: var(--bg);
border: 1px solid var(--secondary);
opacity: 0;
cursor: crosshair;
}
.canvas-node:hover .canvas-port,
.canvas-node.is-selected .canvas-port { opacity: 1; }
.canvas-port:hover { background: var(--primary-hover); }
.canvas-port-top { top: 0; left: 50%; }
.canvas-port-bottom { top: 100%; left: 50%; }
.canvas-port-left { top: 50%; left: 0; }
.canvas-port-right { top: 50%; left: 100%; }
.canvas-handle {
position: absolute;
width: 10px;
height: 10px;
margin: -5px;
background: var(--primary-hover);
opacity: 0;
}
.canvas-node.is-selected .canvas-handle { opacity: 1; }
.canvas-handle-nw { top: 0; left: 0; cursor: nwse-resize; }
.canvas-handle-ne { top: 0; left: 100%; cursor: nesw-resize; }
.canvas-handle-se { top: 100%; left: 100%; cursor: nwse-resize; }
.canvas-handle-sw { top: 100%; left: 0; cursor: nesw-resize; }
/* --- edges ---
The hit path is a fat transparent stroke under the visible curve: a 2px
bezier is effectively unclickable without it. */
.canvas-edge-hit {
stroke: transparent;
stroke-width: 16;
pointer-events: stroke;
cursor: pointer;
}
.canvas-edge-line { stroke-width: 2; pointer-events: none; }
.canvas-edge-head { pointer-events: none; }
.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); stroke-width: 3; }
.canvas-edge.is-selected .canvas-edge-head { fill: var(--primary-hover); }
.canvas-edge-label {
font-family: "Iosevka Etoile", monospace;
font-size: 12px;
fill: var(--text);
/* A stroke painted under the fill gives the label a halo against whatever
it overlaps, without measuring a background rect. */
stroke: var(--bg);
stroke-width: 4;
paint-order: stroke;
pointer-events: none;
}
.canvas-edge-temp {
stroke: var(--primary-hover);
stroke-width: 2;
stroke-dasharray: 6 4;
pointer-events: none;
}
/* --- overlays --- */
.canvas-marquee {
position: absolute;
border: 1px dashed var(--primary-hover);
background: rgba(214, 77, 149, 0.12);
pointer-events: none;
}
.canvas-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 30rem;
padding: var(--space-4);
text-align: center;
pointer-events: none;
}
/* The node editor is fixed to the viewport rather than mounted in the zoomed
world: CodeMirror measures itself in device pixels and a scaled ancestor
breaks its caret and selection maths. */
.canvas-editor {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
}
.canvas-editor .panel-header { padding: var(--space-2) var(--space-3); margin-bottom: 0; }
.canvas-editor-mount { flex: 1 1 auto; min-height: 0; overflow: auto; }
.canvas-editor-mount .cm-editor { height: 100%; }
/* The shared editor theme sets a 60vh floor for the full-page editor; in a
node-sized panel that would force a scrollbar at every size. The theme is
injected at runtime and lands after this sheet, so it needs overriding. */
.canvas-editor-mount .cm-scroller { min-height: 0 !important; }
@media (max-width: 600px) {
.canvas-toolbar { gap: var(--space-2); padding: var(--space-2); }
.canvas-toolbar .btn-tool { padding: 0.2rem 0.4rem; }
body.canvas-mode .canvas-tabs { padding: var(--space-2) var(--space-2) 0; }
/* Touch has no hover, so ports would never appear; show them on selection
only and make both targets finger-sized. */
.canvas-port { width: 16px; height: 16px; margin: -8px; }
.canvas-handle { width: 14px; height: 14px; margin: -7px; }
}