diff --git a/client/nginx.conf b/client/nginx.conf index 23d8a82..da61b30 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -74,12 +74,14 @@ server { # Theme stylesheet plugins link as /index.css (see the plugin development # guide). Exact match, so it wins over the static-asset regex without # shadowing the hashed stylesheets under /assets/. + # + # Served from this image, not proxied to the backend. The backend builds with + # server/ as its Docker context, so it never contains client/src/index.css + # and answered every one of these requests with a 500. This is a client + # asset; it lives where the other client assets live. location = /index.css { - proxy_pass http://${BACKEND_SERVICE}:${BACKEND_PORT}/index.css; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + try_files $uri =404; + add_header Cache-Control "no-cache"; } # Cache static assets from build - but NOT from /Uploads/ or /uploads/ diff --git a/client/vite.config.js b/client/vite.config.js index d077266..1f0c7f6 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -1,6 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +const INDEX_CSS = fileURLToPath(new URL('./src/index.css', import.meta.url)); + +// Plugins run in iframes and link `/index.css` for the theme variables — the +// plugin development guide documents it. Vite bundles src/index.css into a +// hashed asset, so nothing answers that path; emit an unhashed copy at the site +// root for nginx to serve. +// +// Emitted from src/index.css rather than kept as a second file in public/, so +// there is one source and the copy cannot drift from it. +// +// Build only, deliberately. A plugin iframe is loaded from API_BASE_URL, so its +// `/index.css` resolves against the *backend* origin whenever that differs from +// the page's — which is exactly `npm run dev`. The dev server never sees the +// request, and the backend's own handler already answers it there by reading +// the file out of the source tree. In containers API_BASE_URL is empty, the +// iframe is same-origin, and the request lands here instead. +const emitPluginThemeStylesheet = () => ({ + name: 'homeglow-plugin-theme-stylesheet', + generateBundle() { + this.emitFile({ + type: 'asset', + fileName: 'index.css', + source: readFileSync(INDEX_CSS, 'utf8'), + }); + }, +}); + const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const inPackage = (id, pkgName) => { @@ -11,7 +40,7 @@ const inPackage = (id, pkgName) => { }; export default defineConfig({ - plugins: [react()], + plugins: [react(), emitPluginThemeStylesheet()], build: { rollupOptions: { output: { diff --git a/server/index.js b/server/index.js index 2d60453..73fbd2d 100644 --- a/server/index.js +++ b/server/index.js @@ -677,109 +677,16 @@ fastify.get('/index.css', async (request, reply) => { } catch (error) { console.error('Error serving index.css:', error); - // Fallback: serve minimal CSS for widgets - const fallbackCSS = ` - :root { - --background: #f4f4f9; - --card-bg: rgba(255, 255, 255, 0.8); - --card-border: rgba(255, 255, 255, 0.2); - --text-color: #1a1a2e; - --text-color-rgb: 26, 26, 46; - --accent: #6e44ff; - --accent-rgb: 110, 68, 255; - --shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - --backdrop-blur: blur(10px); - --dynamic-text-size: 16px; - --dynamic-card-width: 300px; - --dynamic-card-padding: 20px; - --error-color: #ff4444; - --light-gradient-start: #00ddeb; - --light-gradient-end: #ff6b6b; - --dark-gradient-start: #2e2767; - --dark-gradient-end: #620808; - --light-button-gradient-start: #00ddeb; - --light-button-gradient-end: #ff6b6b; - --dark-button-gradient-start: #2e2767; - --dark-button-gradient-end: #620808; - --gradient: linear-gradient(45deg, var(--light-gradient-start), var(--light-gradient-end)); - } - - [data-theme="dark"] { - --background: #0a0a1a; - --card-bg: rgba(30, 30, 50, 0.7); - --card-border: rgba(100, 100, 150, 0.3); - --text-color: #a6a6d1; - --text-color-rgb: 166, 166, 209; - --accent: #00ddeb; - --accent-rgb: 0, 221, 235; - --shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - --gradient: linear-gradient(45deg, var(--dark-gradient-start), var(--dark-gradient-end)); - } - - html, body { - margin: 0; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--background); - color: var(--text-color); - transition: background 0.3s ease, color 0.3s ease; - touch-action: manipulation; - width: 100%; - height: 100%; - overflow-x: hidden; - overflow-y: auto; - font-size: var(--dynamic-text-size); - } - - .card { - background: var(--card-bg); - border: 1px solid var(--card-border); - border-radius: 12px; - padding: var(--dynamic-card-padding); - backdrop-filter: var(--backdrop-blur); - box-shadow: var(--shadow); - transition: transform 0.2s ease, box-shadow 0.2s ease; - width: 100%; - max-width: var(--dynamic-card-width); - touch-action: manipulation; - } - - .card:hover { - transform: translateY(-5px); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); - } - - h1, h2, h3, h4, h5, h6 { - font-weight: 700; - letter-spacing: 0.5px; - color: var(--text-color); - } - - button { - background: linear-gradient(45deg, var(--light-button-gradient-start), var(--light-button-gradient-end)); - color: var(--text-color); - border: none; - border-radius: 8px; - padding: 10px 20px; - cursor: pointer; - font-size: 1rem; - font-weight: 600; - transition: background 0.3s ease; - touch-action: manipulation; - } - - [data-theme="dark"] button { - background: linear-gradient(45deg, var(--dark-button-gradient-start), var(--dark-button-gradient-end)); - } - - button:hover { - filter: brightness(1.1); - } - `; - - console.log('Serving fallback CSS'); - reply.header('Content-Type', 'text/css'); - reply.header('Access-Control-Allow-Origin', '*'); - return fallbackCSS; + // No substitute stylesheet. This used to answer with a hardcoded palette + // that defined *different* variable names than src/index.css and inverted + // the theme model — light :root with a dark override, where the real sheet + // is dark :root with a light override. Because the containers could never + // find the real file, that substitute was what every plugin actually + // received, and plugin authors wrote against its vocabulary. A stylesheet + // that silently supplies different values under different names is worse + // than none: plugins already carry their own `var(--x, default)` fallbacks, + // and those only work if this path stays quiet. + return reply.status(404).send({ error: 'index.css not found' }); } });