fix: persist theme via html[data-theme] + theme.css custom properties

- Blocking <head> script restores canvas-theme from localStorage
  with prefers-color-scheme fallback (no FOUC)
- New theme.css with CSS custom properties for dark/light
- Removed data-theme from body/header/tab-row (now on <html>)
- Theme toggle writes to documentElement instead of body

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 11:56:51 -08:00
parent b77fb30001
commit 8bd899d146
3 changed files with 207 additions and 15 deletions

View File

@ -470,7 +470,9 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/shell.css?v=6">
<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>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<style>
html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; }
@ -478,9 +480,8 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
</style>
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
</head>
<body data-theme="${theme}">
<script>(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.body.setAttribute('data-theme',t)}catch(e){}})()</script>
<header class="rstack-header" data-theme="${theme}">
<body>
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
@ -494,7 +495,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
<rstack-identity></rstack-identity>
</div>
</header>
<div class="rstack-tab-row" data-theme="${theme}">
<div class="rstack-tab-row">
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
</div>
<div class="rspace-iframe-wrap">
@ -514,8 +515,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
</div>
<script type="module">
import '/shell.js?v=6';
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
import '/shell.js?v=7';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
const tabBar = document.querySelector('rstack-tab-bar');
@ -723,13 +723,14 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${mod.icon}</text></svg>">
<title>${escapeHtml(mod.name)} rSpace</title>
<link rel="stylesheet" href="/shell.css?v=6">
<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>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
${cssBlock}
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
<body data-theme="${theme}">
<script>(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.body.setAttribute('data-theme',t)}catch(e){}})()</script>
<header class="rstack-header" data-theme="${theme}">
<body>
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(mod.id)}"></rstack-app-switcher>
@ -744,8 +745,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</header>
${bodyContent}
<script type="module">
import '/shell.js?v=6';
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
import '/shell.js?v=7';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn');

View File

@ -1266,8 +1266,7 @@ export class RStackIdentity extends HTMLElement {
e.stopPropagation();
const newTheme = themeToggle.checked ? "dark" : "light";
localStorage.setItem("canvas-theme", newTheme);
document.body.setAttribute("data-theme", newTheme);
document.querySelectorAll(".rstack-header, .rstack-tab-row").forEach(el => el.setAttribute("data-theme", newTheme));
document.documentElement.setAttribute("data-theme", newTheme);
this.dispatchEvent(new CustomEvent("theme-change", { bubbles: true, composed: true, detail: { theme: newTheme } }));
this.#render();
});

193
website/public/theme.css Normal file
View File

