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"> <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>"> <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> <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> <style>
html.rspace-embedded .rstack-header { display: none !important; } html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; } html.rspace-embedded .rstack-tab-row { display: none !important; }
@ -478,9 +480,8 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
</style> </style>
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script> <script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
</head> </head>
<body data-theme="${theme}"> <body>
<script>(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.body.setAttribute('data-theme',t)}catch(e){}})()</script> <header class="rstack-header">
<header class="rstack-header" data-theme="${theme}">
<div class="rstack-header__left"> <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> <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> <rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
@ -494,7 +495,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
<rstack-identity></rstack-identity> <rstack-identity></rstack-identity>
</div> </div>
</header> </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> <rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
</div> </div>
<div class="rspace-iframe-wrap"> <div class="rspace-iframe-wrap">
@ -514,8 +515,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
</div> </div>
<script type="module"> <script type="module">
import '/shell.js?v=6'; import '/shell.js?v=7';
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
const tabBar = document.querySelector('rstack-tab-bar'); 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"> <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>"> <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> <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} ${cssBlock}
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script> <script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head> </head>
<body data-theme="${theme}"> <body>
<script>(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.body.setAttribute('data-theme',t)}catch(e){}})()</script> <header class="rstack-header">
<header class="rstack-header" data-theme="${theme}">
<div class="rstack-header__left"> <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> <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> <rstack-app-switcher current="${escapeAttr(mod.id)}"></rstack-app-switcher>
@ -744,8 +745,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</header> </header>
${bodyContent} ${bodyContent}
<script type="module"> <script type="module">
import '/shell.js?v=6'; import '/shell.js?v=7';
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() { function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn'); var btn = document.querySelector('.rstack-header__demo-btn');

View File

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