From 43560b2a3874a8b040ef0123105729f0375fe1a4 Mon Sep 17 00:00:00 2001 From: mrramam Date: Mon, 14 Sep 2026 13:58:30 -0700 Subject: [PATCH 1/2] fix(client): serve /index.css from the frontend instead of the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin iframes link /index.css for the theme variables — the plugin development guide documents it, and all 24 plugins in HomeGlowPlugins use it. In any container deployment that request has always failed. nginx proxied /index.css to the backend, and the backend answers it by reading client/src/index.css off disk, trying four candidate paths. server/Dockerfile builds with server/ as its context and does COPY . ., so the image contains server/ and nothing else; none of those paths can resolve. Every request logged three errors and returned a 500. It works when running natively from a full checkout, which is how it survived. The stylesheet is a client asset, so it is now served by the client. Vite emits an unhashed copy at the site root next to the hashed bundles, and nginx serves that file instead of proxying. The copy is emitted from src/index.css rather than duplicated into public/, so there is one source and no second file to drift. The backend handler is left in place on purpose. A plugin iframe is loaded from API_BASE_URL, so its root-relative /index.css resolves against the backend origin whenever that differs from the page's — which is exactly `npm run dev`. There the backend can read the file, and does. In containers API_BASE_URL is empty, the iframe is same-origin, and the request now lands on nginx. This finishes what #61 started: that fixed the routing so /index.css reached the backend rather than the SPA fallback, but nothing behind it could answer. Plugins still get the default palette rather than a customized theme — the app overrides about fifteen of these variables at runtime from user settings, and interfaceColors lives only in localStorage, so no server-side render could be correct either. Matching the live theme needs the host to inject values into the iframes and is a separate change. --- client/nginx.conf | 12 +++++++----- client/vite.config.js | 31 ++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) 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: { From 8270428840ddd06f66e658270b38c74bbda4d748 Mon Sep 17 00:00:00 2001 From: mrramam Date: Mon, 14 Sep 2026 15:02:30 -0700 Subject: [PATCH 2/2] fix(server): stop substituting a different palette when index.css is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /index.css handler answered a missing file with a hardcoded stylesheet. That substitute defined different variable names than client/src/index.css (--text-color, --text-color-rgb, --error-color, where the real sheet has --text, --text-secondary, --error) and inverted the theme model: light :root with a [data-theme="dark"] override, where the real sheet is dark :root with a [data-theme="light"] override. Because the backend image cannot contain client/src/index.css, that substitute was what every plugin actually received in every container deployment, for the whole life of the feature. Plugin authors wrote against its vocabulary — chore-metrics uses var(--text-color, #333), a name that does not exist in the real stylesheet. 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, so it now returns 404. Paired with serving /index.css from the frontend image: in containers the request no longer reaches this handler at all, and in native development the real file is present and served. This path is now only reachable on a genuinely broken install, where failing honestly is the point. --- server/index.js | 113 +++++------------------------------------------- 1 file changed, 10 insertions(+), 103 deletions(-) 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' }); } });