diff --git a/package.json b/package.json index 02b8faa..ab65b5d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "private": true, "scripts": { "dev": "webpack --watch", @@ -17,6 +17,7 @@ "json-loader": "0.5.7", "postcss": "8.4.16", "postcss-loader": "7.0.1", + "postcss-prefix-selector": "2.1.1", "postcss-preset-env": "7.8.0", "raw-loader": "4.0.2", "sass": "1.54.3", @@ -42,4 +43,4 @@ "vue": "3.5.17", "vue-router": "4.5.1" } -} \ No newline at end of file +} diff --git a/postcss.config.js b/postcss.config.js index e388393..3020d69 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,9 +1,56 @@ -const tailwindcss = require('tailwindcss'); +const tailwindcss = require("tailwindcss"); +const prefixSelector = require("postcss-prefix-selector"); + +// Confines all extension CSS (Tailwind preflight + utilities, lib-vue-components +// styles, SFC blocks) to descendants of `.subturtle-scope`. Without this, the +// universal selectors in Tailwind preflight (`*, ::before, ::after`, `button`, +// `img`, ...) bleed onto the host page and break YouTube's icon rendering and +// video grid layout. +const SCOPE = ".subturtle-scope"; module.exports = { plugins: [ - 'postcss-preset-env', + "postcss-preset-env", tailwindcss, - // ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}) + prefixSelector({ + prefix: SCOPE, + transform(prefix, selector, prefixedSelector) { + const trimmed = selector.trim(); + + // Idempotent guard: lib-vue-components CSS gets visited more than once + // by the loader chain, which would otherwise produce double/triple + // prefixes (`.subturtle-scope .subturtle-scope ...`) that never match. + if (trimmed.startsWith(prefix)) { + const charAfter = trimmed[prefix.length]; + if (charAfter === undefined || /[\s.:>+~\[]/.test(charAfter)) { + return selector; + } + } + + // Drop `body` rules — they target the host page (background, + // font-family, margin reset). Mapping them onto our wrapper paints + // a visible frame around the host UI. + if (trimmed === "body") { + return ":not(*)"; + } + + // Map root-level selectors onto the scope so CSS custom properties + // and typography defaults still cascade into the extension UI. + if (/^(html|:root)$/.test(trimmed)) { + return prefix; + } + + // Tailwind class-based dark mode emits selectors like `.dark .foo`. + // Merge `.dark` with the prefix as a compound selector so a single + // `.subturtle-scope.dark` element activates dark utilities for all + // its descendants — without requiring a separate `.dark` ancestor + // inside the scope. + if (/^\.dark(?=[\s.:>+~\[]|$)/.test(trimmed)) { + return prefix + trimmed; + } + + return prefixedSelector; + }, + }), ], -}; \ No newline at end of file +}; diff --git a/src/common/store/settings.ts b/src/common/store/settings.ts index 2a567ce..92d47e5 100644 --- a/src/common/store/settings.ts +++ b/src/common/store/settings.ts @@ -28,15 +28,55 @@ export const useSettingsStore = defineStore("settings", () => { } } - function applyThemeToDOM(themeValue: Theme) { - document.documentElement.classList.remove("light", "dark"); + let scopeObserver: MutationObserver | null = null; + let currentEffectiveTheme: "dark" | "light" = "dark"; + + function resolveTheme(themeValue: Theme): "dark" | "light" { if (themeValue === "auto") { - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)" - ).matches; - document.documentElement.classList.add(prefersDark ? "dark" : "light"); - } else { - document.documentElement.classList.add(themeValue); + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return themeValue; + } + + function applyToScopeElement(el: Element) { + el.classList.remove("light", "dark"); + el.classList.add(currentEffectiveTheme); + } + + // The `dark` class lives on every `.subturtle-scope` element rather than + // ``, because postcss-prefix-selector rewrites Tailwind's dark rules to + // the compound form `.subturtle-scope.dark ...` — so the same element must + // carry both classes for dark utilities to take effect. + function applyThemeToDOM(themeValue: Theme) { + currentEffectiveTheme = resolveTheme(themeValue); + + document + .querySelectorAll(".subturtle-scope") + .forEach(applyToScopeElement); + + // Vue teleports (e.g. WordSelectionRectangle, the YouTube caption + // container) mount `.subturtle-scope` wrappers after this initial pass, + // so an observer keeps later additions in sync with the active theme. + if (!scopeObserver && typeof MutationObserver !== "undefined") { + scopeObserver = new MutationObserver((mutations) => { + for (const m of mutations) { + m.addedNodes.forEach((node) => { + if (!(node instanceof Element)) return; + if (node.classList?.contains("subturtle-scope")) { + applyToScopeElement(node); + } + node + .querySelectorAll?.(".subturtle-scope") + .forEach(applyToScopeElement); + }); + } + }); + scopeObserver.observe(document.body, { + childList: true, + subtree: true, + }); } } diff --git a/src/console-crane/index.vue b/src/console-crane/index.vue index d776062..311bbbd 100644 --- a/src/console-crane/index.vue +++ b/src/console-crane/index.vue @@ -12,7 +12,7 @@ import { useConsoleCraneStore } from "./stores/console-crane"; import { RouterView, useRouter } from "vue-router"; import Modal from "./components/Modal.vue"; import { getSubturtleDashboardUrlWithToken } from "../common/static/global"; -import { Button, IconButton } from "@codebridger/lib-vue-components/elements"; +import { Button, IconButton, App } from "@codebridger/lib-vue-components"; import { watch, onMounted, onUnmounted, ref, computed } from "vue"; import { analytic } from "../plugins/mixpanel"; @@ -60,30 +60,30 @@ onUnmounted(() => { diff --git a/src/main.ts b/src/main.ts index 5e13047..884ed61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,5 @@ import "./trusted-types-polyfill"; -log("Using version", VERSION); - import "./animation.scss"; import "./tailwind.css"; @@ -12,7 +10,6 @@ import ConsoleCrane from "./console-crane/index.vue"; import { netflix } from "./subtitle/web_netflix/initializer"; import { youtube } from "./subtitle/web_youtube/initializer"; -import { msTeam } from "./subtitle/ms-team/initializer"; import { AppInitializer } from "./common/types/general.type"; import { cleanText } from "./common/helper/text"; import { analytic } from "./plugins/mixpanel"; @@ -26,13 +23,15 @@ import { import { loginWithLastSession } from "./plugins/modular-rest"; import { useSettingsStore } from "./common/store/settings"; +log("Using version", VERSION); + let vueApp!: App; let appInitializer!: AppInitializer; let initialized = false; // Select an app initializer from existing modules // -[youtube, netflix, msTeam].forEach((item) => { +[youtube, netflix].forEach((item) => { if (location.hostname.includes(item.website.host)) { appInitializer = item; } diff --git a/src/subtitle/README.md b/src/subtitle/README.md index c274b80..5a86061 100644 --- a/src/subtitle/README.md +++ b/src/subtitle/README.md @@ -1,7 +1,7 @@ # Subtitle Platform ## Purpose -Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overlays for every supported surface (Netflix, YouTube, MS Teams, future providers). Each integration shares the same learning experience—word selection, translation, and Console Crane—while adapting to the target site’s DOM and policies. +Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overlays for every supported surface (Netflix, YouTube, future providers). Each integration shares the same learning experience—word selection, translation, and Console Crane—while adapting to the target site’s DOM and policies. ### High-Level Flow 1. **Initializer (per surface)** mounts a Vue app once the host caption container appears. @@ -12,7 +12,7 @@ Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overla ## Directory Map | Path | Description | | --- | --- | -| `ms-team`, `web_youtube`, `web_netflix` | Production integrations. Each folder contains `Index.vue`, `initializer.ts`, `static.ts`, and any extra assets unique to that platform. | +| `web_youtube`, `web_netflix` | Production integrations. Each folder contains `Index.vue`, `initializer.ts`, `static.ts`, and any extra assets unique to that platform. | | `_support-template` | Boilerplate for new providers. Copy this folder when bootstrapping another site—then adjust selectors, DOM strategy, and policies. | | `components/` | Cross-surface Vue components. `specific/Word.vue`, `WordSelectionRectangle.vue`, `TranslatedPhrase.vue`, `SelectionAnchor.vue`, and `SvgLoader.vue` compose the interactive overlay. | | `components/components.ts` | Barrel file for exporting shared components to site entries. | @@ -23,7 +23,7 @@ Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overla - **Marker Store (`src/stores/marker.ts`)** – central authority for marked words, hover state, and auto-clear timers (2.5s single, 5s multi). Integrations should only invoke store actions; never manage timers inside view components. - **Word Rendering** – `Subtitle.vue` (per surface) splits caption text into `` components, each wired to `markerStore` for hover/selection state. - **Selection Rectangle** – `WordSelectionRectangle.vue` reads `markerStore` positions to draw DOM overlays that follow host captions even while they animate. -- **Translation Bubble** – `TranslatedPhrase.vue` shows the latest translated text. Surfaces can offset its placement via local constants (e.g., `translationSpacingPx` in MS Teams). +- **Translation Bubble** – `TranslatedPhrase.vue` shows the latest translated text. Surfaces can offset its placement via local constants. - **Console Crane** – `src/console-crane` is mounted alongside subtitle overlays to display word details; surfaces only need to ensure the crane container stays connected and receives context. ## Adding a New Surface diff --git a/src/subtitle/_support-template/initializer.ts b/src/subtitle/_support-template/initializer.ts index 180a1db..c5cb55e 100644 --- a/src/subtitle/_support-template/initializer.ts +++ b/src/subtitle/_support-template/initializer.ts @@ -22,6 +22,9 @@ export const initConfig: AppInitializer = { // @TODO: Find a better way to do this appDiv.id = "subturtle-app"; + // `subturtle-scope` is the anchor the postcss prefix-selector targets; + // every Subturtle-rendered subtree must carry this class to receive styles. + appDiv.classList.add("subturtle-scope"); appDiv.style.position = "relative"; appDiv.style.zIndex = "9999"; diff --git a/src/subtitle/components/specific/WordSelectionRectangle.vue b/src/subtitle/components/specific/WordSelectionRectangle.vue index 6f4c03c..3e5e061 100644 --- a/src/subtitle/components/specific/WordSelectionRectangle.vue +++ b/src/subtitle/components/specific/WordSelectionRectangle.vue @@ -1,6 +1,8 @@ diff --git a/src/subtitle/ms-team/Index.vue b/src/subtitle/ms-team/Index.vue deleted file mode 100644 index 3495da6..0000000 --- a/src/subtitle/ms-team/Index.vue +++ /dev/null @@ -1,219 +0,0 @@ - - - - - diff --git a/src/subtitle/ms-team/README.md b/src/subtitle/ms-team/README.md deleted file mode 100644 index 92aa868..0000000 --- a/src/subtitle/ms-team/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# MS Teams Subtitle Integration - -This directory contains the MS Teams-specific implementation of the Subturtle subtitle learning extension. - -## Overview - -The MS Teams integration provides interactive subtitles with word translation and learning features during Teams meetings. It works by: -- Detecting live captions in MS Teams meetings -- Rendering interactive word overlays on top of the native captions -- Providing click-to-translate functionality -- Displaying a word detail modal for learning - -## Architecture - -### Key Components - -- **`Index.vue`**: Main component that manages subtitle detection and rendering - - Polls for caption elements using `MutationObserver` - - Creates per-dialogue overlay components - - Manages the `ConsoleCrane` modal for word details - -- **`Subtitle.vue`**: Individual subtitle line component - - Renders interactive words with click handlers - - Applies MS Teams' native caption styling - - Handles word selection and highlighting - -- **`initializer.ts`**: Entry point for MS Teams integration - - Waits for caption container to appear - - Mounts the Vue app into the page - -### Data Flow - -1. **Caption Detection**: `Index.vue` uses `MutationObserver` to detect new caption elements -2. **Dialogue Tracking**: Each caption element is tracked with a unique `dialogueIndex` -3. **Word Rendering**: `Subtitle.vue` breaks text into clickable word components -4. **Word Selection**: Clicking a word triggers `OpenWordDetail` in `Word.vue` -5. **Modal Display**: `ConsoleCrane` shows translation and learning options - -### Interaction Enhancements - -The MS Teams overlay uses two key interaction affordances to keep the UI predictable when captions constantly re-render: - -- **Selection lifecycle**: `markerStore` owns the auto-clear lifecycle. When the pointer leaves the selection and the user is no longer marking, the store starts a timeout (≈2.5 s for single-word, ≈5 s for multi-word). Because the logic lives in the store rather than in DOM-bound components, selections expire reliably even while Teams injects new caption nodes. -- **Translation placement**: `Index.vue` exposes `translationSpacingPx`, a single constant that defines how far above the selection the translated phrase floats. Adjusting this value lets us react to future Teams layout changes while keeping the overlay below the selection rectangle’s z-index so the highlight stays visible. - -## MS Teams-Specific Challenges - -### Content Security Policy (CSP) - -MS Teams has a strict CSP that blocks certain JavaScript operations. We've implemented workarounds: - -1. **Trusted Types Polyfill** (`src/trusted-types-polyfill.ts`) - - Intercepts Vue's policy creation attempts - - Provides passthrough policies for pre-compiled templates - -2. **Runtime-Only Vue Build** (`webpack.config.js`) - - Uses `vue.runtime.esm-bundler.js` to avoid template compiler - - All components use SFC (Single File Component) syntax - -### Styling Challenges - -MS Teams captions have dynamic styling that changes based on: -- User preferences (font size, color) -- Meeting state (presenting, recording, etc.) -- Caption position (top, bottom) - -**Solution**: We track and apply the native caption CSS classes: -- Extract `className` from caption elements -- Pass `textClasses` to `Subtitle.vue` -- Apply classes to maintain visual consistency - -### DOM Structure - -MS Teams uses a complex DOM structure with: -- Shadow DOM in some areas -- Dynamically created/destroyed caption elements -- Multiple caption containers for different states - -**Solution**: -- Use `teleport` sparingly to avoid conflicts -- Keep components within `#subturtle-app` container -- Clean up disconnected dialogue elements - -## File Structure - -``` -ms-team/ -├── Index.vue # Main component -├── initializer.ts # Entry point -├── static.ts # Constants (selectors, etc.) -└── components/ - └── Subtitle.vue # Subtitle line component -``` - -## Development - -### Testing in MS Teams - -1. Build the extension: `npm run build` -2. Load the extension in Chrome -3. Join a Teams meeting -4. Enable live captions -5. Click on words to test translation - -### Debugging - -Enable console logs to see: -- `Subturtle: Activated for MS Teams` - Extension loaded -- `Subturtle: OpenWordDetail called` - Word clicked -- Caption detection and dialogue tracking - -### Common Issues - -**Modal not appearing:** -- Check console for CSP errors -- Verify `ConsoleCrane` is mounted in `Index.vue` -- Ensure Trusted Types polyfill is loaded - -**Words not clickable:** -- Verify `pointer-events: auto` in CSS -- Check that `Word.vue` has `@click.stop` handler -- Ensure words are being rendered (inspect DOM) - -**Styling mismatches:** -- Check that `textClasses` are being passed correctly -- Verify caption CSS classes are being extracted -- Inspect computed styles on caption elements - -## Related Files - -- **Trusted Types Polyfill**: `src/trusted-types-polyfill.ts` -- **Webpack Config**: `webpack.config.js` (Vue runtime alias) -- **Word Component**: `src/subtitle/components/specific/Word.vue` -- **ConsoleCrane Modal**: `src/console-crane/index.vue` -- **Marker Store**: `src/stores/marker.ts` (word selection state) diff --git a/src/subtitle/ms-team/components/Subtitle.vue b/src/subtitle/ms-team/components/Subtitle.vue deleted file mode 100644 index 3502edb..0000000 --- a/src/subtitle/ms-team/components/Subtitle.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - diff --git a/src/subtitle/ms-team/initializer.ts b/src/subtitle/ms-team/initializer.ts deleted file mode 100644 index ffe7ec8..0000000 --- a/src/subtitle/ms-team/initializer.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { waitUntil } from "../../common/helper/promise"; -import { AppInitializer } from "../../common/types/general.type"; -import { SUBTILE_CONTAINER_CLASS } from "./static"; - -import msTeamComponent from "./Index.vue"; - -export const msTeam: AppInitializer = { - website: { - host: "teams.microsoft.com", - path: "/light-meetings/launch", - }, - component: msTeamComponent as any, - start: async (app) => { - await waitUntil(() => !!document.querySelector(SUBTILE_CONTAINER_CLASS)); - - let appDiv = document.createElement("div"); - appDiv.id = "subturtle-app"; - - document.body.insertAdjacentElement("afterbegin", appDiv); - - app.mount(appDiv); - return app; - }, -}; diff --git a/src/subtitle/ms-team/static.ts b/src/subtitle/ms-team/static.ts deleted file mode 100644 index bd4b23c..0000000 --- a/src/subtitle/ms-team/static.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const SUBTILE_CONTAINER_CLASS = '[data-tid="closed-caption-v2-virtual-list-content"]'; -export const SUBTITLE_CLASS = '[data-tid="closed-caption-text"]'; -export const SUBTITLE_LINE_CLASS = '[data-tid="closed-caption-text"]'; diff --git a/src/subtitle/web_netflix/Index.vue b/src/subtitle/web_netflix/Index.vue index 742128c..a392ed3 100644 --- a/src/subtitle/web_netflix/Index.vue +++ b/src/subtitle/web_netflix/Index.vue @@ -1,9 +1,6 @@ diff --git a/src/subtitle/web_netflix/initializer.ts b/src/subtitle/web_netflix/initializer.ts index 35ad5a4..cabc922 100644 --- a/src/subtitle/web_netflix/initializer.ts +++ b/src/subtitle/web_netflix/initializer.ts @@ -15,14 +15,14 @@ export const netflix: AppInitializer = { await waitUntil(() => !!document.querySelector(SUBTITLE_CLASS)); let appDiv = document.createElement("div"); - let videoContainer = document.querySelector(".watch-video"); - - videoContainer?.insertBefore(appDiv, videoContainer.firstChild); - appDiv.id = "subturtle-app"; + appDiv.classList.add("subturtle-scope"); appDiv.style.position = "relative"; appDiv.style.zIndex = "9999"; + let videoContainer = document.querySelector(".watch-video"); + videoContainer?.insertBefore(appDiv, videoContainer.firstChild); + app.mount(appDiv); return app; diff --git a/src/subtitle/web_youtube/Index.vue b/src/subtitle/web_youtube/Index.vue index a57e45c..ee707e2 100644 --- a/src/subtitle/web_youtube/Index.vue +++ b/src/subtitle/web_youtube/Index.vue @@ -1,19 +1,19 @@