diff --git a/.cursor/rules/frontend-component.mdc b/.cursor/rules/frontend-component.mdc index 79cf5660f2..c3b7286994 100644 --- a/.cursor/rules/frontend-component.mdc +++ b/.cursor/rules/frontend-component.mdc @@ -16,14 +16,14 @@ Use this rule when writing or modifying Vue components, component SCSS, or Story ## Core Technologies - Vue 3 with TypeScript. -- DaisyUI + Tailwind for styling, with the `daisy-` prefix. +- DaisyUI + Tailwind for styling: unprefixed Tailwind utilities (`flex`, `text-primary`, …) and `daisy-`-prefixed DaisyUI component classes (`daisy-btn`, `daisy-card`, …). - Vitest for testing with Playwright browser mode. - Biome for linting and formatting. ## Icons -- Prefer Lucide for normal UI icons via `lucide-vue-next` (`import { IconName } from "lucide-vue-next"`). Import only the icons each file needs; rely on `currentColor` so icons follow text/theme color. -- `lucide-vue-next` defaults to 24x24 via the `size` prop. Use `:size="..."` and/or `daisy-w-*`, `daisy-h-*`, or `daisy-size-*`. Never use bare `w-*` / `h-*`; Tailwind uses the `daisy-` prefix here. +- Prefer Lucide for normal UI icons via `@lucide/vue` (`import { IconName } from "@lucide/vue"`). Import only the icons each file needs; rely on `currentColor` so icons follow text/theme color. +- `@lucide/vue` defaults to 24x24 via the `size` prop. Use `:size="..."` and/or Tailwind `w-*`, `h-*`, or `size-*` on icons. ## Naming Conventions diff --git a/biome.json b/biome.json index 56763d207c..102dcd4e74 100644 --- a/biome.json +++ b/biome.json @@ -16,6 +16,7 @@ "!**/.idea", "!**/.vscode", "!**/dist", + "!**/storybook-static", "!**/gradle", "!**/node_modules", "!**/*.md", diff --git a/cli/package.json b/cli/package.json index 4532bdee22..1ed0a4f275 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,7 +12,7 @@ }, "engines": { "node": ">=24.14", - "pnpm": ">=11.1.2" + "pnpm": ">=11.1.3" }, "scripts": { "preinstall": "npx only-allow pnpm", @@ -38,13 +38,13 @@ "devDependencies": { "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", - "@types/node": "25.7.0", + "@types/node": "25.9.0", "@types/react": "^19.2.14", "doughnut-test-fixtures": "workspace:*", "esbuild": "0.28.0", "ink": "^7.0.3", "ink-testing-library": "^4.0.0", - "tsx": "4.21.0", + "tsx": "4.22.2", "typescript": "6.0.3", "vitest": "4.1.6" } diff --git a/e2e_test/start/formField.ts b/e2e_test/start/formField.ts index a1e032ec9c..561d83a5d3 100644 --- a/e2e_test/start/formField.ts +++ b/e2e_test/start/formField.ts @@ -48,11 +48,11 @@ const formField = (label: string) => { return self }, expectError(message: string) { - formControl(label).find('.daisy-text-error').findByText(message) + formControl(label).find('.text-error').findByText(message) return self }, expectNoError() { - formControl(label).find('.daisy-text-error').should('not.exist') + formControl(label).find('.text-error').should('not.exist') return self }, type(text: string) { diff --git a/e2e_test/start/pageBase.ts b/e2e_test/start/pageBase.ts index 7ce2ecf222..06c13472fe 100644 --- a/e2e_test/start/pageBase.ts +++ b/e2e_test/start/pageBase.ts @@ -3,10 +3,5 @@ /** Waits until no `.loading-bar` nodes remain (thin bar + spinners). */ export const pageIsNotLoading = () => { - cy.get('body').should( - ($body) => { - expect($body.find('.loading-bar').length).to.eq(0) - }, - { timeout: 30000 } - ) + cy.get('.loading-bar', { timeout: 30000 }).should('not.exist') } diff --git a/e2e_test/start/pageObjects/QuizQuestionPage.ts b/e2e_test/start/pageObjects/QuizQuestionPage.ts index c431326747..e4f6497589 100644 --- a/e2e_test/start/pageObjects/QuizQuestionPage.ts +++ b/e2e_test/start/pageObjects/QuizQuestionPage.ts @@ -22,7 +22,7 @@ const assumeQuestionPage = (stem?: string) => { skipQuestion() { pageIsNotLoading() getQuestionSection().should('exist') - cy.get('.daisy-progress-bar').first().click() + cy.get('.progress-bar').first().click() cy.findByRole('button', { name: 'Move to end of list' }).click() }, answerFirstOption() { diff --git a/e2e_test/start/pageObjects/adminPages/adminDashboardPage.ts b/e2e_test/start/pageObjects/adminPages/adminDashboardPage.ts index 01c8eb9045..a2598d1b70 100644 --- a/e2e_test/start/pageObjects/adminPages/adminDashboardPage.ts +++ b/e2e_test/start/pageObjects/adminPages/adminDashboardPage.ts @@ -1,3 +1,4 @@ +import { clickDaisyDialogButton } from '../../../support/daisyModalHelpers' import { pageIsNotLoading } from '../../pageBase' import { submittableForm } from '../../forms' @@ -16,9 +17,7 @@ export function assumeAdminDashboardPage() { }, deleteSelected() { cy.get('button').contains('Delete Selected').click() - cy.get('.daisy-modal-action') - .findByRole('button', { name: 'Delete' }) - .click() + clickDaisyDialogButton('dialog.daisy-modal', 'Delete') return this }, shouldBeEmpty() { diff --git a/e2e_test/start/pageObjects/assimilationPage.ts b/e2e_test/start/pageObjects/assimilationPage.ts index bade9e8b87..16171e0617 100644 --- a/e2e_test/start/pageObjects/assimilationPage.ts +++ b/e2e_test/start/pageObjects/assimilationPage.ts @@ -11,22 +11,33 @@ const understandingChecklist = () => cy .get('[data-test="refine-note-modal"]') .contains('Understanding Checklist:') - .closest('.daisy-bg-accent') + .closest('.bg-accent') -const mainNoteTitle = () => - cy.get('#main-note-content [data-test="note-title"]', { timeout: 15000 }) +const mainNoteHeadingTitleSelector = + '#main-note-content h2.path-name-heading [role=title], #main-note-content [data-test="note-title"]' + +function waitForAssimilationNoteTitle(expectedTitle?: string) { + pageIsNotLoading() + cy.get('#main-note-content', { timeout: 15000 }).should('be.visible') + const title = cy.get(mainNoteHeadingTitleSelector, { timeout: 15000 }) + if (expectedTitle !== undefined && expectedTitle.trim() !== '') { + title.should('contain', expectedTitle.trim()) + } else { + title.should('exist') + } +} export const assumeAssimilationPage = () => ({ expectToAssimilateAndTotal(toAssimilateAndTotal: string) { const [assimilatedTodayCount, toAssimilateCountForToday, totalCount] = toAssimilateAndTotal.split('/') - cy.get('.daisy-progress-bar').should( + cy.get('.progress-bar').should( 'contain', `Assimilating: ${assimilatedTodayCount}/${toAssimilateCountForToday}` ) // Click progress bar to show tooltip - cy.get('.daisy-progress-bar').first().click() + cy.get('.progress-bar').first().click() // Check tooltip content cy.get('.tooltip-content').within(() => { @@ -61,7 +72,8 @@ export const assumeAssimilationPage = () => ({ return this }, assimilateWithSpellingOption() { - cy.get('[data-test="note-title"]') + waitForAssimilationNoteTitle() + cy.get(mainNoteHeadingTitleSelector) .first() .invoke('text') .then((noteTitle: string) => { @@ -85,7 +97,8 @@ export const assumeAssimilationPage = () => ({ } else { switch (assimilationType) { case 'single note': { - if (title) mainNoteTitle().should('contain', title) + waitForAssimilationNoteTitle(title) + this.waitForAssimilationReady() if (additionalInfo) { cy.get('.note-content').should('contain', additionalInfo) } @@ -93,6 +106,8 @@ export const assumeAssimilationPage = () => ({ } case 'image note': { + waitForAssimilationNoteTitle() + this.waitForAssimilationReady() if (additionalInfo) { const [expectedBodyText, expectedImage] = commonSenseSplit( additionalInfo, @@ -113,9 +128,15 @@ export const assumeAssimilationPage = () => ({ additionalInfo, '; ' ) - if (title) mainNoteTitle().should('contain', title) - if (targetNote) mainNoteTitle().should('contain', targetNote) - if (relationType) mainNoteTitle().should('contain', relationType) + if (title) waitForAssimilationNoteTitle(title) + if (targetNote) waitForAssimilationNoteTitle(targetNote) + if (relationType) { + cy.get(mainNoteHeadingTitleSelector).should( + 'contain', + relationType + ) + } + this.waitForAssimilationReady() } break } @@ -274,6 +295,7 @@ export const assimilation = () => { getAssimilateListItemInSidebar(($el) => { $el.click() }) + pageIsNotLoading() return assumeAssimilationPage() }, } diff --git a/e2e_test/start/pageObjects/bookReadingPage.ts b/e2e_test/start/pageObjects/bookReadingPage.ts index 28609dde11..c4b495ec39 100644 --- a/e2e_test/start/pageObjects/bookReadingPage.ts +++ b/e2e_test/start/pageObjects/bookReadingPage.ts @@ -1,3 +1,7 @@ +import { + expectDaisyDialogBoxVisible, + openDaisyDialog, +} from '../../support/daisyModalHelpers' import { pageIsNotLoading } from '../pageBase' export type BookLayoutRow = { depth: number; title: string } @@ -646,10 +650,8 @@ const bookReadingPage = () => { }, expectReorganizationPreviewDialog() { pageIsNotLoading() - cy.get('[data-testid="book-layout-reorganize-preview-dialog"]').should( - 'have.class', - 'daisy-modal-open' - ) + const dialog = '[data-testid="book-layout-reorganize-preview-dialog"]' + expectDaisyDialogBoxVisible(dialog) cy.get('#book-layout-reorganize-preview-title').should( 'contain', 'Reorganize layout (preview)' @@ -661,23 +663,24 @@ const bookReadingPage = () => { suggestedDepth: number ) { pageIsNotLoading() - cy.get('[data-testid="book-layout-reorganize-preview-dialog"]') - .should('have.class', 'daisy-modal-open') - .within(() => { - cy.contains( - '[data-testid="book-layout-reorganize-preview-row"]', - blockTitle - ) - .should('be.visible') - .and('have.attr', 'data-suggested-depth', String(suggestedDepth)) - }) + const dialog = '[data-testid="book-layout-reorganize-preview-dialog"]' + expectDaisyDialogBoxVisible(dialog) + cy.get(`${dialog}.daisy-modal-open .daisy-modal-box`).within(() => { + cy.contains( + '[data-testid="book-layout-reorganize-preview-row"]', + blockTitle + ) + .should('be.visible') + .and('have.attr', 'data-suggested-depth', String(suggestedDepth)) + }) return this }, confirmAiReorganizeSuggestion() { pageIsNotLoading() - cy.get('[data-testid="book-layout-reorganize-preview-confirm"]') - .should('be.visible') - .click() + openDaisyDialog('[data-testid="book-layout-reorganize-preview-dialog"]') + cy.get('[data-testid="book-layout-reorganize-preview-confirm"]').click({ + force: true, + }) pageIsNotLoading() return this }, @@ -730,18 +733,17 @@ const bookReadingPage = () => { }, expectTitlePromptWithDefaultTitle() { pageIsNotLoading() - cy.get('[data-testid="new-block-title-dialog"]').should('be.visible') - cy.get('[data-testid="new-block-title-input"]').should( - 'not.have.value', - '' - ) + const dialog = '[data-testid="new-block-title-dialog"]' + expectDaisyDialogBoxVisible(dialog) + cy.get('[data-testid="new-block-title-input"]') + .should('exist') + .should('not.have.value', '') return this }, confirmTitlePrompt() { pageIsNotLoading() - cy.get('[data-testid="new-block-title-confirm"]') - .should('be.visible') - .click() + openDaisyDialog('[data-testid="new-block-title-dialog"]') + cy.get('[data-testid="new-block-title-confirm"]').click({ force: true }) return this }, } diff --git a/e2e_test/start/pageObjects/messageCenterIndicator.ts b/e2e_test/start/pageObjects/messageCenterIndicator.ts index 9b49ceb843..0f8472c123 100644 --- a/e2e_test/start/pageObjects/messageCenterIndicator.ts +++ b/e2e_test/start/pageObjects/messageCenterIndicator.ts @@ -1,4 +1,5 @@ import { assumeMessageCenterPage } from './messageCenterPage' +import { pageIsNotLoading } from '../pageBase' export function messageCenterIndicator() { const getMessageInSidebar = ( @@ -21,6 +22,7 @@ export function messageCenterIndicator() { getMessageInSidebar(($el) => { $el.click() }) + pageIsNotLoading() return assumeMessageCenterPage() }, } diff --git a/e2e_test/start/pageObjects/messageCenterPage.ts b/e2e_test/start/pageObjects/messageCenterPage.ts index f4467ed35a..ac49f118fe 100644 --- a/e2e_test/start/pageObjects/messageCenterPage.ts +++ b/e2e_test/start/pageObjects/messageCenterPage.ts @@ -1,28 +1,46 @@ import { pageIsNotLoading } from '../pageBase' import { mainMenu } from './mainMenu' +function withinConversationList(fn: () => void) { + cy.findByText('Message Center').should('be.visible') + pageIsNotLoading() + cy.get('[data-testid="message-center-conversation-item"]').should( + 'have.length.at.least', + 1 + ) + cy.get('.message-center-container').within(fn) +} + export const assumeMessageCenterPage = () => { cy.findByText('Message Center').should('be.visible') return { expectConversation(subject: string, partner: string) { - cy.findByText(subject).should('be.visible') - cy.findByText(partner).should('be.visible') + withinConversationList(() => { + cy.findByText(subject).should('be.visible') + cy.findByText(partner).should('be.visible') + }) return this }, expectMessageDisplayAtUserSide(message: string) { - cy.findByText(message).parents('.daisy-justify-end').should('be.visible') + cy.findByText(message).parents('.justify-end').should('be.visible') return this }, expectMessageDisplayAtOtherSide(message: string) { cy.findByText(message) .parent() .should('be.visible') - .and('not.have.class', 'daisy-justify-end') + .and('not.have.class', 'justify-end') return this }, conversation(conversationSubject: string) { - cy.findByText(conversationSubject).parent().should('be.visible').click() + withinConversationList(() => { + cy.get( + `[data-testid="message-center-conversation-item"][data-conversation-subject="${conversationSubject}"]` + ) + .should('be.visible') + .click() + }) pageIsNotLoading() return { expectMessage(message: string) { diff --git a/e2e_test/start/pageObjects/noteMoreOptionsForm.ts b/e2e_test/start/pageObjects/noteMoreOptionsForm.ts index 7e94ae57e0..8ab0510362 100644 --- a/e2e_test/start/pageObjects/noteMoreOptionsForm.ts +++ b/e2e_test/start/pageObjects/noteMoreOptionsForm.ts @@ -3,12 +3,26 @@ import { assumeAssimilationPage } from './assimilationPage' import { toolbarButton } from './toolbarButton' import { questionListPage } from './questionListPage' +const moreOptionsDetails = () => + cy + .get('details[data-auto-collapse-dropdown]') + .filter(':has(summary[title="more options"])') + +const isMoreOptionsDropdownOpen = ($details: JQuery) => + $details.prop('open') === true || $details.hasClass('daisy-dropdown-open') + export const makeSureNoteMoreOptionsFormIsOpen = () => { - cy.findByRole('button', { name: 'more options' }).then(($button) => { - if (!$button.hasClass('daisy-btn-active')) { - cy.wrap($button).click() + moreOptionsDetails().then(($details) => { + if (!isMoreOptionsDropdownOpen($details)) { + cy.wrap($details).find('summary[title="more options"]').click() } }) + moreOptionsDetails().should(($details) => { + expect(isMoreOptionsDropdownOpen($details)).to.eq(true) + }) + cy.findByRole('button', { name: 'Assimilation settings' }).should( + 'be.visible' + ) return noteMoreOptionsPage() } @@ -40,7 +54,9 @@ const noteMoreOptionsPage = () => { return questionListPage() }, openAssimilationPage() { - toolbarButton('Assimilation settings').click() + cy.findByRole('button', { name: 'Assimilation settings' }) + .scrollIntoView() + .click() cy.url().should('include', '/assimilate/') pageIsNotLoading() return assumeAssimilationPage().waitForAssimilationReady() diff --git a/e2e_test/start/pageObjects/notePage.ts b/e2e_test/start/pageObjects/notePage.ts index 308ea47d92..0e746c906b 100644 --- a/e2e_test/start/pageObjects/notePage.ts +++ b/e2e_test/start/pageObjects/notePage.ts @@ -9,6 +9,7 @@ import { sidebarChildNotePageMethods } from './noteSidebar' import { assumeAssociateWikidataDialog } from './associateWikidataDialog' import { toolbarButton } from './toolbarButton' import { makeSureNoteMoreOptionsFormIsOpen } from './noteMoreOptionsForm' +import { questionListPage } from './questionListPage' import { assumeAssimilationPage } from './assimilationPage' /** Matches `noteShowHref()` (`/n{id}`), `/n/:id`, or legacy `/d/n/:id` note links. */ @@ -360,7 +361,7 @@ export const assumeNotePage = ( const isWikidata = keyNorm === 'wikidata_id' || keyNorm === 'wikidataid' if (isWikidata) { - cy.contains('.daisy-font-mono', value).should('exist') + cy.contains('.font-mono', value).should('exist') } else if (keyNorm === 'image') { cy.get('[data-testid="rich-note-image-property-path"]').should( ($el) => { @@ -505,7 +506,13 @@ export const assumeNotePage = ( this.openQuestionList().addQuestionPage().refineQuestion(row) }, expectQuestionsInList(expectedQuestions: Record[]) { - this.openQuestionList().expectQuestion(expectedQuestions) + cy.get('body').then(($body) => { + if ($body.find('.question-table').length > 0) { + questionListPage().expectQuestion(expectedQuestions) + } else { + this.openQuestionList().expectQuestion(expectedQuestions) + } + }) }, sendMessageToNoteOwner(message: string) { this.toolbarButton('Star a conversation about this note').click() diff --git a/e2e_test/start/pageObjects/noteSidebar.ts b/e2e_test/start/pageObjects/noteSidebar.ts index 5047592a00..05f3d89b5e 100644 --- a/e2e_test/start/pageObjects/noteSidebar.ts +++ b/e2e_test/start/pageObjects/noteSidebar.ts @@ -17,16 +17,21 @@ function folderTreitemByLabel(folderLabel: string) { function expandFolder(label: string) { pageIsNotLoading() + revealFolderInSidebar(label) + folderTreitemByLabel(label) + .find('[role="treeitem"]', { timeout: sidebarActionTimeoutMs }) + .should('have.length.at.least', 1) + return +} + +/** Expand a folder row so note children are in the DOM (no subfolder requirement). */ +function revealFolderInSidebar(label: string) { folderTreitemByLabel(label).then(($el) => { if (($el.attr('aria-expanded') ?? 'false') === 'false') { cy.wrap($el).find('.folder-row .chevron-btn').first().click() } }) - folderTreitemByLabel(label) - .should('have.attr', 'aria-expanded', 'true') - .find('[role="treeitem"]', { timeout: sidebarActionTimeoutMs }) - .should('have.length.at.least', 1) - return + folderTreitemByLabel(label).should('have.attr', 'aria-expanded', 'true') } function openSidebarIfCollapsed() { @@ -185,7 +190,7 @@ export const noteSidebar = () => { expectSidebarNoteUnderOpenFolder(folderLabel: string, noteTitle: string) { pageIsNotLoading() - expandFolder(folderLabel) + revealFolderInSidebar(folderLabel) folderTreitemByLabel(folderLabel) .find(`[role="treeitem"].sidebar-note-li[aria-label="${noteTitle}"]`, { timeout: sidebarActionTimeoutMs, diff --git a/e2e_test/start/pageObjects/noteTargetSearchDialog.ts b/e2e_test/start/pageObjects/noteTargetSearchDialog.ts index 2b3eb2030b..ae8927ff49 100644 --- a/e2e_test/start/pageObjects/noteTargetSearchDialog.ts +++ b/e2e_test/start/pageObjects/noteTargetSearchDialog.ts @@ -33,7 +33,7 @@ function ensureAllMyNotebooksAndSubscriptionsScopeOn() { if ($btn.is(':disabled')) { return } - if (!$btn.hasClass('daisy-text-primary')) { + if (!$btn.hasClass('text-primary')) { cy.wrap($btn).click() } } @@ -42,7 +42,7 @@ function ensureAllMyNotebooksAndSubscriptionsScopeOn() { function ensureSemanticSearchOn() { cy.findByRole('button', { name: 'Semantic search' }).then(($btn) => { - if (!$btn.hasClass('daisy-text-primary')) { + if (!$btn.hasClass('text-primary')) { cy.wrap($btn).click() } }) diff --git a/e2e_test/start/pageObjects/recallPage.ts b/e2e_test/start/pageObjects/recallPage.ts index f8fe947a46..6f7676ede1 100644 --- a/e2e_test/start/pageObjects/recallPage.ts +++ b/e2e_test/start/pageObjects/recallPage.ts @@ -27,12 +27,12 @@ const recallPage = () => { const [recalledTodayCount, toRecallCountForToday, totalCount] = numberOfRecalls.split('/') - cy.get('.daisy-progress-bar').should( + cy.get('.progress-bar').should( 'contain', `Recalling: ${recalledTodayCount}/${toRecallCountForToday}` ) // Click progress bar to show recall session options dialog - cy.get('.daisy-progress-bar').first().click() + cy.get('.progress-bar').first().click() // Check dialog content cy.contains('Recall Session Options').should('be.visible') diff --git a/e2e_test/start/pageObjects/sidebarFolderOrganizeForm.ts b/e2e_test/start/pageObjects/sidebarFolderOrganizeForm.ts index cbeff28c4d..dfa7d6d6e8 100644 --- a/e2e_test/start/pageObjects/sidebarFolderOrganizeForm.ts +++ b/e2e_test/start/pageObjects/sidebarFolderOrganizeForm.ts @@ -1,3 +1,7 @@ +import { + clickPopupConfirmOk, + declineMergeConfirmIfShown, +} from '../../support/daisyModalHelpers' import { pageIsNotLoading } from '../pageBase' const submitTimeoutMs = 20000 @@ -59,14 +63,7 @@ export function assumeSidebarFolderOrganizeForm(): SidebarFolderOrganizeForm { cy.get('[data-testid="folder-move-submit"]', { timeout: submitTimeoutMs }) .should('not.be.disabled') .click() - cy.get('body', { timeout: 8000 }).then(($body) => { - if ($body.text().includes('Merge into it?')) { - cy.get('dialog') - .filter(':visible') - .findByRole('button', { name: 'Cancel' }) - .click() - } - }) + declineMergeConfirmIfShown() return assumeSidebarFolderOrganizeForm() }, @@ -74,13 +71,13 @@ export function assumeSidebarFolderOrganizeForm(): SidebarFolderOrganizeForm { cy.get('[data-testid="folder-move-submit"]', { timeout: submitTimeoutMs }) .should('not.be.disabled') .click() - cy.findByRole('button', { name: 'OK' }).click() + clickPopupConfirmOk() pageIsNotLoading() }, expectErrorText(text: string) { cy.get('[data-testid="folder-move-dialog"]') - .find('.daisy-text-error') + .find('.text-error') .should('contain.text', text) return assumeSidebarFolderOrganizeForm() }, @@ -91,7 +88,7 @@ export function assumeSidebarFolderOrganizeForm(): SidebarFolderOrganizeForm { }) .should('not.be.disabled') .click() - cy.findByRole('button', { name: 'OK' }).click() + clickPopupConfirmOk() pageIsNotLoading() }, @@ -101,8 +98,8 @@ export function assumeSidebarFolderOrganizeForm(): SidebarFolderOrganizeForm { }) .should('not.be.disabled') .click() - cy.findByRole('button', { name: 'OK' }).click() - cy.findByRole('button', { name: 'OK' }).click() + clickPopupConfirmOk() + clickPopupConfirmOk() pageIsNotLoading() }, } diff --git a/e2e_test/step_definitions/assimilation.ts b/e2e_test/step_definitions/assimilation.ts index d62e393af8..513825d607 100644 --- a/e2e_test/step_definitions/assimilation.ts +++ b/e2e_test/step_definitions/assimilation.ts @@ -141,7 +141,7 @@ Then( if (expectedResult === 'success') { start.assumeAssimilationPage().expectPopupClosed() start - .jumpToNotePage(noteTitle) + .jumpToNotePage(noteTitle, true) .openAssimilationSettings() .expectMemoryTrackerInfo([{ type: 'spelling', 'Recall Count': '0' }]) } else { diff --git a/e2e_test/step_definitions/note.ts b/e2e_test/step_definitions/note.ts index 97caf3f642..5abee7a705 100644 --- a/e2e_test/step_definitions/note.ts +++ b/e2e_test/step_definitions/note.ts @@ -483,7 +483,7 @@ When( ) Then('there should be no more undo to do', () => { - cy.get('.btn[title="undo"]').should('not.exist') + cy.get('.daisy-btn[title^="undo"]').should('not.exist') }) Then('I type {string} in the title', (content: string) => { diff --git a/e2e_test/step_definitions/testability.ts b/e2e_test/step_definitions/testability.ts index a1fc58179c..35e390aa6d 100644 --- a/e2e_test/step_definitions/testability.ts +++ b/e2e_test/step_definitions/testability.ts @@ -4,6 +4,7 @@ // @ts-check import { Then, When } from '@badeball/cypress-cucumber-preprocessor' +import { clickDaisyDialogButton } from '../support/daisyModalHelpers' import start from '../start' When('Someone triggered an exception', () => { @@ -30,7 +31,7 @@ When('I check the checkbox for the failure report item', () => { When('I click the delete button', () => { cy.get('button').contains('Delete Selected').click() - cy.get('.daisy-modal-action').findByRole('button', { name: 'Delete' }).click() + clickDaisyDialogButton('dialog.daisy-modal', 'Delete') }) Then('the failure report should be empty', () => { diff --git a/e2e_test/step_definitions/user.ts b/e2e_test/step_definitions/user.ts index 6765b5342c..cba809ee96 100644 --- a/e2e_test/step_definitions/user.ts +++ b/e2e_test/step_definitions/user.ts @@ -43,13 +43,18 @@ Given("I'm on the login page", () => { }) When('I identify myself as a new user', () => { + cy.intercept('GET', '/api/healthcheck').as('devLoginHealthcheck') cy.get('#username').type('user') cy.get('#password').type('password') - cy.get('form').submit() + cy.get('#login-button').click() + cy.wait('@devLoginHealthcheck') }) -When('I should be asked to create my profile', () => { - cy.get('body').should('contain', 'Please create your profile') +Then('I should be asked to create my profile', () => { + cy.findByRole('heading', { + name: /Please create your profile/i, + timeout: 15000, + }).should('be.visible') }) When('I save my profile with:', (data: DataTable) => { diff --git a/e2e_test/support/daisyModalHelpers.ts b/e2e_test/support/daisyModalHelpers.ts new file mode 100644 index 0000000000..750c0c2b7f --- /dev/null +++ b/e2e_test/support/daisyModalHelpers.ts @@ -0,0 +1,47 @@ +/** + * daisyUI v5 native ``: `daisy-modal-open` toggles app state but + * the UA keeps `display: none` until `showModal()`. Cypress often cannot open the dialog, so + * interact with controls using `{ force: true }` on the open dialog subtree. + * + * `Modal.vue` popups (`dialog.modal-mask.popups`) are visible without `[open]`; target the + * visible dialog for OK clicks. + */ + +export function openDaisyDialog(selector: string): void { + cy.get(`${selector}.daisy-modal-open`, { timeout: 10000 }).should('exist') +} + +/** `usePopups().confirm()` via Modal.vue */ +export function clickPopupConfirmOk(): void { + cy.get('dialog').filter(':visible').contains('button', 'OK').click() +} + +/** Decline `usePopups().confirm()` when a merge prompt is shown. */ +export function declineMergeConfirmIfShown(): void { + cy.get('dialog', { timeout: 10000 }) + .filter(':visible') + .then(($dialogs) => { + const mergeDialog = [...$dialogs].find((el) => + el.textContent?.includes('Merge into it?') + ) + if (mergeDialog) { + cy.wrap(mergeDialog).contains('button', 'Cancel').click() + } + }) +} + +export function clickDaisyDialogButton( + dialogSelector: string, + buttonName: string +): void { + openDaisyDialog(dialogSelector) + cy.get(`${dialogSelector}.daisy-modal-open`) + .find('.daisy-modal-action') + .contains('button', buttonName) + .click({ force: true }) +} + +export function expectDaisyDialogBoxVisible(dialogSelector: string): void { + openDaisyDialog(dialogSelector) + cy.get(`${dialogSelector}.daisy-modal-open .daisy-modal-box`).should('exist') +} diff --git a/flake.lock b/flake.lock index 2fe2b055ca..86c022cd9a 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774799055, - "narHash": "sha256-Tsq9BCz0q47ej1uFF39m4tuhcwru/ls6vCCJzutEpaw=", + "lastModified": 1778737229, + "narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "107cba9eb4a8d8c9f8e9e61266d78d340867913a", + "rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5", "type": "github" }, "original": { diff --git a/frontend/.gitignore b/frontend/.gitignore index 3639f64681..d9fdd464de 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,5 +2,6 @@ node_modules .DS_Store dist dist-ssr +storybook-static *.local *storybook.log diff --git a/frontend/.storybook/decorators/noteStorageDecorator.ts b/frontend/.storybook/decorators/noteStorageDecorator.ts new file mode 100644 index 0000000000..afb176e57e --- /dev/null +++ b/frontend/.storybook/decorators/noteStorageDecorator.ts @@ -0,0 +1,37 @@ +import type { NoteRealm } from "@generated/doughnut-backend-api" +import type { Decorator } from "@storybook/vue3-vite" +import { onUnmounted } from "vue" +import { useStorageAccessor } from "@/composables/useStorageAccessor" +import createNoteStorage from "@/store/createNoteStorage" +import { + mockAssimilationStoryApis, + restoreStorySdkMocks, +} from "../storySdkMocks" + +/** Fresh note storage and optional preloaded realms for stories that use NoteRealmLoader. */ +export function withNoteStorage( + seedRealms?: (args: Record) => NoteRealm[] | undefined +): Decorator { + return (story, context) => ({ + setup() { + useStorageAccessor().value = createNoteStorage() + const realms = seedRealms?.(context.args) ?? [] + if (realms.length > 0) { + mockAssimilationStoryApis(realms) + const storage = useStorageAccessor() + for (const realm of realms) { + storage.value.refreshNoteRealm(realm) + } + } + onUnmounted(restoreStorySdkMocks) + }, + components: { story }, + template: "", + }) +} + +export const assimilationPageStorageDecorator = withNoteStorage((args) => { + const notes = args.notes + if (!Array.isArray(notes) || notes.length === 0) return [] + return notes as NoteRealm[] +}) diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index b3c8695a82..c3c7a5eca6 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from "@storybook/vue3-vite" +import tailwindcss from "@tailwindcss/vite" import { mergeConfig } from "vite" const config: StorybookConfig = { @@ -10,6 +11,7 @@ const config: StorybookConfig = { }, async viteFinal(config) { return mergeConfig(config, { + plugins: [tailwindcss()], resolve: { tsconfigPaths: true, }, diff --git a/frontend/.storybook/storySdkMocks.ts b/frontend/.storybook/storySdkMocks.ts new file mode 100644 index 0000000000..8feda845a3 --- /dev/null +++ b/frontend/.storybook/storySdkMocks.ts @@ -0,0 +1,109 @@ +import type { + Circle, + NoteRealm, + Notebook, +} from "@generated/doughnut-backend-api" +import { + AiController, + AssimilationController, + NoteController, + NotebookController, +} from "@generated/doughnut-backend-api/sdk.gen" + +type MockedCall = { + // biome-ignore lint/suspicious/noExplicitAny: restored SDK method reference + target: any + key: string + original: unknown +} + +const activeMocks: MockedCall[] = [] + +function wrapSdkResponse(data: T) { + return { + data, + error: undefined, + request: {} as Request, + response: {} as Response, + } +} + +function spyMethod( + target: T, + key: K, + replacement: T[K] +) { + activeMocks.push({ + target, + key: key as string, + original: target[key], + }) + target[key] = replacement +} + +export function restoreStorySdkMocks() { + for (const { target, key, original } of activeMocks) { + target[key] = original + } + activeMocks.length = 0 +} + +function mockNotebookGetForNoteRealm(realm: NoteRealm, circle?: Circle) { + const ts = + realm.note.noteTopology.updatedAt ?? + realm.note.noteTopology.createdAt ?? + new Date().toISOString() + const notebook: Notebook = { + id: realm.notebookRealm.notebook.id, + name: "Notebook", + notebookSettings: { skipMemoryTrackingEntirely: false }, + createdAt: realm.note.noteTopology.createdAt ?? ts, + updatedAt: ts, + ...(circle ? { circle } : {}), + } + spyMethod(NotebookController, "get", async () => + wrapSdkResponse({ + notebook, + hasAttachedBook: false, + readonly: realm.notebookRealm.readonly ?? false, + }) + ) +} + +/** Mocks SDK calls used when AssimilationPageView renders Assimilation → NoteShow. */ +export function mockAssimilationStoryApis(noteRealms: NoteRealm[]) { + const byNoteId = new Map(noteRealms.map((r) => [r.note.id, r])) + const notebookIds = new Set( + noteRealms.map((r) => r.notebookRealm.notebook.id) + ) + + spyMethod(NoteController, "showNote", (async (options) => { + const realm = byNoteId.get(options.path.note) + if (realm) return wrapSdkResponse(realm) + return { + data: undefined, + error: { message: "Not Found" }, + request: {} as Request, + response: { status: 404 } as Response, + } + }) as typeof NoteController.showNote) + + spyMethod(NoteController, "getNoteInfo", async () => + wrapSdkResponse({ memoryTrackers: [] }) + ) + + spyMethod(AiController, "generateUnderstandingChecklist", async () => + wrapSdkResponse({ points: [] }) + ) + + spyMethod(AssimilationController, "assimilate", async () => + wrapSdkResponse([]) + ) + + for (const notebookId of notebookIds) { + const realm = noteRealms.find( + (r) => r.notebookRealm.notebook.id === notebookId + ) + if (realm) mockNotebookGetForNoteRealm(realm) + } +} diff --git a/frontend/biome.json b/frontend/biome.json index f35ae8cf0c..1c6a4b613d 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -12,6 +12,7 @@ "**", "!**/.github", "!**/dist", + "!**/storybook-static", "!**/node_modules", "!**/public", "!**/*.cjs", @@ -225,6 +226,11 @@ } } }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, "javascript": { "formatter": { "trailingCommas": "es5", diff --git a/frontend/package.json b/frontend/package.json index 0f31b04fa0..e1f0b89da6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=24.14", - "pnpm": ">=11.1.2" + "pnpm": ">=11.1.3" }, "scripts": { "preinstall": "npx only-allow pnpm", @@ -22,9 +22,9 @@ "test": "CI=true vitest run --browser=chromium" }, "dependencies": { + "@lucide/vue": "^1.0.0", "epubjs": "^0.3.93", "file-saver": "^2.0.5", - "lucide-vue-next": "^1.0.0", "mini-debounce": "^1.0.8", "pdfjs-dist": "5.7.284", "quill": "npm:@dotwee/quill@2.0.9", @@ -39,19 +39,19 @@ "@storybook/addon-docs": "10.4.0", "@storybook/vue3": "10.4.0", "@storybook/vue3-vite": "10.4.0", + "@tailwindcss/vite": "^4.3.0", "@testing-library/user-event": "^14.6.1", "@testing-library/vue": "^8.1.0", - "@types/node": "25.7.0", + "@types/node": "25.9.0", "@types/turndown": "^5.0.6", - "@vitejs/plugin-vue": "^6.0.6", + "@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue-jsx": "^5.1.5", "@vitest/browser-playwright": "4.1.6", "@vitest/ui": "4.1.6", "@vue/compiler-core": "3.5.34", "@vue/server-renderer": "^3.5.34", "@vue/test-utils": "2.4.10", - "autoprefixer": "^10.5.0", - "daisyui": "4.12.24", + "daisyui": "5.5.20", "doughnut-test-fixtures": "workspace:*", "es-toolkit": "^1.46.0", "esbuild": "0.28.0", @@ -59,14 +59,12 @@ "marked": "^18.0.3", "optionator": "^0.9.4", "playwright": "^1.60.0", - "postcss": ">=8.5.14", - "postcss-load-config": "6.0.1", "sass": "1.99.0", "storybook": "10.4.0", - "tailwindcss": "3.4.19", + "tailwindcss": "4.3.0", "terser": "^5.47.1", "typescript": "6.0.3", - "unimport": "^6.2.0", + "unimport": "^6.3.0", "unplugin-auto-import": "^21.0.0", "unplugin-vue-components": "^32.0.0", "unplugin-vue-inspector": "^3.0.0", @@ -78,7 +76,7 @@ "vitest-fetch-mock": "^0.4.5", "vue": "3.5.34", "vue-router": "5.0.7", - "vue-tsc": "3.2.9" + "vue-tsc": "3.3.0" }, "overrides": { "quill": "npm:@dotwee/quill@2.0.9" diff --git a/frontend/postcss.config.ts b/frontend/postcss.config.ts deleted file mode 100644 index f0fdc6fd54..0000000000 --- a/frontend/postcss.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import autoprefixer from "autoprefixer" -import tailwindcss from "tailwindcss" - -export default { - plugins: [tailwindcss(), autoprefixer()], -} diff --git a/frontend/src/DoughnutApp.vue b/frontend/src/DoughnutApp.vue index 9f51e6adca..1276b30f3f 100644 --- a/frontend/src/DoughnutApp.vue +++ b/frontend/src/DoughnutApp.vue @@ -57,14 +57,14 @@ onMounted(async () => {