Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*",
Expand All @@ -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"
}
Expand All @@ -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/*"]
}
]
Expand Down
6 changes: 6 additions & 0 deletions scripts/build-bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions src/content/shadow-css.js
Original file line number Diff line number Diff line change
@@ -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<CSSStyleSheet|null>}
*/
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 };
})();
4 changes: 4 additions & 0 deletions src/content/sidebar-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <style> as
// immediate/critical CSS (the adopted sheet loads async via fetch).
window._sbShadowCss?.ensureShadowStylesheet(host.shadowRoot);
syncHostThemeClasses(host);
document.body.appendChild(host);
sb._uiHost = host;
Expand Down
29 changes: 29 additions & 0 deletions tests/e2e/helpers/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,35 @@ async function evalInContentWorld(context, op, arg) {
svgWidth: r ? Math.round(r.width) : null,
};
},
// Await the transformed content.css sheet + report adoption. Proves
// the runtime fetch → transform → adoptedStyleSheets path works.
shadowSheetReady: async () => {
if (!window._sbShadowCss) return { ok: false };
const sheet = await window._sbShadowCss.loadShadowSheet();
const host = document.getElementById('skillbridge-root');
const root = host && host.shadowRoot;
if (root) window._sbShadowCss.ensureShadowStylesheet(root);
await new Promise((r) => setTimeout(r, 0));
let hasHostDark = false;
try {
if (sheet) {
for (const rule of sheet.cssRules) {
if (rule.selectorText && rule.selectorText.includes(':host(.si18n-dark)')) {
hasHostDark = true;
break;
}
}
}
} catch (_e) {
/* cssRules can throw on cross-origin sheets; ours is same-origin */
}
return {
ok: true,
sheetLoaded: !!sheet,
adopted: root ? root.adoptedStyleSheets.length : 0,
hasHostDark,
};
},
// ── Store-asset capture ops (additive; unused by the E2E specs) ──
// Open/close the flashcard sub-panel (sidebar must be open first).
toggleFlashcardPanel: () => {
Expand Down
11 changes: 11 additions & 0 deletions tests/e2e/shadow-isolation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,15 @@ test.describe('SkillBridge — shadow UI isolation', () => {
expect(probe.background).toBe('rgb(61, 64, 91)'); // brand navy, not host blue
expect(probe.svgWidth).toBe(24); // chat-bubble icon intact, not collapsed to 0
});

test('content.css is fetched, transformed, and adopted into the shadow root', async () => {
const r = await evalInContentWorld(extCtx.context, 'shadowSheetReady');
expect(r.ok).toBe(true);
expect(r.sheetLoaded).toBe(true);
// The transform rewrote html.si18n-dark → :host(.si18n-dark) so dark mode
// still themes the shadowed UI.
expect(r.hasHostDark).toBe(true);
// …and the shared sheet is adopted into #skillbridge-root's shadow root.
expect(r.adopted).toBeGreaterThanOrEqual(1);
});
});
70 changes: 70 additions & 0 deletions tests/shadow-css.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Unit tests for the shadow stylesheet transform.
*
* Loads the real src/content/shadow-css.js IIFE (so production-code bugs can't
* hide behind a re-implementation) and exercises transformForShadow, including
* a completeness pass over the real content.css.
*/

/* global describe, test, expect */

const fs = require('fs');
const path = require('path');

const fakeWindow = {};
const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'content', 'shadow-css.js'), 'utf8');
// Only `window` is touched at load time; fetch/chrome/CSSStyleSheet are
// referenced lazily inside loadShadowSheet (never called here).
new Function('window', src)(fakeWindow);
const { transformForShadow } = fakeWindow._sbShadowCss;

describe('shadow-css transformForShadow', () => {
test('rewrites html.si18n-dark descendant selectors to :host(.si18n-dark)', () => {
expect(transformForShadow('html.si18n-dark #skillbridge-fab { color: red; }')).toBe(
':host(.si18n-dark) #skillbridge-fab { color: red; }',
);
});

test('rewrites the RTL :is() language prefix', () => {
expect(transformForShadow('body:is(.si18n-lang-ar, .si18n-lang-he) #x { left: 0; }')).toBe(
':host(:is(.si18n-lang-ar, .si18n-lang-he)) #x { left: 0; }',
);
});

test('rewrites a single language prefix, including hyphenated codes', () => {
expect(transformForShadow('body.si18n-lang-ko .a {}')).toBe(':host(.si18n-lang-ko) .a {}');
expect(transformForShadow('body.si18n-lang-zh-CN .a {}')).toBe(':host(.si18n-lang-zh-CN) .a {}');
});

test('transforms every occurrence in a comma-separated group', () => {
expect(transformForShadow('html.si18n-dark #a,\nhtml.si18n-dark #b {}')).toBe(
':host(.si18n-dark) #a,\n:host(.si18n-dark) #b {}',
);
});

test('leaves non-boundary selectors untouched (incl. the .si18n-dark-toggle-btn class)', () => {
const css = '#skillbridge-fab { color: var(--si18n-accent); }\n.si18n-dark-toggle-btn svg { width: 16px; }';
expect(transformForShadow(css)).toBe(css);
});

test('does not mangle a longer html.si18n-dark-xxx token (lookahead guard)', () => {
expect(transformForShadow('html.si18n-dark-mode #x {}')).toBe('html.si18n-dark-mode #x {}');
});

test('non-string input returns empty string', () => {
expect(transformForShadow(null)).toBe('');
expect(transformForShadow(undefined)).toBe('');
});

test('real content.css: no ancestor theme prefix survives, and :host forms appear', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'content', 'content.css'), 'utf8');
const out = transformForShadow(css);
// Every boundary-crossing selector must be rewritten — a leftover would
// silently no-op inside the shadow root (dark/RTL theming would break).
expect(out).not.toMatch(/html\.si18n-dark(?![\w-])/);
expect(out).not.toMatch(/body:is\(\.si18n-lang/);
expect(out).not.toMatch(/body\.si18n-lang-/);
expect(out).toContain(':host(.si18n-dark)');
expect(out).toContain(':host(:is(.si18n-lang-ar, .si18n-lang-he))');
});
});
Loading