Skip to content

Commit 12d1ec8

Browse files
authored
Merge pull request #29 from codebridger/fix/popup-console-pages-86exu5xd7
fix(popup): render Practice/Preview console pages in the popup router
2 parents dcea832 + 950a15d commit 12d1ec8

9 files changed

Lines changed: 282 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ Implications:
6060
- **Asset URLs need `web_accessible_resources`.** `chrome.runtime.getURL("/assets/foo.png")` returns `chrome-extension://invalid/` for paths not declared accessible. The manifest already exposes `assets/*` to all URLs.
6161
- Webpack entry points live in [webpack.config.js](webpack.config.js) — add a new entry there for any new content script.
6262

63+
## Code comments
64+
65+
Multi-line comments and docstrings are fine — encouraged, even, where the *why* is non-obvious (ESM init-order traps, cross-bundle behaviour, browser/extension quirks). Match the surrounding style (`navigation.ts`, `modular-rest.ts`, the `word-detail` JSDoc). There is **no** "one short line max" rule; don't collapse an explanatory block to a single line just to be terse.
66+
6367
## Shared APIs
6468

6569
### ConsoleCrane bridge
@@ -114,6 +118,8 @@ Also emits `loading: boolean` mirroring its internal pending state — bind it o
114118

115119
If you ever need this from a content-script bundle that runs alongside ConsoleCrane, use the [bridge](#consolecrane-bridge) instead — don't double-mount the same component on the same page.
116120

121+
**The popup hosts the console pages in its own router — no modal.** `WordDetailModule`'s "Practice with AI" / "Preview flashcard" buttons open the `practice-config` / `flashcard-preview` console pages. On the popup page there's no `console-crane.js` content script and so no modal to render into. Instead the popup registers those console modules as routes in [its own router](src/popup/router.ts) (each wrapped in a back-header [ConsolePageScaffold](src/popup/components/ConsolePageScaffold.vue) under [src/popup/views/](src/popup/views/)), and [src/popup/App.vue](src/popup/App.vue) `provide()`s an `openConsolePage(page, params)` navigator that `router.push`es that route (params Unicode-safely encoded via `encodeRouteParams`). `WordDetailModule` `inject`s it, so the actions render as full popup pages with a working Back. In the console-crane content script there's no provider, so the same module falls back to the store-driven modal (unchanged). Home is `<keep-alive>`d (`include="HomeView"`) so Back returns to the existing translation rather than a blank input. The console modules (`practice-config`, `flashcard-preview`) are router-agnostic — they read only `route.params.data` — so they work in either router unchanged. Regression-tested in [tests/e2e/popup-console-crane.spec.ts](tests/e2e/popup-console-crane.spec.ts).
122+
117123
### Settings store
118124

119125
[src/common/store/settings.ts](src/common/store/settings.ts) — Pinia store, syncs through background via `SYNC_SETTINGS`. Holds:
@@ -325,6 +331,7 @@ tests/
325331
translate-flow.spec.ts # full Persian translate-and-save with page.route stubs
326332
password-login.spec.ts # popup password form end-to-end with stubbed /user/login
327333
visual-scale.spec.ts # rem→px rewrite regression net
334+
popup-console-crane.spec.ts # popup.html: Practice/Preview navigate to console pages in the popup router (no modal)
328335
```
329336

330337
### Test totals
@@ -461,6 +468,7 @@ Automated:
461468
- Selection → Subturtle icon → translated card → Save → ConsoleCrane opens with WordDetail rendering Persian content. → [tests/e2e/translate-flow.spec.ts](tests/e2e/translate-flow.spec.ts).
462469
- Toggling Nibble OFF for a host **while ConsoleCrane is open** does NOT close the modal or release the body scroll lock. → [tests/e2e/console-crane-lifecycle.spec.ts](tests/e2e/console-crane-lifecycle.spec.ts).
463470
- Popup translate input: auto-focus on open, spinner while pending, re-submit different word resets, no double-fetch on enter mash. → [tests/translate-card.test.ts](tests/translate-card.test.ts).
471+
- On `popup.html`, translating a phrase then clicking **Practice with AI** / **Preview flashcard** navigates to the matching console page in the popup's own router (no modal); **Back** returns to the kept-alive Home with the translation intact. → [tests/e2e/popup-console-crane.spec.ts](tests/e2e/popup-console-crane.spec.ts).
464472
- Per-host Nibble toggle persists and normalizes (`www.` strip, case fold, dedup). → [tests/settings-host.test.ts](tests/settings-host.test.ts).
465473
- ConsoleCrane on Persian / CJK / emoji inputs throws no `InvalidCharacterError` from `btoa` — covered at the encode level, the bridge level, and the full select-and-save flow. → [tests/route-params.test.ts](tests/route-params.test.ts), [tests/e2e/nibble-flow.spec.ts](tests/e2e/nibble-flow.spec.ts), [tests/e2e/translate-flow.spec.ts](tests/e2e/translate-flow.spec.ts).
466474
- Visual scale is consistent on default-html-fontsize and 24px-html-fontsize hosts (postcss `rem→px` rewrite regression net). → [tests/e2e/visual-scale.spec.ts](tests/e2e/visual-scale.spec.ts).

src/console-crane/modules/word-detail/index.vue

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -333,38 +333,45 @@ const context = computed(() => {
333333
return wordData.value?.context || getProps().context || "";
334334
});
335335
336+
/**
337+
* How phrase actions open a console page. The popup provides a router-based
338+
* navigator (`openConsolePage`) that renders practice/flashcard as full popup
339+
* pages. In the console-crane content script there is no provider, so we fall
340+
* back to the store-driven modal. Keeps this shared module agnostic to its host.
341+
*/
342+
const openConsolePage = inject<
343+
((page: string, params: Record<string, any>) => void) | null
344+
>("openConsolePage", null);
345+
346+
function openConsole(page: string, params: Record<string, any>) {
347+
if (openConsolePage) openConsolePage(page, params);
348+
else useConsoleCraneStore().toggleConsoleCrane(page, params, true);
349+
}
350+
336351
/** Open the AI practice config page for this phrase. */
337352
function startPracticeWithAI() {
338353
analytic.track("practice-now_opened");
339-
useConsoleCraneStore().toggleConsoleCrane(
340-
"practice-config",
341-
{
342-
phrase: cleanText(getProps().word || ""),
343-
translation: cleanText(wordData.value?.translation?.phrase || ""),
344-
context: context.value,
345-
chunks: wordData.value?.chunks || [],
346-
direction: wordData.value?.direction,
347-
language_info: wordData.value?.language_info,
348-
linguistic_data: wordData.value?.linguistic_data,
349-
},
350-
true
351-
);
354+
openConsole("practice-config", {
355+
phrase: cleanText(getProps().word || ""),
356+
translation: cleanText(wordData.value?.translation?.phrase || ""),
357+
context: context.value,
358+
chunks: wordData.value?.chunks || [],
359+
direction: wordData.value?.direction,
360+
language_info: wordData.value?.language_info,
361+
linguistic_data: wordData.value?.linguistic_data,
362+
});
352363
}
353364
354365
/** Open the flashcard cloze preview page for this phrase. */
355366
function openFlashcardPreview() {
356367
analytic.track("flashcard-preview_opened");
357-
useConsoleCraneStore().toggleConsoleCrane(
358-
"flashcard-preview",
359-
{
360-
phrase: cleanText(getProps().word || ""),
361-
translation: cleanText(wordData.value?.translation?.phrase || ""),
362-
context: context.value,
363-
chunks: wordData.value?.chunks || [],
364-
direction: wordData.value?.direction,
365-
},
366-
true
367-
);
368+
openConsole("flashcard-preview", {
369+
phrase: cleanText(getProps().word || ""),
370+
translation: cleanText(wordData.value?.translation?.phrase || ""),
371+
context: context.value,
372+
chunks: wordData.value?.chunks || [],
373+
direction: wordData.value?.direction,
374+
});
368375
}
369376
370377
/**

src/popup/App.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
<template>
22
<div class="bg-white dark:bg-gray-950">
33
<PopupLoader v-if="!ready" />
4-
<router-view v-else />
4+
<router-view v-else v-slot="{ Component }">
5+
<!-- Keep Home alive so returning from a console page (Back) restores the
6+
existing translation + action buttons instead of a blank input. -->
7+
<keep-alive include="HomeView">
8+
<component :is="Component" />
9+
</keep-alive>
10+
</router-view>
511
</div>
612
</template>
713

814
<script lang="ts" setup>
9-
import { ref } from "vue";
15+
import { ref, provide } from "vue";
1016
import { RouterView, useRouter } from "vue-router";
1117
import PopupLoader from "./components/PopupLoader.vue";
18+
import { encodeRouteParams } from "../console-crane/route-params";
1219
1320
const router = useRouter();
1421
const ready = ref(false);
1522
router.isReady().then(() => {
1623
ready.value = true;
1724
});
25+
26+
// The console pages (practice-config, flashcard-preview) live in THIS popup
27+
// router and render as full popup pages — there is no modal on the popup page.
28+
// WordDetailModule (reused from console-crane) injects this navigator and pushes
29+
// the popup router. In the console-crane content script there is no provider, so
30+
// the same module falls back to its store-driven modal — see
31+
// src/console-crane/modules/word-detail/index.vue.
32+
provide("openConsolePage", (page: string, params: Record<string, any>) => {
33+
router.push({ name: page, params: { data: encodeRouteParams(params) } });
34+
});
1835
</script>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<template>
2+
<!-- Frame for a console page rendered inside the popup router: a sticky back
3+
header over the page body. The popup is a real page, so Back is plain
4+
router navigation (returns to the kept-alive Home with its translation). -->
5+
<div class="min-h-[600px] w-full bg-white dark:bg-gray-950 overflow-y-auto">
6+
<div
7+
class="sticky top-0 z-10 flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-950/95 backdrop-blur"
8+
>
9+
<button
10+
type="button"
11+
class="inline-flex items-center gap-1 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
12+
@click="goBack"
13+
>
14+
<i class="i-mdi-arrow-left text-lg" />
15+
Back
16+
</button>
17+
</div>
18+
<slot />
19+
</div>
20+
</template>
21+
22+
<script setup lang="ts">
23+
import { useRouter } from "vue-router";
24+
25+
const router = useRouter();
26+
27+
function goBack() {
28+
// Prefer real history back so we land on the kept-alive Home; fall back to a
29+
// Home push if this page was somehow entered without a prior entry.
30+
const hasBack = !!(window.history.state && window.history.state.back != null);
31+
if (hasBack) router.back();
32+
else router.push({ name: "home" });
33+
}
34+
</script>

src/popup/router.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import HomeView from "./views/HomeView.vue";
88
import LoginView from "./views/LoginView.vue";
99
import IntroView from "./views/IntroView.vue";
1010
import HelpView from "./views/HelpView.vue";
11+
import PracticeConfigView from "./views/PracticeConfigView.vue";
12+
import FlashcardPreviewView from "./views/FlashcardPreviewView.vue";
1113

1214
const routes: RouteRecordRaw[] = [
1315
{
@@ -30,6 +32,19 @@ const routes: RouteRecordRaw[] = [
3032
name: "help",
3133
component: HelpView,
3234
},
35+
// Console pages, hosted in the popup router so they render as full popup pages
36+
// (not a modal). Reached from WordDetailModule's phrase actions via the
37+
// openConsolePage navigator provided in App.vue. `:data` is the encoded params.
38+
{
39+
path: "/practice-config/:data",
40+
name: "practice-config",
41+
component: PracticeConfigView,
42+
},
43+
{
44+
path: "/flashcard-preview/:data",
45+
name: "flashcard-preview",
46+
component: FlashcardPreviewView,
47+
},
3348
];
3449

3550
export const router = createRouter({
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<template>
2+
<ConsolePageScaffold>
3+
<FlashcardPreviewPage />
4+
</ConsolePageScaffold>
5+
</template>
6+
7+
<script setup lang="ts">
8+
// Popup route wrapper around the shared flashcard-preview console module. The
9+
// module reads its data from this popup route's `:data` param — router-agnostic.
10+
import ConsolePageScaffold from "../components/ConsolePageScaffold.vue";
11+
import FlashcardPreviewPage from "../../console-crane/modules/flashcard-preview/index.vue";
12+
</script>

src/popup/views/HomeView.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,10 @@ import { getSubturtleDashboardUrlWithToken } from "../../common/static/global";
297297
import { useSettingsStore } from "../../common/store/settings";
298298
import TranslateCard from "../components/TranslateCard.vue";
299299
300+
// Named so App.vue's <keep-alive include="HomeView"> can cache this view —
301+
// returning from a console page (Back) then restores the existing translation.
302+
defineOptions({ name: "HomeView" });
303+
300304
const router = useRouter();
301305
const isLoading = ref(false);
302306
const showLogoutConfirm = ref(false);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<template>
2+
<ConsolePageScaffold>
3+
<PracticeConfigPage />
4+
</ConsolePageScaffold>
5+
</template>
6+
7+
<script setup lang="ts">
8+
// Popup route wrapper around the shared practice-config console module. The
9+
// module reads { phrase, ... } from this popup route's `:data` param (encoded by
10+
// App.vue's openConsolePage navigator) — it's router-agnostic.
11+
import ConsolePageScaffold from "../components/ConsolePageScaffold.vue";
12+
import PracticeConfigPage from "../../console-crane/modules/practice-config/index.vue";
13+
</script>
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { test, expect } from "./extension-fixture";
2+
import type { Route, Page } from "@playwright/test";
3+
4+
// Regression test for ClickUp 86exu5xd7 — "Bring the ConsoleCrane into popup app".
5+
//
6+
// The popup reuses WordDetailModule, whose "Practice with AI" / "Preview
7+
// flashcard" buttons open the practice-config / flashcard-preview console pages.
8+
// On the popup page (no console-crane.js content script) these are hosted in the
9+
// popup's OWN router and render as full popup pages — no modal. This drives the
10+
// real popup.html end-to-end:
11+
//
12+
// open popup.html → translate stub → WordDetail renders + action buttons →
13+
// click → popup router navigates to the console page (no modal) → Back →
14+
// Home is restored (kept-alive) with the translation still shown.
15+
16+
async function stubTranslate(page: Page) {
17+
await page.route("**/function/run", async (route: Route) => {
18+
const body = route.request().postDataJSON();
19+
if (body?.name === "translateWithContext") {
20+
const phrase: string = body.args?.phrase ?? "";
21+
const context: string = body.args?.context ?? "";
22+
23+
if (body.args?.translationType === "simple") {
24+
return route.fulfill({
25+
status: 200,
26+
contentType: "application/json",
27+
body: JSON.stringify({ data: `[stub] ${phrase}` }),
28+
});
29+
}
30+
31+
// detailed — a truthy translation.phrase is what makes the action buttons show.
32+
return route.fulfill({
33+
status: 200,
34+
contentType: "application/json",
35+
body: JSON.stringify({
36+
data: {
37+
translation: { phrase: `[translated] ${phrase}`, context: "" },
38+
direction: { source: "ltr", target: "ltr" },
39+
language_info: { source: "English", target: "Spanish" },
40+
linguistic_data: {
41+
isValid: true,
42+
type: "noun",
43+
definition: "a container with a flat base and sides",
44+
phonetic: { transliteration: "bɒks" },
45+
formality_level: "neutral",
46+
},
47+
chunks: [],
48+
context,
49+
},
50+
}),
51+
});
52+
}
53+
return route.fallback();
54+
});
55+
56+
// Auth + saved-phrase lookups offline so the anonymous-login chain doesn't
57+
// reach the network or stall the flow.
58+
await page.route("**/user/**", (route) =>
59+
route.fulfill({
60+
status: 200,
61+
contentType: "application/json",
62+
body: JSON.stringify({ data: {} }),
63+
})
64+
);
65+
await page.route("**/data-provider/**", (route) =>
66+
route.fulfill({
67+
status: 200,
68+
contentType: "application/json",
69+
body: JSON.stringify({ data: null }),
70+
})
71+
);
72+
}
73+
74+
/**
75+
* Open the popup page and translate a phrase. Asserts there is NO ConsoleCrane
76+
* modal app on the popup page (the old approach), then returns once WordDetail
77+
* has rendered its action buttons.
78+
*/
79+
async function openPopupAndTranslate(page: Page, extensionId: string) {
80+
await stubTranslate(page);
81+
await page.goto(`chrome-extension://${extensionId}/popup.html`);
82+
83+
// No modal ConsoleCrane app is mounted on the popup page anymore.
84+
await expect(page.locator("#subturtle-console-crane-root")).toHaveCount(0);
85+
86+
const input = page.getByPlaceholder("Type or paste any text");
87+
await expect(input).toBeVisible({ timeout: 15_000 });
88+
await input.fill("box");
89+
await input.press("Enter");
90+
91+
await expect(
92+
page.locator('#subturtle-popup button:has-text("Practice with AI")')
93+
).toBeVisible({ timeout: 15_000 });
94+
}
95+
96+
test.describe("Popup: ConsoleCrane phrase actions (router pages, no modal)", () => {
97+
test('"Practice with AI" navigates to the practice page; Back restores Home', async ({
98+
context,
99+
extensionId,
100+
}) => {
101+
const page = await context.newPage();
102+
await openPopupAndTranslate(page, extensionId);
103+
104+
await page
105+
.locator('#subturtle-popup button:has-text("Practice with AI")')
106+
.click();
107+
108+
// Popup router navigated to the console page — full popup page, no modal.
109+
await expect(page.locator("#subturtle-popup")).toContainText(
110+
"Choose a coach voice",
111+
{ timeout: 15_000 }
112+
);
113+
expect(page.url()).toContain("#/practice-config/");
114+
await expect(page.locator("#subturtle-console-crane-root")).toHaveCount(0);
115+
116+
// Back returns to the kept-alive Home with the translation still rendered.
117+
await page.locator('#subturtle-popup button:has-text("Back")').click();
118+
await expect(page.getByPlaceholder("Type or paste any text")).toBeVisible();
119+
await expect(
120+
page.locator('#subturtle-popup button:has-text("Practice with AI")')
121+
).toBeVisible();
122+
123+
await page.close();
124+
});
125+
126+
test('"Preview flashcard" navigates to the flashcard page', async ({
127+
context,
128+
extensionId,
129+
}) => {
130+
const page = await context.newPage();
131+
await openPopupAndTranslate(page, extensionId);
132+
133+
await page
134+
.locator('#subturtle-popup button:has-text("Preview flashcard")')
135+
.click();
136+
137+
await expect(page.locator("#subturtle-popup")).toContainText(
138+
"Flashcard preview",
139+
{ timeout: 15_000 }
140+
);
141+
expect(page.url()).toContain("#/flashcard-preview/");
142+
await expect(page.locator("#subturtle-console-crane-root")).toHaveCount(0);
143+
144+
await page.close();
145+
});
146+
});

0 commit comments

Comments
 (0)