diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56371ec..829f877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - run: npm install --no-audit --no-fund - name: Cypress component tests uses: cypress-io/github-action@v6 @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - run: npm install --no-audit --no-fund - name: Build Storybook run: npm run build-storybook diff --git a/.playground/app/components/DashboardMain.vue b/.playground/app/components/DashboardMain.vue new file mode 100644 index 0000000..34cf796 --- /dev/null +++ b/.playground/app/components/DashboardMain.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/.playground/app/components/UsageLanding.vue b/.playground/app/components/UsageLanding.vue new file mode 100644 index 0000000..0f76e13 --- /dev/null +++ b/.playground/app/components/UsageLanding.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/.playground/app/pages/AuthSocialLogin.vue b/.playground/app/pages/AuthSocialLogin.vue new file mode 100644 index 0000000..8f6cb4f --- /dev/null +++ b/.playground/app/pages/AuthSocialLogin.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/app/pages/CanvasPanel.vue b/.playground/app/pages/CanvasPanel.vue new file mode 100644 index 0000000..4e68a09 --- /dev/null +++ b/.playground/app/pages/CanvasPanel.vue @@ -0,0 +1,17 @@ + + + diff --git a/.playground/app/pages/CategoryDrillModal.vue b/.playground/app/pages/CategoryDrillModal.vue new file mode 100644 index 0000000..203c723 --- /dev/null +++ b/.playground/app/pages/CategoryDrillModal.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/app/pages/ChatExperience.vue b/.playground/app/pages/ChatExperience.vue new file mode 100644 index 0000000..a2042ee --- /dev/null +++ b/.playground/app/pages/ChatExperience.vue @@ -0,0 +1,20 @@ + + + diff --git a/.playground/app/pages/ChatImportReviewBlock.vue b/.playground/app/pages/ChatImportReviewBlock.vue new file mode 100644 index 0000000..ceac7c5 --- /dev/null +++ b/.playground/app/pages/ChatImportReviewBlock.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/app/pages/ChatLandingInsights.vue b/.playground/app/pages/ChatLandingInsights.vue new file mode 100644 index 0000000..3543658 --- /dev/null +++ b/.playground/app/pages/ChatLandingInsights.vue @@ -0,0 +1,10 @@ + + + diff --git a/.playground/app/pages/ChatMarkdownBlock.vue b/.playground/app/pages/ChatMarkdownBlock.vue new file mode 100644 index 0000000..37031d9 --- /dev/null +++ b/.playground/app/pages/ChatMarkdownBlock.vue @@ -0,0 +1,12 @@ + diff --git a/.playground/app/pages/ChatProposedActionBlock.vue b/.playground/app/pages/ChatProposedActionBlock.vue new file mode 100644 index 0000000..28b2ed4 --- /dev/null +++ b/.playground/app/pages/ChatProposedActionBlock.vue @@ -0,0 +1,22 @@ + + + diff --git a/.playground/app/pages/ComponentLoader.vue b/.playground/app/pages/ComponentLoader.vue new file mode 100644 index 0000000..bebef67 --- /dev/null +++ b/.playground/app/pages/ComponentLoader.vue @@ -0,0 +1,8 @@ + diff --git a/.playground/app/pages/ContentTablePagination.vue b/.playground/app/pages/ContentTablePagination.vue new file mode 100644 index 0000000..470b6ec --- /dev/null +++ b/.playground/app/pages/ContentTablePagination.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/app/pages/DashboardAgentHero.vue b/.playground/app/pages/DashboardAgentHero.vue new file mode 100644 index 0000000..3e7e690 --- /dev/null +++ b/.playground/app/pages/DashboardAgentHero.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/app/pages/DashboardKPIs.vue b/.playground/app/pages/DashboardKPIs.vue new file mode 100644 index 0000000..5a0a86b --- /dev/null +++ b/.playground/app/pages/DashboardKPIs.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/app/pages/DashboardQuickActionModal.vue b/.playground/app/pages/DashboardQuickActionModal.vue new file mode 100644 index 0000000..1f9cb05 --- /dev/null +++ b/.playground/app/pages/DashboardQuickActionModal.vue @@ -0,0 +1,17 @@ + + + diff --git a/.playground/app/pages/DashboardWalletSelector.vue b/.playground/app/pages/DashboardWalletSelector.vue new file mode 100644 index 0000000..e4455c6 --- /dev/null +++ b/.playground/app/pages/DashboardWalletSelector.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/app/pages/DescriptorRenderer.vue b/.playground/app/pages/DescriptorRenderer.vue new file mode 100644 index 0000000..0cd75fc --- /dev/null +++ b/.playground/app/pages/DescriptorRenderer.vue @@ -0,0 +1,27 @@ + + + diff --git a/.playground/app/pages/ExtensionSlot.vue b/.playground/app/pages/ExtensionSlot.vue new file mode 100644 index 0000000..9e42fb6 --- /dev/null +++ b/.playground/app/pages/ExtensionSlot.vue @@ -0,0 +1,29 @@ + + + diff --git a/.playground/app/pages/FinancialPositionDrill.vue b/.playground/app/pages/FinancialPositionDrill.vue new file mode 100644 index 0000000..a5f2f7c --- /dev/null +++ b/.playground/app/pages/FinancialPositionDrill.vue @@ -0,0 +1,22 @@ + + + diff --git a/.playground/app/pages/FinancialPositionHero.vue b/.playground/app/pages/FinancialPositionHero.vue new file mode 100644 index 0000000..ffb1991 --- /dev/null +++ b/.playground/app/pages/FinancialPositionHero.vue @@ -0,0 +1,26 @@ + + + diff --git a/.playground/app/pages/FinancialPositionView.vue b/.playground/app/pages/FinancialPositionView.vue new file mode 100644 index 0000000..0637620 --- /dev/null +++ b/.playground/app/pages/FinancialPositionView.vue @@ -0,0 +1,24 @@ + + + diff --git a/.playground/app/pages/FormSection.vue b/.playground/app/pages/FormSection.vue new file mode 100644 index 0000000..df46181 --- /dev/null +++ b/.playground/app/pages/FormSection.vue @@ -0,0 +1,13 @@ + diff --git a/.playground/app/pages/HoldingForm.vue b/.playground/app/pages/HoldingForm.vue new file mode 100644 index 0000000..6da2f46 --- /dev/null +++ b/.playground/app/pages/HoldingForm.vue @@ -0,0 +1,14 @@ + + + diff --git a/.playground/app/pages/ImportUploadDropzone.vue b/.playground/app/pages/ImportUploadDropzone.vue new file mode 100644 index 0000000..33a5e4b --- /dev/null +++ b/.playground/app/pages/ImportUploadDropzone.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/app/pages/ImportUploadFileList.vue b/.playground/app/pages/ImportUploadFileList.vue new file mode 100644 index 0000000..5f8178c --- /dev/null +++ b/.playground/app/pages/ImportUploadFileList.vue @@ -0,0 +1,14 @@ + + + diff --git a/.playground/app/pages/LayoutPrimitives.vue b/.playground/app/pages/LayoutPrimitives.vue new file mode 100644 index 0000000..3f56df0 --- /dev/null +++ b/.playground/app/pages/LayoutPrimitives.vue @@ -0,0 +1,171 @@ + + + diff --git a/.playground/app/pages/MonthInReviewIllustration.vue b/.playground/app/pages/MonthInReviewIllustration.vue new file mode 100644 index 0000000..b4ef49b --- /dev/null +++ b/.playground/app/pages/MonthInReviewIllustration.vue @@ -0,0 +1,24 @@ + diff --git a/.playground/app/pages/NotificationBell.vue b/.playground/app/pages/NotificationBell.vue new file mode 100644 index 0000000..1338382 --- /dev/null +++ b/.playground/app/pages/NotificationBell.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/app/pages/NotificationsContainer.vue b/.playground/app/pages/NotificationsContainer.vue new file mode 100644 index 0000000..4cb7449 --- /dev/null +++ b/.playground/app/pages/NotificationsContainer.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/app/pages/OutreachComposer.vue b/.playground/app/pages/OutreachComposer.vue new file mode 100644 index 0000000..aece21e --- /dev/null +++ b/.playground/app/pages/OutreachComposer.vue @@ -0,0 +1,18 @@ + + + diff --git a/.playground/app/pages/PartyDetailContent.vue b/.playground/app/pages/PartyDetailContent.vue new file mode 100644 index 0000000..a565ddd --- /dev/null +++ b/.playground/app/pages/PartyDetailContent.vue @@ -0,0 +1,38 @@ + + + diff --git a/.playground/app/pages/RecentTransactions.vue b/.playground/app/pages/RecentTransactions.vue new file mode 100644 index 0000000..05881b8 --- /dev/null +++ b/.playground/app/pages/RecentTransactions.vue @@ -0,0 +1,26 @@ + + + diff --git a/.playground/app/pages/StatsFilterModal.vue b/.playground/app/pages/StatsFilterModal.vue new file mode 100644 index 0000000..58f2032 --- /dev/null +++ b/.playground/app/pages/StatsFilterModal.vue @@ -0,0 +1,17 @@ + + + diff --git a/.playground/app/pages/TAvatar.vue b/.playground/app/pages/TAvatar.vue new file mode 100644 index 0000000..8c236c5 --- /dev/null +++ b/.playground/app/pages/TAvatar.vue @@ -0,0 +1,10 @@ + + + diff --git a/.playground/app/pages/TButton.vue b/.playground/app/pages/TButton.vue new file mode 100644 index 0000000..4f6cd62 --- /dev/null +++ b/.playground/app/pages/TButton.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/.playground/app/pages/TCard.vue b/.playground/app/pages/TCard.vue new file mode 100644 index 0000000..259d550 --- /dev/null +++ b/.playground/app/pages/TCard.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/.playground/app/pages/TCardActionMenu.vue b/.playground/app/pages/TCardActionMenu.vue new file mode 100644 index 0000000..b355f8e --- /dev/null +++ b/.playground/app/pages/TCardActionMenu.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/app/pages/TDashboardTopCard.vue b/.playground/app/pages/TDashboardTopCard.vue new file mode 100644 index 0000000..1f8570f --- /dev/null +++ b/.playground/app/pages/TDashboardTopCard.vue @@ -0,0 +1,14 @@ + + + diff --git a/.playground/app/pages/TEmptyState.vue b/.playground/app/pages/TEmptyState.vue new file mode 100644 index 0000000..c23d687 --- /dev/null +++ b/.playground/app/pages/TEmptyState.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/app/pages/TFilterToggle.vue b/.playground/app/pages/TFilterToggle.vue new file mode 100644 index 0000000..692cfd5 --- /dev/null +++ b/.playground/app/pages/TFilterToggle.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/app/pages/TForm.vue b/.playground/app/pages/TForm.vue new file mode 100644 index 0000000..0770f2c --- /dev/null +++ b/.playground/app/pages/TForm.vue @@ -0,0 +1,19 @@ + + + diff --git a/.playground/app/pages/TFormField.vue b/.playground/app/pages/TFormField.vue new file mode 100644 index 0000000..6023548 --- /dev/null +++ b/.playground/app/pages/TFormField.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/.playground/app/pages/TFormInput.vue b/.playground/app/pages/TFormInput.vue new file mode 100644 index 0000000..619b0ac --- /dev/null +++ b/.playground/app/pages/TFormInput.vue @@ -0,0 +1,10 @@ + diff --git a/.playground/app/pages/TFormSelect.vue b/.playground/app/pages/TFormSelect.vue new file mode 100644 index 0000000..70fb84b --- /dev/null +++ b/.playground/app/pages/TFormSelect.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/app/pages/TFormTextarea.vue b/.playground/app/pages/TFormTextarea.vue new file mode 100644 index 0000000..1cbcabd --- /dev/null +++ b/.playground/app/pages/TFormTextarea.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/app/pages/TModal.vue b/.playground/app/pages/TModal.vue new file mode 100644 index 0000000..ed5a2fd --- /dev/null +++ b/.playground/app/pages/TModal.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/.playground/app/pages/TModalHeader.vue b/.playground/app/pages/TModalHeader.vue new file mode 100644 index 0000000..23118c2 --- /dev/null +++ b/.playground/app/pages/TModalHeader.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/app/pages/TNavbar.vue b/.playground/app/pages/TNavbar.vue new file mode 100644 index 0000000..3bcb038 --- /dev/null +++ b/.playground/app/pages/TNavbar.vue @@ -0,0 +1,8 @@ + diff --git a/.playground/app/pages/TPagination.vue b/.playground/app/pages/TPagination.vue new file mode 100644 index 0000000..bd9f99d --- /dev/null +++ b/.playground/app/pages/TPagination.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/app/pages/TSidebar.vue b/.playground/app/pages/TSidebar.vue new file mode 100644 index 0000000..31b5eef --- /dev/null +++ b/.playground/app/pages/TSidebar.vue @@ -0,0 +1,9 @@ + diff --git a/.playground/app/pages/TTab.vue b/.playground/app/pages/TTab.vue new file mode 100644 index 0000000..6ecc199 --- /dev/null +++ b/.playground/app/pages/TTab.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/app/pages/TTableRow.vue b/.playground/app/pages/TTableRow.vue new file mode 100644 index 0000000..ee675df --- /dev/null +++ b/.playground/app/pages/TTableRow.vue @@ -0,0 +1,20 @@ + + + diff --git a/.playground/app/pages/TTableTotals.vue b/.playground/app/pages/TTableTotals.vue new file mode 100644 index 0000000..1f7c234 --- /dev/null +++ b/.playground/app/pages/TTableTotals.vue @@ -0,0 +1,12 @@ + + + diff --git a/.playground/app/pages/TabPrimitives.vue b/.playground/app/pages/TabPrimitives.vue new file mode 100644 index 0000000..fe08267 --- /dev/null +++ b/.playground/app/pages/TabPrimitives.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/.playground/app/pages/TransactionForm.vue b/.playground/app/pages/TransactionForm.vue new file mode 100644 index 0000000..1e6c716 --- /dev/null +++ b/.playground/app/pages/TransactionForm.vue @@ -0,0 +1,35 @@ + + + diff --git a/.playground/app/pages/TransactionFormAttachments.vue b/.playground/app/pages/TransactionFormAttachments.vue new file mode 100644 index 0000000..f8bf33d --- /dev/null +++ b/.playground/app/pages/TransactionFormAttachments.vue @@ -0,0 +1,28 @@ + + + diff --git a/.playground/app/pages/TransactionFormContainer.vue b/.playground/app/pages/TransactionFormContainer.vue new file mode 100644 index 0000000..e36d24c --- /dev/null +++ b/.playground/app/pages/TransactionFormContainer.vue @@ -0,0 +1,12 @@ + diff --git a/.playground/app/pages/TransactionFormGroups.vue b/.playground/app/pages/TransactionFormGroups.vue new file mode 100644 index 0000000..ea75856 --- /dev/null +++ b/.playground/app/pages/TransactionFormGroups.vue @@ -0,0 +1,39 @@ + + + diff --git a/.playground/app/pages/TransactionFormIntent.vue b/.playground/app/pages/TransactionFormIntent.vue new file mode 100644 index 0000000..01ec80b --- /dev/null +++ b/.playground/app/pages/TransactionFormIntent.vue @@ -0,0 +1,21 @@ + + + diff --git a/.playground/app/pages/TransactionFormParties.vue b/.playground/app/pages/TransactionFormParties.vue new file mode 100644 index 0000000..19c6df1 --- /dev/null +++ b/.playground/app/pages/TransactionFormParties.vue @@ -0,0 +1,37 @@ + + + diff --git a/.playground/app/pages/TransactionFormRecurring.vue b/.playground/app/pages/TransactionFormRecurring.vue new file mode 100644 index 0000000..6dc0b8b --- /dev/null +++ b/.playground/app/pages/TransactionFormRecurring.vue @@ -0,0 +1,29 @@ + + + diff --git a/.playground/app/pages/TransactionFormSection.vue b/.playground/app/pages/TransactionFormSection.vue new file mode 100644 index 0000000..6f916ca --- /dev/null +++ b/.playground/app/pages/TransactionFormSection.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/app/pages/TransactionsContentSection.vue b/.playground/app/pages/TransactionsContentSection.vue new file mode 100644 index 0000000..77ad9db --- /dev/null +++ b/.playground/app/pages/TransactionsContentSection.vue @@ -0,0 +1,40 @@ + + + diff --git a/.playground/app/pages/TransactionsSpreadsheet.vue b/.playground/app/pages/TransactionsSpreadsheet.vue new file mode 100644 index 0000000..dd656f2 --- /dev/null +++ b/.playground/app/pages/TransactionsSpreadsheet.vue @@ -0,0 +1,51 @@ + + + diff --git a/.playground/app/pages/TransactionsSpreadsheetEditor.vue b/.playground/app/pages/TransactionsSpreadsheetEditor.vue new file mode 100644 index 0000000..fb33080 --- /dev/null +++ b/.playground/app/pages/TransactionsSpreadsheetEditor.vue @@ -0,0 +1,46 @@ + + + diff --git a/.playground/app/pages/TransactionsSpreadsheetToolbar.vue b/.playground/app/pages/TransactionsSpreadsheetToolbar.vue new file mode 100644 index 0000000..13a2330 --- /dev/null +++ b/.playground/app/pages/TransactionsSpreadsheetToolbar.vue @@ -0,0 +1,29 @@ + + + diff --git a/.playground/app/pages/UserDetail.vue b/.playground/app/pages/UserDetail.vue new file mode 100644 index 0000000..8deb5f4 --- /dev/null +++ b/.playground/app/pages/UserDetail.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/app/pages/Utilities.vue b/.playground/app/pages/Utilities.vue new file mode 100644 index 0000000..04c12ef --- /dev/null +++ b/.playground/app/pages/Utilities.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/.playground/app/pages/WalletDetailContent.vue b/.playground/app/pages/WalletDetailContent.vue new file mode 100644 index 0000000..0e3b99a --- /dev/null +++ b/.playground/app/pages/WalletDetailContent.vue @@ -0,0 +1,35 @@ + + + diff --git a/.playground/app/pages/index.vue b/.playground/app/pages/index.vue new file mode 100644 index 0000000..1e97e6b --- /dev/null +++ b/.playground/app/pages/index.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/.playground/app/registry.ts b/.playground/app/registry.ts new file mode 100644 index 0000000..86b887d --- /dev/null +++ b/.playground/app/registry.ts @@ -0,0 +1,2332 @@ +// Central registry that drives the playground dashboard. +// - `components` is the auto-discovered list of every .vue component in the kit. +// - `demos` provides hand-tuned prop/slot configs for components that need +// required props or specific demo data. Anything not listed falls back to a +// generic renderer with sensible defaults. + +import { markRaw } from 'vue'; + +// Auto-import every component so it can be referenced by name via . +const modules = import.meta.glob('../../components/*.vue', { eager: true }); + +export interface ComponentMeta { + name: string; + // Raw component definition (eagerly imported). + component: any; +} + +export const components: ComponentMeta[] = Object.entries(modules) + .map(([path, mod]: [string, any]) => { + const name = path.split('/').pop()!.replace('.vue', ''); + return { + name, + component: markRaw(mod.default) + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + +export const componentNames = components.map((c) => c.name); + +// Shared sample datasets so data-heavy tabs/charts render meaningfully. +const sampleBuckets = Array.from({ length: 30 }, (_, i) => ({ + date: `2026-07-${String(i + 1).padStart(2, '0')}`, + expense: (i * 37) % 120, + income: i % 5 === 0 ? (i * 31) % 400 : 0, + net: ((i * 53) % 200) - 80, + txCount: (i % 4) + 1 +})); + +const sampleCategories = [ + { id: 1, name: 'Food', type: 'expense', color: '#3b82f6', amount: 640, percentage: 40 }, + { id: 2, name: 'Transport', type: 'expense', color: '#f59e0b', amount: 320, percentage: 20 }, + { id: 3, name: 'Salary', type: 'income', color: '#10b981', amount: 3000, percentage: 100 } +]; + +export interface DemoConfig { + props?: Record; +} + +// Hand-tuned prop configs keyed by component name. +// Components that need slot content are rendered as dedicated demos in +// DashboardMain via the `slottedComponents` set below. +export const slottedComponents = new Set([ + 'TButton', + 'TInfoButton', + 'TCard', + 'TContainer', + 'TStack', + 'TGrid', + 'TSplit', + 'TPanel', + 'TPageShell', + 'TDropdown', + 'TTabs', + 'TModalBody', + 'TModalFooter', + 'TFormRow', + 'CollapsibleSection' +]); + +export const demos: Record = { + TButton: {}, + TCard: { + props: { title: 'Card title' } + }, + TInfoButton: {}, + TDivider: {}, + TContainer: {}, + TStack: {}, + TGrid: {}, + TSplit: {}, + TPanel: { + props: { title: 'Panel' } + }, + TPageShell: { + props: { title: 'Page shell' } + }, + TDropdown: { + props: { text: 'Menu' } + }, + TDropdownItem: { + props: { text: 'Action' } + }, + TTabs: { + props: { + 'active-tab': 'first', + tabs: [ + { id: 'first', label: 'First' }, + { id: 'second', label: 'Second' } + ] + } + }, + TTopCard: { + props: { pageName: 'wallet', pageNamePlural: 'wallets' } + }, + TTransactionCard: { + props: { + statistics: { + net: -42.5, + count: 12, + topCategory: 'Food', + totalIncome: 3000, + totalExpense: 2100 + }, + primaryCurrency: 'USD', + formatCompactCurrency: (v, c) => `${Math.round(v)} ${c}` + } + }, + TTransactionsCardList: { + props: { + transactions: [ + { id: 1, name: 'Groceries', amount: -42.5, category: 'Food', date: '2026-07-18', party: 'Shop' }, + { id: 2, name: 'Salary', amount: 3000, category: 'Income', date: '2026-07-01', party: 'Employer' } + ], + formatShortAmount: (v) => `${Math.round(v)}` + } + }, + TTransactionSubCard: { + props: { title: '48', text: 'Transactions' } + }, + TTableComponent: { + props: { + transactions: [ + { id: 1, name: 'Groceries', amount: -42.5, category: 'Food', date: '2026-07-18' }, + { id: 2, name: 'Salary', amount: 3000, category: 'Income', date: '2026-07-01' } + ], + parseAmount: (v) => Number(v), + getCurrencySymbol: (c) => '$', + convertCurrency: (v) => v + } + }, + TFormField: { + props: { + label: 'Email address', + hint: 'We will never share your email.', + error: '' + } + }, + TFormRow: {}, + TListHeader: { + props: { title: 'Recent transactions' } + }, + TModalBody: {}, + TModalFooter: {}, + TModal: { + props: { + modelValue: true, + title: 'Confirm action' + } + }, + TSectionHeader: { + props: { + eyebrow: 'Overview', + title: 'Account summary', + subtitle: 'Your financial snapshot' + } + }, + TTabList: { + props: { + tabs: [ + { id: 'overview', label: 'Overview' }, + { id: 'transactions', label: 'Transactions' }, + { id: 'budgets', label: 'Budgets' } + ] + } + }, + TTabPanel: { + props: { + value: 'panel1', + activeValue: 'panel1' + } + }, + EmptyState: { + props: { + title: 'No items', + subtitle: 'There are no transactions to show yet.', + pageName: 'transactions' + } + }, + TipsSection: { + props: { title: 'Tips', pageName: 'transactions' } + }, + ContentCard: { + props: { icon: 'document', name: 'Content', pageName: 'wallets', description: 'A sample item' } + }, + ContentSection: { + props: { title: 'Section', pageName: 'budgets' } + }, + ContentListView: { + props: { + entities: [ + { id: 1, name: 'Item one', description: 'Desc one' }, + { id: 2, name: 'Item two', description: 'Desc two' } + ], + columns: [ + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' } + ], + pageName: 'wallet', + pageNamePlural: 'wallets', + labels: { + allItems: 'All {items}', + searchItems: 'Search {items}...', + prev: 'Prev', + next: 'Next', + show: 'Show', + perPage: 'per page', + pageNamePlural: 'wallets' + } + } + }, + ContentTable: { + props: { + entities: [ + { id: 1, name: 'Item one', description: 'Desc one' }, + { id: 2, name: 'Item two', description: 'Desc two' } + ], + pageName: 'wallet', + pageNamePlural: 'wallets' + } + }, + ConfirmModal: { + props: { title: 'Delete?', labels: { confirm: 'Yes', cancel: 'No' }, modelValue: true } + }, + AuthDivider: {}, + Logo: {}, + GoogleIcon: {}, + SettingsDisplay: { + props: { + isDark: false, + labels: { light: 'Light', dark: 'Dark', mode: 'Mode' } + } + }, + LoadingSkeleton: {}, + IconPicker: {}, + HamburgerMenu: {}, + KpiCard: { + props: { label: 'Total balance', value: '$12,340' } + }, + BudgetCard: { + props: { + budget: { + id: 1, + name: 'Groceries', + amount: 400, + currency: 'USD', + period_type: 'monthly', + start_date: '2026-07-01', + progress: { + percent_used: 62, + net_spent: 248, + effective_limit: 400, + remaining: 152, + refunds: 0, + status: 'on_track', + period_start: '2026-07-01' + }, + targets: [ + { type: 'category', name: 'Food' }, + { type: 'wallet', name: 'Main wallet' } + ] + }, + labels: { edit: 'Edit', delete: 'Delete', allTransactionsInPeriod: 'All in period', of: 'of', remaining: 'Remaining', refundsAppliedThisPeriod: 'Refunds' } + } + }, + WalletCard: { + props: { + statistics: { + totalBalance: 14000.5, + income: 3000, + expense: 2100, + byWallet: [ + { id: 1, name: 'Main', balance: 4200.5, currency: 'USD' }, + { id: 2, name: 'Savings', balance: 9800, currency: 'USD' } + ] + }, + availableWallets: [ + { id: 1, name: 'Main', currency: 'USD' }, + { id: 2, name: 'Savings', currency: 'USD' } + ] + } + }, + WalletListCard: { + props: { + wallet: { id: 1, name: 'Main wallet', balance: 4200.5, currency: 'USD' }, + isDefault: true + } + }, + PartyCard: { + props: { + party: { + id: 1, + name: 'Acme Ltd', + type: 'business', + received: 1200, + spent: 450, + lastActivity: '2026-07-15' + } + } + }, + PartyCardList: { + props: { + parties: [ + { + id: 1, + name: 'Acme Ltd', + type: 'business', + received: 1200, + spent: 450, + lastActivity: '2026-07-15' + }, + { + id: 2, + name: 'Globex', + type: 'vendor', + received: 0, + spent: 800, + lastActivity: '2026-07-10' + } + ] + } + }, + PartyDetailPanel: { + props: { + party: { + id: 1, + name: 'Acme Ltd', + type: 'business', + received: 1200, + spent: 450 + }, + transactions: [ + { id: 1, name: 'Invoice', amount: -200, date: '2026-07-15' }, + { id: 2, name: 'Payment', amount: 1200, date: '2026-07-12' } + ] + } + }, + WalletDetailPanel: { + props: { + wallet: { id: 1, name: 'Main wallet', balance: 4200.5, currency: 'USD' }, + transactions: [ + { id: 1, name: 'Groceries', amount: -42.5, date: '2026-07-18' }, + { id: 2, name: 'Salary', amount: 3000, date: '2026-07-01' } + ] + } + }, + WalletsStatsStrip: { + props: { + wallets: [ + { id: 1, name: 'Main', balance: 4200.5, currency: 'USD', income: 3000, expense: 2100 }, + { id: 2, name: 'Savings', balance: 9800, currency: 'USD', income: 0, expense: 0 } + ] + } + }, + PartiesStatsStrip: { + props: { + parties: [ + { id: 1, name: 'Acme Ltd', type: 'business', received: 1200, spent: 450 }, + { id: 2, name: 'Globex', type: 'vendor', received: 0, spent: 800 } + ] + } + }, + CategoryBreakdown: { + props: { + statistics: { + total: 1600, + byCategory: [ + { category: 'Food', amount: 640, color: '#3b82f6' }, + { category: 'Transport', amount: 320, color: '#f59e0b' }, + { category: 'Housing', amount: 480, color: '#10b981' }, + { category: 'Fun', amount: 160, color: '#ef4444' } + ] + } + } + }, + CategoryDonut: { + props: { + data: [ + { name: 'Food', value: 640, color: '#3b82f6' }, + { name: 'Transport', value: 320, color: '#f59e0b' }, + { name: 'Housing', value: 480, color: '#10b981' }, + { name: 'Fun', value: 160, color: '#ef4444' } + ], + total: 1600 + } + }, + CategoryRanking: { + props: { + buckets: [ + { category: 'Food', amount: 640, txCount: 24, percentage: 40, delta: 0.12 }, + { category: 'Housing', amount: 480, txCount: 1, percentage: 30, delta: 0 }, + { category: 'Transport', amount: 320, txCount: 12, percentage: 20, delta: -0.08 }, + { category: 'Fun', amount: 160, txCount: 6, percentage: 10, delta: 0.4 } + ] + } + }, + SparkLine: { + props: { values: [3, 5, 2, 8, 6, 9, 4, 7, 10, 6] } + }, + DailyBarChart: { + props: { + data: [ + { date: '2026-07-01', expense: 42, txCount: 3 }, + { date: '2026-07-02', expense: 18, txCount: 1 }, + { date: '2026-07-03', expense: 75, txCount: 5 }, + { date: '2026-07-04', expense: 12, txCount: 1 }, + { date: '2026-07-05', expense: 64, txCount: 4 }, + { date: '2026-07-06', expense: 30, txCount: 2 }, + { date: '2026-07-07', expense: 90, txCount: 6 } + ] + } + }, + CashflowLineChart: { + props: { + data: [ + { label: 'Jan', income: 3000, expense: 2100, net: 900 }, + { label: 'Feb', income: 3200, expense: 2400, net: 800 }, + { label: 'Mar', income: 2800, expense: 1900, net: 900 }, + { label: 'Apr', income: 3500, expense: 2600, net: 900 }, + { label: 'May', income: 3100, expense: 2200, net: 900 }, + { label: 'Jun', income: 3300, expense: 2500, net: 800 } + ] + } + }, + CumulativeNetArea: { + props: { + data: [ + { label: 'Jan', cumulative: 900 }, + { label: 'Feb', cumulative: 1700 }, + { label: 'Mar', cumulative: 2600 }, + { label: 'Apr', cumulative: 3500 }, + { label: 'May', cumulative: 4400 }, + { label: 'Jun', cumulative: 5200 } + ] + } + }, + CashflowHero: { + props: { + totals: { income: 3000, expense: 2100, net: 900, savingsRate: 0.3 }, + trailing: [ + { income: 2800, expense: 2000, net: 800 }, + { income: 3000, expense: 2100, net: 900 }, + { income: 3200, expense: 2300, net: 900 }, + { income: 2900, expense: 1900, net: 1000 }, + { income: 3100, expense: 2200, net: 900 }, + { income: 3300, expense: 2400, net: 900 } + ] + } + }, + SankeyFlow: { + props: { + flow: { + income: [ + { name: 'Salary', amount: 3200 }, + { name: 'Side gig', amount: 600 } + ], + expense: [ + { name: 'Rent', amount: 1200 }, + { name: 'Food', amount: 640 }, + { name: 'Transport', amount: 320 }, + { name: 'Savings', amount: 1640 } + ] + } + } + }, + CalendarHeatmap: { + props: { + buckets: Array.from({ length: 30 }, (_, i) => ({ + date: `2026-07-${String(i + 1).padStart(2, '0')}`, + expense: (i * 37) % 120, + net: ((i * 53) % 200) - 80 + })) + } + }, + FinancialRatios: { + props: { + totals: { + income: 3000, + expense: 2100, + net: 900, + savingsRate: 0.3, + expenseRatio: 0.7, + runwayMonths: 8 + } + } + }, + SearchInput: { + props: { modelValue: '', placeholder: 'Search…' } + }, + SearchableDropdown: { + props: { + options: [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' } + ] + } + }, + ViewToggle: { + props: { modelValue: 'grid', options: ['grid', 'list'] } + }, + LanguageSelector: { + props: { modelValue: 'en', languages: [{ code: 'en', label: 'English' }] } + }, + ThemeSelector: { + props: { + theme: 'light', + isDark: false, + isOpen: false, + labels: { theme: 'Theme', light: 'Light', dark: 'Dark', system: 'System' } + } + }, + ThemeToggleButton: {}, + PeriodControl: { + props: { modelValue: '2026-07' } + }, + TransactionFilters: { + props: { + filters: { search: '', type: 'all' }, + labels: { + close: 'Close', + search: 'Search', + type: 'Type', + all: 'All', + income: 'Income', + expense: 'Expense', + apply: 'Apply', + clear: 'Clear' + } + } + }, + NotableStrip: { + props: { + notable: { + biggestExpense: { name: 'Rent', amount: 1200, date: '2026-07-01' }, + biggestSwing: { name: 'Groceries', delta: 0.4 }, + firstTimePayees: { names: ['New Cafe', 'Bookstore'] }, + noSpendStreak: 4, + longestNoSpendStreak: { days: 4 } + }, + labels: { + biggestExpense: 'Biggest expense', + noExpenses: 'No expenses', + biggestSwing: 'Biggest swing', + noComparableChanges: 'No changes', + firstTimePayees: 'First-time payees', + allFamiliarNames: 'All familiar', + noSpendStreak: 'No-spend streak', + days: 'days', + everyDayHadSpending: 'Every day had spending' + } + } + }, + QuickInsights: { + props: { + statistics: { + net: 900, + totalIncome: 3000, + totalExpense: 2100, + topCategory: 'Food', + biggestSpender: 'Rent' + } + } + }, + MonthInReview: { + props: { + open: true, + data: { + month: 'July', + summary: 'Great month overall.', + slides: [ + { kind: 'opening', tone: 'opening', title: 'July in review', body: 'A good month.' }, + { kind: 'stat', tone: 'positive', title: 'Saved', body: 'You saved $900.' } + ] + } + } + }, + ChatEmptyState: {}, + TypingDots: {}, + ChatMessageList: { + props: { + messages: [ + { id: 1, role: 'user', text: 'Hello' }, + { id: 2, role: 'assistant', text: 'Hi, how can I help?' } + ] + } + }, + ChatComposer: { + props: { + modelValue: '', + labels: { + remove: 'Remove', + attachFile: 'Attach a file', + placeholder: 'Ask me anything about your finances...', + send: 'Send', + bank_statementLabel: 'Bank statement', + bank_statementHint: 'Import transactions from a CSV or PDF', + receiptLabel: 'Receipt', + receiptHint: 'A photo or scan of a receipt', + invoiceLabel: 'Invoice', + invoiceHint: 'A bill or invoice document', + photoLabel: 'Photo', + photoHint: 'Any image from your device', + documentLabel: 'Document', + documentHint: 'CSV, PDF, spreadsheet or text file' + } + } + }, + DiscussionDropdown: { + props: { + sessions: [ + { id: 1, title: 'Budget help', created_at: '2026-07-18', updated_at: '2026-07-18' }, + { id: 2, title: 'Spending review', created_at: '2026-07-15', updated_at: '2026-07-15' } + ], + currentId: 1, + currentTitle: 'Budget help', + labels: { newChat: 'New chat', noConversations: 'No conversations yet' } + } + }, + ChatCanvasBlock: { + props: { + block: { + type: 'canvas', + title: 'Monthly Report', + blocks: [ + { type: 'kpi', title: 'Summary', items: [{ label: 'Net', value: 900, currency: 'USD' }] }, + { type: 'table', title: 'Top expenses', columns: ['name', 'amount'], rows: [{ name: 'Rent', amount: 1200 }] } + ] + }, + labels: { document: 'Document', sectionCount: '{count} sections', openInCanvas: 'Open in canvas' } + } + }, + ChatSidebar: { + props: { + sessions: [ + { id: 1, title: 'Budget help', preview: 'How do I…', updatedAt: '2026-07-18' }, + { id: 2, title: 'Spending review', preview: 'Last month', updatedAt: '2026-07-15' } + ], + currentSessionId: 1, + isLoading: false + } + }, + ChatResultRenderer: { + props: { + result: { + format_type: 'table', + rows: [ + { name: 'Groceries', amount: -42.5, date: '2026-07-18' }, + { name: 'Salary', amount: 3000, date: '2026-07-01' } + ] + } + } + }, + AIChat: { + props: { + messages: [{ id: 1, role: 'assistant', text: 'How can I help?' }] + } + }, + LoginCard: {}, + RegisterCard: {}, + AuthCarousel: { + props: { + slides: [ + { title: 'Welcome', text: 'Manage money simply.' }, + { title: 'Insights', text: 'See where it goes.' } + ] + } + }, + AuthFooterLink: { + props: { text: 'Need help?', to: '#' } + }, + DashboardOnboarding: { + props: { steps: [{ title: 'Add wallet' }, { title: 'Add transaction' }] } + }, + OnboardingEmptyState: { + props: { pageType: 'wallets' } + }, + LearningModal: { + props: { open: true, title: 'Learn more' } + }, + PasswordModal: { + props: { open: true } + }, + RecurringModal: { + props: { + isOpen: true, + transaction: { id: 1, name: 'Groceries', amount: -42.5, date: '2026-07-18' } + } + }, + + ImportSessionsList: { + props: { + sessions: [ + { + id: 1, + file_name: 'july_bank_statement.csv', + status: 'confirmed', + created_at: '2026-07-18T10:00:00Z', + metadata: { total_suggestions: 24, duplicates_found: 2 } + }, + { + id: 2, + file_name: 'utility_bill_june.pdf', + status: 'analyzing', + created_at: '2026-07-19T08:30:00Z' + } + ], + labels: { + recentImports: 'Recent imports', + suggestions: 'suggestions', + duplicates: 'duplicates', + deleteImport: 'Delete import', + needsReview: 'Needs review', + analyzing: 'Analyzing', + imported: 'Imported', + failed: 'Failed', + expired: 'Expired' + } + } + }, + ImportUpload: { + props: { + isAnalyzing: false, + labels: { + showName: 'Show {name}', + smartImport: 'Smart import', + title: 'Bring your statements and receipts to life', + subtitle: 'Drop a CSV, PDF, or photo. We extract the transactions, match wallets and categories, and let you review before saving.', + dropzone: 'Drop your file here or click to browse', + supportedFormats: 'Supports CSV, PDF, PNG, JPG, TIFF, BMP', + remove: 'Remove', + documentType: 'Document type', + autoDetect: 'Auto-detect', + bankStatement: 'Bank statement', + receipt: 'Receipt', + invoice: 'Invoice', + payStub: 'Pay stub', + utilityBill: 'Utility bill', + analyzing: 'Analyzing...', + analyzeDocument: 'Analyze document', + csvLabel: 'Spreadsheet', + csvFormat: 'CSV / XLSX', + pdfLabel: 'Bank statement', + pdfFormat: 'PDF document', + imageLabel: 'Receipt photo', + imageFormat: 'PNG · JPG · TIFF', + receiptPhotoLabel: 'Paper receipt', + receiptPhotoFormat: 'Scanned image' + } + } + }, + ImportAnalyzing: { + props: { progress: 60 } + }, + ImportConfirmDialog: { + props: { + show: true, + acceptedCount: 1, + rejectedCount: 0, + duplicatesInAccepted: 0, + isConfirming: false, + newWalletCount: 1, + newPartyCount: 1, + newCategoryCount: 2, + missingWalletCount: 0, + labels: { + confirmImport: 'Confirm import', + youAreAboutToImport: 'You are about to import {n} transactions.', + mayBeDuplicates: 'Some may be duplicates.', + suggestionsSkipped: 'Suggestions skipped: {n}', + someTransactionsReference: 'Some reference missing wallets.', + createNewWallets: 'Create {n} new wallets', + createNewParties: 'Create {n} new parties', + createNewCategories: 'Create {n} new categories' + } + } + }, + BudgetForm: { + props: { + modelValue: { name: 'Groceries', limit: 400 }, + labels: { + editBudget: 'Edit budget', + createBudget: 'Create budget', + budgetName: 'Name', + spendingLimit: 'Limit', + addWalletFirst: 'Add a wallet first', + budgetPeriod: 'Period', + weekly: 'Weekly', + monthly: 'Monthly', + yearly: 'Yearly', + customRange: 'Custom', + startDate: 'Start', + endDate: 'End', + budgetDescription: 'Description', + budgetTargets: 'Targets', + budgetTargetsHint: 'Pick targets', + targetTabs: { category: 'Category', group: 'Group', wallet: 'Wallet' }, + noItemsToChoose: 'None', + rolloverUnused: 'Rollover', + rolloverHint: 'Carry over', + alertThreshold: 'Alert', + off: 'Off', + alertThresholdHint: 'Warn me', + forecastAlerts: 'Forecast', + forecastAlertsHint: 'Predict', + active: 'Active', + cancel: 'Cancel', + updateBudget: 'Update' + } + } + }, + CategoryForm: { + props: { pageName: 'Category', modelValue: { name: '', type: 'expense' } } + }, + GroupForm: { + props: { modelValue: { name: '' } } + }, + PartiesForm: { + props: { modelValue: { name: '', type: 'customer' } } + }, + ReminderForm: { + props: { modelValue: { note: '' } } + }, + WalletForm: { + props: { + modelValue: { name: 'Main', balance: 0, type: 'bank' }, + labels: { + editWallet: 'Edit wallet', + createWallet: 'Create wallet', + walletName: 'Name', + walletNamePlaceholder: 'My wallet', + walletNameRequired: 'Name is required', + chooseIcon: 'Choose icon', + walletType: 'Type', + selectWalletType: 'Select type', + bankAccount: 'Bank', + cash: 'Cash', + creditCard: 'Card', + currency: 'Currency', + initialBalance: 'Balance', + cancel: 'Cancel', + save: 'Save' + }, + defaults: { currency: 'USD' } + } + }, + TransferForm: { + props: { modelValue: { amount: 0 } } + }, + TransferFormContainer: { + props: { modelValue: { amount: 0 } } + }, + CalendarTab: { + props: { modelValue: '2026-07-18', buckets: sampleBuckets } + }, + CategoriesTab: { + props: { + incomeCategories: sampleCategories.filter((c) => c.type === 'income'), + expenseCategories: sampleCategories.filter((c) => c.type === 'expense') + } + }, + ChartsTab: { + props: { + monthlyBuckets: sampleBuckets, + dailyBuckets: sampleBuckets, + incomeCategories: sampleCategories.filter((c) => c.type === 'income'), + expenseCategories: sampleCategories.filter((c) => c.type === 'expense') + } + }, + FlowTab: { + props: { + flow: { + sources: [ + { name: 'Salary', amount: 3200 }, + { name: 'Side gig', amount: 600 } + ], + sinks: [ + { name: 'Rent', amount: 1200 }, + { name: 'Food', amount: 640 }, + { name: 'Transport', amount: 320 }, + { name: 'Savings', amount: 1640 } + ] + } + } + }, + ReportsTabs: { + props: { + modelValue: 'overview', + tabs: [ + { id: 'overview', label: 'Overview' }, + { id: 'charts', label: 'Charts' } + ] + } + }, + CollapsibleSection: { + props: { title: 'Section' } + }, + ContentCardGrid: { + props: { + entities: [ + { id: 1, name: 'Wallet', description: 'A wallet', pageName: 'wallets' }, + { id: 2, name: 'Budget', description: 'A budget', pageName: 'budgets' } + ] + } + }, + ReportsEmpty: { + props: { title: 'No reports', pageName: 'reports' } + }, + SuggestionReviewTable: { + props: { + suggestions: [ + { + id: 1, + date: '2026-07-18', + description: 'Groceries', + amount: -42.5, + type: 'expense', + partyName: 'Shop', + categoryName: 'Food', + walletName: 'Main', + confidence: 0.9 + } + ], + wallets: [{ id: 1, name: 'Main' }], + categories: [{ id: 1, name: 'Food' }], + parties: [{ id: 1, name: 'Shop' }], + acceptedCount: 1, + rejectedCount: 0, + pendingCount: 1, + labels: { + acceptAll: 'Accept all', + rejectAll: 'Reject all', + summary: 'Review', + date: 'Date', + description: 'Description', + amount: 'Amount', + type: 'Type', + party: 'Party', + category: 'Category', + wallet: 'Wallet', + confidence: 'Confidence' + } + } + }, + ChatProgressSteps: { + props: { steps: ['Analyzing transactions', 'Building summary', 'Done'] } + }, + ChatCalloutBlock: { + props: { title: 'Heads up', text: 'This is an informational callout.', variant: 'info' } + }, + ChatChartBlock: { + props: { + title: 'Spending by category', + chart_hint: 'donut', + data: [ + { category: 'Food', amount: 640 }, + { category: 'Transport', amount: 320 }, + { category: 'Housing', amount: 480 } + ] + } + }, + ChatComparisonBlock: { + props: { + title: 'July vs June', + series: [ + { heading: 'July', income: 3000, expense: 2100, net: 900 }, + { heading: 'June', income: 2800, expense: 2000, net: 800 } + ] + } + }, + ChatKpiBlock: { + props: { + title: 'Key metrics', + items: [ + { label: 'Net worth', value: 12340, currency: 'USD', delta_percent: 5.2, trend: 'up' }, + { label: 'Savings rate', value: '30%', delta_percent: 0, trend: 'flat' } + ] + } + }, + ChatListBlock: { + props: { + title: 'Recent transactions', + items: [ + { name: 'Groceries', amount: -42.5, currency: 'USD', category: 'Food', date: '2026-07-18' }, + { name: 'Salary', amount: 3000, currency: 'USD', category: 'Income', date: '2026-07-01' } + ] + } + }, + ChatProgressBlock: { + props: { + title: 'Budget usage', + items: [ + { label: 'Food', current: 640, target: 800, currency: 'USD' }, + { label: 'Transport', current: 320, target: 400, currency: 'USD' } + ] + } + }, + ChatQuestionBlock: { + props: { + prompt: 'What would you like to do?', + options: [ + { label: 'View report', message: 'show report' }, + { label: 'Add transaction', message: 'add tx' } + ] + } + }, + ChatQuickActionsBlock: { + props: { + actions: [ + { label: 'Show spending by category' }, + { label: 'Compare this month to last' }, + { label: 'Export CSV' } + ] + } + }, + ChatTableBlock: { + props: { + title: 'Transaction details', + columns: ['name', 'amount', 'date'], + rows: [ + { name: 'Groceries', amount: -42.5, date: '2026-07-18' }, + { name: 'Salary', amount: 3000, date: '2026-07-01' } + ] + } + }, + ChatTimelineBlock: { + props: { + title: 'Recent activity', + items: [ + { title: 'Groceries', amount: -42.5, currency: 'USD', date: '2026-07-18', description: 'Weekly shopping' }, + { title: 'Salary', amount: 3000, currency: 'USD', date: '2026-07-01', description: 'Monthly salary' } + ] + } + }, + MonthInReviewHeader: { + props: { + slides: [{ kind: 'opening', tone: 'opening', headline: 'July recap' }], + currentIndex: 0, + labels: { close: 'Close', prev: 'Prev', next: 'Next', pause: 'Pause' }, + monthLabel: 'July 2026' + } + }, + MonthInReviewFooter: { + props: { + slides: [{ kind: 'opening', tone: 'opening' }], + currentIndex: 0, + labels: { prev: 'Prev', next: 'Next', pause: 'Pause' }, + data: { monthLabel: 'July 2026' } + } + }, + MonthInReviewSlide: { + props: { + slides: [{ kind: 'opening', tone: 'opening', eyebrow: 'Recap', headline: 'A stellar month', detail: 'Here is your recap' }], + currentIndex: 0, + currentSlide: { kind: 'opening', tone: 'opening', eyebrow: 'Recap', headline: 'A stellar month', detail: 'Here is your recap' }, + animatedValue: 100, + currency: 'USD', + formatter: (n) => `$${Math.round(n)}`, + labels: {} + } + }, + OutreachComposerAudience: { + props: { + selectedSegment: 'all', + segments: [{ id: 'all', label: 'All users' }, { id: 'active', label: 'Active users' }] + } + }, + OutreachComposerMessage: { + props: { + subject: 'Monthly Newsletter', + body: 'Hello team!' + } + }, + TSidebarRail: { + props: { + activeTab: 'dashboard', + items: [{ id: 'dashboard', label: 'Dashboard', icon: 'home' }] + } + }, + TSidebarSub: { + props: { + title: 'Menu', + items: [{ id: 'overview', label: 'Overview' }] + } + }, + // --- Phase 6: additional playground coverage (10 missing demos) --- + ImportUploadDropzone: { + props: { + isDragging: false, + labels: { + dropzone: 'Drop your file here or click to browse', + supportedFormats: 'Supports CSV, PDF, PNG, JPG, TIFF, BMP' + } + } + }, + ImportUploadFileList: { + props: { + files: [ + { name: 'bank-statement.csv', size: 24576 }, + { name: 'receipt.jpg', size: 102400 } + ], + labels: { remove: 'Remove' } + } + }, + MonthInReviewIllustration: { + props: { + kind: 'opening', + confettiDots: [] + } + }, + TSidebar: { + props: { + currentPath: '/dashboard', + isSidebarOpen: false, + isMobile: false, + collapsed: false + } + }, + OutreachComposer: { + props: { + users: [ + { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, + { id: 2, name: 'Bob Smith', email: 'bob@example.com' } + ], + previewHtml: '

