Skip to content

migrates the app's domain-specific UI components - #4

Open
Lantum-Brendan wants to merge 20 commits into
masterfrom
feat/domain-components
Open

Lantum-Brendan wants to merge 20 commits into
masterfrom
feat/domain-components

Conversation

@Lantum-Brendan

Copy link
Copy Markdown
  • Adds domain components across (dashboard, reports, transactions, settings, wallets, budgets, imports, onboarding, parties, AI, etc.)
  • Composes them from the kit's primitives (TButton, TCard, TTabs, …) and theme tokens
  • Ships a *.stories.js Storybook story alongside every component
  • Adds supporting composables/ (useTheme, useSidebar, useNotifications, …), types/, and utils/ (currency, colors)
  • Keeps components decoupled from app state, routing, and i18n so the host can consume them via a Nuxt layer
  • Ends with the migration of financial position, imports, and the outreach composer

Pulled the shared foundation over from webui — design tokens,
surface/form/utility styles, plus the color, currency, dropdown
and theme helpers — so ui-kit has its base layer.

Foundation (Layer 0) now 10/10 curated:
- tokens.css + _vars.scss (0.1-0.3, pre-existing)
- _surfaces.scss, _form-styles.scss, _utilities.scss (0.4)
- utils/colors.ts, utils/currency.ts (0.5)
- composables/useDropdown.ts, useTheme.ts (0.5)

Verified one-at-a-time: tsc clean, sass compile, happy-dom
functional checks and parity vs webui.
…oggleButton with stories

Decouple ThemeToggleButton i18n -> props, keep useTheme.

Verified per file: diff vs webui, sass compile, vitest happy-dom mount, storybook build 17 stories.
… and ViewToggle with stories

Decouple EmptyState/ViewToggle i18n -> props, replace solar icon with lucide Package.
Improve LoadingSkeleton visibility: bg-gray -> border-light gradient.

Verified per file: diff vs webui, sass compile, vitest happy-dom mount, storybook build.
…tor, CollapsibleSection, KpiCard, ConfirmModal, NotificationsContainer, SparkLine, ReportsTabs

- AuthSocialLogin: relative kit imports (TButton + GoogleIcon)
- AuthCarousel: fixed SCSS path, self-contained carousel widget
- ThemeSelector: raw <button>/<div> → TDropdown + TDropdownItem
- LanguageSelector: raw <button>/<div> → TDropdown + TDropdownItem
- CollapsibleSection: header-button → TButton(text), edit-button → TButton(outline)
- KpiCard: <div class=kpi-card> → TCard
- ConfirmModal: close/cancel/confirm buttons → TButton, content div → TCard
- NotificationsContainer: close <button> → TButton(text), imports ConfirmModal
- SparkLine: migrated pure SVG chart, updated SCSS path
- ReportsTabs: migrated pill-tab nav with animated indicator, updated SCSS path
…primitives

- DescriptorRenderer: restore plugin escape hatch, sidebar.nav, and onboarding branches
- EmptyState: replace string concatenation with title/description props and slots
- Logo: bundle package logo.svg directly via static import
- TInput: introduce base input primitive and refactor SearchInput to compose it
- TAvatar: add extensible menu items prop and slots for custom surfaces
- Clean up transitional migration comments and verify strict typing
- Migrate ThemeSelector, LanguageSelector, AuthCarousel, AuthSocialLogin, CollapsibleSection, ConfirmModal, NotificationsContainer, KpiCard, ReportsTabs, and SparkLine
- Refactor selectors and modals to compose TButton, TDivider, and TCard primitives
- Bundle local flag SVGs and illustration assets directly in ui-kit
- Add useNotifications composable for toast and modal states
- Add Storybook stories for all 10 components
…PeriodControl, TDashboardTopCard, StatsFilterModal, NotificationBell, TTransactionCard, TTransactionSubCard and chart components with stories
Copilot AI lite review requested due to automatic review settings September 21, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sourceant

sourceant Bot commented Sep 21, 2026

Copy link
Copy Markdown

Code Review Summary