@ -0,0 +1,193 @@
/* rSpace Theme System
* CSS custom properties for dark/light theming.
* Dark is default on :root; light overrides on [data-theme="light"].
* prefers-color-scheme fallback applies when no data-theme is set yet. */
:root {
color-scheme: dark;
/* Surface */
--rs-bg-page: #0f172a;
--rs-bg-surface: #1e293b;
--rs-bg-surface-raised: #334155;
--rs-bg-surface-sunken: #0f172a;
--rs-bg-overlay: rgba(15, 23, 42, 0.85);
--rs-bg-hover: rgba(255, 255, 255, 0.05);
--rs-bg-active: rgba(6, 182, 212, 0.1);
/* Text */
--rs-text-primary: #e2e8f0;
--rs-text-secondary: #94a3b8;
--rs-text-muted: #64748b;
--rs-text-inverse: #0f172a;
/* Borders */
--rs-border: rgba(255, 255, 255, 0.1);
--rs-border-subtle: rgba(255, 255, 255, 0.06);
--rs-border-strong: rgba(255, 255, 255, 0.2);
/* Glass */
--rs-glass-bg: rgba(15, 23, 42, 0.85);
--rs-glass-border: rgba(255, 255, 255, 0.08);
/* Shadows */
--rs-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--rs-shadow-md: 0 4px 20px rgba(0, 0, 0, 0.4);
--rs-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
/* Spinner */
--rs-spinner-track: rgba(255, 255, 255, 0.1);
--rs-spinner-head: #14b8a6;
/* Accent (same both themes) */
--rs-accent: #14b8a6;
--rs-accent-hover: #0d9488;
/* Primary (same both themes) */
--rs-primary: #4f46e5;
--rs-primary-hover: #6366f1;
/* Semantic (same both themes) */
--rs-error: #ef4444;
--rs-success: #22c55e;
--rs-warning: #fbbf24;
/* Gradients (same both themes) */
--rs-gradient-brand: linear-gradient(135deg, #14b8a6, #22d3ee);
--rs-gradient-primary: linear-gradient(135deg, #6366f1, #4f46e5);
--rs-gradient-cta: linear-gradient(135deg, #14b8a6, #0d9488);
/* Component-specific tokens */
--rs-input-bg: #0f172a;
--rs-input-border: #334155;
--rs-input-text: #e2e8f0;
--rs-btn-secondary-bg: rgba(255, 255, 255, 0.08);
--rs-btn-secondary-text: #94a3b8;
--rs-card-bg: rgba(255, 255, 255, 0.03);
--rs-card-border: rgba(255, 255, 255, 0.06);
/* Canvas */
--rs-canvas-bg: #0f172a;
--rs-canvas-grid: rgba(255, 255, 255, 0.04);
/* Toolbar */
--rs-toolbar-bg: #1e293b;
--rs-toolbar-btn-bg: #334155;
--rs-toolbar-btn-hover: #475569;
--rs-toolbar-btn-text: #e2e8f0;
--rs-toolbar-sep: #334155;
--rs-toolbar-panel-bg: #1e293b;
--rs-toolbar-panel-border: #334155;
}
[data-theme="light"] {
color-scheme: light;
/* Surface */
--rs-bg-page: #f8fafc;
--rs-bg-surface: #ffffff;
--rs-bg-surface-raised: #f1f5f9;
--rs-bg-surface-sunken: #f8fafc;
--rs-bg-overlay: rgba(255, 255, 255, 0.9);
--rs-bg-hover: rgba(0, 0, 0, 0.04);
--rs-bg-active: #e0f2fe;
/* Text */
--rs-text-primary: #0f172a;
--rs-text-secondary: #374151;
--rs-text-muted: #64748b;
--rs-text-inverse: #e2e8f0;
/* Borders */
--rs-border: rgba(0, 0, 0, 0.1);
--rs-border-subtle: rgba(0, 0, 0, 0.06);
--rs-border-strong: rgba(0, 0, 0, 0.2);
/* Glass */
--rs-glass-bg: rgba(255, 255, 255, 0.9);
--rs-glass-border: rgba(0, 0, 0, 0.08);
/* Shadows */
--rs-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--rs-shadow-md: 0 4px 20px rgba(0, 0, 0, 0.15);
--rs-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.18);
/* Spinner */
--rs-spinner-track: rgba(0, 0, 0, 0.08);
--rs-spinner-head: #14b8a6;
/* Component-specific tokens */
--rs-input-bg: #f8fafc;
--rs-input-border: #e2e8f0;
--rs-input-text: #334155;
--rs-btn-secondary-bg: rgba(0, 0, 0, 0.05);
--rs-btn-secondary-text: #374151;
--rs-card-bg: rgba(0, 0, 0, 0.02);
--rs-card-border: rgba(0, 0, 0, 0.06);
/* Canvas */
--rs-canvas-bg: #ffffff;
--rs-canvas-grid: #f1f5f9;
/* Toolbar */
--rs-toolbar-bg: #ffffff;
--rs-toolbar-btn-bg: #f1f5f9;
--rs-toolbar-btn-hover: #e2e8f0;
--rs-toolbar-btn-text: #0f172a;
--rs-toolbar-sep: #e2e8f0;
--rs-toolbar-panel-bg: #ffffff;
--rs-toolbar-panel-border: #e2e8f0;
}
/* prefers-color-scheme fallback: if JS hasn't set data-theme yet */
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
color-scheme: light;
--rs-bg-page: #f8fafc;
--rs-bg-surface: #ffffff;
--rs-bg-surface-raised: #f1f5f9;
--rs-bg-surface-sunken: #f8fafc;
--rs-bg-overlay: rgba(255, 255, 255, 0.9);
--rs-bg-hover: rgba(0, 0, 0, 0.04);
--rs-bg-active: #e0f2fe;
--rs-text-primary: #0f172a;
--rs-text-secondary: #374151;
--rs-text-muted: #64748b;
--rs-text-inverse: #e2e8f0;
--rs-border: rgba(0, 0, 0, 0.1);
--rs-border-subtle: rgba(0, 0, 0, 0.06);
--rs-border-strong: rgba(0, 0, 0, 0.2);
--rs-glass-bg: rgba(255, 255, 255, 0.9);
--rs-glass-border: rgba(0, 0, 0, 0.08);
--rs-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--rs-shadow-md: 0 4px 20px rgba(0, 0, 0, 0.15);
--rs-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.18);
--rs-spinner-track: rgba(0, 0, 0, 0.08);
--rs-spinner-head: #14b8a6;
--rs-input-bg: #f8fafc;
--rs-input-border: #e2e8f0;
--rs-input-text: #334155;
--rs-btn-secondary-bg: rgba(0, 0, 0, 0.05);
--rs-btn-secondary-text: #374151;
--rs-card-bg: rgba(0, 0, 0, 0.02);
--rs-card-border: rgba(0, 0, 0, 0.06);
--rs-canvas-bg: #ffffff;
--rs-canvas-grid: #f1f5f9;
--rs-toolbar-bg: #ffffff;
--rs-toolbar-btn-bg: #f1f5f9;
--rs-toolbar-btn-hover: #e2e8f0;
--rs-toolbar-btn-text: #0f172a;
--rs-toolbar-sep: #e2e8f0;
--rs-toolbar-panel-bg: #ffffff;
--rs-toolbar-panel-border: #e2e8f0;
}
}