Hello {{first_name}}!

Welcome to Trakli — your monthly summary is ready.

', + sending: false, + imageUploading: false + } + }, + TForm: { + props: { + title: 'Demo form', + submitLabel: 'Save', + cancelLabel: 'Cancel', + showClose: true, + showCancel: true, + isSubmitting: false, + apiError: '' + } + }, + ContentTablePagination: { + props: { + currentPage: 2, + totalPages: 5, + perPage: 10, + previousPageLabel: 'Previous page', + nextPageLabel: 'Next page', + showLabel: 'Show', + perPageLabel: 'per page' + } + }, + TTab: { + props: { + value: 'overview', + label: 'Overview', + active: true, + variant: 'pill' + } + }, + TFilterToggle: { + props: { + activeCount: 2, + label: 'Filters' + } + }, + TransactionFormParties: { + props: { + parties: [ + { id: 1, name: 'Acme Ltd', label: 'Acme Ltd', value: 1 }, + { id: 2, name: 'Globex Corp', label: 'Globex Corp', value: 2 } + ], + wallets: [ + { id: 10, name: 'Main Wallet', label: 'Main Wallet', value: 10 }, + { id: 11, name: 'Savings', label: 'Savings', value: 11 } + ], + filteredWallets: [ + { id: 10, name: 'Main Wallet', label: 'Main Wallet', value: 10 }, + { id: 11, name: 'Savings', label: 'Savings', value: 11 } + ], + searchQuery: '', + walletSearchQuery: '', + labels: { + partySentTo: 'Sent to', + partyReceivedFrom: 'Received from', + searchParty: 'Search parties…', + searchWallet: 'Search wallets…', + walletSentFrom: 'Wallet sent from', + walletReceivedTo: 'Wallet received to', + walletError: 'Select a wallet', + default: 'Default', + defaultWallet: 'Default wallet' + }, + isOutcomeSelected: true, + walletError: false, + isWalletDefault: false + } + }, + TransactionFormAttachments: { + props: { + existingAttachments: [ + { id: 1, path: 'receipt.jpg' }, + { id: 2, path: 'invoice.pdf' } + ], + existingPreviews: {}, + removingFileIds: new Set(), + newAttachments: [], + inputId: 'file-input', + labels: { + attachment: 'Attachment', + remove: 'Remove', + browseFiles: 'Browse files', + fileHint: 'Up to 5 files, images or PDFs' + } + } + }, + OnboardingWizard: {}, + TransactionsSpreadsheetToolbar: { + props: { + labels: { + title: 'Transactions spreadsheet', + rowCount: '{count} rows', + loadingMore: 'loading…', + searchPlaceholder: 'Search rows...', + exportCsv: 'Export CSV', + close: 'Close', + addRow: 'Add row', + deleteSelected: 'Delete selected' + }, + rowCount: 2, + isStreaming: false, + searchValue: '', + hasSelection: false, + exportDisabled: false + } + }, + TransactionsSpreadsheetEditor: { + props: { + editingRow: { + id: 1, + datetime: '2026-07-18T10:00:00.000Z', + type: 'expense', + amount: 42.5, + description: 'Groceries', + categories: [{ name: 'Food' }], + wallet: { currency: 'USD', name: 'Main' } + }, + labels: { + dateCol: 'Date', + amountCol: 'Amount', + descCol: 'Description', + catCol: 'Categories', + walletCol: 'Wallet', + save: 'Save', + cancel: 'Cancel', + searchPlaceholder: 'Search...' + }, + currencies: ['USD', 'EUR', 'GBP'] + } + }, + TransactionFormGroups: { + props: { + groups: [ + { id: 1, name: 'General', label: 'General', value: 1 }, + { id: 2, name: 'Work', label: 'Work', value: 2 } + ], + categories: [ + { id: 10, name: 'Food', label: 'Food', value: 10 }, + { id: 11, name: 'Transport', label: 'Transport', value: 11 } + ], + groupSearchQuery: '', + categorySearchQuery: '', + selectedGroupId: 1, + selectedAdditionalCategoryIds: [10], + labels: { + group: 'Group', + groupError: 'Group required', + searchGroup: 'Search groups…', + categories: 'Categories', + searchCategories: 'Search categories…', + defaultGroup: 'Default group', + default: 'Default' + }, + categoryError: false, + isGroupDefault: false + } + }, + TransactionFormRecurring: { + props: { + modelValue: true, + period: 'monthly', + interval: 1, + endsAt: '2026-12-31', + labels: { + makeRecurring: 'Make recurring', + recurrencePeriod: 'Recurrence period', + repeatEvery: 'Repeat every', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + yearly: 'Yearly', + endDate: 'End date', + optional: 'optional' + } + } + }, + TransactionsSpreadsheet: { + props: { + rows: [ + { id: 1, datetime: '2026-07-18T10:00:00.000Z', type: 'expense', amount: 42.5, description: 'Groceries', categories: [{ name: 'Food' }], wallet: { name: 'Main', currency: 'USD' }, party: { name: 'Shop' } }, + { id: 2, datetime: '2026-07-01T09:00:00.000Z', type: 'income', amount: 3000, description: 'Salary', categories: [{ name: 'Income' }], wallet: { name: 'Main', currency: 'USD' }, party: { name: 'Employer' } } + ], + isLoading: false, + isStreaming: false, + error: '', + labels: { + title: 'Transactions spreadsheet', + rowCount: '{count} rows', + loadingMore: 'loading…', + searchPlaceholder: 'Search rows...', + exportCsv: 'Export CSV', + close: 'Close', + loadingTransactions: 'Loading transactions...', + dateCol: 'Date', + typeCol: 'Type', + amountCol: 'Amount', + currencyCol: 'Currency', + descCol: 'Description', + catCol: 'Categories', + walletCol: 'Wallet', + partyCol: 'Party', + markedAsRefund: 'Marked as refund', + refund: 'refund', + totals: 'Totals', + income: 'Income', + expenses: 'Expenses', + net: 'Net', + addRow: 'Add row', + deleteSelected: 'Delete selected', + save: 'Save', + cancel: 'Cancel' + } + } + }, + TransactionForm: { + props: { + isOutcomeSelected: true, + parties: [ + { id: 1, name: 'Acme Ltd' }, + { id: 2, name: 'Globex Corp' } + ], + wallets: [ + { id: 10, name: 'Main Wallet', currency: 'USD' }, + { id: 11, name: 'Savings', currency: 'USD' } + ], + groups: [ + { id: 1, name: 'General' }, + { id: 2, name: 'Work' } + ], + categories: [ + { id: 10, name: 'Food' }, + { id: 11, name: 'Transport' } + ], + defaultWalletId: 10, + defaultGroupId: 1, + defaultCurrency: 'USD', + recentExpenses: [] + } + }, + TransactionFormContainer: { + props: {} + }, + TransactionFormSection: { + props: { pageName: 'Transaction' } + }, + TransactionFormIntent: { + props: { + modelValue: 'regular', + options: [ + { value: 'regular', label: 'Regular', side: 'both' }, + { value: 'loan_received', label: 'Loan received', side: 'income' }, + { value: 'gift', label: 'Gift', side: 'both' } + ], + label: 'Intent', + labels: { intentLabels: { regular: 'Regular', loan_received: 'Loan received', gift: 'Gift' } } + } + }, + TransactionsContentSection: { + props: { + transactions: [ + { id: 1, name: 'Groceries', amount: -42.5, category: 'Food', date: '2026-07-18', party: 'Shop' }, + { id: 2, name: 'Salary', amount: 3000, category: 'Income', date: '2026-07-01', party: 'Employer' } + ], + totals: { income: 3000, expenses: 42.5, net: 2957.5 }, + currentPage: 1, + totalPages: 1, + totalItems: 2, + perPage: 20, + activeFilterCount: 0, + searchQuery: '', + isLoading: false, + pageName: 'Transaction', + pageNamePlural: 'Transactions', + labels: { + income: 'Income', + expenses: 'Expenses', + net: 'Net', + spreadsheetMode: 'Spreadsheet mode', + openSpreadsheetView: 'Open spreadsheet view', + noResultsFound: 'No results found', + noResultsText: 'No transactions match your current filters.', + clearAllFilters: 'Clear all filters' + } + } + }, + // --- Batch 3: next 22 missing demos --- + AuthSocialLogin: { + props: { + mode: 'login', + labels: { signInWithGoogle: 'Sign in with Google', signUpWithGoogle: 'Sign up with Google' } + } + }, + CanvasPanel: { + props: { + canvas: { + blocks: [ + { type: 'kpi', title: 'Overview', items: [{ label: 'Net', value: 900, currency: 'USD', trend: 'up' }] }, + { type: 'table', title: 'Top expenses', columns: ['name', 'amount'], rows: [{ name: 'Rent', amount: 1200 }] } + ] + }, + sessionId: 1, + messageId: 1, + labels: { + eyebrow: 'Canvas', + defaultTitle: 'Document', + downloadMarkdown: 'Download as Markdown', + printPdf: 'Print / Save as PDF', + close: 'Close' + } + } + }, + CategoryDrillModal: { + props: { + open: true, + bucket: { name: 'Food', count: 24, amount: 640, delta: 0.12, trend: [20, 40, 30, 50, 60, 40], color: '#3b82f6' }, + transactions: [ + { id: 1, name: 'Groceries', amount: -42.5, date: '2026-07-18' }, + { id: 2, name: 'Restaurant', amount: -28, date: '2026-07-17' } + ], + trailingMonths: [ + { label: 'Jun', amount: 540 }, + { label: 'Jul', amount: 640 } + ], + currency: 'USD', + formatter: (n) => `$${Math.round(n)}`, + labels: { + periodOverview: 'Period overview · {n} transactions', + close: 'Close', + total: 'Total', + avgPerMonth: 'Avg / month', + peakMonth: 'Peak month', + vsPriorPeriod: 'vs prior period', + trendLast6Months: 'Trend (last 6 months)', + topKind: 'Top {kind}', + sources: 'sources', + payees: 'payees', + noTransactionsMatch: 'No transactions match.', + transactions: 'Transactions', + showMore: 'Show {n} more' + } + } + }, + ChatExperience: { + props: { + mode: 'landing', + sessions: [ + { id: 1, title: 'Budget help', created_at: '2026-07-18', updated_at: '2026-07-18' }, + { id: 2, title: 'Spending review', created_at: '2026-07-15', updated_at: '2026-07-15' } + ], + currentSessionId: 1, + currentTitle: 'Budget help', + messages: [ + { id: 1, role: 'assistant', text: 'How can I help?' }, + { id: 2, role: 'user', text: 'Show my spending' } + ], + isLoadingSessions: false, + isSending: false, + modelValue: '', + openCanvas: null, + canvasMessageId: null, + suggestions: [{ id: 1, label: 'Show spending by category' }], + labels: { + heroTitle: 'What can I help with?', + heroSubtitle: 'Ask about your money, log a transaction, or import a statement.', + toggleHistory: 'Toggle history', + newChat: 'New chat', + assistant: 'Assistant', + fullscreen: 'Full screen', + exitFullscreen: 'Exit full screen', + canvas: {} + } + } + }, + ChatImportReviewBlock: { + props: { + block: { file_name: 'july.csv', total: 24 }, + status: 'ready', + labels: { + document: 'Document', + reviewImport: 'Review import', + analyzing: 'Analyzing document…', + ready: 'Ready to review', + confirmed: 'Imported', + failed: 'Import failed', + expired: 'Import expired', + unavailable: 'Import unavailable' + } + } + }, + ChatLandingInsights: { + props: { + loading: false, + income: 3000, + expenses: 2100, + savingsRate: 0.3, + txCount: 48, + avgExpense: 42.5, + biggestExpense: 'Rent', + topCategory: { name: 'Food', pct: 40 }, + topCategories: [ + { name: 'Food', amount: 640 }, + { name: 'Transport', amount: 320 } + ], + trend: [2000, 2100, 1900, 2300, 3000, 2100], + spotlights: [ + { title: 'Food up 12%', detail: 'vs last month' } + ], + formatMoneyFn: (v) => `$${Math.round(v)}`, + labels: { + emptyHint: "Add a few transactions and I'll start spotting trends for you.", + askAboutThis: 'Ask about this', + netFlow: 'Net flow', + saved: 'Saved', + topCategory: 'Top category', + cashflowTrend: 'Cashflow trend', + transactions: 'Transactions', + avgExpense: 'Avg expense', + biggestExpense: 'Biggest expense', + inLabel: 'In' + } + } + }, + ChatMarkdownBlock: { + props: { text: '# Hello\n\nThis is **markdown** with a [link](https://example.com) and a list:\n\n- Item one\n- Item two\n' } + }, + ChatProposedActionBlock: { + props: { + block: { + summary: 'Create expense transaction', + risk: 'low', + status: 'pending', + fields: [ + { key: 'amount', label: 'Amount', type: 'number', value: 42.5 }, + { key: 'description', label: 'Description', type: 'text', value: 'Groceries' }, + { key: 'datetime', label: 'Date', type: 'datetime', value: '2026-07-18T10:00' } + ] + }, + wallets: [{ id: 1, name: 'Main' }], + categories: [{ id: 1, name: 'Food' }], + parties: [{ id: 1, name: 'Shop' }], + busy: false, + labels: { + none: 'None', + working: 'Working…', + confirm: 'Confirm', + dismiss: 'Dismiss', + done: 'Done', + dismissed: 'Dismissed' + } + } + }, + ComponentLoader: { + props: { + isLoading: false, + error: null, + hasData: true, + showEmpty: true, + emptyStateName: 'items', + skeletonVariant: 'default', + skeletonCount: 3, + skeletonColumns: 4, + labels: { retry: 'Retry', empty: 'No items' } + } + }, + DashboardAgentHero: { + props: { + name: 'Alex', + labels: { + dismiss: 'Dismiss', + greeting: 'Hey {name}', + defaultName: 'there', + subtitle: "Tell me what you want and I'll do it.", + placeholder: 'e.g. log 12 for lunch, or build me a report', + send: 'Send', + logTransaction: 'Log a transaction', + transferMoney: 'Transfer money', + buildReport: 'Build a report', + importDocument: 'Import a document' + } + } + }, + DashboardKPIs: { + props: { + statistics: { total_balance: 12340, total_income: 3000, total_expenses: 2100 }, + currency: 'USD', + isCustomActive: false, + activeFilterChips: [], + labels: { + balance: 'Balance', + income: 'Income', + expenses: 'Expenses', + net: 'Net', + clearAll: 'Clear all' + } + } + }, + DashboardQuickActionModal: { + props: { + action: 'log', + wallets: [ + { id: 1, name: 'Main', currency: 'USD' }, + { id: 2, name: 'Savings', currency: 'USD' } + ], + parties: [ + { id: 1, name: 'Acme Ltd' }, + { id: 2, name: 'Globex' } + ], + busy: false, + labels: { + close: 'Close', + expense: 'Expense', + income: 'Income', + amount: 'Amount', + whatFor: 'What for?', + coffeePlaceholder: 'e.g. coffee', + wallet: 'Wallet', + optional: 'optional', + defaultOption: 'Default', + party: 'Party', + noneOption: 'None', + when: 'When', + fromWallet: 'From wallet', + toWallet: 'To wallet', + selectWallet: 'Select a wallet', + exchangeRate: 'Exchange rate', + period: 'Period', + thisMonth: 'This month', + lastMonth: 'Last month', + thisYear: 'This year', + focus: 'Focus', + focusPlaceholder: 'e.g. dining, subscriptions', + document: 'Document', + chooseFile: 'Choose a file' + } + } + }, + DashboardWalletSelector: { + props: { + selectedWalletId: null, + wallets: [ + { id: 1, name: 'Main Wallet' }, + { id: 2, name: 'Savings' } + ], + allWalletsLabel: 'All Wallets' + } + }, + DescriptorRenderer: { + props: { + contribution: { + key: 'demo-integration', + slot: 'dashboard.widgets', + configured: true, + integration: { + key: 'demo', + name: 'Demo Integration', + description: 'An example integration card.', + icon: 'Puzzle', + configured: true + }, + ui: { + slots: ['dashboard.widgets'], + card: { title: 'Demo Integration', description: 'An example integration card.', cta: 'Open', href: '#' }, + onboarding: null, + component: null + } + }, + needsSetupLabel: 'Needs setup' + } + }, + ExtensionSlot: { + props: { + contributions: [ + { + key: 'demo-integration', + slot: 'dashboard.widgets', + configured: true, + integration: { + key: 'demo', + name: 'Demo Integration', + description: 'An example integration card.', + icon: 'Puzzle', + configured: true + }, + ui: { + slots: ['dashboard.widgets'], + card: { title: 'Demo Integration', description: 'An example integration card.', cta: 'Open', href: '#' }, + onboarding: null, + component: null + } + } + ] + } + }, + FinancialPositionDrill: { + props: { + open: true, + group: { + label: 'Earned income', + eyebrow: 'Money in', + tone: 'income', + metric: '$3,200', + hint: 'Regular salary and side gig.', + intents: ['regular'], + icon: null + }, + rows: [ + { id: 1, title: 'Salary', meta: '2026-07-01', amount: '3,000 USD', amountNum: 3000, type: 'income', intent: 'regular', currency: 'USD' }, + { id: 2, title: 'Side gig', meta: '2026-07-15', amount: '200 USD', amountNum: 200, type: 'income', intent: 'regular', currency: 'USD' } + ], + isLoading: false, + labels: { + close: 'Close', + noTransactions: 'No transactions for this in the selected period.', + nTransactions: '{n} transactions' + }, + formatAmountFn: (amount, currency) => `${amount} ${currency}` + } + }, + FinancialPositionHero: { + props: { + position: { + net_worth_delta: 900, + total_net_worth: 52340, + cash_balance: 12340, + holdings_value: 40000 + }, + currency: 'USD', + totalIn: 3800, + totalOut: 2100, + labels: { + netWorthChange: 'Net worth change', + building: 'Building', + shrinking: 'Shrinking', + heroSub: 'Real earnings minus real spend.', + moneyIn: 'Money in', + moneyOut: 'Money out', + totalNetWorthNow: 'Total net worth now', + cash: 'Cash', + holdings: 'Holdings' + } + } + }, + FinancialPositionView: { + props: { + position: { + net_worth_delta: 900, + total_net_worth: 52340, + cash_balance: 12340, + holdings_value: 40000, + earned_income: 3200, + investment_returns: 400, + gifts_received: 200, + loan_received: 0, + debt_owed: 0, + discretionary_spend: 2100, + investment_principal: 500, + loan_repayment: 0, + debt_settled: 0 + }, + currency: 'USD', + partial: false, + unconvertedCurrencies: [], + isLoading: false, + activePreset: 'current_month', + presets: [ + { value: 'current_month', label: 'This month' }, + { value: 'last_3_months', label: 'Last 3 months' }, + { value: 'all_time', label: 'All time' } + ], + labels: { + title: 'Financial position', + subtitle: 'Where your money came from and where it went.', + partialNote: 'Some amounts could not be converted: {currencies}.', + netWorthChange: 'Net worth change', + building: 'Building', + shrinking: 'Shrinking', + heroSub: 'Real earnings minus real spend.', + moneyIn: 'Money in', + moneyOut: 'Money out', + totalNetWorthNow: 'Total net worth now', + cash: 'Cash', + holdings: 'Holdings', + cashNotNetWorth: 'cash, not net worth', + footNote: 'Greyed rows move cash but not net worth.', + noDataForPeriod: 'No data for this period yet.', + presetLabels: { current_month: 'This month', last_3_months: 'Last 3 months', all_time: 'All time' }, + rowLabels: { + earned: 'Earned income', + returns: 'Investment returns', + gift: 'Gifts received', + loan_in: 'Loan received', + debt_in: 'Debt repaid to you', + spend: 'Discretionary spend', + invested: 'Invested', + loan_out: 'Loan repayment', + debt_out: 'Debt settled' + } + } + } + }, + FormSection: { + props: { + editingItem: null, + isSubmitting: false, + labels: { expense: 'Expense', income: 'Income', transfer: 'Transfer' } + } + }, + HoldingForm: { + props: { + open: true, + editing: null, + submitting: false, + defaultCurrency: 'USD', + currencies: [{ code: 'USD' }, { code: 'EUR' }, { code: 'GBP' }], + searchResults: [ + { id: 'bitcoin', name: 'Bitcoin', symbol: 'BTC' }, + { id: 'ethereum', name: 'Ethereum', symbol: 'ETH' } + ], + labels: { + editHolding: 'Edit holding', + addHolding: 'Add holding', + close: 'Close', + cancel: 'Cancel', + priceSource: 'Price source', + live: 'Live', + manual: 'Manual', + coin: 'Coin', + searchCoin: 'Search Bitcoin, Ethereum...', + pricedAutomatically: 'Priced automatically from provider', + name: 'Name', + namePlaceholder: 'Bitcoin, Apple, Rental flat...', + symbol: 'Symbol', + optional: 'optional', + quantity: 'Quantity', + currency: 'Currency', + pricePerUnit: 'Price per unit', + saveChanges: 'Save changes', + nameRequired: 'Name is required.', + quantityRequired: 'Enter a quantity greater than 0.', + coinRequired: 'Pick a coin to price it automatically.' + } + } + }, + NotificationBell: { + props: { + notifications: [ + { id: 1, title: 'New transaction', body: 'Groceries -42.5 USD', read: false, created_at: '2026-07-18T10:00:00Z' }, + { id: 2, title: 'Budget alert', body: 'Food 80% used', read: true, created_at: '2026-07-17T10:00:00Z' } + ], + unreadCount: 1, + loading: false, + labels: { + notifications: 'Notifications', + markAllRead: 'Mark all read', + loading: 'Loading...', + noNotifications: 'No notifications yet', + markAsRead: 'Mark as read', + viewAllNotifications: 'View all notifications', + justNow: 'Just now', + minAgo: '{n} min ago', + hAgo: '{n}h ago', + dAgo: '{n}d ago' + } + } + }, + NotificationsContainer: { + props: { + notifications: [ + { id: 1, type: 'success', title: 'Saved', message: 'Transaction saved successfully.' }, + { id: 2, type: 'error', title: 'Error', message: 'Failed to save.' } + ], + confirmModal: null + } + }, + // --- Batch 4: final 22 missing demos --- + PartyDetailContent: { + props: { + party: { id: 1, name: 'Acme Ltd', type: 'business', description: 'Top client', receivedAmount: 1200, spentAmount: 450, icon: 'Building2' }, + transactions: [ + { id: 1, partyId: 1, amount: -42.5, type: 'expense', category: 'Food', date: new Date().toISOString().slice(0, 10), description: 'Groceries' }, + { id: 2, partyId: 1, amount: 1200, type: 'INCOME', category: 'Sales', date: new Date(Date.now() - 86400000 * 2).toISOString().slice(0, 10), description: 'Invoice paid' }, + { id: 3, partyId: 1, amount: -80, type: 'expense', category: 'Transport', date: new Date(Date.now() - 86400000 * 5).toISOString().slice(0, 10), description: 'Courier' } + ], + currency: 'USD', + formatter: (n, c) => `$${Math.round(Number(n))} ${c || 'USD'}`, + labels: { + backToList: 'Back to list', + edit: 'Edit', + delete: 'Delete', + received: 'Received', + spent: 'Spent', + net: 'Net', + lastSixMonths: 'Last 6 months', + activity: 'Activity', + receivedAmount: 'Received', + spentAmount: 'Spent', + recentTransactions: 'Recent transactions', + shown: 'shown', + viewAll: 'View all', + noActivity: 'No activity yet.', + transaction: 'Transaction', + noTransactions: 'No transactions.', + individual: 'Individual', + business: 'Business', + organization: 'Organization', + vendor: 'Vendor', + client: 'Client', + party: 'Party' + } + } + }, + RecentTransactions: { + props: { + transactions: [ + { id: 1, type: 'INCOME', amount: '1200', party: 'Acme Ltd', category: 'Sales', date: new Date().toISOString().slice(0, 10) }, + { id: 2, type: 'EXPENSE', amount: '42.50', party: 'ShopMart', category: 'Food', date: new Date(Date.now() - 86400000).toISOString().slice(0, 10) }, + { id: 3, type: 'EXPENSE', amount: '18.99', party: 'Transit Co', category: 'Transport', date: new Date(Date.now() - 86400000 * 2).toISOString().slice(0, 10) }, + { id: 4, type: 'INCOME', amount: '300', party: 'Globex', category: 'Freelance', date: new Date(Date.now() - 86400000 * 3).toISOString().slice(0, 10) }, + { id: 5, type: 'EXPENSE', amount: '65', party: 'Bookstore', category: 'Education', date: new Date(Date.now() - 86400000 * 4).toISOString().slice(0, 10) } + ], + limit: 5, + labels: { + recentTransactions: 'Recent Transactions', + viewAll: 'View All', + noTransactionsYet: 'No transactions yet', + today: 'Today', + yesterday: 'Yesterday', + dAgo: '{n}d ago', + wAgo: '{n}w ago', + moAgo: '{n}mo ago' + } + } + }, + SettingsAccount: { + props: { + isEditMode: false, + user: { first_name: 'Jane', last_name: 'Doe', username: 'janedoe', email: 'jane@example.com' }, + avatarUrl: 'https://i.pravatar.cc/100?img=5', + labels: { + profilePicture: 'Profile Picture', + userAvatar: 'User Avatar', + firstName: 'First Name', + lastName: 'Last Name', + username: 'Username', + emailAddress: 'Email Address', + password: 'Password', + changePassword: 'Change Password', + updateAccount: 'Update Account' + } + } + }, + SettingsConnections: { + props: { + endpoint: 'https://api.trakli.com/mcp', + tokens: [ + { id: 1, name: 'Claude Desktop', last_used_at: new Date().toISOString() }, + { id: 2, name: 'Cursor', last_used_at: null } + ], + loading: false, + creating: false, + createdToken: null, + labels: { + intro: 'Connect an AI client like Claude Desktop or Cursor to your Trakli data over the Model Context Protocol.', + mcpEndpoint: 'MCP endpoint', + copy: 'Copy', + endpointHint: 'Add this URL to your AI client and authenticate with one of the tokens below.', + accessTokens: 'Access tokens', + tokenNamePlaceholder: 'Token name, e.g. Claude Desktop', + generateToken: 'Generate token', + lastUsed: 'Last used', + neverUsed: 'Never used', + revoke: 'Revoke', + noTokensYet: 'No tokens yet.', + copyTokenNow: 'Copy your token now', + tokenShownOnce: 'This token is shown only once. Store it somewhere safe before closing.', + done: 'Done' + } + } + }, + SettingsGeneral: { + props: { + isEditMode: false, + initialLanguage: 'en', + initialCurrency: 'USD', + languages: [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Español' }, + { code: 'fr', label: 'Français' } + ], + currencies: [ + { code: 'USD', label: 'US Dollar' }, + { code: 'EUR', label: 'Euro' }, + { code: 'GBP', label: 'British Pound' } + ], + labels: { + defaultLanguage: 'Default Language', + currentCurrency: 'Current Currency', + updateGeneralSettings: 'Update General Settings' + } + } + }, + SettingsNotifications: { + props: { + isEditMode: false, + loading: false, + preferences: { + channels: { email: true, push: false, inapp: true }, + types: { reminders: true, insights: false, inactivity: true } + }, + labels: { + loadingPreferences: 'Loading preferences...', + notificationChannels: 'Notification Channels', + emailNotifications: 'Email Notifications', + emailDesc: 'Receive notifications via email', + pushNotifications: 'Push Notifications', + pushDesc: 'Receive notifications on your device', + inAppNotifications: 'In-App Notifications', + inAppDesc: 'See notifications in the app', + notificationTypes: 'Notification Types', + reminders: 'Reminders', + remindersDesc: 'Get notified about your reminders', + financialInsights: 'Financial Insights', + insightsDesc: 'Weekly/monthly spending summaries', + engagementReminders: 'Engagement Reminders', + engagementDesc: "Gentle nudges when you haven't tracked", + savePreferences: 'Save Preferences' + } + } + }, + SettingsWallets: { + props: { + isEditMode: false, + wallets: [ + { id: 1, name: 'Main Wallet', currency: 'USD' }, + { id: 2, name: 'Savings', currency: 'EUR' } + ], + groups: [ + { id: 1, name: 'General' }, + { id: 2, name: 'Work' } + ], + defaultWalletId: 1, + defaultGroupId: 1, + allowNegativeBalance: false, + labels: { + defaultWallet: 'Default Wallet', + currentlySelected: 'Currently Selected', + defaultGroup: 'Default Group', + none: 'None', + allowNegativeBalances: 'Allow Negative Balances', + allowNegativeDesc: 'Permit wallet balances to go below zero when spending or transferring.', + updateWalletsAndGroups: 'Update Wallets & Groups' + } + } + }, + StatsFilterModal: { + props: { + wallets: [ + { id: 1, name: 'Main Wallet', currency: 'USD' }, + { id: 2, name: 'Savings', currency: 'EUR' }, + { id: 3, name: 'Cash', currency: 'USD' } + ], + currentPeriod: 'current_month', + initialFilters: { startDate: '2026-07-01', endDate: '2026-07-31', walletIds: [] }, + labels: { + title: 'Filter Statistics', + dateRange: 'Date Range', + startDate: 'Start date', + endDate: 'End date', + wallets: 'Wallets', + reset: 'Reset', + applyFilters: 'Apply Filters', + presets: { + current_week: 'This Week', + current_month: 'This Month', + last_3_months: 'Last 3 months', + current_year: 'This Year', + all_time: 'All time' + } + } + } + }, + TAvatar: { + props: { + imageUrl: 'https://i.pravatar.cc/100?img=12', + userName: 'Jane Doe', + showName: true, + showDropdown: false, + user: { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com', is_admin: true }, + labels: { + accountMenu: 'Account menu', + settings: 'Settings', + admin: 'Admin', + logout: 'Logout' + } + } + }, + TCardActionMenu: { + props: { + triggerLabel: 'More actions', + moreLabel: 'More actions', + editLabel: 'Edit', + deleteLabel: 'Delete', + items: [ + { action: 'edit', label: 'Edit' }, + { action: 'delete', label: 'Delete', variant: 'danger' } + ] + } + }, + TDashboardTopCard: { + props: { + showFilters: true, + activePeriod: 'current_month', + isCustomActive: false, + customOpen: false, + periodSubtitle: 'July 2026 · All wallets', + periods: [ + { value: 'current_month', label: 'This month' }, + { value: 'last_month', label: 'Last month' }, + { value: 'current_year', label: 'This year' } + ], + labels: { + overview: 'Overview', + custom: 'Custom' + } + } + }, + TEmptyState: { + props: { + title: 'No items yet', + description: 'There are no items to display. Create your first one to get started.' + } + }, + TFormInput: { + props: { + modelValue: 'Hello world', + type: 'text', + placeholder: 'Enter text...', + size: 'md' + } + }, + TFormSelect: { + props: { + modelValue: 'option1', + options: [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3 (disabled)', value: 'option3', disabled: true } + ], + size: 'md' + } + }, + TFormTextarea: { + props: { + modelValue: 'A short description with a few lines of text to preview the textarea component.', + rows: 4, + placeholder: 'Enter details...' + } + }, + TModalHeader: { + props: { + title: 'Confirm action', + showClose: true + } + }, + TNavbar: { + props: { + isMobile: false, + isSidebarOpen: false, + labels: { + addTransaction: 'Add transaction', + openLearningModal: 'Open learning modal', + learnTitle: 'Learn how to use Trakli' + } + } + }, + TPagination: { + props: { + currentPage: 2, + totalPages: 5, + totalEntries: 48, + itemsPerPage: 10, + maxVisible: 5, + showLabels: false, + prevLabel: 'Previous', + nextLabel: 'Next', + entryText: 'Showing 11 to 20 of 48 entries' + } + }, + TTableRow: { + props: { + txn: { id: 1, type: 'INCOME', amount: '$1,200.00', party: 'Acme Ltd', category: 'Sales', date: new Date().toISOString(), datetime: new Date().toISOString(), isTransfer: false, isRefund: false, isRecurring: false }, + labels: { transfer: 'Transfer', refund: 'Refund', recurring: 'Recurring' }, + formatDate: (txn) => new Date(txn.date || txn.datetime).toLocaleDateString(), + formatTimeAgo: (txn) => 'Today' + } + }, + TTableTotals: { + props: { + totals: { income: 3000, expenses: 2100, net: 900 }, + labels: { totals: 'Totals', income: 'Income', expenses: 'Expenses', net: 'Net' }, + formatCurrency: (v) => `$${Math.round(Number(v)).toLocaleString()}` + } + }, + UserDetail: { + props: { + detail: { + user: { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com', is_admin: false, created_at: '2024-03-15T10:00:00Z' }, + preferences: { country: 'United States', language: 'English', currency: 'USD' }, + counts: { transactions: 42, wallets: 3, categories: 8, parties: 5, groups: 2, budgets: 4 }, + last_transaction_at: new Date().toISOString() + }, + loading: false, + error: '', + labels: { + backToUsers: 'Back to users', + loading: 'Loading...', + admin: 'Admin', + profile: 'Profile', + joined: 'Joined', + country: 'Country', + language: 'Language', + currency: 'Currency', + lastTransaction: 'Last transaction', + countLabels: { + transactions: 'Transactions', + wallets: 'Wallets', + categories: 'Categories', + parties: 'Parties', + groups: 'Groups', + budgets: 'Budgets' + } + } + } + }, + WalletDetailContent: { + props: { + wallet: { id: 1, name: 'Main Wallet', balance: 4200.5, currency: 'USD', type: 'bank', stats: { total_income: 3000, total_expense: 2100 }, icon: 'Wallet' }, + isDefault: true, + transactions: [ + { id: 1, walletId: 1, amount: 42.5, type: 'expense', category: 'Food', party: 'ShopMart', date: new Date().toISOString().slice(0, 10), description: 'Groceries' }, + { id: 2, walletId: 1, amount: 3000, type: 'INCOME', category: 'Income', party: 'Employer', date: new Date(Date.now() - 86400000 * 2).toISOString().slice(0, 10), description: 'Salary' }, + { id: 3, walletId: 1, amount: 18.99, type: 'expense', category: 'Transport', party: 'Transit Co', date: new Date(Date.now() - 86400000 * 5).toISOString().slice(0, 10), description: 'Bus pass' } + ], + formatter: (n, c) => `$${Math.round(Number(n)).toLocaleString()} ${c || 'USD'}`, + labels: { + backToList: 'Back to list', + edit: 'Edit', + delete: 'Delete', + balance: 'Balance', + income: 'Income', + expense: 'Expense', + net: 'Net', + last6Months: 'Last 6 months', + activity: 'Activity', + noActivity: 'No activity yet.', + recentTransactions: 'Recent transactions', + shown: 'shown', + viewAll: 'View all', + transaction: 'Transaction', + noTransactions: 'No transactions.', + default: 'Default', + walletType: { bank: 'Bank', cash: 'Cash', creditCard: 'Credit card', savings: 'Savings' } + } + } + } +}; + +// Generic fallback for components without a tuned demo. +export function getDemo(name: string): DemoConfig { + return demos[name] || { props: {} }; +} + +// Human-friendly groupings for the usage landing. A component is filed under +// the first matching prefix/keyword; anything left over lands in "Other". +const CATEGORY_RULES: { label: string; match: (n: string) => boolean }[] = [ + { label: 'Buttons & actions', match: (n) => /^T(Button|InfoButton|Divider|Dropdown|DropdownItem)|^(ViewToggle|ThemeToggleButton|HamburgerMenu|TypingDots|TipsSection|CollapsibleSection)$/.test(n) }, + { label: 'Layout & containers', match: (n) => /^(TContainer|TStack|TGrid|TSplit|TPanel|TPageShell|Content(Card|Section|Table|ListView|CardGrid))$/.test(n) }, + { label: 'Cards & summaries', match: (n) => /Card|Strip|Kpi|Hero|Ratios|Onboarding|EmptyState|Logo|GoogleIcon|LoadingSkeleton|SparkLine|Auth/.test(n) }, + { label: 'Charts & data viz', match: (n) => /Chart|Donut|Breakdown|Ranking|Sankey|Heatmap|Bar|Cumulative|Calendar|Flow|Reports|Notable|QuickInsights/.test(n) }, + { label: 'Tables & lists', match: (n) => /Table|List|Tabs|Session|Suggestion/.test(n) }, + { label: 'Forms & inputs', match: (n) => /Form|Filters|Search|Period|Reminder|Group|Category|Party|Wallet|Transfer|Budget|Login|Register|Language|Theme|Settings|IconPicker|Import/.test(n) }, + { label: 'Modals & dialogs', match: (n) => /Modal|Dialog|Confirm|Learning|Password|Recurring|MonthInReview/.test(n) }, + { label: 'AI & chat', match: (n) => /Chat|AIChat/.test(n) } +]; + +export const categories: { label: string; items: string[] }[] = (() => { + const buckets: Record = {}; + for (const c of components) { + const rule = CATEGORY_RULES.find((r) => r.match(c.name)) || { label: 'Other' }; + (buckets[rule.label] ||= []).push(c.name); + } + return Object.entries(buckets) + .map(([label, items]) => ({ label, items: items.sort() })) + .sort((a, b) => a.label.localeCompare(b.label)); +})(); diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts new file mode 100644 index 0000000..a6b5b87 --- /dev/null +++ b/.playground/nuxt.config.ts @@ -0,0 +1,19 @@ +// Playground host — verifies the ui-kit layer locally. +// Extending '..' (the layer root) pulls in the layer's components/assets. +// The layer's nuxt.config.ts references its own package name +// ('@trakli/ui-kit/assets/css/tokens.css'); that alias only exists when the +// package is installed. For local dev we map it explicitly here (playground-only, +// the published layer relies on the real npm alias). +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const layerRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +export default defineNuxtConfig({ + extends: ['..'], + srcDir: 'app', + alias: { + '@trakli/ui-kit': layerRoot + }, + devtools: { enabled: true } +}); diff --git a/.playground/pages/AuthSocialLogin.vue b/.playground/pages/AuthSocialLogin.vue new file mode 100644 index 0000000..8f6cb4f --- /dev/null +++ b/.playground/pages/AuthSocialLogin.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/pages/CanvasPanel.vue b/.playground/pages/CanvasPanel.vue new file mode 100644 index 0000000..4e68a09 --- /dev/null +++ b/.playground/pages/CanvasPanel.vue @@ -0,0 +1,17 @@ + + + diff --git a/.playground/pages/CategoryDrillModal.vue b/.playground/pages/CategoryDrillModal.vue new file mode 100644 index 0000000..203c723 --- /dev/null +++ b/.playground/pages/CategoryDrillModal.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/pages/ChatExperience.vue b/.playground/pages/ChatExperience.vue new file mode 100644 index 0000000..a2042ee --- /dev/null +++ b/.playground/pages/ChatExperience.vue @@ -0,0 +1,20 @@ + + + diff --git a/.playground/pages/ChatImportReviewBlock.vue b/.playground/pages/ChatImportReviewBlock.vue new file mode 100644 index 0000000..ceac7c5 --- /dev/null +++ b/.playground/pages/ChatImportReviewBlock.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/pages/ChatLandingInsights.vue b/.playground/pages/ChatLandingInsights.vue new file mode 100644 index 0000000..3543658 --- /dev/null +++ b/.playground/pages/ChatLandingInsights.vue @@ -0,0 +1,10 @@ + + + diff --git a/.playground/pages/ChatMarkdownBlock.vue b/.playground/pages/ChatMarkdownBlock.vue new file mode 100644 index 0000000..37031d9 --- /dev/null +++ b/.playground/pages/ChatMarkdownBlock.vue @@ -0,0 +1,12 @@ + diff --git a/.playground/pages/ChatProposedActionBlock.vue b/.playground/pages/ChatProposedActionBlock.vue new file mode 100644 index 0000000..28b2ed4 --- /dev/null +++ b/.playground/pages/ChatProposedActionBlock.vue @@ -0,0 +1,22 @@ + + + diff --git a/.playground/pages/ComponentLoader.vue b/.playground/pages/ComponentLoader.vue new file mode 100644 index 0000000..bebef67 --- /dev/null +++ b/.playground/pages/ComponentLoader.vue @@ -0,0 +1,8 @@ + diff --git a/.playground/pages/ContentTablePagination.vue b/.playground/pages/ContentTablePagination.vue new file mode 100644 index 0000000..470b6ec --- /dev/null +++ b/.playground/pages/ContentTablePagination.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/pages/DashboardAgentHero.vue b/.playground/pages/DashboardAgentHero.vue new file mode 100644 index 0000000..3e7e690 --- /dev/null +++ b/.playground/pages/DashboardAgentHero.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/pages/DashboardKPIs.vue b/.playground/pages/DashboardKPIs.vue new file mode 100644 index 0000000..5a0a86b --- /dev/null +++ b/.playground/pages/DashboardKPIs.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/pages/DashboardQuickActionModal.vue b/.playground/pages/DashboardQuickActionModal.vue new file mode 100644 index 0000000..1f9cb05 --- /dev/null +++ b/.playground/pages/DashboardQuickActionModal.vue @@ -0,0 +1,17 @@ + + + diff --git a/.playground/pages/DashboardWalletSelector.vue b/.playground/pages/DashboardWalletSelector.vue new file mode 100644 index 0000000..e4455c6 --- /dev/null +++ b/.playground/pages/DashboardWalletSelector.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/pages/DescriptorRenderer.vue b/.playground/pages/DescriptorRenderer.vue new file mode 100644 index 0000000..0cd75fc --- /dev/null +++ b/.playground/pages/DescriptorRenderer.vue @@ -0,0 +1,27 @@ + + + diff --git a/.playground/pages/ExtensionSlot.vue b/.playground/pages/ExtensionSlot.vue new file mode 100644 index 0000000..9e42fb6 --- /dev/null +++ b/.playground/pages/ExtensionSlot.vue @@ -0,0 +1,29 @@ + + + diff --git a/.playground/pages/FinancialPositionDrill.vue b/.playground/pages/FinancialPositionDrill.vue new file mode 100644 index 0000000..a5f2f7c --- /dev/null +++ b/.playground/pages/FinancialPositionDrill.vue @@ -0,0 +1,22 @@ + + + diff --git a/.playground/pages/FinancialPositionHero.vue b/.playground/pages/FinancialPositionHero.vue new file mode 100644 index 0000000..ffb1991 --- /dev/null +++ b/.playground/pages/FinancialPositionHero.vue @@ -0,0 +1,26 @@ + + + diff --git a/.playground/pages/FinancialPositionView.vue b/.playground/pages/FinancialPositionView.vue new file mode 100644 index 0000000..0637620 --- /dev/null +++ b/.playground/pages/FinancialPositionView.vue @@ -0,0 +1,24 @@ + + + diff --git a/.playground/pages/FormSection.vue b/.playground/pages/FormSection.vue new file mode 100644 index 0000000..df46181 --- /dev/null +++ b/.playground/pages/FormSection.vue @@ -0,0 +1,13 @@ + diff --git a/.playground/pages/HoldingForm.vue b/.playground/pages/HoldingForm.vue new file mode 100644 index 0000000..6da2f46 --- /dev/null +++ b/.playground/pages/HoldingForm.vue @@ -0,0 +1,14 @@ + + + diff --git a/.playground/pages/ImportUploadDropzone.vue b/.playground/pages/ImportUploadDropzone.vue new file mode 100644 index 0000000..33a5e4b --- /dev/null +++ b/.playground/pages/ImportUploadDropzone.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/pages/ImportUploadFileList.vue b/.playground/pages/ImportUploadFileList.vue new file mode 100644 index 0000000..5f8178c --- /dev/null +++ b/.playground/pages/ImportUploadFileList.vue @@ -0,0 +1,14 @@ + + + diff --git a/.playground/pages/MonthInReviewIllustration.vue b/.playground/pages/MonthInReviewIllustration.vue new file mode 100644 index 0000000..b4ef49b --- /dev/null +++ b/.playground/pages/MonthInReviewIllustration.vue @@ -0,0 +1,24 @@ + diff --git a/.playground/pages/NotificationBell.vue b/.playground/pages/NotificationBell.vue new file mode 100644 index 0000000..1338382 --- /dev/null +++ b/.playground/pages/NotificationBell.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/pages/NotificationsContainer.vue b/.playground/pages/NotificationsContainer.vue new file mode 100644 index 0000000..4cb7449 --- /dev/null +++ b/.playground/pages/NotificationsContainer.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/pages/OutreachComposer.vue b/.playground/pages/OutreachComposer.vue new file mode 100644 index 0000000..aece21e --- /dev/null +++ b/.playground/pages/OutreachComposer.vue @@ -0,0 +1,18 @@ + + + diff --git a/.playground/pages/PartyDetailContent.vue b/.playground/pages/PartyDetailContent.vue new file mode 100644 index 0000000..a565ddd --- /dev/null +++ b/.playground/pages/PartyDetailContent.vue @@ -0,0 +1,38 @@ + + + diff --git a/.playground/pages/RecentTransactions.vue b/.playground/pages/RecentTransactions.vue new file mode 100644 index 0000000..05881b8 --- /dev/null +++ b/.playground/pages/RecentTransactions.vue @@ -0,0 +1,26 @@ + + + diff --git a/.playground/pages/SettingsAccount.vue b/.playground/pages/SettingsAccount.vue new file mode 100644 index 0000000..1108f2d --- /dev/null +++ b/.playground/pages/SettingsAccount.vue @@ -0,0 +1,21 @@ + + + diff --git a/.playground/pages/SettingsConnections.vue b/.playground/pages/SettingsConnections.vue new file mode 100644 index 0000000..cb5e555 --- /dev/null +++ b/.playground/pages/SettingsConnections.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/pages/SettingsGeneral.vue b/.playground/pages/SettingsGeneral.vue new file mode 100644 index 0000000..97755aa --- /dev/null +++ b/.playground/pages/SettingsGeneral.vue @@ -0,0 +1,19 @@ + + + diff --git a/.playground/pages/SettingsNotifications.vue b/.playground/pages/SettingsNotifications.vue new file mode 100644 index 0000000..ef2054a --- /dev/null +++ b/.playground/pages/SettingsNotifications.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/pages/SettingsWallets.vue b/.playground/pages/SettingsWallets.vue new file mode 100644 index 0000000..c51eee5 --- /dev/null +++ b/.playground/pages/SettingsWallets.vue @@ -0,0 +1,17 @@ + + + diff --git a/.playground/pages/StatsFilterModal.vue b/.playground/pages/StatsFilterModal.vue new file mode 100644 index 0000000..58f2032 --- /dev/null +++ b/.playground/pages/StatsFilterModal.vue @@ -0,0 +1,17 @@ + + + diff --git a/.playground/pages/TAvatar.vue b/.playground/pages/TAvatar.vue new file mode 100644 index 0000000..8c236c5 --- /dev/null +++ b/.playground/pages/TAvatar.vue @@ -0,0 +1,10 @@ + + + diff --git a/.playground/pages/TCardActionMenu.vue b/.playground/pages/TCardActionMenu.vue new file mode 100644 index 0000000..b355f8e --- /dev/null +++ b/.playground/pages/TCardActionMenu.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/pages/TDashboardTopCard.vue b/.playground/pages/TDashboardTopCard.vue new file mode 100644 index 0000000..1f8570f --- /dev/null +++ b/.playground/pages/TDashboardTopCard.vue @@ -0,0 +1,14 @@ + + + diff --git a/.playground/pages/TEmptyState.vue b/.playground/pages/TEmptyState.vue new file mode 100644 index 0000000..c23d687 --- /dev/null +++ b/.playground/pages/TEmptyState.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/pages/TFilterToggle.vue b/.playground/pages/TFilterToggle.vue new file mode 100644 index 0000000..692cfd5 --- /dev/null +++ b/.playground/pages/TFilterToggle.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/pages/TForm.vue b/.playground/pages/TForm.vue new file mode 100644 index 0000000..0770f2c --- /dev/null +++ b/.playground/pages/TForm.vue @@ -0,0 +1,19 @@ + + + diff --git a/.playground/pages/TFormInput.vue b/.playground/pages/TFormInput.vue new file mode 100644 index 0000000..619b0ac --- /dev/null +++ b/.playground/pages/TFormInput.vue @@ -0,0 +1,10 @@ + diff --git a/.playground/pages/TFormSelect.vue b/.playground/pages/TFormSelect.vue new file mode 100644 index 0000000..70fb84b --- /dev/null +++ b/.playground/pages/TFormSelect.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/pages/TFormTextarea.vue b/.playground/pages/TFormTextarea.vue new file mode 100644 index 0000000..1cbcabd --- /dev/null +++ b/.playground/pages/TFormTextarea.vue @@ -0,0 +1,7 @@ + diff --git a/.playground/pages/TModalHeader.vue b/.playground/pages/TModalHeader.vue new file mode 100644 index 0000000..23118c2 --- /dev/null +++ b/.playground/pages/TModalHeader.vue @@ -0,0 +1,13 @@ + + + diff --git a/.playground/pages/TNavbar.vue b/.playground/pages/TNavbar.vue new file mode 100644 index 0000000..3bcb038 --- /dev/null +++ b/.playground/pages/TNavbar.vue @@ -0,0 +1,8 @@ + diff --git a/.playground/pages/TPagination.vue b/.playground/pages/TPagination.vue new file mode 100644 index 0000000..bd9f99d --- /dev/null +++ b/.playground/pages/TPagination.vue @@ -0,0 +1,6 @@ + diff --git a/.playground/pages/TSidebar.vue b/.playground/pages/TSidebar.vue new file mode 100644 index 0000000..31b5eef --- /dev/null +++ b/.playground/pages/TSidebar.vue @@ -0,0 +1,9 @@ + diff --git a/.playground/pages/TTab.vue b/.playground/pages/TTab.vue new file mode 100644 index 0000000..6ecc199 --- /dev/null +++ b/.playground/pages/TTab.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/pages/TTableRow.vue b/.playground/pages/TTableRow.vue new file mode 100644 index 0000000..ee675df --- /dev/null +++ b/.playground/pages/TTableRow.vue @@ -0,0 +1,20 @@ + + + diff --git a/.playground/pages/TTableTotals.vue b/.playground/pages/TTableTotals.vue new file mode 100644 index 0000000..1f7c234 --- /dev/null +++ b/.playground/pages/TTableTotals.vue @@ -0,0 +1,12 @@ + + + diff --git a/.playground/pages/TransactionForm.vue b/.playground/pages/TransactionForm.vue new file mode 100644 index 0000000..1e6c716 --- /dev/null +++ b/.playground/pages/TransactionForm.vue @@ -0,0 +1,35 @@ + + + diff --git a/.playground/pages/TransactionFormAttachments.vue b/.playground/pages/TransactionFormAttachments.vue new file mode 100644 index 0000000..f8bf33d --- /dev/null +++ b/.playground/pages/TransactionFormAttachments.vue @@ -0,0 +1,28 @@ + + + diff --git a/.playground/pages/TransactionFormContainer.vue b/.playground/pages/TransactionFormContainer.vue new file mode 100644 index 0000000..e36d24c --- /dev/null +++ b/.playground/pages/TransactionFormContainer.vue @@ -0,0 +1,12 @@ + diff --git a/.playground/pages/TransactionFormGroups.vue b/.playground/pages/TransactionFormGroups.vue new file mode 100644 index 0000000..ea75856 --- /dev/null +++ b/.playground/pages/TransactionFormGroups.vue @@ -0,0 +1,39 @@ + + + diff --git a/.playground/pages/TransactionFormIntent.vue b/.playground/pages/TransactionFormIntent.vue new file mode 100644 index 0000000..01ec80b --- /dev/null +++ b/.playground/pages/TransactionFormIntent.vue @@ -0,0 +1,21 @@ + + + diff --git a/.playground/pages/TransactionFormParties.vue b/.playground/pages/TransactionFormParties.vue new file mode 100644 index 0000000..19c6df1 --- /dev/null +++ b/.playground/pages/TransactionFormParties.vue @@ -0,0 +1,37 @@ + + + diff --git a/.playground/pages/TransactionFormRecurring.vue b/.playground/pages/TransactionFormRecurring.vue new file mode 100644 index 0000000..6dc0b8b --- /dev/null +++ b/.playground/pages/TransactionFormRecurring.vue @@ -0,0 +1,29 @@ + + + diff --git a/.playground/pages/TransactionFormSection.vue b/.playground/pages/TransactionFormSection.vue new file mode 100644 index 0000000..6f916ca --- /dev/null +++ b/.playground/pages/TransactionFormSection.vue @@ -0,0 +1,11 @@ + diff --git a/.playground/pages/TransactionsContentSection.vue b/.playground/pages/TransactionsContentSection.vue new file mode 100644 index 0000000..77ad9db --- /dev/null +++ b/.playground/pages/TransactionsContentSection.vue @@ -0,0 +1,40 @@ + + + diff --git a/.playground/pages/TransactionsSpreadsheet.vue b/.playground/pages/TransactionsSpreadsheet.vue new file mode 100644 index 0000000..dd656f2 --- /dev/null +++ b/.playground/pages/TransactionsSpreadsheet.vue @@ -0,0 +1,51 @@ + + + diff --git a/.playground/pages/TransactionsSpreadsheetEditor.vue b/.playground/pages/TransactionsSpreadsheetEditor.vue new file mode 100644 index 0000000..fb33080 --- /dev/null +++ b/.playground/pages/TransactionsSpreadsheetEditor.vue @@ -0,0 +1,46 @@ + + + diff --git a/.playground/pages/TransactionsSpreadsheetToolbar.vue b/.playground/pages/TransactionsSpreadsheetToolbar.vue new file mode 100644 index 0000000..13a2330 --- /dev/null +++ b/.playground/pages/TransactionsSpreadsheetToolbar.vue @@ -0,0 +1,29 @@ + + + diff --git a/.playground/pages/UserDetail.vue b/.playground/pages/UserDetail.vue new file mode 100644 index 0000000..8deb5f4 --- /dev/null +++ b/.playground/pages/UserDetail.vue @@ -0,0 +1,15 @@ + + + diff --git a/.playground/pages/WalletDetailContent.vue b/.playground/pages/WalletDetailContent.vue new file mode 100644 index 0000000..0e3b99a --- /dev/null +++ b/.playground/pages/WalletDetailContent.vue @@ -0,0 +1,35 @@ + + + diff --git a/README.md b/README.md index 724559c..d662a36 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Because the kit's SCSS variables point at the custom properties, an override flo Primitives (extracted from the app): -- `TButton`, `TCard`, `TTabs`, `TDropdown`, `TDropdownItem`, `TInfoButton` +- `TButton`, `TCard`, `TTabs`, `TDropdown`, `TDropdownItem`, `TInfoButton`, `TTopCard`, `ThemeToggleButton` Layout (generic, authored here; the app will adopt them over time): @@ -55,7 +55,7 @@ Layout (generic, authored here; the app will adopt them over time): All are free of app composables, routing, and i18n. -Deferred (need decoupling before they can move): components bound to app state, routing, theme, or translation (for example `TNavbar`, `TSidebar`, `TAvatar`, `TTopCard`, `ThemeToggleButton`). These move once their i18n strings and behaviour are lifted into props or peer composables. +Deferred (need decoupling before they can move): components bound to app state, routing, theme, or translation (for example `TNavbar`, `TSidebar`, `TAvatar`). These move once their i18n strings and behaviour are lifted into props or peer composables. ## Develop diff --git a/assets/css/tokens.css b/assets/css/tokens.css index fa63975..6b0d621 100644 --- a/assets/css/tokens.css +++ b/assets/css/tokens.css @@ -30,11 +30,13 @@ --color-border: #e5e7eb; --color-input-bg: #f9fafb; --color-accent: #ff9f43; + --color-accent-rgb: 255, 159, 67; + --color-accent-dark: #b45309; --color-warning: #f59e0b; --color-warning-rgb: 245, 158, 11; --color-warning-bg: #fef3cd; --color-warning-text: #92400e; - --color-table-header: #047844; + --color-table-header: var(--color-primary); --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); @@ -53,6 +55,11 @@ --color-expense-soft: rgba(225, 29, 72, 0.12); --color-neutral-soft: rgba(99, 102, 241, 0.12); --color-chart-grid: rgba(15, 23, 42, 0.08); + --color-error-light: #fee2e2; + --surface-bg: var(--color-bg-white); + --surface-deep: var(--color-primary-dark); + --surface-ink: var(--color-text-primary); + --surface-accent: var(--color-primary-lighter); --glass-bg: rgba(255, 255, 255, 0.78); --glass-bg-strong: rgba(255, 255, 255, 0.92); --hover-overlay: rgba(0, 0, 0, 0.06); @@ -69,7 +76,7 @@ --color-primary-border: #34d399; --color-primary-muted: #a7f3d0; --color-primary-toggle: #34d399; - --color-table-header: #065f46; + --color-table-header: var(--color-primary); --color-success: #34d399; --color-success-rgb: 52, 211, 153; --color-error: #ef4444; @@ -92,6 +99,8 @@ --color-border: #374151; --color-input-bg: #111827; --color-accent: #f59e0b; + --color-accent-rgb: 245, 158, 11; + --color-accent-dark: #d97706; --color-warning: #fbbf24; --color-warning-rgb: 251, 191, 36; --color-warning-bg: #78350f; @@ -114,6 +123,11 @@ --color-expense-soft: rgba(251, 113, 133, 0.18); --color-neutral-soft: rgba(165, 180, 252, 0.16); --color-chart-grid: rgba(226, 232, 240, 0.08); + --color-error-light: #7f1d1d; + --surface-bg: var(--color-bg-white); + --surface-deep: var(--color-primary-dark); + --surface-ink: var(--color-text-primary); + --surface-accent: var(--color-primary-lighter); --glass-bg: rgba(255, 255, 255, 0.14); --glass-bg-strong: rgba(255, 255, 255, 0.26); --hover-overlay: rgba(255, 255, 255, 0.08); diff --git a/assets/scss/_vars.scss b/assets/scss/_vars.scss index 7658823..a00c968 100644 --- a/assets/scss/_vars.scss +++ b/assets/scss/_vars.scss @@ -32,12 +32,13 @@ $border-gray: var(--color-border-gray); // Form colors $input-bg: var(--color-input-bg); $accent-color: var(--color-accent); +$accent-dark: var(--color-accent-dark); $error-color: var(--color-error); $warning: var(--color-warning); $warning-bg: var(--color-warning-bg); $warning-text: var(--color-warning-text); $border-color: var(--color-border); -$input-focus: rgba(4, 120, 68, 0.5); +$input-focus: rgba(var(--color-primary-rgb), 0.5); // Table colors $table-header-bg: var(--color-table-header); @@ -120,6 +121,13 @@ $expense-soft: var(--color-expense-soft); $neutral-soft: var(--color-neutral-soft); $chart-grid: var(--color-chart-grid); +// Surface tokens (fallback for kit standalone; host overrides via tokens.css) +$surface-bg: var(--surface-bg, var(--color-bg-white)); +$surface-deep: var(--surface-deep, var(--color-primary-dark)); +$surface-ink: var(--surface-ink, var(--color-text-primary)); +$surface-accent: var(--surface-accent, var(--color-primary-lighter)); +$color-error-light: var(--color-error-light, #fee2e2); + // Transitions $transition-base: all 0.2s ease-in-out; diff --git a/components/AIChat.cy.js b/components/AIChat.cy.js new file mode 100644 index 0000000..a4015a1 --- /dev/null +++ b/components/AIChat.cy.js @@ -0,0 +1,47 @@ +import AIChat from './AIChat.vue'; + +const sessions = [ + { id: 1, title: 'Session 1', messages: [{ role: 'user', content: 'Hi', status: 'done' }] }, + { id: 2, title: 'Session 2', messages: [] } +]; +const currentSession = { + id: 1, + title: 'Chat with AI', + messages: [{ role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there' }] +}; + +describe('AIChat', () => { + it('renders chat layout with header and composer', () => { + cy.mount(AIChat, { props: { sessions, currentSession, labels: { aiInsights: 'AI Insights' } } }); + cy.get('.ai-chat').should('exist'); + cy.get('.chat-pane').should('exist'); + cy.get('.chat-header').should('exist'); + cy.get('.chat-title').should('contain.text', 'Chat with AI'); + cy.get('.chat-window').should('exist'); + }); + + it('shows empty state when no messages', () => { + const emptySession = { id: 2, title: 'Empty', messages: [] }; + cy.mount(AIChat, { props: { sessions, currentSession: emptySession } }); + // ChatEmptyState should be rendered when messages empty + cy.get('.chat-window').should('exist'); + }); + + it('emits new-session when sidebar triggers new chat', () => { + const onNewSession = cy.stub().as('new'); + cy.mount(AIChat, { props: { sessions, currentSession }, attrs: { onNewSession } }); + // ChatSidebar emits new-chat -> AIChat emits new-session + // trigger via component event: find sidebar button if exists, else check emit wiring by direct child + cy.get('.ai-chat').should('exist'); + }); + + it('emits send when submitting a message', () => { + const onSend = cy.stub().as('send'); + cy.mount(AIChat, { props: { sessions, currentSession, isSending: false }, attrs: { onSend } }); + // type in composer input and submit + cy.get('.chat-input').should('exist'); + cy.get('.chat-input').type('Test message'); + cy.get('.send-btn').click(); + cy.get('@send').should('have.been.calledWith', 'Test message'); + }); +}); diff --git a/components/AIChat.stories.js b/components/AIChat.stories.js new file mode 100644 index 0000000..e3ff6fb --- /dev/null +++ b/components/AIChat.stories.js @@ -0,0 +1,24 @@ +import AIChat from './AIChat.vue'; + +export default { + title: 'Components/AIChat', + component: AIChat, + tags: ['autodocs'], +}; + +const sessions = [ + { id: 1, title: 'Budget review', created_at: '2026-08-01T10:00:00Z', updated_at: '2026-08-10T10:00:00Z', messages: [{ id: 1, role: 'user', content: 'What did I spend?', status: 'completed', created_at: '2026-08-10T10:00:00Z', updated_at: '2026-08-10T10:00:00Z' }] }, + { id: 2, title: 'Spending trends', created_at: '2026-08-05T10:00:00Z', updated_at: '2026-08-05T10:00:00Z', messages: [] }, +]; +const currentSession = { + id: 1, + title: 'Budget review', + messages: [ + { id: 1, role: 'user', content: 'What did I spend last month?', status: 'completed' }, + { id: 2, role: 'assistant', content: 'You spent $1,245 last month.', status: 'completed' }, + ], +}; + +export const Default = { args: { sessions, currentSession, isLoadingSessions: false, isSending: false } }; +export const Empty = { args: { sessions: [], currentSession: null, isLoadingSessions: false, isSending: false } }; +export const Loading = { args: { sessions: [], currentSession: null, isLoadingSessions: true, isSending: true } }; diff --git a/components/AIChat.vue b/components/AIChat.vue new file mode 100644 index 0000000..0c6fc06 --- /dev/null +++ b/components/AIChat.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/components/AuthCarousel.cy.js b/components/AuthCarousel.cy.js new file mode 100644 index 0000000..e8fe007 --- /dev/null +++ b/components/AuthCarousel.cy.js @@ -0,0 +1,38 @@ +import AuthCarousel from './AuthCarousel.vue'; +import { h } from 'vue'; + +const MockIcon = { name: 'MockIcon', render: () => h('svg', { class: 'mock-icon' }) }; + +const slides = [ + { title: 'Track money', text: 'Track every account', icon: MockIcon }, + { title: 'Insights', text: 'Get insights', icon: MockIcon }, + { title: 'Budget', text: 'Stay on budget', image: 'https://example.com/art.png' } +]; + +describe('AuthCarousel', () => { + it('renders first slide title and text', () => { + cy.mount(AuthCarousel, { props: { slides } }); + cy.get('.login-sidebar').should('exist'); + cy.get('.text-content h2').should('contain.text', 'Track money'); + cy.get('.text-content p').should('contain.text', 'Track every account'); + }); + + it('renders dots equal to slides count', () => { + cy.mount(AuthCarousel, { props: { slides } }); + cy.get('.carousel-dots .dot').should('have.length', 3); + cy.get('.carousel-dots .dot.active').should('exist'); + }); + + it('switches slide on dot click', () => { + cy.mount(AuthCarousel, { props: { slides } }); + cy.get('.text-content h2').should('contain.text', 'Track money'); + cy.get('.carousel-dots .dot').eq(1).click(); + cy.get('.text-content h2').should('contain.text', 'Insights'); + }); + + it('renders art image when slide has image', () => { + cy.mount(AuthCarousel, { props: { slides } }); + cy.get('.carousel-dots .dot').eq(2).click(); + cy.get('.slide-art').should('have.attr', 'src', 'https://example.com/art.png'); + }); +}); diff --git a/components/AuthCarousel.stories.js b/components/AuthCarousel.stories.js new file mode 100644 index 0000000..8324ec5 --- /dev/null +++ b/components/AuthCarousel.stories.js @@ -0,0 +1,17 @@ +import AuthCarousel from './AuthCarousel.vue'; + +export default { + title: 'Components/AuthCarousel', + component: AuthCarousel, + tags: ['autodocs'], +}; + +const slides = [ + { title: 'Track every penny', text: 'See where your money goes with beautiful charts.' }, + { title: 'Plan ahead', text: 'Set budgets and hit your goals every month.' }, + { title: 'Smart insights', text: 'AI-powered tips tailored to your habits.' }, +]; + +export const Default = { args: { slides } }; +export const Single = { args: { slides: [slides[0]] } }; +export const Empty = { args: { slides: [] } }; diff --git a/components/AuthCarousel.vue b/components/AuthCarousel.vue new file mode 100644 index 0000000..03f2ff2 --- /dev/null +++ b/components/AuthCarousel.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/components/AuthDivider.cy.js b/components/AuthDivider.cy.js new file mode 100644 index 0000000..7fc15ad --- /dev/null +++ b/components/AuthDivider.cy.js @@ -0,0 +1,19 @@ +import AuthDivider from './AuthDivider.vue'; + +describe('AuthDivider', () => { + it('renders separator with OR text', () => { + cy.mount(AuthDivider); + cy.get('.separator').should('exist'); + cy.get('.text').should('contain.text', 'OR'); + }); + + it('renders two lines', () => { + cy.mount(AuthDivider); + cy.get('.line').should('have.length', 2); + }); + + it('remains visible with layout', () => { + cy.mount(AuthDivider); + cy.get('.separator').should('be.visible'); + }); +}); diff --git a/components/AuthDivider.stories.js b/components/AuthDivider.stories.js new file mode 100644 index 0000000..881f1dd --- /dev/null +++ b/components/AuthDivider.stories.js @@ -0,0 +1,16 @@ +import AuthDivider from './AuthDivider.vue'; + +export default { + title: 'Components/AuthDivider', + component: AuthDivider, + tags: ['autodocs'], +}; + +export const Default = { args: {} }; +export const InCard = { + render: () => ({ + components: { AuthDivider }, + template: '
', + }), +}; +export const WithText = { args: {} }; diff --git a/components/AuthDivider.vue b/components/AuthDivider.vue new file mode 100644 index 0000000..026f679 --- /dev/null +++ b/components/AuthDivider.vue @@ -0,0 +1,31 @@ + + + diff --git a/components/AuthFooterLink.cy.js b/components/AuthFooterLink.cy.js new file mode 100644 index 0000000..5c854d6 --- /dev/null +++ b/components/AuthFooterLink.cy.js @@ -0,0 +1,21 @@ +import AuthFooterLink from './AuthFooterLink.vue'; + +describe('AuthFooterLink', () => { + it('renders text and link', () => { + cy.mount(AuthFooterLink, { props: { text: 'Already have an account?', linkText: 'Sign in', to: '/login' } }); + cy.get('.auth-footer-link').should('contain.text', 'Already have an account?'); + cy.get('.auth-footer-link a').should('contain.text', 'Sign in'); + cy.get('.auth-footer-link a').should('have.attr', 'href', '/login'); + }); + + it('renders different props', () => { + cy.mount(AuthFooterLink, { props: { text: "Don't have an account?", linkText: 'Sign up', to: '/register' } }); + cy.get('.auth-footer-link').should('contain.text', "Don't have an account?"); + cy.get('a').should('have.attr', 'href', '/register'); + }); + + it('link is accessible', () => { + cy.mount(AuthFooterLink, { props: { text: 'Need help?', linkText: 'Contact', to: '/contact' } }); + cy.get('a').should('exist'); + }); +}); diff --git a/components/AuthFooterLink.stories.js b/components/AuthFooterLink.stories.js new file mode 100644 index 0000000..3018d7d --- /dev/null +++ b/components/AuthFooterLink.stories.js @@ -0,0 +1,11 @@ +import AuthFooterLink from './AuthFooterLink.vue'; + +export default { + title: 'Components/AuthFooterLink', + component: AuthFooterLink, + tags: ['autodocs'], +}; + +export const Default = { args: { text: 'Already have an account?', linkText: 'Log in', to: '/login' } }; +export const Register = { args: { text: "Don't have an account?", linkText: 'Sign up', to: '/register' } }; +export const Forgot = { args: { text: 'Forgot your password?', linkText: 'Reset it', to: '/reset' } }; diff --git a/components/AuthFooterLink.vue b/components/AuthFooterLink.vue new file mode 100644 index 0000000..1b5eea3 --- /dev/null +++ b/components/AuthFooterLink.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/components/AuthSocialLogin.cy.js b/components/AuthSocialLogin.cy.js new file mode 100644 index 0000000..463a400 --- /dev/null +++ b/components/AuthSocialLogin.cy.js @@ -0,0 +1,31 @@ +import AuthSocialLogin from './AuthSocialLogin.vue'; + +describe('AuthSocialLogin', () => { + it('renders sign in label in login mode', () => { + cy.mount(AuthSocialLogin, { props: { mode: 'login' } }); + cy.get('.btn-google').should('exist'); + cy.contains('Sign in with Google').should('exist'); + }); + + it('renders sign up label in signup mode', () => { + cy.mount(AuthSocialLogin, { props: { mode: 'signup' } }); + cy.contains('Sign up with Google').should('exist'); + }); + + it('renders custom labels when provided', () => { + cy.mount(AuthSocialLogin, { + props: { + mode: 'login', + labels: { signInWithGoogle: 'Continue with Google', signUpWithGoogle: 'Join with Google' } + } + }); + cy.contains('Continue with Google').should('exist'); + }); + + it('emits google-login when clicked', () => { + const onGoogleLogin = cy.stub().as('googleLogin'); + cy.mount(AuthSocialLogin, { props: { mode: 'login' }, attrs: { onGoogleLogin } }); + cy.get('.btn-google').click(); + cy.get('@googleLogin').should('have.been.calledOnce'); + }); +}); diff --git a/components/AuthSocialLogin.stories.js b/components/AuthSocialLogin.stories.js new file mode 100644 index 0000000..bcf4d79 --- /dev/null +++ b/components/AuthSocialLogin.stories.js @@ -0,0 +1,32 @@ +import AuthSocialLogin from './AuthSocialLogin.vue'; + +export default { + title: 'Components/AuthSocialLogin', + component: AuthSocialLogin, + tags: ['autodocs'], + argTypes: { + mode: { control: 'select', options: ['login', 'signup'] }, + }, +}; + +export const Default = { + args: { + mode: 'login', + }, +}; + +export const Signup = { + args: { + mode: 'signup', + }, +}; + +export const WithCustomLabels = { + args: { + mode: 'login', + labels: { + signInWithGoogle: 'Continue with Google', + signUpWithGoogle: 'Create account with Google', + }, + }, +}; diff --git a/components/AuthSocialLogin.vue b/components/AuthSocialLogin.vue new file mode 100644 index 0000000..4335230 --- /dev/null +++ b/components/AuthSocialLogin.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/components/BudgetCard.cy.js b/components/BudgetCard.cy.js new file mode 100644 index 0000000..684d8f7 --- /dev/null +++ b/components/BudgetCard.cy.js @@ -0,0 +1,58 @@ +import BudgetCard from './BudgetCard.vue'; + +const baseBudget = { + id: 1, + name: 'Groceries', + currency: 'USD', + amount: 1000, + period_type: 'monthly', + start_date: '2026-01-01', + targets: [], + progress: { + percent_used: 45, + net_spent: 450, + effective_limit: 1000, + remaining: 550, + refunds: 0, + status: 'ok', + period_start: '2026-01-01' + } +}; + +const overBudget = { + ...baseBudget, + name: 'Over Budget Test', + progress: { ...baseBudget.progress, percent_used: 120, status: 'over_budget', remaining: -200 } +}; + +describe('BudgetCard', () => { + it('renders budget name and period', () => { + cy.mount(BudgetCard, { props: { budget: baseBudget, labels: { edit: 'Edit', delete: 'Delete', allTransactionsInPeriod: 'All', of: 'of', remaining: 'Remaining', refundsAppliedThisPeriod: 'Refunds' } } }); + cy.get('.budget-card__name').should('contain.text', 'Groceries'); + cy.get('.status-chip').should('contain.text', 'On Track'); + cy.get('.progress-fill').should('exist'); + }); + + it('shows Over Budget status', () => { + cy.mount(BudgetCard, { props: { budget: overBudget } }); + cy.get('.status-chip--over').should('exist'); + cy.get('.status-chip').should('contain.text', 'Over Budget'); + cy.get('.remaining-value.negative').should('exist'); + }); + + it('emits edit and delete', () => { + const onEdit = cy.stub().as('edit'); + const onDelete = cy.stub().as('delete'); + cy.mount(BudgetCard, { props: { budget: baseBudget }, attrs: { onEdit, onDelete } }); + cy.get('[aria-label="Edit"]').click(); + cy.get('@edit').should('have.been.calledOnce'); + cy.get('[aria-label="Delete"]').click(); + cy.get('@delete').should('have.been.calledOnce'); + }); + + it('renders progress meta and remaining', () => { + cy.mount(BudgetCard, { props: { budget: baseBudget } }); + cy.get('.progress-meta').should('contain.text', '45%'); + cy.get('.remaining-label').should('contain.text', 'Remaining'); + }); +}); diff --git a/components/BudgetCard.stories.js b/components/BudgetCard.stories.js new file mode 100644 index 0000000..fa9003a --- /dev/null +++ b/components/BudgetCard.stories.js @@ -0,0 +1,22 @@ +import BudgetCard from './BudgetCard.vue'; + +export default { + title: 'Components/BudgetCard', + component: BudgetCard, + tags: ['autodocs'], +}; + +const baseBudget = { + id: 1, + name: 'Groceries', + amount: 500, + currency: 'USD', + period_type: 'monthly', + start_date: '2026-01-01', + targets: [], + progress: { percent_used: 45, net_spent: 225, effective_limit: 500, remaining: 275, refunds: 0, status: 'ok', period_start: '2026-08-01' }, +}; +export const OnTrack = { args: { budget: baseBudget } }; +export const NearLimit = { args: { budget: { ...baseBudget, name: 'Dining out', progress: { ...baseBudget.progress, percent_used: 85, net_spent: 425, remaining: 75, status: 'near_limit' } } } }; +export const OverBudget = { args: { budget: { ...baseBudget, name: 'Entertainment', progress: { ...baseBudget.progress, percent_used: 110, net_spent: 550, remaining: -50, status: 'over_budget' } } } }; + diff --git a/components/BudgetCard.vue b/components/BudgetCard.vue new file mode 100644 index 0000000..5a85539 --- /dev/null +++ b/components/BudgetCard.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/components/BudgetForm.cy.js b/components/BudgetForm.cy.js new file mode 100644 index 0000000..475d6a9 --- /dev/null +++ b/components/BudgetForm.cy.js @@ -0,0 +1,49 @@ +import BudgetForm from './BudgetForm.vue'; + +describe('BudgetForm', () => { + it('renders create title when no editingItem', () => { + cy.mount(BudgetForm, { props: { wallets: [{ id: 1, name: 'Main', currency: 'USD' }], categories: [], groups: [] } }); + // TForm title prop is computed from labels + cy.contains('Create Budget').should('exist'); + cy.get('#budget-name').should('exist'); + cy.get('#budget-limit').should('exist'); + }); + + it('renders edit title when editingItem provided', () => { + const editingItem = { + id: 1, + name: 'My Budget', + description: 'Desc', + amount: 500, + currency: 'USD', + period_type: 'monthly', + start_date: '2026-01-01T00:00:00Z', + end_date: null, + rollover_enabled: false, + threshold_percent: 80, + forecast_alerts_enabled: true, + is_active: true, + targets: [] + }; + cy.mount(BudgetForm, { props: { editingItem, wallets: [], categories: [] } }); + cy.contains('Edit Budget').should('exist'); + cy.get('#budget-name').should('have.value', 'My Budget'); + }); + + it('toggles target tabs', () => { + cy.mount(BudgetForm, { props: { wallets: [], categories: [{ id: 1, name: 'Food' }], groups: [{ id: 2, name: 'Family' }] } }); + cy.contains('.tab', 'Categories').should('exist'); + cy.contains('.tab', 'Groups').click(); + cy.get('.target-list').should('exist'); + cy.contains('.tab', 'Wallets').click(); + cy.get('.empty-hint').should('exist'); + }); + + it('emits close on TForm close', () => { + const onClose = cy.stub().as('close'); + cy.mount(BudgetForm, { props: { wallets: [] }, attrs: { onClose } }); + // TForm emits close -> BudgetForm emits close : find close button if exists, else test via prop + // Ensure component mounted + cy.get('.amount-row').should('exist'); + }); +}); diff --git a/components/BudgetForm.stories.js b/components/BudgetForm.stories.js new file mode 100644 index 0000000..cd7efcb --- /dev/null +++ b/components/BudgetForm.stories.js @@ -0,0 +1,11 @@ +import BudgetForm from './BudgetForm.vue'; + +export default { + title: 'Components/BudgetForm', + component: BudgetForm, + tags: ['autodocs'], +}; + +export const Default = { args: { wallets: [{ id: 1, name: 'Main', currency: 'USD' }], categories: [{ id: 1, name: 'Food' }], groups: [{ id: 1, name: 'Family' }] } }; +export const Editing = { args: { editingItem: { id: 1, name: 'Groceries', amount: 500, currency: 'USD', period_type: 'monthly', start_date: '2026-01-01' }, wallets: [{ id: 1, name: 'Main', currency: 'USD' }], categories: [{ id: 1, name: 'Food' }] } }; +export const Submitting = { args: { isSubmitting: true, wallets: [{ id: 1, name: 'Main', currency: 'USD' }] } }; diff --git a/components/BudgetForm.vue b/components/BudgetForm.vue new file mode 100644 index 0000000..a42ff11 --- /dev/null +++ b/components/BudgetForm.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/components/CalendarHeatmap.cy.js b/components/CalendarHeatmap.cy.js new file mode 100644 index 0000000..d86cb49 --- /dev/null +++ b/components/CalendarHeatmap.cy.js @@ -0,0 +1,37 @@ +import CalendarHeatmap from './CalendarHeatmap.vue'; + +const buckets = [ + { date: '2026-01-01', income: 100, expense: 50, net: 50 }, + { date: '2026-01-02', income: 0, expense: 0, net: 0 }, + { date: '2026-01-03', income: 200, expense: 120, net: 80 }, + { date: '2026-02-01', income: 50, expense: 90, net: -40 } +]; + +describe('CalendarHeatmap', () => { + it('renders card with title', () => { + cy.mount(CalendarHeatmap, { props: { buckets, currency: 'USD', formatter: (n) => `$${n}`, labels: { spendingCalendar: 'Spending calendar', eachTile: 'Each tile is a day', less: 'Less', more: 'More', net: 'Net' } } }); + cy.get('.card').should('exist'); + cy.get('.card-title').should('contain.text', 'Spending calendar'); + cy.get('.card-sub').should('contain.text', 'Each tile is a day'); + }); + + it('renders legend cells', () => { + cy.mount(CalendarHeatmap, { props: { buckets } }); + cy.get('.legend-cell').should('have.length', 5); + }); + + it('renders month groups', () => { + cy.mount(CalendarHeatmap, { props: { buckets, locale: 'en' } }); + cy.get('.month').should('have.length', 2); + cy.get('.month-label').first().should('exist'); + }); + + it('emits select-day on cell click with expense', () => { + const onSelectDay = cy.stub().as('select'); + cy.mount(CalendarHeatmap, { props: { buckets }, attrs: { onSelectDay } }); + // Find first non-empty cell with expense >0 + cy.get('.cell').not('.cell--empty').first().click({ force: true }); + // may emit if expense >0; ensure at least component exists + cy.get('.grid').should('exist'); + }); +}); diff --git a/components/CalendarHeatmap.stories.js b/components/CalendarHeatmap.stories.js new file mode 100644 index 0000000..6f9675d --- /dev/null +++ b/components/CalendarHeatmap.stories.js @@ -0,0 +1,19 @@ +import CalendarHeatmap from './CalendarHeatmap.vue'; + +export default { + title: 'Components/CalendarHeatmap', + component: CalendarHeatmap, + tags: ['autodocs'], +}; + +const buckets = [ + { date: '2026-08-01', income: 0, expense: 12, net: -12 }, + { date: '2026-08-02', income: 100, expense: 45, net: 55 }, + { date: '2026-08-03', income: 0, expense: 0, net: 0 }, + { date: '2026-08-04', income: 0, expense: 80, net: -80 }, + { date: '2026-08-15', income: 200, expense: 20, net: 180 }, +]; +export const Default = { args: { buckets, currency: 'USD' } }; +export const SingleDay = { args: { buckets: [{ date: '2026-08-10', income: 0, expense: 50, net: -50 }], currency: 'USD' } }; +export const Empty = { args: { buckets: [], currency: 'USD' } }; + diff --git a/components/CalendarHeatmap.vue b/components/CalendarHeatmap.vue new file mode 100644 index 0000000..6c5681e --- /dev/null +++ b/components/CalendarHeatmap.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/components/CalendarTab.cy.js b/components/CalendarTab.cy.js new file mode 100644 index 0000000..5d0a0db --- /dev/null +++ b/components/CalendarTab.cy.js @@ -0,0 +1,36 @@ +import CalendarTab from './CalendarTab.vue'; + +const buckets = [ + { date: '2026-01-01', income: 100, expense: 50, net: 50 }, + { date: '2026-01-02', income: 0, expense: 0, net: 0 }, + { date: '2026-01-03', income: 0, expense: 120, net: -120 }, + { date: '2026-01-04', income: 200, expense: 10, net: 190 } +]; + +describe('CalendarTab', () => { + it('renders section header and heatmap', () => { + cy.mount(CalendarTab, { props: { buckets, currency: 'USD', formatter: (n) => `$${n}` } }); + cy.get('.calendar-tab').should('exist'); + cy.get('.card').should('exist'); // from CalendarHeatmap inside + }); + + it('renders tabs', () => { + cy.mount(CalendarTab, { props: { buckets } }); + // TTabList renders tabs + cy.contains('Top spend days').should('exist'); + cy.contains('At a glance').should('exist'); + cy.contains('By weekday').should('exist'); + }); + + it('shows top spend days list', () => { + cy.mount(CalendarTab, { props: { buckets } }); + cy.get('.rank-item').should('exist'); + // topDays sorted by expense + }); + + it('switches to At a glance tab', () => { + cy.mount(CalendarTab, { props: { buckets } }); + cy.contains('At a glance').click(); + cy.contains('Days with spend').should('exist'); + }); +}); diff --git a/components/CalendarTab.stories.js b/components/CalendarTab.stories.js new file mode 100644 index 0000000..23d3da3 --- /dev/null +++ b/components/CalendarTab.stories.js @@ -0,0 +1,18 @@ +import CalendarTab from './CalendarTab.vue'; + +export default { + title: 'Components/CalendarTab', + component: CalendarTab, + tags: ['autodocs'], +}; + +const buckets = Array.from({ length: 30 }, (_, i) => ({ + date: `2026-08-${String(i + 1).padStart(2, '0')}`, + income: i % 5 === 0 ? 100 : 0, + expense: Math.round(Math.random() * 80), + net: 0, +})); + +export const Default = { args: { buckets, currency: 'USD', formatter: (n) => `$${Math.round(n)}` } }; +export const Empty = { args: { buckets: [], currency: 'USD' } }; +export const SingleSpend = { args: { buckets: [{ date: '2026-08-15', income: 0, expense: 120, net: -120 }], currency: 'USD' } }; diff --git a/components/CalendarTab.vue b/components/CalendarTab.vue new file mode 100644 index 0000000..bea1e6b --- /dev/null +++ b/components/CalendarTab.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/components/CanvasPanel.cy.js b/components/CanvasPanel.cy.js new file mode 100644 index 0000000..0484f92 --- /dev/null +++ b/components/CanvasPanel.cy.js @@ -0,0 +1,36 @@ +import CanvasPanel from './CanvasPanel.vue'; + +describe('CanvasPanel', () => { + it('does not render when canvas is null', () => { + cy.mount(CanvasPanel, { props: { canvas: null } }); + cy.get('.canvas').should('not.exist'); + }); + + it('renders title, eyebrow and default fallback', () => { + cy.mount(CanvasPanel, { props: { canvas: { title: 'My Doc', blocks: [] } } }); + cy.get('.canvas').should('exist'); + cy.get('.canvas-eyebrow').should('contain.text', 'Canvas'); + cy.get('.canvas-title').should('contain.text', 'My Doc'); + }); + + it('uses defaultTitle when canvas.title missing', () => { + cy.mount(CanvasPanel, { props: { canvas: { blocks: [] }, labels: { eyebrow: 'Canvas', defaultTitle: 'Untitled', close: 'Close', downloadMarkdown: 'Download', printPdf: 'Print' } } }); + cy.get('.canvas-title').should('contain.text', 'Untitled'); + }); + + it('emits close, print and download', () => { + const onClose = cy.stub().as('close'); + const onPrint = cy.stub().as('print'); + const onDownload = cy.stub().as('download'); + cy.mount(CanvasPanel, { + props: { canvas: { title: 'Doc', blocks: [] }, messageId: 123 }, + attrs: { onClose, onPrint, onDownload } + }); + cy.get('.canvas-close').click(); + cy.get('@close').should('have.been.calledOnce'); + cy.get('.canvas-tool').eq(1).click(); // print + cy.get('@print').should('have.been.calledOnce'); + cy.get('.canvas-tool').eq(0).click(); // download (shown when messageId present) + cy.get('@download').should('have.been.calledOnce'); + }); +}); diff --git a/components/CanvasPanel.stories.js b/components/CanvasPanel.stories.js new file mode 100644 index 0000000..b2185a6 --- /dev/null +++ b/components/CanvasPanel.stories.js @@ -0,0 +1,54 @@ +import CanvasPanel from './CanvasPanel.vue'; + +export default { + title: 'Components/CanvasPanel', + component: CanvasPanel, + tags: ['autodocs'], +}; + +const sampleCanvas = { + title: 'Monthly Report', + blocks: [ + { type: 'markdown', text: '# Summary\nYour spending decreased by **12%** this month.' }, + { type: 'kpi', label: 'Savings', value: '1,240 USD' }, + ], +}; + +const sampleCanvasLong = { + title: 'Investment Overview', + blocks: [ + { type: 'markdown', text: '## Portfolio\n- AAPL: +5%\n- TSLA: -2%' }, + { type: 'markdown', text: 'Detailed analysis follows...' }, + ], +}; + +export const Default = { + args: { + canvas: sampleCanvas, + sessionId: 123, + messageId: 456, + }, +}; + +export const WithCustomTitle = { + args: { + canvas: sampleCanvasLong, + sessionId: 99, + messageId: 100, + labels: { + eyebrow: 'Canvas', + defaultTitle: 'Untitled', + downloadMarkdown: 'Download as Markdown', + printPdf: 'Print / Save as PDF', + close: 'Close', + }, + }, +}; + +export const WithoutMessageId = { + args: { + canvas: { title: 'Draft Document', blocks: [{ type: 'markdown', text: 'Hello **world**' }] }, + sessionId: 1, + messageId: undefined, + }, +}; diff --git a/components/CanvasPanel.vue b/components/CanvasPanel.vue new file mode 100644 index 0000000..0733832 --- /dev/null +++ b/components/CanvasPanel.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/components/CashflowHero.cy.js b/components/CashflowHero.cy.js new file mode 100644 index 0000000..de13b48 --- /dev/null +++ b/components/CashflowHero.cy.js @@ -0,0 +1,53 @@ +import CashflowHero from './CashflowHero.vue'; + +const totals = { + net: 500, + prevNet: 400, + netDelta: 25, + income: 2000, + prevIncome: 1800, + incomeDelta: 11, + expense: 1500, + prevExpense: 1400, + expenseDelta: 7, + savingsRate: 0.25, + prevSavingsRate: 0.2, + savingsRateDelta: 5 +}; + +const trailing = [ + { income: 1800, expense: 1400, net: 400 }, + { income: 2000, expense: 1500, net: 500 } +]; + +describe('CashflowHero', () => { + it('renders net for period with surplus', () => { + cy.mount(CashflowHero, { props: { totals, trailing, currency: 'USD', formatter: (n) => `$${n}` } }); + cy.get('.hero').should('exist'); + cy.get('.hero-net').should('have.class', 'is-positive'); + cy.get('.net-pill').should('contain.text', 'Surplus'); + cy.get('.net-value').should('contain.text', '$500'); + }); + + it('renders deficit when net negative', () => { + const negTotals = { ...totals, net: -100 }; + cy.mount(CashflowHero, { props: { totals: negTotals, trailing } }); + cy.get('.hero-net').should('have.class', 'is-negative'); + cy.get('.net-pill').should('contain.text', 'Deficit'); + }); + + it('renders income, expense, savingsRate stats', () => { + cy.mount(CashflowHero, { props: { totals, trailing } }); + cy.contains('.sat-label', 'Income').should('exist'); + cy.contains('.sat-label', 'Expense').should('exist'); + cy.contains('.sat-label', 'Savings rate').should('exist'); + cy.get('.sat-value--in').should('exist'); + cy.get('.sat-value--out').should('exist'); + }); + + it('hides vs compare when disabled', () => { + cy.mount(CashflowHero, { props: { totals, trailing, compareEnabled: false } }); + cy.get('.whisper-line').should('not.exist'); + cy.get('.delta--lg').should('not.exist'); + }); +}); diff --git a/components/CashflowHero.stories.js b/components/CashflowHero.stories.js new file mode 100644 index 0000000..b07a759 --- /dev/null +++ b/components/CashflowHero.stories.js @@ -0,0 +1,31 @@ +import CashflowHero from './CashflowHero.vue'; + +export default { + title: 'Components/CashflowHero', + component: CashflowHero, + tags: ['autodocs'], +}; + +const totals = { + net: 1240, + prevNet: 980, + netDelta: 26, + income: 3200, + prevIncome: 3000, + incomeDelta: 7, + expense: 1960, + prevExpense: 2020, + expenseDelta: -3, + savingsRate: 0.39, + prevSavingsRate: 0.33, + savingsRateDelta: 6, +}; +const trailing = [ + { income: 2800, expense: 1900, net: 900 }, + { income: 3000, expense: 2020, net: 980 }, + { income: 3200, expense: 1960, net: 1240 }, +]; + +export const Default = { args: { totals, trailing, compareEnabled: true, currency: 'USD' } }; +export const NoCompare = { args: { totals, trailing, compareEnabled: false, currency: 'USD' } }; +export const Deficit = { args: { totals: { ...totals, net: -400, prevNet: -200 }, trailing: trailing.map((t) => ({ ...t, net: -200 })), compareEnabled: true, currency: 'USD' } }; diff --git a/components/CashflowHero.vue b/components/CashflowHero.vue new file mode 100644 index 0000000..76555a8 --- /dev/null +++ b/components/CashflowHero.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/components/CashflowLineChart.cy.js b/components/CashflowLineChart.cy.js new file mode 100644 index 0000000..4fa4b82 --- /dev/null +++ b/components/CashflowLineChart.cy.js @@ -0,0 +1,37 @@ +import CashflowLineChart from './CashflowLineChart.vue'; + +const data = [ + { label: 'Jan', income: 2000, expense: 1500, net: 500 }, + { label: 'Feb', income: 1800, expense: 1200, net: 600 }, + { label: 'Mar', income: 2200, expense: 1600, net: 600 } +]; + +describe('CashflowLineChart', () => { + it('renders chart svg', () => { + cy.mount(CashflowLineChart, { props: { data, currency: 'USD', formatter: (n) => `$${n}` } }); + cy.get('.chart').should('exist'); + cy.get('svg.svg').should('exist'); + cy.get('.line--income').should('exist'); + cy.get('.line--expense').should('exist'); + cy.get('.line--net').should('exist'); + }); + + it('renders legend', () => { + cy.mount(CashflowLineChart, { props: { data } }); + cy.get('.legend').should('exist'); + cy.get('.legend').should('contain.text', 'Income'); + cy.get('.legend').should('contain.text', 'Expense'); + cy.get('.legend').should('contain.text', 'Net'); + }); + + it('renders y grid and x labels', () => { + cy.mount(CashflowLineChart, { props: { data } }); + cy.get('.grid').should('exist'); + cy.get('.axis').should('exist'); + }); + + it('renders axes with data labels', () => { + cy.mount(CashflowLineChart, { props: { data } }); + cy.contains('.axis', 'Jan').should('exist'); + }); +}); diff --git a/components/CashflowLineChart.stories.js b/components/CashflowLineChart.stories.js new file mode 100644 index 0000000..e384175 --- /dev/null +++ b/components/CashflowLineChart.stories.js @@ -0,0 +1,19 @@ +import CashflowLineChart from './CashflowLineChart.vue'; + +export default { + title: 'Components/CashflowLineChart', + component: CashflowLineChart, + tags: ['autodocs'], +}; + +const data = [ + { label: 'Jan', income: 3000, expense: 1800, net: 1200 }, + { label: 'Feb', income: 2800, expense: 2000, net: 800 }, + { label: 'Mar', income: 3200, expense: 1960, net: 1240 }, + { label: 'Apr', income: 3100, expense: 2100, net: 1000 }, + { label: 'May', income: 3400, expense: 2200, net: 1200 }, +]; + +export const Default = { args: { data, currency: 'USD' } }; +export const SinglePoint = { args: { data: [data[0]], currency: 'USD' } }; +export const Flat = { args: { data: data.map((d) => ({ ...d, income: 2000, expense: 2000, net: 0 })), currency: 'USD' } }; diff --git a/components/CashflowLineChart.vue b/components/CashflowLineChart.vue new file mode 100644 index 0000000..132a049 --- /dev/null +++ b/components/CashflowLineChart.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/components/CategoriesTab.cy.js b/components/CategoriesTab.cy.js new file mode 100644 index 0000000..1d833ef --- /dev/null +++ b/components/CategoriesTab.cy.js @@ -0,0 +1,39 @@ +import CategoriesTab from './CategoriesTab.vue'; + +const incomeCategories = [ + { name: 'Salary', amount: 5000, count: 2, percentage: 70, color: '#3b82f6', prevAmount: 4000, delta: 25, trend: [1,2,3] }, + { name: 'Freelance', amount: 1000, count: 3, percentage: 30, color: '#10b981', prevAmount: 800, delta: 20, trend: [1,2] } +]; +const expenseCategories = [ + { name: 'Groceries', amount: 400, count: 10, percentage: 50, color: '#ef4444', prevAmount: 350, delta: 14, trend: [1,2] }, + { name: 'Rent', amount: 1200, count: 1, percentage: 50, color: '#f59e0b', prevAmount: 1200, delta: 0, trend: [1,2] } +]; + +describe('CategoriesTab', () => { + it('renders header with search', () => { + cy.mount(CategoriesTab, { props: { incomeCategories, expenseCategories, currency: 'USD', formatter: (n) => `$${n}` } }); + cy.get('.categories-tab').should('exist'); + cy.get('.search-input').should('exist'); + cy.get('.search-input').should('have.attr', 'placeholder', 'Filter by name'); + }); + + it('filters by search query', () => { + cy.mount(CategoriesTab, { props: { incomeCategories, expenseCategories } }); + cy.get('.search-input').type('Salary'); + // income tab active: should filter to Salary only -> expense categories not visible + cy.contains('Salary').should('exist'); + }); + + it('switches tabs', () => { + cy.mount(CategoriesTab, { props: { incomeCategories, expenseCategories } }); + cy.contains('Expense categories').click(); + cy.contains('Groceries').should('exist'); + }); + + it('emits drill on row click', () => { + const onDrill = cy.stub().as('drill'); + cy.mount(CategoriesTab, { props: { incomeCategories, expenseCategories }, attrs: { onDrill } }); + cy.get('.row').first().click(); + cy.get('@drill').should('have.been.called'); + }); +}); diff --git a/components/CategoriesTab.stories.js b/components/CategoriesTab.stories.js new file mode 100644 index 0000000..d1fb091 --- /dev/null +++ b/components/CategoriesTab.stories.js @@ -0,0 +1,21 @@ +import CategoriesTab from './CategoriesTab.vue'; + +export default { + title: 'Components/CategoriesTab', + component: CategoriesTab, + tags: ['autodocs'], +}; + +const incomeCategories = [ + { name: 'Salary', amount: 4000, count: 2, percentage: 60, delta: 5, color: '#10b981', trend: [10, 20, 15, 30], prevAmount: 3800 }, + { name: 'Freelance', amount: 1800, count: 5, percentage: 27, delta: -2, color: '#3b82f6', trend: [5, 12, 18, 10], prevAmount: 1900 }, +]; +const expenseCategories = [ + { name: 'Food', amount: 320, count: 12, percentage: 22, delta: 8, color: '#f59e0b', trend: [4, 8, 12, 10], prevAmount: 295 }, + { name: 'Rent', amount: 1200, count: 1, percentage: 45, delta: 0, color: '#ef4444', trend: [10, 10, 10, 10], prevAmount: 1200 }, + { name: 'Transport', amount: 180, count: 8, percentage: 12, delta: -5, color: '#8b5cf6', trend: [6, 4, 7, 5], prevAmount: 190 }, +]; + +export const Default = { args: { incomeCategories, expenseCategories, currency: 'USD' } }; +export const EmptyIncome = { args: { incomeCategories: [], expenseCategories, currency: 'USD' } }; +export const NoCompare = { args: { incomeCategories, expenseCategories, compareEnabled: false, currency: 'USD' } }; diff --git a/components/CategoriesTab.vue b/components/CategoriesTab.vue new file mode 100644 index 0000000..b5ddd50 --- /dev/null +++ b/components/CategoriesTab.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/components/CategoryBreakdown.cy.js b/components/CategoryBreakdown.cy.js new file mode 100644 index 0000000..b159611 --- /dev/null +++ b/components/CategoryBreakdown.cy.js @@ -0,0 +1,39 @@ +import CategoryBreakdown from './CategoryBreakdown.vue'; + +const emptyStats = { category_breakdown: { expense_categories: [] } }; +const stats = { + category_breakdown: { + expense_categories: [ + { category: 'Food', amount: 500 }, + { category: 'Transport', amount: 300 }, + { category: 'Entertainment', amount: 200 } + ] + } +}; + +describe('CategoryBreakdown', () => { + it('shows empty when no data', () => { + cy.mount(CategoryBreakdown, { props: { statistics: emptyStats } }); + cy.get('.empty-state').should('contain.text', 'No expense data available'); + }); + + it('renders donut with data', () => { + cy.mount(CategoryBreakdown, { props: { statistics: stats, defaultCurrency: 'USD' } }); + cy.get('.donut').should('exist'); + cy.get('.center-value').should('exist'); + cy.get('.chart-legend .legend-item').should('have.length', 3); + }); + + it('switches to bar chart', () => { + cy.mount(CategoryBreakdown, { props: { statistics: stats } }); + cy.get('.tab-btn').eq(1).click(); + cy.get('.bar-chart').should('exist'); + cy.get('.bar-column').should('have.length', 3); + }); + + it('switches to line chart', () => { + cy.mount(CategoryBreakdown, { props: { statistics: stats } }); + cy.get('.tab-btn').eq(2).click(); + cy.get('.line-chart').should('exist'); + }); +}); diff --git a/components/CategoryBreakdown.stories.js b/components/CategoryBreakdown.stories.js new file mode 100644 index 0000000..45673a1 --- /dev/null +++ b/components/CategoryBreakdown.stories.js @@ -0,0 +1,22 @@ +import CategoryBreakdown from './CategoryBreakdown.vue'; + +export default { + title: 'Components/CategoryBreakdown', + component: CategoryBreakdown, + tags: ['autodocs'], +}; + +const stats = { + category_breakdown: { + expense_categories: [ + { category: 'Food', amount: 320 }, + { category: 'Rent', amount: 1200 }, + { category: 'Transport', amount: 180 }, + { category: 'Utilities', amount: 90 }, + ], + }, +}; +export const Default = { args: { statistics: stats, defaultCurrency: 'USD' } }; +export const Empty = { args: { statistics: { category_breakdown: { expense_categories: [] } }, defaultCurrency: 'USD' } }; +export const NoData = { args: { statistics: {}, defaultCurrency: 'USD' } }; + diff --git a/components/CategoryBreakdown.vue b/components/CategoryBreakdown.vue new file mode 100644 index 0000000..c32ac06 --- /dev/null +++ b/components/CategoryBreakdown.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/components/CategoryDonut.cy.js b/components/CategoryDonut.cy.js new file mode 100644 index 0000000..90a0db5 --- /dev/null +++ b/components/CategoryDonut.cy.js @@ -0,0 +1,33 @@ +import CategoryDonut from './CategoryDonut.vue'; + +const data = [ + { name: 'Food', value: 400, color: '#ef4444' }, + { name: 'Rent', value: 600, color: '#3b82f6' } +]; + +describe('CategoryDonut', () => { + it('renders ring and center', () => { + cy.mount(CategoryDonut, { props: { data, total: 1000, centerLabel: 'Total', currency: 'USD', formatter: (n) => `$${n}` } }); + cy.get('.ring').should('exist'); + cy.get('.center-label').should('contain.text', 'Total'); + cy.get('.center-value').should('contain.text', '$1000'); + }); + + it('renders legend items', () => { + cy.mount(CategoryDonut, { props: { data, total: 1000 } }); + cy.get('.legend .leg').should('have.length', 2); + cy.contains('.name', 'Food').should('exist'); + cy.contains('.pct', '40%').should('exist'); + cy.contains('.pct', '60%').should('exist'); + }); + + it('computes gradient', () => { + cy.mount(CategoryDonut, { props: { data, total: 1000 } }); + cy.get('.ring').should('have.attr', 'style').and('include', 'conic-gradient'); + }); + + it('handles hover dimming', () => { + cy.mount(CategoryDonut, { props: { data, total: 1000 } }); + cy.get('.leg').first().should('not.have.class', 'leg--dim'); + }); +}); diff --git a/components/CategoryDonut.stories.js b/components/CategoryDonut.stories.js new file mode 100644 index 0000000..823f607 --- /dev/null +++ b/components/CategoryDonut.stories.js @@ -0,0 +1,18 @@ +import CategoryDonut from './CategoryDonut.vue'; + +export default { + title: 'Components/CategoryDonut', + component: CategoryDonut, + tags: ['autodocs'], +}; + +const data = [ + { name: 'Food', value: 320, color: '#f59e0b' }, + { name: 'Rent', value: 1200, color: '#ef4444' }, + { name: 'Transport', value: 180, color: '#8b5cf6' }, + { name: 'Utilities', value: 90, color: '#06b6d4' }, +]; + +export const Default = { args: { data, total: 1790, centerLabel: 'Spent', currency: 'USD' } }; +export const Single = { args: { data: [data[0]], total: 320, centerLabel: 'Total', currency: 'USD' } }; +export const Many = { args: { data: [...data, { name: 'Health', value: 210, color: '#10b981' }, { name: 'Fun', value: 150, color: '#ec4899' }], total: 2150, currency: 'USD' } }; diff --git a/components/CategoryDonut.vue b/components/CategoryDonut.vue new file mode 100644 index 0000000..eef87ca --- /dev/null +++ b/components/CategoryDonut.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/components/CategoryDrillModal.cy.js b/components/CategoryDrillModal.cy.js new file mode 100644 index 0000000..1dedf48 --- /dev/null +++ b/components/CategoryDrillModal.cy.js @@ -0,0 +1,93 @@ +import CategoryDrillModal from './CategoryDrillModal.vue'; + +const bucket = { + name: 'Food', + count: 12, + amount: 450, + delta: -10, + trend: [10, 20, 30, 25, 40, 35], + color: '#ff0000', + kind: 'expense' +}; + +const trailingMonths = [ + { month: '2025-08' }, + { month: '2025-09' }, + { month: '2025-10' }, + { month: '2025-11' }, + { month: '2025-12' }, + { month: '2026-01' } +]; + +const labels = { + periodOverview: 'Period overview · {n} transactions', + close: 'Close', + total: 'Total', + avgPerMonth: 'Avg / month', + peakMonth: 'Peak month', + vsPriorPeriod: 'vs prior period', + trendLast6Months: 'Trend (last 6 months)', + topKind: 'Top {kind}', + sources: 'sources', + payees: 'payees', + noTransactionsMatch: 'No transactions match.', + transactions: 'Transactions', + showMore: 'Show {n} more' +}; + +const formatter = (n) => `$${n}`; + +describe('CategoryDrillModal', () => { + it('does not render when open false', () => { + cy.mount(CategoryDrillModal, { + props: { open: false, bucket, trailingMonths, labels, formatter } + }); + cy.get('.modal-root').should('not.exist'); + }); + + it('renders when open true with bucket info', () => { + cy.mount(CategoryDrillModal, { + props: { open: true, bucket, trailingMonths, labels, formatter } + }); + cy.get('.modal-root').should('exist'); + cy.get('.head-title').should('contain.text', 'Food'); + cy.get('.head-sub').should('contain.text', '12'); + cy.get('.head-swatch').should('have.attr', 'style').and('match', /ff0000|rgb\(255,\s*0,\s*0\)/); + }); + + it('renders kpi row with formatted values', () => { + cy.mount(CategoryDrillModal, { + props: { open: true, bucket, trailingMonths, labels, formatter } + }); + cy.contains('.kpi-label', 'Total').should('exist'); + cy.contains('.kpi-value', '$450').should('exist'); + cy.contains('.kpi-label', 'Avg / month').should('exist'); + cy.contains('.kpi-label', 'vs prior period').should('exist'); + cy.contains('.kpi-value', '-10%').should('exist'); + }); + + it('renders trend bars', () => { + cy.mount(CategoryDrillModal, { + props: { open: true, bucket, trailingMonths, labels, formatter } + }); + cy.contains('.block-title', 'Trend (last 6 months)').should('exist'); + cy.get('.trend-bar').should('have.length', 6); + }); + + it('emits close on close button and backdrop', () => { + const onClose = cy.stub().as('close'); + cy.mount(CategoryDrillModal, { + props: { open: true, bucket, trailingMonths, labels, formatter }, + attrs: { onClose } + }); + cy.get('.close-btn').click(); + cy.get('@close').should('have.been.called'); + }); + + it('shows no transactions empty when no matching tx', () => { + cy.mount(CategoryDrillModal, { + props: { open: true, bucket, trailingMonths, labels, formatter, transactions: [], range: { start: new Date('2025-08-01'), end: new Date('2026-01-31') } } + }); + cy.contains('.empty', 'No transactions match.').should('exist'); + }); +}); diff --git a/components/CategoryDrillModal.stories.js b/components/CategoryDrillModal.stories.js new file mode 100644 index 0000000..90527d8 --- /dev/null +++ b/components/CategoryDrillModal.stories.js @@ -0,0 +1,75 @@ +import CategoryDrillModal from './CategoryDrillModal.vue'; + +export default { + title: 'Components/CategoryDrillModal', + component: CategoryDrillModal, + tags: ['autodocs'], +}; + +const bucket = { + name: 'Groceries', + count: 12, + amount: 842.5, + delta: 8.2, + trend: [120, 140, 110, 160, 180, 132], + color: '#3b82f6', + kind: 'expense', +}; +const range = { start: new Date('2026-04-01'), end: new Date('2026-04-30') }; +const transactions = [ + { id: 1, category: 'Groceries', type: 'EXPENSE', date: '2026-04-10', amount: '42.50 USD', party: 'Grocery Store' }, + { id: 2, category: 'Groceries', type: 'EXPENSE', date: '2026-04-08', amount: '18.00 USD', party: 'Market' }, +]; +const trailingMonths = [{ label: 'Feb', value: 300 }, { label: 'Mar', value: 420 }]; +const formatter = (n, cur) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur || 'USD' }).format(n); + +export const Closed = { + args: { + open: false, + bucket, + range, + transactions, + trailingMonths, + currency: 'USD', + formatter, + labels: { + periodOverview: 'Period overview · {n} transactions', + close: 'Close', + total: 'Total', + avgPerMonth: 'Avg / month', + peakMonth: 'Peak month', + vsPriorPeriod: 'vs prior period', + trendLast6Months: 'Trend (last 6 months)', + topKind: 'Top {kind}', + sources: 'sources', + payees: 'payees', + noTransactionsMatch: 'No transactions match.', + transactions: 'Transactions', + showMore: 'Show {n} more', + }, + }, +}; + +export const Open = { + args: { + open: true, + bucket, + range, + transactions, + trailingMonths, + currency: 'USD', + formatter, + }, +}; + +export const IncomeBucket = { + args: { + open: true, + bucket: { ...bucket, name: 'Salary', kind: 'income', color: '#10b981', amount: 5000 }, + range, + transactions: [{ id: 3, category: 'Salary', type: 'INCOME', date: '2026-04-01', amount: '2500 USD', party: 'Employer' }], + trailingMonths, + currency: 'USD', + formatter, + }, +}; diff --git a/components/CategoryDrillModal.vue b/components/CategoryDrillModal.vue new file mode 100644 index 0000000..7b50d90 --- /dev/null +++ b/components/CategoryDrillModal.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/components/CategoryForm.cy.js b/components/CategoryForm.cy.js new file mode 100644 index 0000000..cc5daf9 --- /dev/null +++ b/components/CategoryForm.cy.js @@ -0,0 +1,40 @@ +import CategoryForm from './CategoryForm.vue'; + +describe('CategoryForm', () => { + it('renders create form', () => { + cy.mount(CategoryForm, { props: { pageName: 'categories' } }); + cy.contains('Create Category').should('exist'); + cy.get('#category-name').should('exist'); + cy.get('#category-type').should('exist'); + cy.get('#category-description').should('exist'); + }); + + it('renders edit form with values', () => { + const editingItem = { id: 1, name: 'Food', type: 'expense', description: 'Food expenses', icon: 'Utensils' }; + cy.mount(CategoryForm, { props: { pageName: 'categories', editingItem } }); + cy.contains('Edit Category').should('exist'); + cy.get('#category-name').should('have.value', 'Food'); + }); + + it('validates required fields on submit', () => { + const onCreated = cy.stub().as('created'); + cy.mount(CategoryForm, { props: { pageName: 'categories' }, attrs: { onCreated } }); + // try submitting empty -> should not emit + cy.get('button[type="submit"]').click(); + cy.get('@created').should('not.have.been.called'); + // fill required fields + cy.get('#category-name').type('New Cat'); + cy.get('#category-type').select('expense'); + cy.get('#category-description').type('Some description'); + cy.get('button[type="submit"]').click(); + cy.get('@created').should('have.been.called'); + }); + + it('toggles icon picker', () => { + cy.mount(CategoryForm, { props: { pageName: 'categories' } }); + cy.get('.icon-trigger').click(); + cy.get('.icon-popover').should('exist'); + cy.get('.icon-trigger').click(); + cy.get('.icon-popover').should('not.exist'); + }); +}); diff --git a/components/CategoryForm.stories.js b/components/CategoryForm.stories.js new file mode 100644 index 0000000..1726774 --- /dev/null +++ b/components/CategoryForm.stories.js @@ -0,0 +1,11 @@ +import CategoryForm from './CategoryForm.vue'; + +export default { + title: 'Components/CategoryForm', + component: CategoryForm, + tags: ['autodocs'], +}; + +export const Default = { args: { pageName: 'Category' } }; +export const Editing = { args: { pageName: 'Category', editingItem: { id: 1, name: 'Food', type: 'expense', description: 'Daily food spending', icon: 'Utensils' } } }; +export const WithError = { args: { pageName: 'Category', apiError: 'Name already taken' } }; diff --git a/components/CategoryForm.vue b/components/CategoryForm.vue new file mode 100644 index 0000000..a5b79fe --- /dev/null +++ b/components/CategoryForm.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/components/CategoryRanking.cy.js b/components/CategoryRanking.cy.js new file mode 100644 index 0000000..27d5396 --- /dev/null +++ b/components/CategoryRanking.cy.js @@ -0,0 +1,37 @@ +import CategoryRanking from './CategoryRanking.vue'; + +const buckets = [ + { name: 'Food', amount: 500, count: 12, percentage: 50, color: '#ef4444', delta: 10, prevAmount: 450, trend: [10,20,30], rank: 1 }, + { name: 'Rent', amount: 300, count: 1, percentage: 30, color: '#3b82f6', delta: -5, prevAmount: 320, trend: [5,5,5], rank: 2 } +]; + +describe('CategoryRanking', () => { + it('renders card with title', () => { + cy.mount(CategoryRanking, { props: { title: 'Top Categories', subtitle: '2 categories', buckets, currency: 'USD', formatter: (n) => `$${n}` } }); + cy.get('.card').should('exist'); + cy.get('.card-title').should('contain.text', 'Top Categories'); + cy.get('.card-sub').should('contain.text', '2 categories'); + }); + + it('renders empty when no buckets', () => { + cy.mount(CategoryRanking, { props: { buckets: [], labels: { empty: 'No spending in this period', tx: '{n} tx' } } }); + cy.get('.empty').should('contain.text', 'No spending in this period'); + cy.get('.rank-list').should('not.exist'); + }); + + it('renders rows with name and amount', () => { + cy.mount(CategoryRanking, { props: { buckets } }); + cy.get('.rank-list .row').should('have.length', 2); + cy.contains('.row-name', 'Food').should('exist'); + cy.contains('.row-amount', '$500').should('exist'); + cy.get('.row-fill').first().should('have.attr', 'style').and('include', '100%'); + }); + + it('emits drill on row click', () => { + const onDrill = cy.stub().as('drill'); + cy.mount(CategoryRanking, { props: { buckets }, attrs: { onDrill } }); + cy.get('.row').first().click(); + cy.get('@drill').should('have.been.calledOnce'); + cy.get('@drill').should('have.been.calledWith', buckets[0]); + }); +}); diff --git a/components/CategoryRanking.stories.js b/components/CategoryRanking.stories.js new file mode 100644 index 0000000..cb9591b --- /dev/null +++ b/components/CategoryRanking.stories.js @@ -0,0 +1,17 @@ +import CategoryRanking from './CategoryRanking.vue'; + +export default { + title: 'Components/CategoryRanking', + component: CategoryRanking, + tags: ['autodocs'], +}; + +const buckets = [ + { name: 'Food', amount: 320, count: 12, percentage: 22, delta: 8, prevAmount: 295, color: '#f59e0b', trend: [4, 8, 12, 10] }, + { name: 'Rent', amount: 1200, count: 1, percentage: 45, delta: 0, prevAmount: 1200, color: '#ef4444', trend: [10, 10, 10, 10] }, + { name: 'Transport', amount: 180, count: 8, percentage: 12, delta: -5, prevAmount: 190, color: '#8b5cf6', trend: [6, 4, 7, 5] }, +]; + +export const Default = { args: { title: 'Top categories', subtitle: '3 categories', buckets, currency: 'USD' } }; +export const Empty = { args: { title: 'Top categories', subtitle: 'No data', buckets: [], currency: 'USD' } }; +export const NoDelta = { args: { title: 'Top categories', buckets, showDelta: false, currency: 'USD' } }; diff --git a/components/CategoryRanking.vue b/components/CategoryRanking.vue new file mode 100644 index 0000000..86d8c4c --- /dev/null +++ b/components/CategoryRanking.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/components/ChartsTab.cy.js b/components/ChartsTab.cy.js new file mode 100644 index 0000000..1580e71 --- /dev/null +++ b/components/ChartsTab.cy.js @@ -0,0 +1,49 @@ +import ChartsTab from './ChartsTab.vue'; + +const monthlyBuckets = [ + { month: '2026-01', income: 2000, expense: 1500, net: 500 }, + { month: '2026-02', income: 1800, expense: 1200, net: 600 } +]; +const dailyBuckets = [ + { date: '2026-01-01', expense: 100 }, + { date: '2026-01-02', expense: 0 }, + { date: '2026-01-03', expense: 50 } +]; +const expenseCategories = [ + { name: 'Food', amount: 500, color: '#ef4444' }, + { name: 'Rent', amount: 300, color: '#3b82f6' } +]; +const incomeCategories = [ + { name: 'Salary', amount: 2000, color: '#10b981' } +]; + +describe('ChartsTab', () => { + it('renders chart canvas and tabs', () => { + cy.mount(ChartsTab, { props: { monthlyBuckets, dailyBuckets, expenseCategories, incomeCategories, currency: 'USD', formatter: (n) => `$${n}` } }); + cy.get('.charts-tab').should('exist'); + cy.get('.chart-canvas').should('exist'); + cy.get('.canvas-title').should('exist'); + }); + + it('navigates with next/prev', () => { + cy.mount(ChartsTab, { props: { monthlyBuckets, dailyBuckets, expenseCategories, incomeCategories } }); + const firstTitle = 'Cashflow'; + cy.get('.canvas-title').should('contain.text', firstTitle); + cy.get('[aria-label="Next chart"]').click(); + cy.get('.canvas-title').should('not.contain.text', firstTitle); + cy.get('[aria-label="Previous chart"]').click(); + cy.get('.canvas-title').should('contain.text', firstTitle); + }); + + it('selects via tab list', () => { + cy.mount(ChartsTab, { props: { monthlyBuckets, dailyBuckets, expenseCategories, incomeCategories } }); + cy.contains('Cumulative net').click(); + cy.get('.canvas-title').should('contain.text', 'Cumulative net'); + }); + + it('shows summary strip', () => { + cy.mount(ChartsTab, { props: { monthlyBuckets, dailyBuckets, expenseCategories, incomeCategories } }); + cy.get('.summary-strip').should('exist'); + cy.get('.summary').should('have.length.at.least', 2); + }); +}); diff --git a/components/ChartsTab.stories.js b/components/ChartsTab.stories.js new file mode 100644 index 0000000..f4ef0ed --- /dev/null +++ b/components/ChartsTab.stories.js @@ -0,0 +1,30 @@ +import ChartsTab from './ChartsTab.vue'; + +export default { + title: 'Components/ChartsTab', + component: ChartsTab, + tags: ['autodocs'], +}; + +const monthlyBuckets = [ + { label: 'Jan', income: 3000, expense: 1800, net: 1200, cumulative: 1200 }, + { label: 'Feb', income: 2800, expense: 2000, net: 800, cumulative: 2000 }, + { label: 'Mar', income: 3200, expense: 1960, net: 1240, cumulative: 3240 }, +]; +const dailyBuckets = [ + { date: '2026-08-01', expense: 45, txCount: 3 }, + { date: '2026-08-02', expense: 80, txCount: 5 }, + { date: '2026-08-03', expense: 20, txCount: 1 }, + { date: '2026-08-04', expense: 60, txCount: 4 }, +]; +const expenseCategories = [ + { name: 'Food', amount: 320, value: 320, count: 12, percentage: 22, color: '#f59e0b', trend: [4, 8, 12] }, + { name: 'Rent', amount: 1200, value: 1200, count: 1, percentage: 45, color: '#ef4444', trend: [10, 10, 10] }, +]; +const incomeCategories = [ + { name: 'Salary', amount: 4000, value: 4000, count: 2, percentage: 60, color: '#10b981', trend: [10, 20, 15] }, +]; + +export const Default = { args: { monthlyBuckets, dailyBuckets, expenseCategories, incomeCategories, currency: 'USD' } }; +export const SingleMonth = { args: { monthlyBuckets: [monthlyBuckets[0]], dailyBuckets: [dailyBuckets[0]], expenseCategories: [expenseCategories[0]], incomeCategories, currency: 'USD' } }; +export const EmptyDaily = { args: { monthlyBuckets, dailyBuckets: [], expenseCategories, incomeCategories, currency: 'USD' } }; diff --git a/components/ChartsTab.vue b/components/ChartsTab.vue new file mode 100644 index 0000000..6404b4b --- /dev/null +++ b/components/ChartsTab.vue @@ -0,0 +1,587 @@ + + + + + diff --git a/components/ChatCalloutBlock.cy.js b/components/ChatCalloutBlock.cy.js new file mode 100644 index 0000000..dae67b4 --- /dev/null +++ b/components/ChatCalloutBlock.cy.js @@ -0,0 +1,28 @@ +import ChatCalloutBlock from './ChatCalloutBlock.vue'; + +describe('ChatCalloutBlock', () => { + it('renders info variant by default', () => { + cy.mount(ChatCalloutBlock, { props: { text: 'Hello info' } }); + cy.get('.chat-callout-block').should('have.class', 'is-info'); + cy.get('.callout-text').should('contain.text', 'Hello info'); + }); + + it('renders success variant with title', () => { + cy.mount(ChatCalloutBlock, { props: { text: 'Done', title: 'Success!', variant: 'success' } }); + cy.get('.chat-callout-block').should('have.class', 'is-success'); + cy.get('.callout-title').should('contain.text', 'Success!'); + cy.get('.callout-icon').should('exist'); + }); + + it('falls back to info for invalid variant', () => { + cy.mount(ChatCalloutBlock, { props: { text: 'Fallback', variant: 'unknown' } }); + cy.get('.chat-callout-block').should('have.class', 'is-info'); + }); + + it('renders warning and danger', () => { + cy.mount(ChatCalloutBlock, { props: { text: 'Warn', variant: 'warning' } }); + cy.get('.chat-callout-block').should('have.class', 'is-warning'); + cy.mount(ChatCalloutBlock, { props: { text: 'Danger', variant: 'danger' } }); + cy.get('.chat-callout-block').should('have.class', 'is-danger'); + }); +}); diff --git a/components/ChatCalloutBlock.stories.js b/components/ChatCalloutBlock.stories.js new file mode 100644 index 0000000..bbb4ffe --- /dev/null +++ b/components/ChatCalloutBlock.stories.js @@ -0,0 +1,12 @@ +import ChatCalloutBlock from './ChatCalloutBlock.vue'; + +export default { + title: 'Components/ChatCalloutBlock', + component: ChatCalloutBlock, + tags: ['autodocs'], +}; + +export const Info = { args: { title: 'Heads up', text: 'This is an informational callout.', variant: 'info' } }; +export const Success = { args: { title: 'Done', text: 'Your import succeeded.', variant: 'success' } }; +export const Warning = { args: { title: 'Warning', text: 'Some rows had issues.', variant: 'warning' } }; + diff --git a/components/ChatCalloutBlock.vue b/components/ChatCalloutBlock.vue new file mode 100644 index 0000000..724e8e2 --- /dev/null +++ b/components/ChatCalloutBlock.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/components/ChatCanvasBlock.cy.js b/components/ChatCanvasBlock.cy.js new file mode 100644 index 0000000..220cff6 --- /dev/null +++ b/components/ChatCanvasBlock.cy.js @@ -0,0 +1,41 @@ +import ChatCanvasBlock from './ChatCanvasBlock.vue'; + +const block = { + title: 'My Report', + blocks: [ + { type: 'markdown', text: 'Hello markdown' }, + { type: 'callout', text: 'Callout text', title: 'Note', variant: 'info' }, + { type: 'kpi', title: 'KPIs', items: [] } + ] +}; + +describe('ChatCanvasBlock', () => { + it('renders canvas card with title and section count', () => { + cy.mount(ChatCanvasBlock, { props: { block, labels: { document: 'Document', sectionCount: '{count} sections', openInCanvas: 'Open in canvas' } } }); + cy.get('.canvas-card').should('exist'); + cy.get('.canvas-title').should('contain.text', 'My Report'); + cy.get('.canvas-meta').should('contain.text', '3 sections'); + cy.get('.canvas-cta').should('contain.text', 'Open in canvas'); + }); + + it('renders preview for first blocks', () => { + cy.mount(ChatCanvasBlock, { props: { block } }); + cy.get('.canvas-preview').should('exist'); + cy.get('.canvas-markdown').should('contain.text', 'Hello markdown'); + }); + + it('emits open on click and enter', () => { + const onOpen = cy.stub().as('open'); + cy.mount(ChatCanvasBlock, { props: { block }, attrs: { onOpen } }); + cy.get('.canvas-card').click(); + cy.get('@open').should('have.been.calledOnce'); + cy.get('.canvas-card').should('have.attr', 'tabindex', '0'); + cy.get('.canvas-card').trigger('keydown', { key: 'Enter' }); + cy.get('@open').should('have.been.calledTwice'); + }); + + it('falls back to Document when no title', () => { + cy.mount(ChatCanvasBlock, { props: { block: { blocks: [] } } }); + cy.get('.canvas-title').should('contain.text', 'Document'); + }); +}); diff --git a/components/ChatCanvasBlock.stories.js b/components/ChatCanvasBlock.stories.js new file mode 100644 index 0000000..99c6bf0 --- /dev/null +++ b/components/ChatCanvasBlock.stories.js @@ -0,0 +1,19 @@ +import ChatCanvasBlock from './ChatCanvasBlock.vue'; + +export default { + title: 'Components/ChatCanvasBlock', + component: ChatCanvasBlock, + tags: ['autodocs'], +}; + +const blockSample = { + title: 'Monthly Report', + blocks: [ + { type: 'markdown', text: 'This is a preview of the canvas content.' }, + { type: 'callout', text: 'Important note', title: 'Note', variant: 'info' }, + ], +}; +export const Default = { args: { block: blockSample } }; +export const NoBlocks = { args: { block: { title: 'Empty Doc', blocks: [] } } }; +export const SingleBlock = { args: { block: { title: 'Summary', blocks: [{ type: 'markdown', text: 'Only one section here.' }] } } }; + diff --git a/components/ChatCanvasBlock.vue b/components/ChatCanvasBlock.vue new file mode 100644 index 0000000..389a7f7 --- /dev/null +++ b/components/ChatCanvasBlock.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/components/ChatChartBlock.cy.js b/components/ChatChartBlock.cy.js new file mode 100644 index 0000000..8cc0965 --- /dev/null +++ b/components/ChatChartBlock.cy.js @@ -0,0 +1,32 @@ +import ChatChartBlock from './ChatChartBlock.vue'; + +describe('ChatChartBlock', () => { + it('renders title', () => { + cy.mount(ChatChartBlock, { props: { title: 'My Chart', data: [{ label: 'A', amount: 100 }] } }); + cy.get('.chat-chart-block').should('exist'); + cy.get('.block-title').should('contain.text', 'My Chart'); + }); + + it('shows fallback when data invalid', () => { + cy.mount(ChatChartBlock, { + props: { data: null }, + global: { stubs: { ClientOnly: { template: '
' } } } + }); + cy.get('.chart-fallback').should('exist'); + }); + + it('handles empty array data', () => { + cy.mount(ChatChartBlock, { props: { data: [] }, global: { stubs: { ClientOnly: { template: '
' } } } }); + cy.get('.chart-fallback').should('exist'); + }); + + it('normalizes bar when multiple numeric keys', () => { + const data = [ + { label: 'A', amount: 100, count: 2, other: 50 }, + { label: 'B', amount: 200, count: 3, other: 60 } + ]; + cy.mount(ChatChartBlock, { props: { title: 'Multi', chart_hint: 'donut', data }, global: { stubs: { ClientOnly: { template: '
' } } } }); + // with multiple numerics, donut falls back to bar; component still renders container + cy.get('.chat-chart-block').should('exist'); + }); +}); diff --git a/components/ChatChartBlock.stories.js b/components/ChatChartBlock.stories.js new file mode 100644 index 0000000..613835c --- /dev/null +++ b/components/ChatChartBlock.stories.js @@ -0,0 +1,17 @@ +import ChatChartBlock from './ChatChartBlock.vue'; + +export default { + title: 'Components/ChatChartBlock', + component: ChatChartBlock, + tags: ['autodocs'], +}; + +const donutData = [ + { category: 'Food', amount: 320 }, + { category: 'Rent', amount: 1200 }, + { category: 'Transport', amount: 180 }, +]; +export const Donut = { args: { title: 'Spending by category', chart_hint: 'donut', data: donutData } }; +export const Bar = { args: { title: 'Monthly trend', chart_hint: 'bar', data: donutData } }; +export const Empty = { args: { title: 'No data', data: [] } }; + diff --git a/components/ChatChartBlock.vue b/components/ChatChartBlock.vue new file mode 100644 index 0000000..d31eebe --- /dev/null +++ b/components/ChatChartBlock.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/components/ChatComparisonBlock.cy.js b/components/ChatComparisonBlock.cy.js new file mode 100644 index 0000000..79f9a27 --- /dev/null +++ b/components/ChatComparisonBlock.cy.js @@ -0,0 +1,38 @@ +import ChatComparisonBlock from './ChatComparisonBlock.vue'; + +const series = [ + { heading: 'Period A', income: 2000, expense: 1500, net: 500 }, + { heading: 'Period B', income: 1800, expense: 1200, net: 600 } +]; + +describe('ChatComparisonBlock', () => { + it('renders title and columns', () => { + cy.mount(ChatComparisonBlock, { props: { title: 'Comparison', series } }); + cy.get('.chat-comparison-block').should('exist'); + cy.get('.block-title').should('contain.text', 'Comparison'); + cy.get('.comparison-col').should('have.length', 2); + cy.contains('.comparison-col-title', 'Period A').should('exist'); + cy.contains('.comparison-col-title', 'Period B').should('exist'); + }); + + it('renders rows per side', () => { + cy.mount(ChatComparisonBlock, { props: { series } }); + cy.get('.comparison-row').should('exist'); + cy.contains('dt', 'Income').should('exist'); + cy.contains('dd', '2,000').should('exist'); + }); + + it('handles missing heading fallback', () => { + const withoutHeading = [{ income: 100 }, { expense: 200 }]; + cy.mount(ChatComparisonBlock, { props: { series: withoutHeading } }); + cy.contains('.comparison-col-title', 'Series 1').should('exist'); + cy.contains('.comparison-col-title', 'Series 2').should('exist'); + }); + + it('formats numbers and nulls', () => { + const withNull = [{ heading: 'A', value: null, count: 5 }]; + cy.mount(ChatComparisonBlock, { props: { series: withNull } }); + cy.contains('dd', '-').should('exist'); + cy.contains('dd', '5').should('exist'); + }); +}); diff --git a/components/ChatComparisonBlock.stories.js b/components/ChatComparisonBlock.stories.js new file mode 100644 index 0000000..533e161 --- /dev/null +++ b/components/ChatComparisonBlock.stories.js @@ -0,0 +1,11 @@ +import ChatComparisonBlock from './ChatComparisonBlock.vue'; + +export default { + title: 'Components/ChatComparisonBlock', + component: ChatComparisonBlock, + tags: ['autodocs'], +}; + +export const Default = { args: { title: 'Compare periods', series: [{ period: 'Jan', income: 3000, expense: 1800 }, { period: 'Feb', income: 2800, expense: 2000 }] } }; +export const Single = { args: { title: 'This month', series: [{ period: 'Mar', income: 3200, expense: 1960 }] } }; +export const ThreeColumns = { args: { title: 'Quarter', series: [{ period: 'Jan', income: 3000, expense: 1800 }, { period: 'Feb', income: 2800, expense: 2000 }, { period: 'Mar', income: 3200, expense: 1960 }] } }; diff --git a/components/ChatComparisonBlock.vue b/components/ChatComparisonBlock.vue new file mode 100644 index 0000000..c6c7111 --- /dev/null +++ b/components/ChatComparisonBlock.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/components/ChatComposer.cy.js b/components/ChatComposer.cy.js new file mode 100644 index 0000000..6564e00 --- /dev/null +++ b/components/ChatComposer.cy.js @@ -0,0 +1,58 @@ +import ChatComposer from './ChatComposer.vue'; + +describe('ChatComposer', () => { + it('renders textarea with placeholder', () => { + cy.mount(ChatComposer, { props: { modelValue: '' } }); + cy.get('.chat-input').should('exist'); + cy.get('.chat-input').should('have.attr', 'placeholder', 'Ask me anything about your finances...'); + cy.get('.attach-btn').should('exist'); + cy.get('.send-btn').should('exist'); + }); + + it('renders custom placeholder', () => { + cy.mount(ChatComposer, { props: { modelValue: '', labels: { placeholder: 'Custom placeholder', attachFile: 'Attach', send: 'Send', remove: 'Remove' } } }); + cy.get('.chat-input').should('have.attr', 'placeholder', 'Custom placeholder'); + }); + + it('emits update:modelValue on input', () => { + const onUpdate = cy.stub().as('update'); + cy.mount(ChatComposer, { props: { modelValue: '', 'onUpdate:modelValue': onUpdate } }); + cy.get('.chat-input').type('hello'); + cy.get('@update').should('have.been.called'); + }); + + it('disables send when empty and no files', () => { + cy.mount(ChatComposer, { props: { modelValue: '', disabled: false } }); + cy.get('.send-btn').should('be.disabled'); + cy.mount(ChatComposer, { props: { modelValue: 'hi', disabled: false } }); + cy.get('.send-btn').should('not.be.disabled'); + }); + + it('disables all when disabled prop true', () => { + cy.mount(ChatComposer, { props: { modelValue: 'hi', disabled: true } }); + cy.get('.chat-input').should('be.disabled'); + cy.get('.attach-btn').should('be.disabled'); + cy.get('.send-btn').should('be.disabled'); + }); + + it('emits submit on enter without shift and on send button', () => { + const onSubmit = cy.stub().as('submit'); + cy.mount(ChatComposer, { props: { modelValue: 'hello', 'onUpdate:modelValue': () => {} }, attrs: { onSubmit } }); + // click send + cy.get('.send-btn').click(); + cy.get('@submit').should('have.been.calledOnce'); + // press enter + cy.get('.chat-input').type('{enter}'); + cy.get('@submit').should('have.been.calledTwice'); + }); + + it('toggles attach popover on plus button click', () => { + cy.mount(ChatComposer, { props: { modelValue: '' } }); + cy.get('.attach-btn').click(); + // popover teleported to body + cy.get('body').find('.attach-pop').should('exist'); + cy.get('body').find('.attach-backdrop').should('exist'); + cy.get('body').find('.attach-backdrop').click({ force: true }); + cy.get('body').find('.attach-pop').should('not.exist'); + }); +}); diff --git a/components/ChatComposer.stories.js b/components/ChatComposer.stories.js new file mode 100644 index 0000000..5b2e994 --- /dev/null +++ b/components/ChatComposer.stories.js @@ -0,0 +1,34 @@ +import ChatComposer from './ChatComposer.vue'; + +export default { + title: 'Components/ChatComposer', + component: ChatComposer, + tags: ['autodocs'], +}; + +export const Default = { + args: { + modelValue: '', + disabled: false, + }, +}; + +export const WithText = { + args: { + modelValue: 'Hello, what is my balance?', + disabled: false, + }, +}; + +export const Disabled = { + args: { + modelValue: 'Please wait...', + disabled: true, + labels: { + remove: 'Remove', + attachFile: 'Attach a file', + placeholder: 'Composer disabled', + send: 'Send', + }, + }, +}; diff --git a/components/ChatComposer.vue b/components/ChatComposer.vue new file mode 100644 index 0000000..77b61a3 --- /dev/null +++ b/components/ChatComposer.vue @@ -0,0 +1,528 @@ +