Migrates the app's domain-specific UI (dashboard, reports, transactions, settings, wallets, budgets, imports, onboarding, parties, AI/outreach, financial position, holdings) into a reusable component kit intended to be consumed by a host app through a Nuxt layer. Components are composed from the kit's primitives (TButton, TCard, TPanel, TTabs, TInput, SearchableDropdown, ComponentLoader, …) and shared SCSS/theme tokens, ship with a *.stories.js Storybook story each, and stay decoupled from app state, routing and i18n (labels as props, interactions as events, i18n probed optionally with a fallback). Supporting composables/ (useTheme, useSidebar, useNotifications, useMarkdown, useAvatar, …), types/ (budget, integration) and dependency-free utils/ (currency, colors) come with it, along with UI_ARCHITECTURE.md, .cursor/rules/component-migration.md, bundled brand/flag assets, Tier 0 SCSS partials and .storybook/preview.js Nuxt-helper shims. The architectural direction is sound, but the review flags concrete regressions: theming-contract violations (hardcoded colours, a select with no dropdown arrow, duplicate button/surface systems that collide with the host's identically named globals), UTC/local date handling across several filters and charts, no-op or bypassed validation in the transaction forms, unguarded useI18n() calls, and a dozen near-identical but diverging i18n fallback shims.

🚀 Key Improvements

  • Components are consistently composed from kit primitives and shared theme tokens rather than bespoke markup, and every new component ships a *.stories.js story exercising meaningful states (Empty/Loading/Error, DeficitPeriod, WithoutComparison, EditMode/ViewMode) with autodocs.
  • Decoupling is applied uniformly: data arrives via props, I/O is injected as seams (fetchTransactions, fetchFileBlob/deleteFile, fetchRecentExpenses, fetchUsers/uploadMedia/sendOutreach, fetchDetail, debounced searchCoins, createToken/revokeToken), intent leaves as events (submit, select, created/updated, remove-filter, mark-read), and i18n is read through typeof useI18n === 'function' with a fallback so components mount standalone.
  • assets/scss/_surfaces.scss introduces a token-driven tone system where each .surface--* variant only re-declares --surface-bg/--surface-ink/--surface-accent, plus a matching :root.dark palette, so consumers inherit tone without extra selectors.
  • components/ContentTable.vue and components/ContentCardGrid.vue share one prop-driven data shape (columns/card-fields with optional render callbacks, defaultItemId badge, empty state), so the table and card presentations are interchangeable while ContentListView.vue keeps filtering, pagination and page-size logic in one place.
  • The heavier components handle lifecycle correctly: components/TransactionForm.vue tracks per-file loading/removal sets and revokes object URLs for new and existing previews on unmount, and components/TransferForm.vue derives wallet options, currencies, exchange-rate visibility and the converted amount as computed values.
  • utils/currency.ts and utils/colors.ts are deliberately dependency-free (currency via Intl.NumberFormat, deterministic HSL generation with base-palette fallback), and types/budget.ts / types/integration.ts define the budget and extension-point models.
  • UI_ARCHITECTURE.md gives the migration an explicit layer model, a component-by-component [ ]/[x] tracker, a gap analysis and a phased rollout path, and .cursor/rules/component-migration.md codifies the standards (props/slots over concatenated copy, bundled assets, primitive composition, no @ts-nocheck).
  • .storybook/preview.js adds t/locale/setLocale helpers, an en message map and window.useCookie/window.useApi stubs alongside the existing NuxtLink shim so Nuxt-coupled components mount in isolation.
  • package.json now publishes utils/ and composables/ alongside components/ and assets/, wiring runtime peer deps (@dicebear/*, markdown-it, lucide-vue-next) as both peer and dev dependencies.

📉 Regressions

  • The theming contract ("components never hardcode colours") is broken in several places, so a host cannot re-theme by overriding custom properties: assets/scss/_surfaces.scss hardcodes the --investment/--loan/--gift tones (light and dark), assets/scss/_transactions-cards.scss, components/TInput.vue, components/NotificationBell.vue, components/transactions/TransactionFilters.vue, components/transactions/TTransactionsCardList.vue and components/reports/ReportsEmpty.vue all embed literal hex/rgba values despite sibling rules already using rgba(var(--color-primary-rgb), …).
  • assets/scss/_form-styles.scss: .form-select sets appearance: none and reserves padding-right for a chevron but never supplies background-image, so the select renders with no dropdown affordance; the same file also re-declares generic .btn/.btn-primary/.btn-secondary that duplicate TButton and, as unscoped globals, collide with the host app's identically named classes.
  • assets/scss/_surfaces.scss (tone-card, .surface*) and assets/scss/_form-styles.scss (.btn*) add a second global surface/button system alongside the TPanel/TCard/TButton primitives and the host app's copies of the same files — at equal specificity, last-loaded wins, so drift between the two silently changes rendering.
  • A dozen near-identical i18n fallback shims were introduced (e.g. CategoryForm, DashboardKPIs, DashboardAgentHero, CategoryBreakdown, CalendarTab, CashflowHero, ChartsTab, FlowTab, FinancialRatios, SettingsGeneral, PasswordModal, Settings*, ConfirmModal, RecurringModal, LearningModal, ImportUpload, HoldingForm) and they have diverged: most drop interpolation params, so standalone rendering emits literal Hey {name}, {n} categories with income…, in {name} and {count} instead of substituted text. They should be one shared composable.
  • useI18n() is called unguarded in components/ThemeSelector.vue, components/modals/ConfirmModal.vue and components/auth/AuthSocialLogin.vue, unlike the rest of the kit, so these throw during setup() in Storybook or any host without @nuxtjs/i18n.
  • Local/UTC date handling is wrong in several components, shifting dates by a day for non-UTC users: components/StatsFilterModal.vue derives presets and today via toISOString(), components/reminders/ReminderForm.vue serialises a datetime-local through toISOString(), components/reports/CalendarHeatmap.vue computes todayISO in UTC, components/reports/CalendarTab.vue appends T00:00:00 unconditionally (Invalid Date on full ISO input, corrupting weekday aggregation), components/dashboard/RecentTransactions.vue truncates only one side of the day diff ("Today" instead of "Yesterday"), and components/reports/charts/DailyBarChart.vue assumes date-only input.
  • components/TransactionForm.vue: validateRequiredFields() resets partyError/categoryError/walletError to false and never sets them, so the :error UI for party/group/wallet is dead and submit can emit walletId: null; the currency list (XAF, USD, EUR, GBP, NGN) is also hardcoded here and in TransferForm.vue, and refund amounts are parsed with Number("250 USD")NaN while refund ids are compared without the number normalisation used elsewhere.
  • components/FormSection.vue: the else branch treats every non-EXPENSE type as income, so editing a TRANSFER selects the Income tab and routes through handleTransactionSubmit, emitting submit instead of the transfer event the component defines.
  • components/settings/PasswordModal.vue: the old password is validated against the hardcoded literal 'current123' — a plaintext credential shipped in the package that bypasses the host's real verification — and no close event is emitted on success (the defineEmits return value is discarded), so the modal stays open on stale state. SettingsAccount.vue similarly fabricates success messages from the typed email and never hands edits to the host, and SettingsConnections.vue lets host callback rejections become unhandled rejections with no user feedback.
  • components/wallets/WalletDetailPanel.vue: import * as LucideIcons plus a dynamic namespace lookup defeats tree-shaking, bundling the entire lucide-vue-next icon set (~1000+ components) into the kit.
  • utils/currency.ts mis-parses separator-only thousands groups: "1,234,567" and "1.234.567" parse as 1.234 rather than 1234567, because only the first separator is replaced and the final-separator rule is applied even when both separator characters are not present.
  • composables/useNotifications.ts: ...notification overrides the duration: 5000 default with undefined, so showSuccess/showWarning/showInfo never schedule auto-removal and those toasts persist; its private t() returns raw keys and hardcodes one English sentence, so composed strings are never translated.
  • Silent financial inaccuracy: components/TTableComponent.vue converts unknown currencies at an assumed rate of 1 (identical to USD), producing wrong Income/Expense/Net totals with no signal, and components/ContentTable.vue can emit two '...' keys in the paginator while ContentListView.vue's no-i18n fallback forces an All prefix onto every message with an items param (turning "Search categories…" into "All categories").
  • Dropped events break host integration: components/extensions/ExtensionSlot.vue forwards only action, so DescriptorRenderer's next (the onboarding Continue button) is lost, and components/extensions/DescriptorRenderer.vue forwards next but not action on the escape-hatch path; components/onboarding/OnboardingEmptyState.vue declares a secondary-action emit it never fires.
  • Component prop contracts are not honoured: components/ContentSection.vue passes :page-name to EmptyState (which doesn't declare it) and gates the button on an empty actionLabel, so the EmptyStateExample story renders generic text with no create button; components/onboarding/DashboardOnboarding.vue declares hasWallets/hasCategories/hasParties but hardcodes completed: false; components/StatsFilterModal.vue crashes on an explicit null initialFilters passed by TDashboardTopCard.vue; components/ComponentLoader.vue forwards empty-string defaults that override EmptyState's own defaults; components/TTopCard.vue emits an undeclared dynamic buttonAction and calls useRouter() outside setup.
  • Routing/state that the layer is meant to be free of is reintroduced: BudgetCard.vue, parties/PartyDetailPanel.vue (/transactions?partyId=), FinancialPositionView.vue (/holdings, twice) and DashboardOnboarding.vue hardcode Trakli routes; TransactionFormContainer.vue/TransferFormContainer.vue re-implement the TPanel surface card; ComponentLoader.vue re-encodes the host's private API-error shape; ComponentLoader/ContentBreakdown duplication aside, CategoryBreakdown.vue scales bars and the line chart against topCategories[0], assuming unsorted input is sorted (and synthesises the line series from a sine function), PartyCard.vue fabricates a "1h ago" fallback, and PartiesForm.vue leaves debug console.log calls that run on every editingItem change.
  • assets/... icon/flag SVGs ship without accessible names or focusable="false", components/HamburgerMenu.vue removes the focus outline with no replacement (WCAG 2.4.7), components/modals/LearningModal.vue never focuses its overlay so its Escape handler can't fire, components/SearchInput.vue never clears its debounce timer on unmount, components/reports/ReportsEmpty.vue uses a document-global SVG gradient id that collides across instances, components/reports/ChartsTab.vue handles arrow keys on both the section and window (advancing twice per press) and rebuilds Intl.DateTimeFormat per row, components/reports/SankeyFlow.vue writes hoveredLinkIdx that is never read, components/auth/AuthSocialLogin.vue uses Math.random() for the OAuth state CSRF parameter, and UI_ARCHITECTURE.md uses ~40 absolute file:///home/lantum/... links, defines an undefined "Layer 5", reports counts that contradict its own tables, and carries a 0 / 241+ (0%) tracker number that contradicts its own [x] marks.

💡 Minor Suggestions

  • These two blockquotes say the same thing twice ("absolute foundation" vs "absolute ground floor"), leaving the reader unsure which wording is authoritative. Merge them into a single description of Layer 0.
  • This absolute file:///home/lantum/Desktop/Projects/webui/... link (and the ~40 others like it in this document, e.g. lines 66, 79, 116, 149, 378-383, 917-931) only resolves on the author's machine. On GitHub, in CI, or for any other contributor it is a dead link, and it needlessly publishes the author's local directory structure/username. Use repository-relative paths instead. While fixing this, please confirm the filename: the kit's own README.md documents assets/scss/_vars.scss while this document points at assets/scss/_variables.scss, so at least one of the two is stale.
  • The document defines a 5-layer model, Layers 0 through 4 (see the diagram at lines 5-11 and the Layer 4 heading at line 389). "Layer 5" is never defined, so this heading points at something that does not exist. Name it after the layer it actually covers.
  • These two summary rows disagree with the domain tables above them:
  • Parties — section 3.5 (lines 281-285) lists five components (PartiesForm, PartyCard, PartyCardList, PartyDetailPanel, PartiesStatsStrip), not four; PartyCardList is missing from both the count and the description.
  • Holdings & Reminders — section 3.6 (lines 290-294) lists five entries (GroupForm, HoldingForm, ReminderForm, RecurringModal, LearningModal); the summary reports two and omits the two modals entirely.

Because these rows feed the 241+ grand total, the drift propagates.

  • Reports is reported as 19 components, but section 3.9 (lines 319-331) lists 13: CashflowHero, MonthInReview, CategoryRanking, CategoryDrillModal, FinancialRatios, NotableStrip, CalendarHeatmap, SankeyFlow, ReportsEmpty, CategoriesTab, ChartsTab, CalendarTab, FlowTab. Correcting this also removes the largest single error in the 241+ grand total.
  • The bulb icon ships with no accessible name and no focusable="false". Inline SVGs without these attributes can become stray focus stops and are announced as unlabeled graphics. Since this is a decorative illustration, mark it appropriately. existing_code must be present for the replacement to anchor.
  • The flag assets have no accessible label, so a language/region picker that relies on the flag as the only content is unreadable to assistive tech. Add role="img" plus a <title> (and focusable="false"). Consider applying the same pattern to es.svg, fr.svg, gb.svg, it.svg, and pt.svg for consistency across the flag set.
  • .surface--investment, --loan and --gift hardcode raw colours (#0284c7, rgba(14, 165, 233, 0.5), …), breaking the theming contract ("Components never hardcode colours") and the file's own comment. Because these values are literals, a host cannot retheme these surface tones the way it can retheme --color-income/--color-expense. Wrap each value in a token with the current literal as the fallback: this preserves the exact current rendering while letting a host override the tone via its global stylesheet.
  • outline: none removes the focus indicator without providing a replacement, which makes the control unusable for keyboard users (a WCAG 2.4.7 failure). Use :focus-visible with a visible outline, matching the pattern already used by the password toggle in LoginCard.vue.
  • Hardcoded white breaks the theming contract documented in the README ("Components never hardcode colours"; the badge will not adapt when a host overrides the palette, e.g. dark theme). Use the inverse text token instead.
  • The README's theming contract says components never hardcode colours and must read the CSS custom properties so a host can re-theme by overriding them. This hardcoded rgba(4, 120, 68, 0.15) will not follow a host's --color-primary-rgb override (other components in this same PR use rgba(var(--color-primary-rgb), …)).
  • Same theming-contract violation as above: the error focus ring hardcodes rgba(220, 38, 38, 0.15). TAvatar.vue already uses var(--color-error-rgb), so use the token here too so host overrides propagate.
  • The t('key') !== 'key' ? t('key') : 'fallback' pattern repeated in defaultSlides is brittle and duplicates the key string. A small tr(key, fallback) helper keeps the copy in one place and makes the intent obvious; then replace each chain below with tr('carousel.ai.title', 'Just ask'), etc.
  • The line chart does not plot real data: each category's series is synthesised from a deterministic sine function (0.7 + Math.sin(categoryIndex + i * 1.5) * 0.3) with an arbitrary 5-point window, so it implies trends that don't exist in the input. This is easy to misread as a real time series. At minimum document the synthetic nature; better, drive it from actual per-period amounts or drop the line view.
  • null is not a valid Vue prop-type constructor; only String/Number/Boolean/Array/Object/Date/Function/Symbol/custom constructors are accepted. Because the value here is always a string, a number, or null (which is skipped by validation for a non-required prop), the null entry is effectively dead, but it should be removed so the type declaration reflects reality.
  • The panel is a modal (role="dialog" aria-modal="true") but does not respond to the Escape key, which is the expected dismissal affordance for dialogs. Capture the emit and attach/remove a document keydown listener while the drawer is open (guarded for SSR).
  • Unlike HoldingForm.vue's modal (role="dialog" aria-modal="true"), this dialog exposes no dialog semantics, so assistive technology will not announce it as a modal. Add role="dialog" and aria-modal="true" on the panel (and consider aria-labelledby pointing at the title) for parity and accessibility.
  • The close control renders an icon only, so it has no accessible name. Add an aria-label (reusing t keeps it translated in hosts that provide i18n and falls back to the literal string otherwise).
  • The template renders {{ w.name }} ({{ w.currency }}), but this wallet mock omits currency, so the story shows Main Bank Account (). Add a currency to the fixture so the story reflects real data shape.
  • z-index: 1000 is hardcoded, while the sibling LearningModal.vue uses the $z-index-modal token. Use the token so both dialogs stay in sync with the theme.
  • secondary-action is declared in defineEmits but never emitted anywhere in the template (only create is emitted). Dead emit declarations mislead consumers of the component. Remove it, or wire it up to a real action.
  • These .btn, .btn-secondary and .btn-primary rules are dead code — the template submits via <TButton> (which carries its own styles), so nothing uses these classes. Meanwhile line 27 renders <div class="error-text">{{ props.apiError }}</div> but .error-text is never defined, so the validation/API error is unstyled. Replace the dead button styles with the missing error style.
  • monthShort constructs a new Intl.DateTimeFormat on every invocation, and it is called inside the monthlyData and cumulativeData .map callbacks — i.e. twice per bucket on every recompute. Hoist the formatter into a computed so it is only rebuilt when locale changes (keeping reactivity) rather than per row.
  • These argTypes declare controls for selectedPeriod/compareEnabled, but the Default story drives state from internal refs and its render never reads args. The controls therefore render in the Storybook panel but have no effect, which is misleading. Either wire the story to args or drop the unused argTypes (the props are still documented via autodocs).
  • A computed with no reactive dependency never re-evaluates, so this works today, but it obscures intent and is fragile: any future reactive read inside the getter would regenerate the id and break the url(#...) reference mid-render. A plain instance-scoped constant is clearer and equally stable.
  • These colours are hardcoded (#059669, #dc2626), which contradicts the theming contract documented in README.md ("Components never hardcode colours"). The kit already defines --color-income / --color-expense custom properties (used in TransactionsContentSection.vue), so use those tokens so hosts can theme them.
  • The refund badge hardcodes rgba(255, 159, 67, 0.18) and #b45309, violating the repo's theming contract. The adjacent .recurring-indicator already derives the same amber from tokens (rgba(var(--color-warning-rgb), ...) and $warning-text); the refund badge should do the same so it stays consistent across themes.
  • import * as LucideIcons combined with the dynamic LucideIcons[v] lookup in resolvedIcon prevents tree-shaking, so the entire lucide-vue-next icon set (~1000+ components) is bundled into the UI kit. Replace the namespace import with an explicit map of the icons the wallet model can reference so only those are bundled.
  • Follow-up to the import change: resolve the icon through the curated WALLET_ICONS map instead of the namespace import, so only the allow-listed icons are bundled and an unknown icon name falls back to Wallet.
  • String.prototype.substr is a legacy, deprecated API. Use slice (or a randomUUID) to avoid it in new code.
  • AvatarUser is declared non-nullable, yet the body guards with if (!user) return '' and the sibling getAvatarUrl calls generateFallbackAvatar(user) on a potentially null/undefined user. The parameter type should reflect the runtime contract so the guard is meaningful and callers are honest about what they pass.
  • The MarkdownIt as any cast erases the constructor's option typing for no benefit — MarkdownIt is the default-exported class, and html, linkify, breaks are all valid Options keys. Instantiating directly keeps type checking on the constructor and its options.
  • hash & hash is a valid int32 coercion but reads like a no-op/bug to anyone skimming the function. |= 0 expresses the same intent (keeping the accumulator within 32-bit range) far more legibly.
  • Contract mismatch with the layer this repository ships, plus a non-portable link. README.md documents the theming contract as SCSS variables in assets/scss/_vars.scss that emit no CSS of their own, with default values living in assets/css/tokens.css (:root / :root.dark) and hosts overriding the CSS custom properties. components/TButton.vue imports ../assets/scss/_vars.scss. This row instead points at assets/scss/_variables.scss via an absolute file:///home/... URL, so a host following the inventory looks in the wrong file and never learns the override contract. Use a repo-relative link and the shipped file names; the same applies to every other file:///home/lantum/Desktop/Projects/webui/... link in this document (lines 66, 79, 116, 126, 138, 149, 157, 161-166, 178-197, 207-236, 247-384, 404-419, 565-709).
  • Self-contradicting contract. The legend at line 947 states [x] means "Migrated to modern headless/token-driven architecture", and Layers 0-2 are marked [x] throughout, yet this line reports 0% migrated. The summary counts also disagree with the tables (Layer 2 table has 30 rows, summary says 29; the Data Visualization | 5 bucket lists six charts; Layer 1 summary says 21 while the table has 20 rows). Either recompute the tallies or make the marker semantics explicit, e.g. that in Layers 0-2 [x] means "exists in the codebase" rather than "migrated".
  • These three variants hardcode colours, unlike every other .surface--* variant and unlike the theming contract in README (Components never hardcode colours). A host overriding --color-* custom properties cannot re-theme investment/loan/gift. Route them through RGB-triplet custom properties using the same pattern already used for --color-primary-rgb (see _form-styles.scss line 109). The fallbacks preserve the current rendering exactly, so this is backward-compatible. The same pattern should be applied to the :root.dark block (lines 89-103).
  • This decorative gradient hardcodes the primary colour as rgba(4, 120, 68, 0.08), while the equivalent .card::after gradient at line 41-45 correctly uses rgba(var(--color-primary-rgb), 0.08). The README theming contract states components must not hardcode colours so a host can re-theme by overriding the custom properties; this literal defeats that and will visibly desync from the rest of the card when a host overrides --color-primary. Use the same custom property as the sibling rule.
  • Debug console.log calls left in a component that is published as part of the UI layer. They execute on every editingItem change (the watcher is immediate: true) and dump the full party object to the host console. Remove them.
  • The party-type filter omits organization, even though PartyCard recognizes organization as a valid type and the party form offers it. A user can see organization parties under "All" but can never filter to them. Add the missing option to keep the filter contract aligned with the data the list renders.
  • This card imports only getCurrencySymbol and then re-implements amount formatting locally. WalletCard.vue renders the same wallet amounts through the shared formatAmount / formatShortAmount helpers. Because both cards present identical data, using two formatters produces inconsistent output (symbol vs. code, short vs. long) and duplicates logic that already exists in ../utils/currency. Import the shared formatters here so both cards share one formatting contract.
  • The card auto-synthesizes the route /budgets/{id} when no to prop is passed. This re-couples a supposedly routing-free kit component to the host's route shape (locale prefixes, /budget vs /budgets, nested routes), and the @click emit already lets the host own navigation. Pass to in explicitly and otherwise render a plain element, matching how TButton only links when to is provided.
  • secondary-action is declared in defineEmits but no element in the template ever emits it, so the component advertises an event a host can bind to but will never receive. Drop the unused event (or wire the element that should emit it) so the declared contract matches the implemented one.
  • The component hard-codes the host's transactions route and query-parameter name (/transactions?partyId=). This contradicts the stated goal of shipping components "decoupled from routing" (README/PR description) and breaks for any host whose route or param name differs — the change reaches into the Trakli host. Expose the destination as a prop (e.g. transactionsTo, defaulting to the current value) so the host owns its own routing contract.
  • The formatter prop default drops the currency entirely and returns a bare rounded number, so a host that does not pass a formatter gets 1200 here while the sibling CalendarHeatmap.vue renders proper currency via the shared formatShortAmount helper. Since the same utility already satisfies this responsibility, use it as the fallback instead of the ad-hoc rounding function (extend the existing utils/currency import accordingly).
  • formatter and currency are declared but never referenced anywhere in the component (the template only renders ratio.formatted, which is built from toFixed() locally). Sibling report tabs (ChartsTab, FlowTab, CategoryRanking) all apply the injected formatter(amount, currency), so a host that passes one shared formatter expects it to apply here too and gets unformatted output instead. Since the ratios are percentages, currency formatting does not apply to this panel — drop the two dead props so the API surface does not advertise formatting support it does not implement. (Consumers passing currency today are unaffected; Vue has no way to error on an undeclared prop, which is exactly why this is silent.)
  • Formatting contract drift between the two reports components. NotableStrip.formatValue falls back to the shared formatShortAmount util (which understands the currency), whereas MonthInReview defaults its formatter to a bare Math.round(n) that drops the currency entirely. Consumers that do not pass an explicit formatter therefore get currency-less integers here but currency-formatted strings from NotableStrip for the same currency value. Reuse the existing shared formatter rather than an ad-hoc rounding function so both sides of the contract agree.
  • Add the shared currency formatter import required by the default formatter fallback above. NotableStrip already imports formatShortAmount from ../../utils/currency; reusing the same module keeps the two report components on one formatting path instead of introducing a second one.
  • The fallback repeats the hardcoded #047844 instead of reading the --color-primary token. Combined with the prop default this means the SVG never follows the host's theming. Fall back to var(--color-primary) so the illustration honours the theming contract like SankeyFlow/CashflowLineChart already do.
  • The component has no way to tell the host to persist the edited profile, so handleSave invents an outcome (see the next suggestion). Add a saveAccount callback prop, matching the callback-prop contract already used by SettingsConnections (createToken/revokeToken).
  • The inlined t() is a fake translator: it returns the key unchanged (so t('Confirm Deletion'), t('Delete'), t('Cancel') are never localized) and hardcodes a single English sentence. This bypasses the host's i18n entirely, contradicting the layer's documented contract that components/composables stay free of translation. Keep the layer i18n-free by defaulting to identity + generic {placeholder} interpolation of the (already English) keys, so a host can inject its own translator without a second formatter being introduced. This replaces the one-off params.item branch with generic interpolation and preserves current English output.
  • These stand-ins are a parallel implementation of the host's Nuxt auto-imports, and two of them don't honor the real contract. useCookie(name) is keyed by name in Nuxt, but this stub returns ref('mock-cookie') for every name, so distinct cookies collapse into one value; useApi drops its arguments. Any story that exercises cookie- or API-driven branches can pass here and fail against the host (Trakli). Make the cookie stub name-aware and note that signatures must track the host.
  • This row marks TAvatar as [x] (migrated to the modern headless/token-driven standard) and describes it as an atom that consumes useAuth. The ui-kit README.md lists TAvatar under Deferred (need decoupling before they can move) precisely because of that dependency, and it is not present in this repo. Tracking it as migrated tells the migrator to lift an auth-coupled duplicate into the kit, which bypasses the layer's decoupling rule (README: "All are free of app composables, routing, and i18n"). Either keep the row pending with an explicit blocker, or restate it as host-only until auth is injected via props/emits.
  • These composables are described as host-app implementations, while this PR states the kit also gains composables/ (useTheme, useSidebar, useNotifications, …). Two implementations of the same name create a genuine contract mismatch: useTheme persistence key and .dark class handling are stateful, so if the kit's copy and the app's copy both run, kit components and app components resolve different themes. The document should name a single canonical owner (or explicitly mark the host copy as deprecated on switch-over) rather than presenting both as current.
  • This row gives a Layer 2 composite its own notification data ownership (direct notificationsApi fetch plus mark-as-read mutations), in parallel with the useNotifications composable this PR adds to the kit. That is the same responsibility implemented twice: the unread badge computed inside the component and any unread count exposed by the composable can disagree after a mark-as-read. Assign notification state to one owner and make the component consume it.
  • The overhaul plan targets assets/scss/_variables.scss, which is not this repo's token entry point. The kit's SCSS variables live in assets/scss/_vars.scss (TButton.vue does @use '../assets/scss/_vars.scss' as *) and the default values are emitted solely by assets/css/tokens.css, which README declares the "one place" defaults live. Executing this plan as written would point the migration at a non-existent file and produce a second CSS source of default token values, breaking the single-source theming contract the kit promises to hosts. Recommend naming the real files and stating that any generated CSS must replace, not supplement, tokens.css.
  • The document declares a five-layer model numbered 0–4 (lines 3-11), so "Layer 5" does not exist and contradicts the document's own heading. This matters for a tracker that is otherwise used to place components; naming the section after the layer it actually amends (Layer 4 — Layouts, Navigation & Pages) keeps numbering unambiguous.
  • The progress counter reports 0 / 241+ (0%) while every row in Layers 0, 1 and 2 is marked [x] (migrated) and only Layers 3–4 are [ ]. A tracker whose headline number contradicts its own checkboxes cannot be trusted to drive the migration. Either derive the number from the checkboxes or state clearly that this document inventories the host app's in-app copies, not kit migrations, and point at the ui-kit README for the shipped set.
  • Duplicate surface implementation. .tone-card (together with the .surface* variants above) is a second, global surface-card system that shadows the existing TPanel primitive (components/TPanel.vue, described in README.md as the "surface card") and TCard. The two contracts differ: TPanel is a scoped Vue component driven by props/tokens, whereas this is an unscoped global class driven by --surface-* custom properties, and it also collides with the identical .surface/.tone-card copy still shipped by the host app (webui/assets/scss/_surfaces.scss). Once the host consumes this layer, both definitions load at equal specificity and last-loaded wins, so any drift between the copies silently changes the rendered card. Prefer the component primitive, or namespace these classes to remove the collision.
  • Duplicate button system. These utility classes re-implement the button contract already owned by the TButton primitive (components/TButton.vue) and are a copy of the host app's webui/assets/scss/_form-styles.scss. They create concrete conflicts: .btn uses border-radius: $radius-lg while TButton uses $radius-md; .btn-primary duplicates the .submit-btn defined earlier in this very file (line 145) but hard-codes color: white instead of the $bg-white token .submit-btn uses, breaking the "components never hardcode colours" rule in the README; and as unscoped globals they collide with the host's identically-named classes once the layer is adopted.
  • This function duplicates the host's established API-error decoding in webui/utils/apiErrors.ts (the comment on line 119 admits it). It hard-codes the host's private error shape (err.response._data.message, err.response._data.errors), so the kit now carries a second, independent decoder for the same contract. If the host changes that shape, adds i18n, or fixes a decoding bug, this copy keeps its old behaviour and the two render different messages for the same failure — a silent contract mismatch that only surfaces in kit-rendered screens. The component already exposes errorFormatter for exactly this reason; keep the built-in fallback host-agnostic instead of re-encoding the private response shape.
  • This is a duplicate implementation of the i18n fallback shim that already exists in TDashboardTopCard.vue (lines 74-79). The two copies have diverged: this one drops interpolation params ((key) => key) while the dashboard's interpolates Showing {period}.. Extract a single useT() composable into the composables/ folder the PR already established so both components share one fallback with identical semantics.
  • These preset entries duplicate the identical list in StatsFilterModal.vue's datePresets (values current_week, current_month, last_3_months, current_year). The two copies must stay in sync: StatsFilterModal also defines all_time and uses its list to compute the date range, while currentPeriodLabel here looks up periodsList, which never contains all_time and therefore falls back to t('this period'). Define the presets once in a shared module and import them in both components.
  • This hardcodes the primary/error colours, bypassing the theming contract stated in the README ("Components never hardcode colours") and the convention used by sibling components (SearchableDropdown and TAvatar read rgba(var(--color-primary-rgb), …) / rgba(var(--color-error-rgb), …)). Because the values are hardcoded, a host override of --color-primary will not flow through the focus/error ring here. Use the token-backed RGB custom properties instead.
  • TransactionFormContainer.vue already provides this same wrapper, and the kit already exposes TPanel (see README “Layout”) which owns exactly this surface-card responsibility (background, border, border-radius, box-shadow, responsive padding). Re-implementing it here duplicates both the sibling wrapper and the primitive, so theme-token changes to TPanel will not reach this container. Compose the primitive instead. (Add the matching import; sibling components in this repo import their collaborators explicitly.)
  • This i18n accessor is duplicated verbatim across four of the newly added components (CategoryForm.vue L114-115, DashboardKPIs.vue L60-61, DashboardAgentHero.vue L105-106, and here). Since the PR introduces a composables/ directory for exactly this kind of shared behaviour, extract this into a single composable (e.g. composables/useTranslate.js) so the fallback logic exists in one place. This also fixes a latent bug: the (k) => k fallback ignores the interpolation params, so DashboardAgentHero's t('Hey {name}', { name: firstName }) renders the literal string Hey {name} whenever useI18n is unavailable.
  • The component bakes an app-specific route into the shared kit. The established kit pattern (TButton.vue accepts an optional to prop) is to take the destination from the host. Add a prop so the "total net worth" card's destination is configurable instead of hardcoded to Trakli's /holdings.
  • Hardcoded app route. The README (lines 52–58) states kit components are "free of ... routing" and defers components bound to routing until they accept it as a prop. Trakli's /holdings path should not be the default for every host consuming the layer; bind it to the holdingsTo prop added above (falling back to a neutral default if unset).
  • This inline i18n adapter is the most complete of three near-identical copies added in this PR (also in ImportSessionsList.vue 56–57 and HoldingForm.vue 148–149). Only this one interpolates {param} placeholders; the other two return the key unchanged, so t('… {count} …', { count }) routed through them silently emits {count}. Consolidating into one shared, decoupled composable removes the divergent contract and keeps the component free of duplicated i18n logic.
  • This optional-translator bootstrap is duplicated verbatim in components/modals/RecurringModal.vue (lines 97-98). Two components now own the same responsibility — resolving a translator with a graceful fallback — as separate sources of truth. If the guard or the fallback ever needs to change (e.g. to support a Nuxt-provided $t or a locale argument), one copy will drift from the other and the two modals will translate differently. Extract it once into a shared composable (the layer already ships a composables/ folder) and consume it here.
  • Same duplicated bootstrap as LearningModal.vue (lines 149-150). Keeping a second, independent copy of the optional-i18n contract here means the two modals can resolve translations differently after any future edit. Reuse the shared useOptionalI18n composable instead of re-implementing it.
  • The navigation target is hardcoded here (and again on lines 128, 137, 146). This is the second parallel implementation of routing knowledge: the host app already defines these routes, and the layer now restates them. Because this component is consumed by the Trakli host via a Nuxt layer, a host route rename (e.g. /transactions/new/transactions/create) breaks these emits silently. Pass the destinations in as props, or emit an intent key ('wallets', 'categories', ...) and let the host map it.
  • This i18n guard/fallback is duplicated from PartiesStatsStrip.vue, but the fallback here does not interpolate {param} placeholders while the copy there does. Prefer extracting a shared useSafeI18n composable; at minimum make the fallback identical so the two components cannot diverge.
  • TButton already implements this loading contract: when its loading prop is set it disables the button and renders the <Loader2 class="spinner" /> itself. Re-creating the disabled state and the spinner here duplicates the primitive and will drift from it. Use the prop instead (note: TButton renders the spinner instead of the default slot while loading; if inline text+busy is genuinely wanted, that divergence should be documented and pushed into TButton).
  • This generateMonthBuckets() fixture is identical to the one in components/reports/CalendarHeatmap.stories.js. Two copies of the same daily-bucket shape will diverge the moment the contract changes. Extract it to a shared story fixture (e.g. components/reports/__fixtures__/buckets.js) and import it in both stories.
  • This two-line i18n bootstrap is duplicated verbatim in PartyDetailPanel.vue and ReminderForm.vue. The fallback semantics (i18n?.t || key) are part of the kit's contract and should be defined once in a composable (e.g. composables/useTranslate.js) so all domain components share one implementation.
  • Sibling CalendarHeatmap.vue implements the same formatter prop but defaults it to the shared currency helper (formatShortAmount), while this component defaults to an inline Math.round that ignores the currency argument and produces a different string. Two implementations of 'render an amount' in the same kit will render money inconsistently. Default to the shared util too (and import formatShortAmount from ../../utils/currency, which this file already uses for parseAmount).
  • This fallback hard-codes the unit " tx" for any key carrying an n param, which is a divergent copy of the same shim used in ChartsTab.vue, FlowTab.vue, and FinancialRatios.vue (where the unit is " months"). Replace the bespoke unit text with generic {param} interpolation so the key itself drives the output and the fallback stays consistent across components (t('{n} tx', { n }) then renders 3 tx).
  • This hand-rolls the anchor-or-button duality and the button styling that TButton already provides. TButton renders a <NuxtLink> when to is set and a <button> otherwise, exposes a left-icon slot, and forwards @click. Reusing it keeps the empty state's call-to-action visually and behaviourally consistent with every other kit button instead of maintaining a parallel .cta implementation that can drift.
  • Consuming the kit's TButton requires importing it here, as the other domain components do (e.g. PeriodControl.vue imports it from ../TButton.vue). Add this import so the call-to-action above can be composed from the primitive rather than re-implemented.
  • niceStep (lines 79–84) and shortNum (lines 120–124) in this file are byte-for-byte identical to the versions in components/reports/charts/CumulativeNetArea.vue (lines 90–95 and 124–128), along with the shared chart constants and onMove tooltip math. Two components with the same responsibility now own two copies of the tick/label math, so a change to axis rounding or the k/M abbreviation has to be made twice and will drift. Extract the duplicated helpers into a shared module and import them here (and in CumulativeNetArea.vue), then delete the local definitions at 79–84 and 120–124.
  • This two-line useI18n fallback is duplicated verbatim in DailyBarChart.vue, PasswordModal.vue, SettingsAccount.vue, SettingsConnections.vue and SettingsDisplay.vue. Six components re-implement the same responsibility (obtain a t() that degrades gracefully when i18n is absent). Extract it into one composable so the fallback behaviour is defined once and can change in one place.
  • This inline formatCurrency is a second, parallel currency formatter. The sibling component in the same directory (TTransactionsCardList.vue) already imports formatShortAmount from the shared ../../utils/currency module that this PR adds, so the money-formatting rule now lives in two places. The inline copy hardcodes en-US and omits the currency symbol/code entirely, so the summary totals here can render differently from the amounts shown in the card/table views for the same data — a concrete contract mismatch. Because the host (Trakli) owns the user's currency/locale, a hardcoded locale also means the migrated component will not follow the app's formatting settings. Reuse the shared helper so all surfaces format identically.
  • This private t() is a second, partial implementation of the app's translation contract. It returns the raw key for most inputs (so t('Confirm'), t('Cancel') only "work" because the English key happens to equal the English label) and hardcodes a single {item} sentence that discards the key it is passed. That is a contract mismatch with a real translator: interpolate the {item} placeholder out of the key instead of replacing the whole message, so the composable stays i18n-agnostic and every call site keeps working unchanged.
  • This file is a copy of the host's webui/utils/colors.ts (still owned by the host), and it introduces hardcoded hex palettes that duplicate the kit's established colour source of truth (assets/css/tokens.css custom properties, per README §"Theming contract"). The header should make clear this is a port pending the host switch, and that the hex values are intentionally scoped to data‑viz rather than the themed UI palette.

🚨 Critical Issues

  • This figure contradicts the checkboxes it claims to summarise: the legend (lines 16-17) defines [x] as "migrated to the modern headless/token-driven standard", and every entry in Layers 0, 1 and 2 is marked [x], so 0 / 241+ (0%) cannot be derived from the document. It also contradicts the alignment table, which rates those same tokens as platform-specific SCSS (line 873) and the styled atoms as "monolithic styled components" with High priority (line 875). Either recompute the number from the checkboxes or redefine what [x] means, and state which — otherwise the tracker's headline metric is meaningless.
  • .form-select removes the native dropdown arrow via appearance: none, but no replacement background-image is provided — background-position/background-repeat/background-size have no effect without one. The result is a select with no visible affordance that it is a dropdown, even though padding-right: 2.5rem reserves space for an arrow. Add the chevron background image (ideally tokenized/derived from a shared asset so a host can retheme it).
  • visiblePages can legitimately contain two '...' entries (e.g. total=10, current=5 => [1, '...', 4, 5, 6, '...', 10]). Keying the v-for with :key="page" therefore produces duplicate '...' keys, which Vue flags with a 'Duplicate keys found during update' warning and which can cause incorrect DOM reuse in the paginator. Note the sibling implementation in ContentListView.vue already keys by index. Key by index here as well.
  • The no-i18n fallback hardcodes the All prefix whenever params.items is present, so every message that receives an items param is rendered as All <items> — including the search placeholder at line 19 (t('Search {items}...', { items: ... })), which will render as "All categories" instead of "Search categories...". This fallback is the path used when the component runs standalone (Storybook/a host without @nuxtjs/i18n), so the wrong string is what users will see. Interpolate the actual message template generically instead of assuming a single phrasing.
  • The else branch treats every type that is not EXPENSE as income, so editing a TRANSFER (or any unknown type) silently selects the Income tab and shows the transaction form instead of the transfer form. Handle each known type explicitly and keep the fallback for the default case.
  • Add onBeforeUnmount to the imports so the debounce timer can be cleaned up when the component unmounts.
  • The pending debounce timer is never cleared on unmount. If the component is destroyed while a debounce is in flight (e.g. navigating away mid-typing), the callback still fires afterwards and emits update:modelValue on a torn-down instance. Add an onBeforeUnmount cleanup.
  • Date.prototype.toISOString() converts to UTC, so for any non-UTC locale the preset start/end dates can shift by a day (e.g. new Date(y, m, 1) local midnight becomes the previous day in negative-offset zones). Format the date in the local timezone instead.
  • today is derived with toISOString(), which yields the UTC date rather than the user's local date. Near midnight this seeds filters.endDate (and the :max bound on the date inputs) with the wrong day. Derive it from local date parts.
  • Unlike the other components in this PR (TTableComponent, TTopCard, TipsSection, TTransactionCard), this calls useI18n() unguarded. In Storybook or any host that does not install @nuxtjs/i18n, useI18n is undefined and this will throw, defeating the kit's stated goal of being decoupled from i18n. Use the same defensive pattern as the rest of the kit.
  • Unknown currencies silently fall back to a rate of 1 (identical to USD), so an unrecognised currency is converted as if it were USD and the Income/Expense/Net totals are wrong without any signal. Surface the missing rate instead of silently producing incorrect financial totals.
  • NewExpense inherits the default isOutcomeSelected: false (income) and NewIncome also sets false, so both stories render the income variant. Since isOutcomeSelected: true produces type: 'EXPENSE' (see TransactionForm.vue onSubmit), the NewExpense story never exercises the expense path. Set it to true.
  • walletError is unconditionally reset to false here and is never set to true anywhere, so the :error="walletError ? t('Wallet is required.') : ''" prop passed to SearchableDropdown (line 89) is dead code and the wallet requirement is never enforced on submit. Either enforce the requirement or drop the error UI so the label doesn't lie about validation.
  • null is not a valid constructor entry for a prop's type array. Vue resolves it as an "allow null" branch rather than a real type check, which is misleading. Keep only the real types and rely on default: null for the fallback.
  • This block is lang="ts" and references the domain types Budget, BudgetPeriodType and BudgetTargetType (lines 186, 193, 247, 254, 298, 304, 319, 322, 331), but none of them is imported or declared in this file. Unless they happen to be ambient globals, type-checking (vue-tsc) fails with 'Cannot find name'. Import them explicitly (adjust the path to the kit's types module) or declare them locally.
  • Math.random() is not cryptographically secure. The OAuth state parameter is a CSRF defence, so a predictable value weakens the flow. Use a CSPRNG (crypto.randomUUID() or crypto.getRandomValues) instead.
  • useI18n() is called unconditionally here, while every other component in this PR guards it (typeof useI18n === 'function'). In a pure component test or a host without the i18n module, this call throws and the component fails to render. Match the guarded pattern used elsewhere (and consider removing the direct useApi/useCookie usage on lines 13-14, which also violates the layer's 'no app state' contract).
  • The form mixes native constraint validation with the custom validateForm() pass. The inputs carry required, so when a user submits an empty form the browser runs its own validation and cancels the submit event entirely; handleSubmit()/validateForm() never run and the *Error divs below never render. The custom error messages are therefore unreachable on empty submit. Add novalidate so the custom validation (and its inline messages) is the single source of truth.
  • Bar heights are scaled against topCategories.value[0]?.value, assuming the first category is the largest. topCategories preserves the incoming order and is never sorted, so any later category with a larger amount yields a height above 100%; combined with overflow: hidden on .bar-track the bar is silently clipped and the chart misrepresents the data. Scale against the actual maximum instead.
  • CategoryForm.vue declares pageName as a required: String prop, but the story's default args omit it, so every story render logs Missing required prop: pageName. Provide the prop in the story args (and consider whether the unused required prop should remain on the component at all, since it is not read anywhere).
  • now is truncated to midnight but date is not, so the day difference is computed against a partially-elapsed day. For an ISO timestamp earlier than the current time but after midnight (e.g. yesterday 23:00), diffMs is a small positive value, Math.floor(diffMs / 86400000) is 0, and the row incorrectly renders "Today" instead of "Yesterday". Normalise date to midnight as well before subtracting.
  • DescriptorRenderer emits both action and next (the onboarding "Continue" button and escape-hatch components emit next), but ExtensionSlot only forwards action. When a slot containing onboarding steps is rendered through ExtensionSlot, the next event is silently dropped. Forward it (and add next: [] to the defineEmits type) so the host can react.
  • ApiTransaction is used as the parameter type here but is never imported or defined in this file. Inline the shape (or import the shared type) so the SFC resolves cleanly.
  • searchCoins is an injected async prop that can reject (network/API error). The awaited call is unguarded, so a rejection produces an unhandled promise rejection and leaves the UI in an inconsistent state. Wrap it in try/catch and reset the results on failure.
  • useI18n() is called unconditionally here, but every other migrated component guards it with typeof useI18n === 'function'. Since this layer is meant to be consumed without app composables or i18n, an unconditional call throws during setup() in a host that has no @nuxtjs/i18n installed. Use the same defensive pattern as ImportUpload.vue / SuggestionReviewTable.vue.
  • The hasWallets prop is declared but never read; this step hardcodes completed: false. As a result the AllComplete story never shows this step as complete and currentStep is permanently pinned to index 0. Wire the prop in.
  • The hasCategories prop is declared but never read; completed is hardcoded to false. The AllComplete story therefore cannot mark this step done. Use the prop.
  • The hasParties prop is declared but never read; completed is hardcoded to false. Use the prop so the story-driven and host-driven states agree.
  • The watcher only fires on isOpen transitions and without immediate. When the modal is mounted already open (as RecurringModal.stories.js does with args.isOpen = true), or when transaction changes while open, the form is never populated from props.transaction, so it silently shows the defaults. Watch the transaction too and run immediately. Note also that recurrenceEndsAt.split('T')[0] assumes a string; guard against a Date/date-only value.
  • Declare the overlay ref and move focus to it when the dialog opens, so the @keydown.esc handler actually receives events. Without this the tabindex/ref on the overlay have no effect.
  • The pageType validator only warns on an unsupported value; Vue does not block it. If a host passes a pageType not present in onboardingConfigs (e.g. a newly added page), config is undefined and the template's t(config.title) throws at render time. Give both lookups a safe fallback so an unknown type degrades instead of crashing.
  • Time-zone bug: a datetime-local input expects local wall-clock (YYYY-MM-DDTHH:mm), but toISOString() serialises to UTC. For any user not on UTC, the edited reminder shows the wrong time, and because handleSubmit re-parses the value as local (new Date(form.trigger_at).toISOString()), saving without changes silently shifts the stored time. Format the value in local time instead.
  • toISOString() returns the UTC date, so todayISO is the wrong day for users behind UTC (e.g. a US evening is already "tomorrow" in UTC). The cell--today highlight will mark the wrong tile. Derive the date from local components instead.
  • b.date + 'T00:00:00' assumes b.date is a date-only string. If the bucket carries a full ISO timestamp (e.g. 2024-03-24T10:00:00), the concatenation yields an Invalid Date, d.getDay() returns NaN, and arr[NaN] += … silently corrupts the weekday aggregation. CategoryDrillModal.vue already guards against this; normalise the date the same way here.
  • Same date-normalisation problem as weekBuckets: appending 'T00:00:00' unconditionally produces an Invalid Date when iso already contains a time component, and Intl.DateTimeFormat.format then throws a RangeError. Use the same guard applied in CategoryDrillModal.vue.
  • When every bucket has zero spend, Math.max(...weekBuckets.value) is 0 and indexOf(0) returns 0, so busiestWeekday misleadingly reports the first weekday (Monday) as the busiest. Guard for the all-zero case and return a neutral placeholder, matching the || '—' pattern used in CategoryDrillModal.vue.
  • topParties[0].amount is used as the bar-width divisor. Because topParties is derived from tx.amount values, a zero total (all amounts 0, or a single zero-amount party) yields 0 / 0, rendering a width: NaN%, which the browser drops. Guard the divisor so the bar falls back to 0%.
  • The component registers two handlers for the same arrow keys: the <section> has @keydown="onKey" (line 2) and onMounted also adds onGlobalKey on window (lines 331-332). When the section is focused, a single ArrowLeft/ArrowRight press first invokes onKey from the section listener, then bubbles up to window and invokes onKey again via onGlobalKey — advancing/rewinding the chart twice per keypress. Guard the global handler against events the section already handled.
  • The i18n fallback ignores interpolation parameters, but the component calls t('in {name}', { name: top.name }) at line 227. Without a host i18n layer the summary label renders the literal string in {name}. Interpolate the placeholders in the fallback so the standalone (nuxt-layer) path degrades cleanly.
  • The bar width is only clamped at the upper bound. maxAmount is guaranteed positive (Math.max(1, ...)), but b.amount is not, so a negative amount yields a negative percentage width (invalid CSS that the browser silently drops). Clamp the lower bound too for consistency.
  • When data is empty, Math.max(0, Math.min(points.length - 1, idx)) clamps hover to 0, even though points[0] does not exist. The template then evaluates points[hover].income.x (see the v-if="hover >= 0" guide line), throwing Cannot read properties of undefined. The chart is rendered for the Empty story, so hovering an empty chart crashes. Guard the empty case before touching hover.
  • The <linearGradient id="ec-grad"> and its url(#ec-grad) reference are document-global. When more than one ReportsEmpty renders (and especially when instances use different primary colours), every instance resolves to the first gradient in the DOM, so the wrong colour is painted. Derive a per-instance id, mirroring what SparkLine.vue/SankeyFlow.vue already do.
  • Bind the gradient to the per-instance id so multiple ReportsEmpty instances no longer collide on the hardcoded ec-grad id.
  • The component compares the entered old password against a hardcoded literal 'current123'. This is a security problem (a plaintext credential baked into a shipped component) and a correctness problem (validation never reflects real auth). At minimum, drop the hardcoded secret and let the host perform verification; here we simply require the field to be filled.
  • defineEmits is called without capturing its return value, so the component has no way to notify the parent to close. Capture the emitter so it can be used after a successful update.
  • After a successful update the fields are reset but the comment // notify parent to close modal is never fulfilled — no event is emitted, so the modal stays open on a stale state. Emit close once the reset completes.
  • For a net cumulative chart negatives are meaningful, and maxAbs is already derived from Math.abs(...) so the Y scale reserves room for them. Clamping the point to Math.max(0, d.cumulative) collapses every negative value onto the zero line, silently hiding data. Scale the raw value instead.
  • The local editable preferences reactive is seeded from props.preferences exactly once and is never re-synced. If the host provides preferences asynchronously (the typical case: mount with defaults, then load the saved preferences), the toggles will keep showing the initial defaults even though the prop changed. Add a watcher that re-hydrates the local copy when the prop updates.
  • Number(response?.last_page) || 1 silently defaults the last page to 1 when the API response omits last_page. Combined with the break condition, a full first page (100 rows) then stops loading even though more pages exist. Consider only treating last_page as a terminator when it was actually provided, and otherwise rely on a short page to signal the end.
  • WalletDetailPanel filters transactions with tx.walletId === props.wallet.id and keys rows by tx.id. These story fixtures provide neither, so the WithWallet story renders an empty recent-transactions list and an empty activity chart — the exact behaviour the story is meant to document. Add id and walletId (matching the wallet's id: 1) so the fixture exercises the real rendering path.
  • The ...notification spread overrides the default duration: 5000 even when notification.duration is undefined (object spread copies the explicit undefined value). Since showSuccess/showWarning/showInfo always pass a duration key (possibly undefined), the default is never applied and these notifications are never auto-removed — the if (newNotification.duration > 0) guard is false. Only showError works because it does duration || 7000. Spread first, then apply a nullish-coalesced default.
  • The multi-separator branch mis-parses values that use only one separator type repeated as thousands separators (e.g. "1,234,567" or "1.234.567"). Because the else-branch keeps whichever separator is not the last one, parseFloat stops at the first separator and returns 1.234 / 1.234 instead of 1234567. Only treat the final separator as a decimal point when both separator characters are present; otherwise strip all separators.
  • The storybook t shim only handles a bare key: const t = (key) => messages[key] || key;. The host's t (vue-i18n) accepts an interpolation/pluralisation payload, so a component calling t('key', { count }) will render the unformatted message (placeholders like {count} left literal) in stories while rendering correctly in the host. Add minimal {name} interpolation so the preview reflects the real accepted-input contract.
  • .form-select disables the native arrow (appearance: none) and reserves space for a custom one (background-position/…right 0.75rem center, background-size: 1.5em 1.5em, padding-right: 2.5rem) but never sets background-image. The grouped rule at lines 65-96 also sets the background: $bg-white shorthand, which resets background-image to none. Net result: the select renders with dead arrow padding and no dropdown indicator. Either supply the intended background-image or drop the arrow reservation so the native indicator returns. Dropping it avoids introducing a second, hardcoded arrow asset.
  • AuthFooterLink resolves its to prop with a raw <a :href> — a full-page navigation. Everywhere else in the kit the same to prop is a router destination: TButton renders <NuxtLink v-if="to" :to="to" ...> (see components/TButton.vue, lines 52-61). So two components interpret the same to contract differently, and this one forces a document reload (and, if to ever becomes data-driven, a javascript:/off-site href). Use NuxtLink, which renders an <a> so the existing .footer-text a styles keep applying.
  • ComponentLoader forwards emptyTitle/emptySubtitle/emptyButtonLabel to EmptyState unconditionally, but defaults them to empty strings. Because the template binds these with :title="emptyTitle" etc., an explicit '' overrides whatever defaults EmptyState defines for itself, so the emptyStateName-driven copy (e.g. the Empty story passing only emptyStateName: 'wallets') collapses to blank text. Defaulting to undefined lets EmptyState's own defaults apply when the caller doesn't supply a value, preserving the child's contract while still allowing overrides. (If EmptyState genuinely requires these values, this is a no-op.)
  • Contract mismatch with the producer this PR also adds. EmptyState declares only title, description, actionLabel, and icon; it does not declare a page-name prop. Passing :page-name="pageName" is therefore dropped (it falls through to the root element as an attribute), so EmptyState always renders its default title "No items yet". Worse, because actionLabel defaults to '' and gates the button with v-if="actionLabel", no create button is rendered and the @create="openForm" handler wired here can never fire. Triggering case: ContentSection.stories.jsEmptyStateExample (pageName: 'Wallet', empty: true) mounts EmptyState expecting a wallet-specific empty state with an add action, but gets the generic text and no button. Map the entity onto the props EmptyState actually accepts (strings should ultimately come from i18n, mirroring ContentListView).
  • The editingItem watcher maps every non-EXPENSE type to the income tab, so an item with type === 'TRANSFER' selects income. The subsequent submit then routes through handleTransactionSubmit and emits submit, never the transfer event the rest of the component defines for transfers. This is a producer/consumer contract break: the component accepts an editingItem describing a transfer but reports it as income to its host. Reuse the existing select* helpers and handle the third type explicitly.
  • new Date() does not throw on malformed input — it yields an Invalid Date, and Invalid Date.toLocaleDateString(...) returns the literal string "Invalid Date" rather than throwing. So the catch branch is unreachable and a bad created_at from the host renders Invalid Date in the dropdown instead of falling back to the raw value. Guard with Number.isNaN(date.getTime()).
  • The null-timestamp fallback fabricates a "1h ago" last-activity time. The surrounding template only hides this block when both amounts are zero, so a party with activity but no lastUpdated will display a false relative time. Return a neutral placeholder instead of inventing a value.
  • Input contract mismatch with the consumer. The prop declares initialFilters: { type: Object, default: () => ({}) }, but TDashboardTopCard passes :initial-filters="customFilters" where customFilters = ref(props.customRange) and customRange defaults to null. Vue only substitutes a prop default when the value is undefined; an explicit null is kept, so props.initialFilters is null and props.initialFilters.startDate throws TypeError: Cannot read properties of null on the first setup() — i.e. the first time the modal is opened via the Custom chip. Guard the access so the declared contract (an object that may be empty) is actually honoured.
  • useI18n() is called unconditionally here, unlike every sibling component in this change, which guards it (typeof useI18n === 'function'). This layer's nuxt.config.ts does not register an i18n module, so a host or Storybook that mounts ThemeSelector without i18n installed will throw a ReferenceError at setup — breaking the stated 'decoupled from i18n' contract. Apply the same defensive guard used by TTopCard, TTableComponent, and TipsSection.
  • convertCurrency defaults an unknown currency's rate to 1 (CURRENCY_RATES[fromCurrency] || 1). Because the table only knows five currencies (USD/EUR/XAF/GBP/CAD), a transaction in any other currency (e.g. NGN/KES) is silently divided by an assumed 1.0 and multiplied by the target rate, producing large, wrong totals with no error surfaced. The rate table is also business data embedded in a presentational component. Guard on missing rates so totals are not silently corrupted.
  • handleAddClick emits the dynamic buttonAction prop, but defineEmits(['add', 'home-click']) (line 116) only declares a fixed set. If a consumer passes any value other than 'add'/'home-click', the component emits an undeclared event: Vue logs a warning and the consumer's listener (registered for a declared event) will not fire. Either constrain buttonAction to the declared set via a validator or emit a single stable event name.
  • Navigation is handled twice: handleHomeClick emits home-click (which the host is expected to act on) and calls useRouter() from inside an event handler. useRouter() runs outside the setup context, so it typically returns undefined and the push silently no-ops — the try/catch hides this. If it does resolve, a host that also navigates on home-click gets a double push. Pick one source of truth: keep the emit and let the host route, or use NuxtLink.
  • formatExpenseOption treats exp.amount as a bare number, but the edit path in the same component parses item.amount by stripping non-digit characters (parseFloat(String(item.amount).replace(/[^\d.]/g, ''))), which proves the transaction amount is emitted/returned in the "<number> <CUR>" format (the form itself emits amount: \${amountNum} ${selectedCurrency.value}`). Calling Number("250 USD")yieldsNaN, so refund picker options render NaN` as the amount. Reuse the same parsing the edit path already uses.
  • The refund id is stored raw from editingItem, but every producer/consumer of the id in this file normalizes to a number: refundOptions maps id: Number(exp.id), handleRefundSelect stores option.id (a number), and the payload emits that number. The lookup below compares Number(exp.id) === formRefundOfTransactionId.value, so if the API returns refundOfTransactionId as a string (which is exactly why Number(exp.id) is used on the other side) the match fails and the picker query is never populated. Normalize the incoming value on the same side of the comparison.
  • walletError, categoryError are reset to false unconditionally and partyError is never set at all, so the :error bindings on the wallet/group SearchableDropdowns can never show, and onSubmit can emit a payload with walletId: null / groupId: undefined. The sibling TransferForm.validateRequiredFields computes fromWalletError/toWalletError from the selected ids; this validator is the existing place to do the same for wallet/group rather than adding a new checker.
  • The container's default args set isOutcomeSelected: false, so NewExpense (which declares no args of its own) renders the income variant — the two stories are effectively identical. The story named NewExpense should exercise the expense path so the Storybook docs match the component contract.
  • pageName is declared required: true but is never referenced in the template, script, or emits of this component. The accompanying CategoryForm.stories.js does not pass it, so Vue logs a "Missing required prop: pageName" warning in Storybook and any consumer that only passes editingItem/apiError/isSubmitting. Since the prop is unused, dropping it removes the spurious required-input contract; if the host still passes pageName it will simply become a fallthrough attribute.
  • The i18n fallback ignores interpolation arguments. t('Hey {name}', { name: firstName }) is the only interpolated call in the migrated set, so when useI18n is not available (e.g. the kit's own Storybook, or a host that consumes the layer without vue-i18n) the greeting renders the literal string Hey {name} instead of the user's name. Make the fallback perform the same {placeholder} substitution the i18n t contract expects.
  • getBarHeight scales bars against the first element of topCategories, i.e. it assumes the incoming categories array is already sorted by amount descending. The prop contract never states or enforces this. If a consumer passes categories in another order, the largest bar exceeds the 100% track (or all bars are squashed), producing incorrect output. Deriving the max from the whole visible set is order-independent and safe for the sorted case too.
  • The line chart uses the same topCategories[0] shortcut as the max, so it carries the same unsorted-input assumption: data points are computed with y = startY + height - (value / maxVal) * height, and a category larger than topCategories[0] will be plotted above the chart's top edge (negative offset). Compute the real maximum across the visible categories instead.
  • DescriptorRenderer declares and emits a next event (defineEmits<{ next: []; action: [SlotContribution] }>()), used by the onboarding step's "Continue" button and by the escape-hatch component. ExtensionSlot binds only @action here, so a host using <ExtensionSlot name="onboarding.steps" @next="advance"> never receives next — clicking Continue is a no-op. Forward next alongside action.
  • The escape hatch forwards next from a plugin-registered component but not action. The default card emits action (@click="$emit('action', contribution)"), so a component mounted through the escape hatch can never drive the same action path, producing an inconsistent contract between the two render paths. Re-emit action as well.
  • Contract mismatch: FinancialPositionDrill loads its rows exclusively through its fetchTransactions prop (load() falls back to { data: [] } when it is absent). FinancialPositionView constructs activeGroup and opens the drawer via openDrill(row), but never forwards a transaction loader, so the drill's watch(...) -> load() always resolves to an empty array and the drawer permanently shows the "No transactions for this in the selected period." empty state, regardless of the data that exists. Forward the loader from the view. This requires the view to also declare the prop (see the defineProps suggestion).
  • The guard does not implement the contract described in the comment above it. The comment says the dialog is blocked when the rows missing a wallet exceed what auto-create covers, but the code only requires newWalletCount > 0. If missingWalletCount (e.g. 5) is larger than newWalletCount (e.g. 1), checking "Create new wallets" clears walletGapUncovered, Confirm import becomes enabled, and the import runs while rows remain without a wallet. Compare the two counts so the guard only clears when auto-create can actually cover every gap.
  • This is the only new component that calls the i18n composable unconditionally, so it diverges from the decoupling contract the kit documents (ImportUpload.vue and SuggestionReviewTable.vue both guard the call). Mounted without an i18n runtime — exactly the case exercised by ConfirmModal.stories.js, which sets isOpen: trueuseI18n() throws during setup and the story/consumer never renders. Reuse the sibling pattern so the fallback interpolator handles the missing-i18n case.
  • hasWallets is declared as a prop and supplied by DashboardOnboarding.stories.js, but the step hard-codes completed: false, so the prop never affects the rendered output. Same for hasCategories (line 130) and hasParties (line 148), which is why the AllComplete story cannot render every step as complete. Wire the props through (or remove them from the component and stories if wallets/categories/parties are intentionally always shown as incomplete).
  • dayHeaders = ['M','T','W','T','F','S','S'] contains duplicate values ('T' and 'S'), so :key="dow" produces duplicate keys. Vue will warn about duplicate keys and can reuse/patch the wrong nodes on re-render. Use the loop index as the key (the list is static).
  • The i18n fallback here discards the interpolation params that this component passes at lines 22 and 34 (t('{n} categories with income in this period', { n: filteredIncome.length })). When useI18n() is unavailable — the intended Storybook / standalone-layer case that these typeof useI18n === 'function' guards exist for — the subtitle renders the literal {n} categories with income in this period. CategoryDrillModal.vue already defines the correct param-aware fallback; mirror it so the t() contract is identical across the reports components.
  • filteredTxs classifies income by exact match on INCOME, but the component's own isIncome() helper (line 158) also accepts CREDIT as income. When the host labels income rows with type: 'CREDIT' (a value this component explicitly recognises as income), wantedType becomes 'INCOME', the exact-match check fails, and an income category drills down to an empty transaction list — while isIncome() would have reported those same rows as income. Route both code paths through the single classifier so the type contract is applied consistently.
  • Contract mismatch: the component declares a currency prop (default 'USD') and every other slide formats through formatter(value, currency), but the closing headline bypasses both and hardcodes the dollar sign (every $100 earned, `$${...}`) and the word dollars. A host that passes currency: 'EUR' (or a non-USD locale) still sees $ on the closing slide, so the accepted input and the produced output disagree. Route the currency through the interpolation params instead of hardcoding the symbol.
  • hoveredLinkIdx is written by the @mouseenter/@mouseleave handlers on each link (lines 27-28) but never read, so hovering a link has no visual effect even though a link--dim state exists. Wire the index into the dim condition so link hover dims the unrelated links, matching the intent signalled by the ref.
  • Two contract bugs in one block. (1) Authorization is hardcoded: handleUpdate validates the old password against the literal 'current123'. A shared kit has no access to the host's credential store, so this either ships a known credential in a distributed package or silently accepts the wrong password — it bypasses the host's real verification. The kit already uses callback props for this (SettingsConnections.createToken/revokeToken); do the same here with verifyOldPassword. (2) The success path clears the fields but never signals the parent, even though the component declares close and the inline comment says // notify parent to close modal. A parent that renders this modal on v-if and listens for @close will stay mounted forever after a successful change. This also requires defineEmits to keep the returned emitter.
  • handleSave fabricates the result of the save: it reports 'A confirmation email has been sent to your new address.' whenever the email is not the literal 'user@example.com', and 'Account information updated successfully!' otherwise. The message therefore depends on a value the user typed, not on what the server did, and the edited fields are never handed to the parent. Delegate to the saveAccount prop and drive the message from the returned result so the UI reflects the real outcome.
  • Error behavior is missing from the async contract. generate and revoke await host-provided callbacks (createToken, revokeToken) that can reject (network/403/validation). generate has only a finally, so a rejection becomes an unhandled promise rejection, emit('refresh') never fires and the user gets no feedback; revoke propagates the rejection the same way. Surface the failure via an error event so the host can react.
  • The accepted date input format disagrees with what the wider system supplies. shortDate/formatLong unconditionally append 'T00:00:00', which only produces a valid Date for date-only strings. The sibling domain component SettingsConnections consumes full ISO datetimes (last_used_at: '2024-03-20T10:00:00Z') and formats them with new Date(iso) directly, so the host/Trakli may well hand this chart a full ISO timestamp. If it does, new Date('2024-03-20T10:00:00ZT00:00:00') is Invalid Date and Intl.DateTimeFormat.format throws RangeError: Invalid time value during render. Normalize the input so both shapes are accepted.
  • TransactionFilters defines wallets and categories props and renders its Wallet/Category SearchableDropdowns from them (defaulting to []). This composition never passes them, so both dropdowns are permanently empty and the wallet/category filters are dead UI even though filter-change is wired up. Pass the inputs through.
  • Contract mismatch on the transaction type field. This component treats type as lowercase (r.type === 'income' / 'expense' in totalIncome/totalExpense at lines 226-231, and the type-dot--${row.type} / row--${row.type} class bindings at lines 83-93). The sibling wallet component consumes the same domain transactions and explicitly defends against the uppercase form: WalletDetailPanel.vue line 260/265 checks tx.type === 'INCOME' || tx.type === 'income', and its story data (WalletDetailPanel.stories.js lines 12-14) uses 'EXPENSE'/'INCOME'. If the host feeds the app's uppercase type, this spreadsheet's Income/Expenses totals evaluate to 0 (no row matches === 'income') and the type dot loses its colour class. Normalising type to lowercase once, at ingestion, fixes the totals, the CSS classes and the CSV output in a single place.
  • The duration: 5000 default is defeated by spread ordering. showSuccess/showWarning/showInfo build the payload with the duration key present (shorthand) even when the argument is undefined, and ...notification then overwrites the default with undefined. newNotification.duration becomes falsy, so the if (newNotification.duration && ...) guard skips scheduling the auto-remove timer and those toasts never dismiss (showError is unaffected only because it does duration || 7000). Spread the caller's payload first and apply the fallback last with ??.
  • The comma-decimal branch documents the rule "the last separator is the decimal separator" but implements it with String.replace(',', '.'), which replaces only the FIRST comma and leaves any earlier commas in place. For an accepted input such as "1,234,567" (dotCount 0, commaCount 2 → this branch), the parse becomes parseFloat("1.234,567") = 1.234 instead of 1234.567. Strip every earlier separator before converting the final comma.
  • The host app renders translations through vue-i18n's global injection, which registers the $-prefixed $t/$locale (and $d, $n) on every component instance. This mock registers only the bare t/locale, so a template written the way the host does it — {{ $t('…') }} — will not resolve in Storybook. Add the $-prefixed aliases alongside the bare names for template parity.
  • :is and :to disagree about when the card is a link. :to computes a fallback /budgets/{id} destination, but :is only renders NuxtLink when the to prop is truthy. With no to prop the root stays a <div> carrying a dead to="/budgets/1" attribute and the computed destination is never used for navigation — the parallel :to fallback is unreachable. Make :is consider the same fallback as :to.
  • DescriptorRenderer declares and emits two events (next and action), but ExtensionSlot re-wraps it and forwards only action. When ExtensionSlot is used to render the onboarding.steps slot, the Continue button's @next is emitted into the void, so the host cannot advance the step. Forward next here as well (and add next: [] to the defineEmits declaration) so the generic wrapper preserves the full contract of the component it wraps.
  • Contract mismatch / duplicate implementation: sibling components ImportUpload.vue and SuggestionReviewTable.vue treat i18n as optional (typeof useI18n === 'function' ? useI18n() : null) so they can be consumed by the Nuxt layer and mounted in Storybook/Cypress without an i18n plugin. ConfirmModal.vue calls useI18n() unguarded, so this component will throw when mounted in isolation (its own story, ConfirmModal.stories.js, mounts it directly) and breaks the stated "decoupled from i18n" rule. Mirror the guarded pattern used by its siblings.
  • This adapter is a duplicate of the one in CalendarTab.vue (85–86) and CashflowHero.vue (105–106), but it diverges from the param-aware fallback in CategoryDrillModal.vue (126–134). CategoryRanking subtitles here are built with params, e.g. t('{n} categories with income in this period', { n: filteredIncome.length }) on line 22 — when the host has no useI18n, the ((k) => k) fallback silently returns the raw template and the count never renders, unlike every other reporting component. Make the fallback honour {key} interpolation (and extract it to a shared composable so the copies cannot drift again).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants