feat: canvas background selector — grid, dot, or blank preference

Add user-selectable canvas background style via data-canvas-bg attribute
and CSS custom properties (--rs-canvas-bg-image, --rs-canvas-bg-size).
Three options: grid (default), dot, blank — persisted in localStorage.

- theme.css: new tokens + [data-canvas-bg] selectors
- rstack-identity.ts: Grid/Dot/Blank selector in user dropdown
- canvas.html: CSS vars, zoom-aware scaling, canvas-bg-change listener
- flows.css: use shared bg-image/bg-size vars (fixes rFlows theme bug)
- FOUC prevention in all entry points (shell.ts, create-space.html)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 18:37:50 -07:00
parent 72100c0922
commit 8072b250ea
6 changed files with 81 additions and 15 deletions

View File

@ -241,8 +241,8 @@
width: 100%; height: 100%; display: block;
cursor: grab;
background-color: var(--rs-canvas-bg);
background-image: radial-gradient(circle, var(--rs-canvas-grid) 1px, transparent 1px);
background-size: 20px 20px;
background-image: var(--rs-canvas-bg-image);
background-size: var(--rs-canvas-bg-size);
}
.flows-canvas-svg.panning { cursor: grabbing; }
.flows-canvas-svg.dragging { cursor: move; }

View File

@ -111,7 +111,7 @@ export function renderShell(opts: ShellOptions): string {
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>
@ -753,7 +753,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.png">
<title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>
@ -1013,7 +1013,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(mod.name)} rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
${cssBlock}
@ -1355,7 +1355,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(subPage.title)} ${escapeHtml(mod.name)} | rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>${MODULE_LANDING_CSS}</style>

View File

@ -362,6 +362,14 @@ export class RStackIdentity extends HTMLElement {
</label>
<span class="theme-icon">🌙</span>
</div>
<div class="dropdown-canvas-row">
<span class="canvas-label">Canvas</span>
<div class="canvas-options" id="canvas-bg-options">
<button class="canvas-opt${(localStorage.getItem("canvas-bg") || "grid") === "grid" ? " active" : ""}" data-bg="grid">Grid</button>
<button class="canvas-opt${localStorage.getItem("canvas-bg") === "dot" ? " active" : ""}" data-bg="dot">Dot</button>
<button class="canvas-opt${localStorage.getItem("canvas-bg") === "blank" ? " active" : ""}" data-bg="blank">Blank</button>
</div>
</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
</div>
@ -406,6 +414,22 @@ export class RStackIdentity extends HTMLElement {
this.dispatchEvent(new CustomEvent("theme-change", { bubbles: true, composed: true, detail: { theme: newTheme } }));
});
}
// Canvas background style selector
const canvasBgOptions = this.#shadow.getElementById("canvas-bg-options");
if (canvasBgOptions) {
canvasBgOptions.addEventListener("click", (e) => {
e.stopPropagation();
const btn = (e.target as HTMLElement).closest("[data-bg]") as HTMLElement | null;
if (!btn) return;
const bg = btn.dataset.bg!;
localStorage.setItem("canvas-bg", bg);
document.documentElement.setAttribute("data-canvas-bg", bg);
canvasBgOptions.querySelectorAll(".canvas-opt").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
window.dispatchEvent(new Event("canvas-bg-change"));
});
}
} else {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
@ -1402,6 +1426,28 @@ const STYLES = `
.theme-toggle input:checked + .theme-slider { background: #6366f1; }
.theme-toggle input:checked + .theme-slider::before { transform: translateX(18px); }
/* Canvas background selector in dropdown */
.dropdown-canvas-row {
display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 6px 16px;
}
.canvas-label {
font-size: 0.7rem; font-weight: 600; opacity: 0.5;
text-transform: uppercase; letter-spacing: 0.04em;
}
.canvas-options { display: flex; gap: 4px; }
.canvas-opt {
font-size: 0.65rem; font-weight: 600; padding: 3px 8px;
border: 1px solid var(--rs-border); border-radius: 6px;
background: transparent; color: var(--rs-text-secondary);
cursor: pointer; transition: all 0.15s;
}
.canvas-opt:hover { background: var(--rs-bg-hover); }
.canvas-opt.active {
background: var(--rs-accent); color: white;
border-color: var(--rs-accent);
}
/* Avatar wrapper + notification badge */
.avatar-wrap { position: relative; }
.notif-badge {

View File

@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>rSpace Canvas</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
<link rel="stylesheet" href="/theme.css?v=1" />
<style>
/* When loaded inside an iframe, hide shell chrome */
@ -972,10 +972,8 @@
width: 100%;
height: 100%;
background-color: var(--rs-canvas-bg);
background-image:
linear-gradient(var(--rs-canvas-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--rs-canvas-grid) 1px, transparent 1px);
background-size: 20px 20px;
background-image: var(--rs-canvas-bg-image);
background-size: var(--rs-canvas-bg-size);
background-position: -1px -1px;
position: relative;
touch-action: none; /* Prevent browser gestures, handle manually */
@ -5414,9 +5412,12 @@
function updateCanvasTransform() {
// Transform only the content layer — canvas viewport stays fixed
canvasContent.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
// Adjust grid to track pan/zoom so it appears infinite
const gridSize = 20 * scale;
canvas.style.backgroundSize = `${gridSize}px ${gridSize}px`;
// Adjust grid/dot pattern to track pan/zoom (skip for blank)
const bgStyle = document.documentElement.getAttribute('data-canvas-bg') || 'grid';
if (bgStyle !== 'blank') {
const gridSize = 20 * scale;
canvas.style.backgroundSize = `${gridSize}px ${gridSize}px`;
}
canvas.style.backgroundPosition = `${panX - 1}px ${panY - 1}px`;
// Keep MI bridge in sync
__miCanvasBridge.setViewport(panX, panY, scale);
@ -5430,6 +5431,9 @@
presence.setCamera(panX, panY, scale);
}
// Re-render canvas background when user changes preference
window.addEventListener("canvas-bg-change", () => updateCanvasTransform());
document.getElementById("zoom-in").addEventListener("click", () => {
scale = Math.min(scale * 1.1, maxScale);
updateCanvasTransform();

View File

@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Create a Space — rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
<link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css">
<style>

View File

@ -69,6 +69,10 @@
/* Canvas */
--rs-canvas-bg: #0f172a;
--rs-canvas-grid: rgba(255, 255, 255, 0.04);
--rs-canvas-dot: rgba(255, 255, 255, 0.06);
--rs-canvas-bg-image: linear-gradient(var(--rs-canvas-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--rs-canvas-grid) 1px, transparent 1px);
--rs-canvas-bg-size: 20px 20px;
/* Toolbar */
--rs-toolbar-bg: #1e293b;
@ -128,6 +132,7 @@
/* Canvas */
--rs-canvas-bg: #fafaf7;
--rs-canvas-grid: #f1f5f9;
--rs-canvas-dot: #e2e8f0;
/* Toolbar */
--rs-toolbar-bg: #fafaf7;
@ -181,6 +186,7 @@
--rs-canvas-bg: #fafaf7;
--rs-canvas-grid: #f1f5f9;
--rs-canvas-dot: #e2e8f0;
--rs-toolbar-bg: #fafaf7;
--rs-toolbar-btn-bg: #f0efe9;
@ -191,3 +197,13 @@
--rs-toolbar-panel-border: #e2e8f0;
}
}
/* ── Canvas background style overrides ── */
[data-canvas-bg="dot"] {
--rs-canvas-bg-image: radial-gradient(circle, var(--rs-canvas-dot) 1px, transparent 1px);
--rs-canvas-bg-size: 20px 20px;
}
[data-canvas-bg="blank"] {
--rs-canvas-bg-image: none;
--rs-canvas-bg-size: auto;
}