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
16 changes: 16 additions & 0 deletions CHANGELOG-DEV.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
## [1.15.1-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.15.0...v1.15.1-dev.1) (2026-06-17)


### Bug Fixes

* **console-crane:** close bundle selector dropdown and contain its list [#86](https://github.com/codebridger/subturtle-extension-apps/issues/86)exzbh61 ([f9f1c8e](https://github.com/codebridger/subturtle-extension-apps/commit/f9f1c8e465f81ae14a8bdf40f42d7642f702be4c)), closes [#86exzbh61](https://github.com/codebridger/subturtle-extension-apps/issues/86exzbh61)
* **console-crane:** keep bundle list within the modal frame [#86](https://github.com/codebridger/subturtle-extension-apps/issues/86)exzbh61 ([ca49b14](https://github.com/codebridger/subturtle-extension-apps/commit/ca49b143cbefa8d6d3097066c0295ce8819a2795)), closes [#86exzbh61](https://github.com/codebridger/subturtle-extension-apps/issues/86exzbh61)

# [1.15.0-dev.2](https://github.com/codebridger/subturtle-extension-apps/compare/v1.15.0-dev.1...v1.15.0-dev.2) (2026-06-17)


### Bug Fixes

* **console-crane:** close bundle selector dropdown and contain its list [#86](https://github.com/codebridger/subturtle-extension-apps/issues/86)exzbh61 ([f9f1c8e](https://github.com/codebridger/subturtle-extension-apps/commit/f9f1c8e465f81ae14a8bdf40f42d7642f702be4c)), closes [#86exzbh61](https://github.com/codebridger/subturtle-extension-apps/issues/86exzbh61)
* **console-crane:** keep bundle list within the modal frame [#86](https://github.com/codebridger/subturtle-extension-apps/issues/86)exzbh61 ([ca49b14](https://github.com/codebridger/subturtle-extension-apps/commit/ca49b143cbefa8d6d3097066c0295ce8819a2795)), closes [#86exzbh61](https://github.com/codebridger/subturtle-extension-apps/issues/86exzbh61)

# [1.15.0-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.1...v1.15.0-dev.1) (2026-06-09)


Expand Down
42 changes: 41 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,46 @@ yarn typecheck # tsc --noEmit via the upstream-error filter

Load `dist/` as an unpacked extension at `chrome://extensions`. There is no separate dev server β€” the bundler writes straight to `dist/`, and Chrome reloads when you click the reload button on the extension card.

## Sibling repositories

This extension does not stand alone β€” it depends on two other `codebridger` repos. They are normally checked out **next to** this repo (`../`), and devs work on all of them side-by-side from the `subturtle-all-apps.code-workspace`.

| Sibling | Repo | Consumed here as | Local checkout |
| --- | --- | --- | --- |
| Server / Dashboard | [`codebridger/subturtle-dashboard-app`](https://github.com/codebridger/subturtle-dashboard-app) | TS types imported by [src/stores/profile.ts](src/stores/profile.ts) via `../../../dashboard-app/frontend/types/database.type`; also the live dev API/auth server at `https://dev.dashboard.subturtle.app/`. | `../dashboard-app` β€” the import path resolves to a dir of that exact name next to this repo's root. CI clones it there (see [release.yml](.github/workflows/release.yml)). |
| pilotUI | [`codebridger/pilotui`](https://github.com/codebridger/pilotui) | npm dependency `pilotui@^1.29.0` β€” the Tailwind/Vue component library the whole UI builds on. | `node_modules/pilotui` (a published, built artifact β€” **not** the source repo). Clone the repo separately to edit the source. |

### Working across siblings (download / search / branch)

When a change on this repo's branch needs a matching change in a sibling β€” a new field on a `dashboard-app` type, a fix in a `pilotui` component β€” you can pull the sibling in and work on it. **Do this on a feature branch in the sibling; never commit to the sibling's `main` just to make this repo's branch pass.** The goal is to verify this repo's branch against the modified sibling while keeping the sibling's `main` clean.

1. **Search locally first.** The sibling is often already on disk next to this repo. Confirm before cloning:
```bash
git -C ../dashboard-app remote -v # expect codebridger/subturtle-dashboard-app
ls node_modules/pilotui # the built pilotui (read-only artifact)
```
`dashboard-app` may also be checked out under its full name `../subturtle-dashboard-app`; the import path needs a dir literally named `dashboard-app`, so symlink or clone into that name.
2. **Download it if missing.**
```bash
git clone https://github.com/codebridger/subturtle-dashboard-app.git ../dashboard-app
git clone https://github.com/codebridger/pilotui.git ../pilotui
```
3. **Branch the sibling, then change it.**
```bash
git -C ../dashboard-app checkout -b <feature-branch> # e.g. feat/new-bundle-field
# …edit types/components in the sibling…
```
This keeps the sibling's `main` untouched while this repo's branch is tested against the patched code.
4. **Point this repo at the local pilotui to test a patch** (the dashboard types are already read straight off `../dashboard-app`):
```bash
(cd ../pilotui && yarn link) && yarn link pilotui # use local pilotui source
# …verify…
yarn unlink pilotui && yarn install --force # revert before committing
```
Never leave a `yarn link` / `file:` override in `package.json` when you commit β€” CI installs `pilotui@^1.29.0` from the registry.

Same confirm-before-acting rule as always: never push a sibling branch or open a PR against a sibling without explicit per-action user confirmation.

## Bundles (+ popup, background)

| Bundle | Entry | Runs on | Purpose |
Expand Down Expand Up @@ -146,7 +186,7 @@ And the `SettingsObject` type in [src/common/types/messaging.ts](src/common/type
- **Selection popup must `@mousedown.prevent.stop`.** Otherwise clicking the popup deselects the page text, the composable detects the empty selection, and the popup unmounts mid-click.
- **The mount root in Nibble must not block the page.** Set `width: 0; height: 0; position: fixed; top: 0; left: 0`. Children use their own `position: fixed` to position themselves relative to the viewport.
- **Theme dark class lives on `.subturtle-scope`, not `<html>`.** Tailwind's `dark:` rules are rewritten by `postcss-prefix-selector` to `.subturtle-scope.dark ...` β€” so the same element must carry both classes. The settings store handles this and a `MutationObserver` keeps Vue Teleport subtrees in sync.
- **`src/stores/profile.ts` imports types from a sibling repo.** The path `../../../dashboard-app/frontend/types/database.type` resolves to a directory _next to_ this repo's root, not inside it. The actual repo is [`codebridger/subturtle-dashboard-app`](https://github.com/codebridger/subturtle-dashboard-app); local builds work because devs check both repos out side-by-side. CI clones the dashboard repo into `../dashboard-app/` before `yarn build` runs (see [.github/workflows/release.yml](.github/workflows/release.yml)). Don't try to "fix" the import to a relative-internal path or vendor the file β€” both will drift.
- **`src/stores/profile.ts` imports types from a sibling repo.** The path `../../../dashboard-app/frontend/types/database.type` resolves to a directory _next to_ this repo's root, not inside it. The actual repo is [`codebridger/subturtle-dashboard-app`](https://github.com/codebridger/subturtle-dashboard-app); local builds work because devs check both repos out side-by-side. CI clones the dashboard repo into `../dashboard-app/` before `yarn build` runs (see [.github/workflows/release.yml](.github/workflows/release.yml)). Don't try to "fix" the import to a relative-internal path or vendor the file β€” both will drift. See [Β§ Sibling repositories](#sibling-repositories) for how to pull in / branch the dashboard and pilotui repos.
- **Playwright Chromium download isn't on CCW's Trusted allowlist.** The chrome-extension-tester-mcp's `postinstall` runs `playwright install chromium`, which pulls from `cdn.playwright.dev` / `playwright.download.prss.microsoft.com`. CCW environments must use Custom network access with those hosts added β€” see [Β§ Cloud agent workflow](#cloud-agent-workflow-claude-code-on-the-web). The setup script caches Chromium into the VM snapshot so per-session cost is zero.

## Adding things
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Latest updates
## Table of Contents

- [Project Overview](#project-overview)
- [Sibling Repositories](#sibling-repositories)
- [Architecture](#architecture)
- [Main Modules](#main-modules)
- [Data Flow](#data-flow)
Expand All @@ -75,6 +76,19 @@ Subturtle is a Chrome extension designed to help users learn English while watch

---

## Sibling Repositories

This extension is one of three side-by-side `codebridger` repositories. The other two are normally checked out **next to** this repo (`../`) and developed together via the `subturtle-all-apps.code-workspace`.

| Sibling | Repository | Role | How it's used here |
| --- | --- | --- | --- |
| Server / Dashboard | [`codebridger/subturtle-dashboard-app`](https://github.com/codebridger/subturtle-dashboard-app) | Backend API, auth, and user dashboard (dev server at `https://dev.dashboard.subturtle.app/`). | The extension imports shared TypeScript types from it (`src/stores/profile.ts`) and talks to its API at runtime. Cloned to `../dashboard-app` locally and in CI. |
| pilotUI | [`codebridger/pilotui`](https://github.com/codebridger/pilotui) | Shared Tailwind/Vue component library. | Installed as the npm dependency `pilotui` and used across the popup, ConsoleCrane, and subtitle UIs. |

**For contributors and AI agents:** a change in this repo sometimes needs a matching change in a sibling. You can **search for the sibling locally first** (it's usually already cloned next to this repo) and **download/clone it if it's missing**. When you need to modify a sibling to test this repo's branch, do it on a **feature branch in the sibling** rather than committing to its `main`. The full search/clone/branch/link workflow is documented in [CLAUDE.md Β§ Sibling repositories](./CLAUDE.md#sibling-repositories).

---

## Architecture

### Main Modules
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "subturtle-extension",
"version": "1.15.0",
"version": "1.15.1-dev.1",
"private": true,
"scripts": {
"dev": "webpack --watch",
Expand Down
152 changes: 147 additions & 5 deletions src/console-crane/components/SelectPhraseBundleV2.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<template>
<div class="relative w-full">
<div ref="rootRef" class="relative w-full">
<Select v-model="selected" :options="options" multiple custom labelKey="title" valueKey="_id"
:placeholder="showSuggestion ? '' : 'Select Phrase Bundles to save...'">
:placeholder="showSuggestion ? '' : 'Select Phrase Bundles to save...'" @open="onDropdownOpen"
@close="onDropdownClose">
<template #selected="{
selectedOption,
selectedOptions,
Expand Down Expand Up @@ -108,6 +109,11 @@ const isCreating = ref(false);
const searchedBundleName = ref("");
const options = ref<PhraseBundleType[]>([]);

// Component root β€” boundary for our own outside-click close (see below).
const rootRef = ref<HTMLElement | null>(null);
// Mirrors pilotui Select's internal open state via its open/close events.
const isDropdownOpen = ref(false);

// In-field suggested bundle (shown only when nothing is selected yet).
const isEditingSuggested = ref(false);
const editBuffer = ref("");
Expand Down Expand Up @@ -273,19 +279,133 @@ watch(searchedBundleName, () => {
}, 300); // 300ms debounce
});

/**
* Close the pilotui Select dropdown.
*
* pilotui's Select owns its open state internally and exposes no close method or
* `open` prop β€” the only outside-driven close it offers is its own document
* click handler, which bails whenever the click lands inside ANY `.relative`
* ancestor (Select.vue `handleClickOutside`). Inside the ConsoleCrane modal β€”
* where almost everything sits under a Tailwind `relative` wrapper β€” that guard
* matches on nearly every click, so the dropdown effectively never closes.
*
* We drive pilotui's own close path instead by dispatching the Escape key its
* trigger button already handles (`handleKeydown` β†’ `closeDropdown`). It's
* idempotent: closing an already-closed dropdown is a no-op, so this can't
* accidentally re-open. Used both for our outside-click handler and by
* SaveWordSectionV2 after a successful save.
*/
function closeDropdown() {
const trigger = rootRef.value?.querySelector<HTMLButtonElement>(
'button[aria-haspopup="true"]'
);
trigger?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
);
}

/**
* Close the dropdown when the user clicks anywhere outside this component.
* Replaces pilotui's `.relative`-based outside detection, which misfires inside
* the modal (see closeDropdown). We only act while open and only for clicks that
* land outside our root, so in-dropdown interactions (multi-select toggles,
* search, create, chip removal, suggestion edit) are untouched.
*/
function handleOutsidePointer(event: Event) {
if (!isDropdownOpen.value) return;
const root = rootRef.value;
if (root && !root.contains(event.target as Node)) {
closeDropdown();
}
}

/**
* Size and place the open dropdown so it never runs past the modal frame
* (ClickUp 86exzbh61 follow-up). The save section sits near the bottom of the
* ConsoleCrane modal, so pilotui's fixed downward max-h-96 panel spilled below
* the visible frame β€” forcing a modal scroll on top of the list's own scroll.
*
* pilotui has no placement API, so on open we measure the trigger against the
* nearest scroll frame and set inline styles on its absolutely-positioned panel:
* flip it upward when it can't fully open downward and there's more room above,
* and cap its height to the available space (minus a gap) in whichever direction
* it opens. The list scrolls internally within that cap (see scoped styles).
*/
function positionDropdown() {
const root = rootRef.value;
if (!root) return;
const panel = root.querySelector<HTMLElement>('[role="listbox"]');
const trigger = root.querySelector<HTMLElement>('button[aria-haspopup="true"]');
if (!panel || !trigger) return;

const GAP = 12; // breathing room between the panel and the frame edge
const MAX = 336; // pilotui's max-h-96 (24rem → 336px after the rem→px rewrite)

const frameEl =
root.closest<HTMLElement>(".overflow-y-auto") ?? document.documentElement;
const frame = frameEl.getBoundingClientRect();
const t = trigger.getBoundingClientRect();

const below = frame.bottom - t.bottom - GAP;
const above = t.top - frame.top - GAP;

// Flip up only when the list can't fully open downward and there's more room
// above; otherwise keep the natural downward placement.
const openUp = below < MAX && above > below;
const avail = Math.max(0, openUp ? above : below);

panel.style.maxHeight = `${Math.min(MAX, avail)}px`;
if (openUp) {
panel.style.top = "auto";
panel.style.bottom = "calc(100% + 4px)";
panel.style.marginTop = "0";
} else {
// Clear any prior upward placement (panel is reused across reposition runs).
panel.style.top = "";
panel.style.bottom = "";
panel.style.marginTop = "";
}
}

// Reposition the open dropdown when the viewport or modal body changes size /
// scrolls; torn down again on close so the listeners only live while open.
let removeReposition: (() => void) | null = null;

function onDropdownOpen() {
isDropdownOpen.value = true;
nextTick(positionDropdown);

if (removeReposition) return;
const handler = () => positionDropdown();
const frameEl: Window | HTMLElement =
rootRef.value?.closest<HTMLElement>(".overflow-y-auto") ?? window;
window.addEventListener("resize", handler);
frameEl.addEventListener("scroll", handler, { passive: true });
removeReposition = () => {
window.removeEventListener("resize", handler);
frameEl.removeEventListener("scroll", handler);
};
}

function onDropdownClose() {
isDropdownOpen.value = false;
removeReposition?.();
removeReposition = null;
}

onMounted(() => {
fetchOptions();
document.addEventListener("pointerdown", handleOutsidePointer);
});

onBeforeUnmount(() => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
document.removeEventListener("pointerdown", handleOutsidePointer);
removeReposition?.();
});

// Expose method for compatibility (Select manages its own open state)
function closeDropdown() { }

defineExpose({
closeDropdown,
});
Expand Down Expand Up @@ -324,4 +444,26 @@ defineExpose({
.relative.w-full :deep(.flex.flex-col > .relative > .relative > button) {
flex: 1 1 auto;
}

/*
Keep the open dropdown panel inside its own max-height instead of spilling the
bundle list out of the modal (ClickUp 86exzbh61). pilotui only gives the
option list an internal scroll in `confirm` mode; in the `custom` mode we use
here the list container is a plain `flex-1` with no overflow, so a long list
grows past the panel's max-height and out of the modal. Make the panel a flex
column and let the list region scroll within it.

Targets pilotui's internal markup (the absolutely-positioned listbox panel and
its `flex-1` body); a pilotui nesting change would make this a cosmetic
regression, not a functional break.
*/
.relative.w-full :deep([role="listbox"]) {
display: flex;
flex-direction: column;
}

.relative.w-full :deep([role="listbox"] > .flex-1) {
min-height: 0;
overflow-y: auto;
}
</style>
5 changes: 3 additions & 2 deletions static/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Subturtle: Learn English with Subtitles, Videos & AI Coach",
"description": "Learn English from any video or web page: tap a subtitle word for its meaning, save and review words, and speak with an AI coach.",
"version": "1.15.0",
"version": "1.15.1.1",
"manifest_version": 3,
"icons": {
"128": "/assets/logo-128.png",
Expand Down Expand Up @@ -79,5 +79,6 @@
"<all_urls>"
]
}
]
],
"version_name": "1.15.1-dev.1"
}
Loading