feat: Extract UI components from trakli/webui with Nuxt playground explorer - #1
Conversation
Code Review SummaryThis PR successfully extracts UI primitives and complex AI chat/reporting components into a reusable library with a comprehensive playground for exploration. 🚀 Key Improvements
💡 Minor Suggestions
🚨 Critical Issues
|
| @@ -0,0 +1,142 @@ | |||
| <script setup> | |||
| import { computed, ref, h, onErrorCaptured, markRaw } from 'vue'; | |||
There was a problem hiding this comment.
The 'watch' function is imported later in the script block (line 27). For consistency and clarity, move all Vue core imports to the top of the file.
| import { computed, ref, h, onErrorCaptured, markRaw } from 'vue'; | |
| import { computed, ref, h, onErrorCaptured, markRaw, watch } from 'vue'; |
|
|
||
| // Reset any prior error whenever the selection changes. | ||
| const selectedKey = computed(() => props.selected); | ||
| import { watch } from 'vue'; |
There was a problem hiding this comment.
Move this import to the top of the script block to follow standard style guides and improve readability.
| import { watch } from 'vue'; | |
| watch(selectedKey, () => { renderError.value = null; }); |
| <script setup> | ||
| import { ref, computed, watch } from 'vue'; | ||
| import IconPicker from './IconPicker.vue'; | ||
| import * as lucideIcons from 'lucide-vue-next'; |
There was a problem hiding this comment.
Importing the entire Lucide icon library will significantly increase the bundle size for anyone consuming this UI kit, even if they only use a few components. Consider using dynamic imports or a pre-defined set of available icons to allow for better tree-shaking.
| import * as lucideIcons from 'lucide-vue-next'; | |
| // Consider importing specific icons or using a dynamic resolution helper | |
| import { ImagePlus, X } from 'lucide-vue-next'; |
| const chatWindow = ref<HTMLElement | null>(null); | ||
| const composerEl = ref<InstanceType<typeof ChatComposer> | null>(null); | ||
|
|
||
| watch( |
There was a problem hiding this comment.
Watching for both length changes and status changes to trigger a scroll-to-bottom might cause redundant render cycles or double-scrolling. Consider consolidating these into a single watcher or using a throttled version.
| watch( | |
| watch( | |
| () => props.currentSession?.messages, | |
| () => scrollToBottom(), | |
| { deep: true } | |
| ); |
nfebe
left a comment
There was a problem hiding this comment.
Looks good so far. Things to address before we can merge:
| Finding | Where | Severity |
|---|---|---|
The package doesn't carry its own assets. public/ isn't published, but Logo, EmptyState, TipsSection, AuthCarousel point at absolute paths like /logo.svg, so they 404 in a consumer; AuthCarousel points at /floating-docs-man.svg, which isn't in the repo at all. Logo should carry the mark: inline the Trakli SVG so it ships, add an icon-only variant and a default slot to swap it, and drive its fill from a token. |
package.json files; Logo.vue:1-30, EmptyState.vue, TipsSection.vue, AuthCarousel.vue |
Blocker |
Overriding the brand colour doesn't fully re-skin. The focus ring is a literal green rgba and the table header repeats the primary hex, so a tier that overrides --color-primary keeps both. Use the token: rgba(var(--color-primary-rgb), .5) and var(--color-primary). |
_vars.scss:40, tokens.css:37 |
Blocker for the rebrand |
The playground chrome doesn't use the kit: the sidebar and landing are hand-built with 61 hex values, one token reference, and no kit components, so it can't re-skin. Rebuild it from TPageShell/TPanel/TCard/TButton/SearchInput reading var(--color-*). |
index.vue, UsageLanding.vue |
Should fix |
| New components list but don't preview. Discovery is automatic, but a usable preview needs a hand-written entry in the demo map (99 of 101 filled); without one the component renders propless and a data-driven one lands in the error box. Co-locate the sample data with each component, or reuse the Storybook stories. | registry.ts:10,825, DashboardMain.vue:20-23 |
Should fix |
A few components write hex where the token exists (#dc2626 is --color-error; #b45309 isn't a token at all), so they don't re-skin or adapt to dark mode. |
GroupForm.vue:232, TransactionFilters.vue:371, TTableComponent.vue:567, TTransactionsCardList.vue:293 |
Should fix |
README is ahead of the code (TTopCard, ThemeToggleButton listed as not-yet-decoupled but both ship here), and there are em dashes in on-screen copy. |
README.md; UsageLanding.vue:8,16,20,32,77,95,104 |
Minor |
Land the first two before adoption; they carry the old brand into whatever consumes this. The playground chrome and the demo map are one rework. A CI check that fails on raw hex in components/*.vue (allowlisting brand-icon and chart-palette files) keeps the contract from drifting.
Caveat: I read the playground source but didn't run it, so the "can't re-skin" and "lands in the error box" notes are read off the code, not watched in a browser.
- Logo: inline SVG with variant prop (full/icon), slot override, fills driven by --color-primary and --color-accent tokens - EmptyState: inline box.svg with named icon slot for consumer override - TipsSection: inline bulbIcon.svg with named icon slot for consumer override - AuthCarousel: replace missing floating-docs-man.svg background with placeholder SVG and image slot - _vars.scss: replace hardcoded focus ring rgba with rgba(var(--color-primary-rgb), 0.5) - tokens.css: --color-table-header now references var(--color-primary) instead of duplicating hex literal - Update Cypress tests to match new inline SVG structure
…nts in playground - Add missing tokens: --color-accent-rgb, --color-accent-dark (light + dark mode) - GroupForm, TransactionFilters, TTableComponent, TTransactionsCardList: replace hardcoded hex with SCSS tokens - Playground index.vue: replace sidebar with TPanel, buttons with TButton, search with SearchInput - Playground UsageLanding.vue: replace keypoint cards with TCard - Remove ~100 lines of custom CSS (green gradient sidebar, button/input styles) - All remaining colors now use CSS custom properties Satisfies PR review Should fix trakli#1 (hardcoded colors) and #2 (dogfood kit components).
…d registry Satisfies PR review Should fix #4 — these 2 components previously rendered empty when selected.
Move TTopCard and ThemeToggleButton from Deferred to Primitives — both already ship in the kit.
|
|
||
| defineEmits(['add', 'navigate-home']); | ||
|
|
||
| const iconMap = { |
There was a problem hiding this comment.
This internal mapping couples the UI component to specific application features (Wallets, Parties, etc.). To make this a generic UI kit primitive, accept the icon component directly via a prop or a named slot instead of maintaining an internal registry of app-specific strings.
| const iconMap = { | |
| const props = defineProps({ | |
| // ... other props | |
| icon: { type: [Object, Function], default: null } | |
| }); | |
| const resolvedIcon = computed(() => props.icon); |
| defineEmits(['drill']); | ||
|
|
||
| const fill = (template, vars) => | ||
| Object.entries(vars).reduce((s, [k, v]) => s.replace(`{${k}}`, v), template); |
There was a problem hiding this comment.
The current .replace() implementation only handles the first occurrence of a placeholder. Using .replaceAll() or a global regex is more robust for localization strings that may reuse a variable (e.g., '{count} items out of {count}').
| Object.entries(vars).reduce((s, [k, v]) => s.replace(`{${k}}`, v), template); | |
| Object.entries(vars).reduce((s, [k, v]) => s.replaceAll(`{${k}}`, String(v)), template); |
| import { Bot, User } from 'lucide-vue-next'; | ||
|
|
||
| interface ChatMessageResult { |
There was a problem hiding this comment.
These TypeScript interfaces are duplicated in multiple AI-related components. Move them to a shared types/chat.ts file to ensure consistency and easier maintenance across the kit.
| import { Bot, User } from 'lucide-vue-next'; | |
| interface ChatMessageResult { | |
| import type { ChatMessage, ChatSession, ChatMessageResult } from '../types/chat'; |
nfebe
left a comment
There was a problem hiding this comment.
There are still lots of issues that have transferred from the application itself but here is an opportunity to solve it.
Example there is GroupForm, BudgetForm, TransferForm all of those are the same elements. Should be TForm and TFormInput etc
Same with things like ReportTab we should need a tabbing system. So A LOT about the structure has not change to have real primitives that still rebuild the current UI correctly.
…main components - Extract form primitives (TForm, TFormField, TFormInput, TFormSelect, TFormTextarea, TFormRow) and refactor GroupForm, BudgetForm, TransferForm, CategoryForm, PartiesForm, WalletForm, and ReminderForm. - Extract tab primitives (TTab, TTabList, TTabPanel) and refactor ReportsTabs, CalendarTab, CategoriesTab, FlowTab, and ChartsTab. - Extract modal primitives (TModal, TModalHeader, TModalBody, TModalFooter) and refactor ConfirmModal, LearningModal, RecurringModal, PasswordModal, and ImportConfirmDialog. - Extract toolbar, pagination, and action primitives (TListHeader, TFilterToggle, TPagination, TEmptyState, TCardActionMenu, TSectionHeader) and refactor TTableComponent, TTransactionsCardList, ContentListView, PartyCardList, and ContentCardGrid. - Extract shared string template helper to utils/fill.ts and Lucide icon resolution to composables/useLucideIcon.ts. - Ensure all T* primitives contain zero domain logic, preserving full backward compatibility across all domain component props/emits interfaces.
| message.value = ''; | ||
| isError.value = false; | ||
|
|
||
| if (oldPassword.value !== 'current123') { |
There was a problem hiding this comment.
Authentication validation should not be hardcoded within a UI kit component. This logic belongs in the application layer. Consider emitting an event with the form data and letting the parent handle the validation or API call.
| if (oldPassword.value !== 'current123') { | |
| if (!oldPassword.value) { |
|
|
||
| import { Plus, Trash2, MessageSquare } from 'lucide-vue-next'; | ||
|
|
||
| const fill = (t: string, v: Record<string, string | number>) => |
There was a problem hiding this comment.
This fill function is redundant. Use the shared utility defined in utils/fill.ts to reduce duplication and improve maintainability.
| const fill = (t: string, v: Record<string, string | number>) => | |
| import { fill } from '../utils/fill'; |
| height: { type: Number, default: 36 } | ||
| }); | ||
|
|
||
| const gradId = computed(() => `spark-grad-${Math.random().toString(36).slice(2, 8)}`); |
There was a problem hiding this comment.
Using Math.random() to generate IDs for SVG elements (gradients/clips) causes hydration mismatches in Nuxt/SSR because the server-generated ID will differ from the client-side one. Use the built-in useId() composable instead.
| const gradId = computed(() => `spark-grad-${Math.random().toString(36).slice(2, 8)}`); | |
| const gradId = useId(); |
| editingItem.value = null; // Clear any existing editing item | ||
| showForm.value = true; | ||
|
|
||
| // Scroll to the form after a brief delay to ensure it's rendered | ||
| nextTick(() => { | ||
| const formSection = document.querySelector('.form-section'); |
There was a problem hiding this comment.
Avoid direct DOM manipulation with document.querySelector in Vue. If multiple instances of this component are mounted, this logic might scroll to the wrong element. Use a template ref on the form section instead.
| editingItem.value = null; // Clear any existing editing item | |
| showForm.value = true; | |
| // Scroll to the form after a brief delay to ensure it's rendered | |
| nextTick(() => { | |
| const formSection = document.querySelector('.form-section'); | |
| formSectionRef.value?.scrollIntoView({ | |
| behavior: 'smooth', | |
| block: 'start', | |
| inline: 'nearest' | |
| }); |
| const containerRef = ref(null); | ||
| const hoverNode = ref(null); | ||
| const hoveredLinkIdx = ref(-1); | ||
| const uid = Math.random().toString(36).slice(2, 8); |
There was a problem hiding this comment.
Using Math.random() for IDs in a Nuxt/SSR environment causes hydration mismatches because the server-generated ID will not match the client-generated ID. Since you are using Vue 3.5+, use the built-in useId() composable instead.
| const uid = Math.random().toString(36).slice(2, 8); | |
| const uid = useId(); |
| vars: Record<string, string | number> | ||
| ): string => | ||
| Object.entries(vars).reduce( | ||
| (acc, [key, value]) => acc.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)), |
There was a problem hiding this comment.
Since the project is targeting Node 22, you can use replaceAll or acc.split().join() for a cleaner and more efficient implementation than creating a new RegExp object inside a reduction loop.
| (acc, [key, value]) => acc.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)), | |
| (acc, [key, value]) => acc.replaceAll(`{${key}}`, String(value)), |
| import { markRaw } from 'vue'; | ||
|
|
||
| // Auto-import every component so it can be referenced by name via <component :is>. | ||
| const modules = import.meta.glob('../../components/*.vue', { eager: true }); |
There was a problem hiding this comment.
Using eager: true for ~100 components will significantly increase the initial bundle size of the playground. Consider removing eager: true and using defineAsyncComponent in the consumer to load components on demand when selected in the sidebar.
| const modules = import.meta.glob('../../components/*.vue', { eager: true }); | |
| const modules = import.meta.glob('../../components/*.vue'); |
…ble test selectors
| type Party = any; | ||
| type SuggestionWithDuplicate = any; | ||
|
|
||
| const fill = (t: string, v: Record<string, any>) => |
There was a problem hiding this comment.
This interpolation utility is redefined in nearly every component that needs it. Since you already have utils/fill.ts, you should import it here to ensure consistency and easier maintenance.
| const fill = (t: string, v: Record<string, any>) => | |
| import { fill } from '../utils/fill'; |
| <script setup lang="ts"> | ||
| import { ChevronDownIcon } from '@heroicons/vue/24/outline'; | ||
|
|
||
| type Wallet = any; |
There was a problem hiding this comment.
Using any for core data structures like Wallets and Categories negates the benefits of TypeScript. Define these as interfaces, preferably in a shared types file, to ensure prop safety across the kit.
| type Wallet = any; | |
| import type { Wallet, Category, Party, SuggestionWithDuplicate } from '../types'; |
| const formatDisplayCurrency = (value) => { | ||
| const currency = props.defaultCurrency; | ||
| const symbol = props.getCurrencySymbol(currency); | ||
| const formatted = new Intl.NumberFormat('en-US', { |
There was a problem hiding this comment.
Hardcoding 'en-US' inside the component prevents the library from supporting internationalization (i18n) properly. Use the locale prop already defined in this or other components.
| const formatDisplayCurrency = (value) => { | |
| const currency = props.defaultCurrency; | |
| const symbol = props.getCurrencySymbol(currency); | |
| const formatted = new Intl.NumberFormat('en-US', { | |
| const formatted = new Intl.NumberFormat(props.locale || 'en-US', { |
| open.value = new Set([...open.value, label]); | ||
| selected.value = ''; | ||
| requestAnimationFrame(() => { | ||
| const head = Array.from(document.querySelectorAll('.sidebar__group-head')) |
There was a problem hiding this comment.
Using document.querySelectorAll in a Vue component is anti-pattern and risky if multiple instances exist or if the DOM structure changes. Use template refs or a purely state-driven approach to trigger animations and scrolling.
| const head = Array.from(document.querySelectorAll('.sidebar__group-head')) | |
| // Replace direct DOM access with a reactive 'flashingGroup' variable | |
| // and use :class="{ 'is-flash': group.label === flashingGroup }" in the template. |
- Add 11 presentational AI chat components from webui: ChatProgressSteps, ChatCalloutBlock, ChatChartBlock, ChatComparisonBlock, ChatKpiBlock, ChatListBlock, ChatProgressBlock, ChatQuestionBlock, ChatQuickActionsBlock, ChatTableBlock, ChatTimelineBlock - Add OnboardingWizard component - Add vue3-apexcharts as peer dependency for ChatChartBlock - Add playground demo configurations for all new components - Fix ChatResultRenderer demo data structure
- ChatComposer: refactored with lucide icons, labels prop - DiscussionDropdown: extracted from webui with labels prop - ChatCanvasBlock: new nested block renderer - ImportSessionsList: extracted from webui with labels prop - ImportUpload: full i18n-free version with labels prop - AuthCarousel: added labels prop for full i18n support - Added vue3-apexcharts as peerDependency - Added playground demo configs for all new components
- Pass labels prop to badge() function in template - Update registry demo to use correct field names (file_name, status) - Add metadata with suggestions/duplicates counts
- Added demo configs for 9 T* components: TFormField, TFormRow, TListHeader, TModalBody, TModalFooter, TModal, TSectionHeader, TTabList, TTabPanel - Added TModalBody, TModalFooter, TFormRow to slotted components set - Added slotted demos in DashboardMain for slot-only components - Added overflow-x: hidden to .content and .demo to prevent sidebar bleed
| </template> | ||
|
|
||
| <script setup> | ||
| import { computed } from 'vue'; |
There was a problem hiding this comment.
Import the shared interpolation utility instead of defining it locally.
| import { computed } from 'vue'; | |
| import { computed } from 'vue'; | |
| import { fill } from '../utils/fill'; |
| </template> | ||
|
|
||
| <script setup> | ||
| import { ref, computed, watch } from 'vue'; |
There was a problem hiding this comment.
Add shared utility import.
| import { ref, computed, watch } from 'vue'; | |
| import { ref, computed, watch } from 'vue'; | |
| import { fill } from '../utils/fill'; |
| amount: 0, | ||
| currency: defaultCurrency.value, | ||
| period_type: (props.defaults?.period ?? 'monthly') as string, | ||
| start_date: new Date().toISOString().slice(0, 10), |
There was a problem hiding this comment.
Defaulting to now.toISOString() uses UTC, which might result in the wrong date for users in many timezones. Use local date instead.
| start_date: new Date().toISOString().slice(0, 10), | |
| start_date: new Date().toLocaleDateString('sv-SE'), |
Summary
Extracted the reusable UI components (buttons, cards, tables, charts, forms, modals, auth, and AI chat) out of
trakli/webuiinto this UI-kit repo, and added a Nuxt playground so every component is live-previewable with realistic sample data.What changed
components/, covering:.stories.js) and Cypress component tests (.cy.js).playground/Nuxt app — a Nuxt layer host (extends: ['..']) that pulls in the kit's components and design tokens.app/pages/index.vue: sidebar dashboard listing every component grouped by category, with search and live rendering.app/registry.ts: auto-discovers allcomponents/*.vue, hand-tunes prop/slot demos with realistic sample data, and auto-buckets components into categories.app/components/UsageLanding.vue: the/page documenting installation & usage.package.json: addeddev(nuxi dev .playground) andbuild(nuxt build .playground) scripts.public/: static assets (logo, icons).SYNC.mdis intentionally left out of the commit (local sync notes).How to review
npm install npm run dev # opens the playground at / — landing page explains usage, sidebar previews each componentNotes
trakli/webui; this commit seeds them here as a standalone kit.@trakli/ui-kitalias locally; the published layer relies on the real npm alias.@nfebe