From 1965765d20f4122a98668ef491836f0d08304bc0 Mon Sep 17 00:00:00 2001 From: heznpc Date: Wed, 10 Jun 2026 04:52:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20shadow=20stylesheet=20loader=20?= =?UTF-8?q?=E2=80=94=20content.css=20transform=20+=20adopt=20(stage=202a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the bulk-CSS mechanism the sidebar migration (stage 2b) needs: load content.css once, rewrite its ancestor theme selectors to :host(...) form, and adopt the result into the shadow UI root. - New src/content/shadow-css.js (standalone module): pure transformForShadow (html.si18n-dark → :host(.si18n-dark); body:is(.si18n-lang-ar,.si18n-lang-he) → :host(:is(...)); body.si18n-lang-XX → :host(.si18n-lang-XX)) + a cached fetch→transform→CSSStyleSheet loader + idempotent adopt. - The CSS path is resolved from the manifest (content_scripts[].css[0]) so it works in both the dev build (src/content/content.css) and the bundle (content.bundle.css). manifest + build-bundle.js make that file web-accessible (build remaps the dev path to content.bundle.css). - getUiRoot adopts the sheet into #skillbridge-root. The FAB keeps its inline critical style (the adopted sheet loads async). Verified: - tests/shadow-css.test.js (8): transform cases incl. a completeness pass over the REAL content.css — asserts NO html./body. theme prefix survives (all 391 rewritten) and the :host forms appear. - shadow-isolation.spec.js: new assertion that content.css is fetched, transformed (contains :host(.si18n-dark)), and adopted into the shadow root — proven against the minified bundle. - 520 unit tests, full E2E (19), lint, prettier, validate, check:* — green. Next (stage 2b): move the sidebar + its dependent modules' ~83 query sites into the shadow root, styled by this adopted sheet. --- manifest.json | 41 +++++++++++--- scripts/build-bundle.js | 6 +++ src/content/shadow-css.js | 85 ++++++++++++++++++++++++++++++ src/content/sidebar-chat.js | 4 ++ tests/e2e/helpers/extension.js | 29 ++++++++++ tests/e2e/shadow-isolation.spec.js | 11 ++++ tests/shadow-css.test.js | 70 ++++++++++++++++++++++++ 7 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 src/content/shadow-css.js create mode 100644 tests/shadow-css.test.js diff --git a/manifest.json b/manifest.json index c838d8d..0be59a0 100644 --- a/manifest.json +++ b/manifest.json @@ -7,10 +7,7 @@ "author": "SkillBridge Contributors", "homepage_url": "https://github.com/heznpc/skillbridge", "default_locale": "en", - "permissions": [ - "storage", - "alarms" - ], + "permissions": ["storage", "alarms"], "host_permissions": [ "https://*.skilljar.com/*", "https://*.youtube.com/*", @@ -28,7 +25,32 @@ "content_scripts": [ { "matches": ["https://*.skilljar.com/*"], - "js": ["src/lib/browser-polyfill.js", "src/lib/log.js", "src/lib/platform.js", "src/lib/selectors.js", "src/lib/constants.js", "src/lib/translator.js", "src/lib/youtube-subtitles.js", "src/lib/protected-terms.js", "src/lib/gemini-block.js", "src/content/content.js", "src/content/gt-queue.js", "src/content/banners.js", "src/content/code-comments.js", "src/content/header-controls.js", "src/content/text-selection.js", "src/content/chat-render.js", "src/content/sidebar-chat.js", "src/content/chat-history.js", "src/content/chat-flashcards.js", "src/content/bookmarks.js", "src/content/resume.js", "src/content/reading-aid.js", "src/content/keyboard-shortcuts.js"], + "js": [ + "src/lib/browser-polyfill.js", + "src/lib/log.js", + "src/lib/platform.js", + "src/lib/selectors.js", + "src/lib/constants.js", + "src/lib/translator.js", + "src/lib/youtube-subtitles.js", + "src/lib/protected-terms.js", + "src/lib/gemini-block.js", + "src/content/content.js", + "src/content/gt-queue.js", + "src/content/banners.js", + "src/content/code-comments.js", + "src/content/header-controls.js", + "src/content/text-selection.js", + "src/content/chat-render.js", + "src/content/shadow-css.js", + "src/content/sidebar-chat.js", + "src/content/chat-history.js", + "src/content/chat-flashcards.js", + "src/content/bookmarks.js", + "src/content/resume.js", + "src/content/reading-aid.js", + "src/content/keyboard-shortcuts.js" + ], "css": ["src/content/content.css"], "run_at": "document_idle" } @@ -43,7 +65,14 @@ }, "web_accessible_resources": [ { - "resources": ["src/lib/page-bridge.js", "src/data/*", "src/bridge/puter.js", "assets/icons/*", "src/shared/constants.json"], + "resources": [ + "src/lib/page-bridge.js", + "src/data/*", + "src/bridge/puter.js", + "assets/icons/*", + "src/shared/constants.json", + "src/content/content.css" + ], "matches": ["https://*.skilljar.com/*"] } ] diff --git a/scripts/build-bundle.js b/scripts/build-bundle.js index 0ee21e7..83186f9 100644 --- a/scripts/build-bundle.js +++ b/scripts/build-bundle.js @@ -81,6 +81,12 @@ async function build() { bundledManifest.content_scripts[0].js = ['content.bundle.js']; bundledManifest.content_scripts[0].css = ['content.bundle.css']; bundledManifest.background.service_worker = 'background.bundle.js'; + // The shadow UI fetches content.css via web_accessible_resources to adopt it + // into the shadow root. In the bundle that file is content.bundle.css, so + // remap the dev path (src/content/content.css) accordingly. + for (const entry of bundledManifest.web_accessible_resources || []) { + entry.resources = entry.resources.map((r) => (r === 'src/content/content.css' ? 'content.bundle.css' : r)); + } fs.writeFileSync(path.join(DIST, 'manifest.json'), JSON.stringify(bundledManifest, null, 2)); // Clean up temp entry diff --git a/src/content/shadow-css.js b/src/content/shadow-css.js new file mode 100644 index 0000000..8090e45 --- /dev/null +++ b/src/content/shadow-css.js @@ -0,0 +1,85 @@ +/** + * SkillBridge — Shadow stylesheet loader + * + * Loads content.css, rewrites its host-page (ancestor) theme selectors into + * :host(...) form, and adopts the result into a shadow root. Standalone module + * loaded before sidebar-chat.js (which owns the shadow UI root). + * + * Why a transform: content.css themes via ancestor selectors + * html.si18n-dark X / body:is(.si18n-lang-ar,.si18n-lang-he) X / + * body.si18n-lang-XX X + * Inside a shadow root those ancestors are out of reach, so they're rewritten + * to :host(...) and the shadow host carries the mirrored state classes (see + * sidebar-chat.js `syncHostThemeClasses`). CSS custom properties (--si18n-*) + * inherit through the boundary, so var() references need no rewrite. + * + * Exposes: window._sbShadowCss = { transformForShadow, loadShadowSheet, + * ensureShadowStylesheet } + */ + +(function () { + 'use strict'; + + /** + * Rewrite content.css ancestor theme selectors into :host(...) form. + * Pure + side-effect free so it can be unit-tested without a browser. + * The negative lookaheads keep `html.si18n-dark` from matching inside a + * longer token (there is none today, but it guards future class names). + * @param {string} css + * @returns {string} + */ + function transformForShadow(css) { + if (typeof css !== 'string') return ''; + return css + .replace(/html\.si18n-dark(?![\w-])/g, ':host(.si18n-dark)') + .replace(/body:is\(\.si18n-lang-ar,\s*\.si18n-lang-he\)/g, ':host(:is(.si18n-lang-ar, .si18n-lang-he))') + .replace(/body\.si18n-lang-([A-Za-z]+(?:-[A-Za-z]+)*)/g, ':host(.si18n-lang-$1)') + .replace(/body\.si18n-rtl(?![\w-])/g, ':host(.si18n-rtl)'); + } + + let _sheetPromise = null; + + /** + * Fetch + transform content.css once and return a shared CSSStyleSheet. + * Cached so every shadow root adopts the same constructed sheet. + * @returns {Promise} + */ + function loadShadowSheet() { + if (_sheetPromise) return _sheetPromise; + // Resolve the stylesheet path from the manifest so this works in both the + // unbundled dev build (src/content/content.css) and the production bundle + // (content.bundle.css). The file must be web-accessible (see manifest / + // build-bundle.js). + const m = chrome.runtime.getManifest(); + const cssPath = m.content_scripts?.[0]?.css?.[0] || 'src/content/content.css'; + _sheetPromise = fetch(chrome.runtime.getURL(cssPath)) + .then((r) => r.text()) + .then((css) => { + const sheet = new window.CSSStyleSheet(); + sheet.replaceSync(transformForShadow(css)); + return sheet; + }) + .catch((err) => { + // Non-fatal: components that move into the shadow keep a small inline + // critical style, so a failed adopt degrades rather than breaks. + console.warn('[SkillBridge] shadow stylesheet load failed:', err && err.message); + return null; + }); + return _sheetPromise; + } + + /** + * Adopt the shared transformed sheet into a shadow root. Idempotent. + * @param {ShadowRoot} root + */ + function ensureShadowStylesheet(root) { + if (!root) return; + loadShadowSheet().then((sheet) => { + if (sheet && !root.adoptedStyleSheets.includes(sheet)) { + root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + } + }); + } + + window._sbShadowCss = { transformForShadow, loadShadowSheet, ensureShadowStylesheet }; +})(); diff --git a/src/content/sidebar-chat.js b/src/content/sidebar-chat.js index 3b77c83..9c130ff 100644 --- a/src/content/sidebar-chat.js +++ b/src/content/sidebar-chat.js @@ -68,6 +68,10 @@ const host = document.createElement('div'); host.id = 'skillbridge-root'; host.attachShadow({ mode: 'open' }); + // Adopt the transformed content.css so UI that moves into this root is + // styled from the single source. The FAB keeps a small inline