Skip to content

feat: Extract UI components from trakli/webui with Nuxt playground explorer - #1

Open
Lantum-Brendan wants to merge 14 commits into
trakli:masterfrom
Lantum-Brendan:feat/extract-components-from-trakli-webui
Open

feat: Extract UI components from trakli/webui with Nuxt playground explorer#1
Lantum-Brendan wants to merge 14 commits into
trakli:masterfrom
Lantum-Brendan:feat/extract-components-from-trakli-webui

Conversation

@Lantum-Brendan

@Lantum-Brendan Lantum-Brendan commented Jul 18, 2026

Copy link
Copy Markdown

Summary

Extracted the reusable UI components (buttons, cards, tables, charts, forms, modals, auth, and AI chat) out of trakli/webui into this UI-kit repo, and added a Nuxt playground so every component is live-previewable with realistic sample data.

What changed

  • ~95 components added under components/, covering:
    • Buttons & actions, layout & containers (T* primitives)
    • Cards & summaries (wallets, budgets, parties, KPIs, onboarding, empty states)
    • Charts & data viz (cashflow, donut, Sankey, heatmap, sparklines, reports)
    • Tables & lists, forms & inputs, modals & dialogs
    • Auth (login, register, carousel) and AI chat (sidebar, composer, message list, result renderer)
    • Most ship with Storybook stories (.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 all components/*.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: added dev (nuxi dev .playground) and build (nuxt build .playground) scripts.
  • public/: static assets (logo, icons).

SYNC.md is 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 component

Notes

  • Components are adapted from trakli/webui; this commit seeds them here as a standalone kit.
  • The playground maps the @trakli/ui-kit alias locally; the published layer relies on the real npm alias.
    @nfebe

@sourceant

sourceant Bot commented Jul 18, 2026

Copy link
Copy Markdown

Code Review Summary

This PR successfully extracts UI primitives and complex AI chat/reporting components into a reusable library with a comprehensive playground for exploration.

🚀 Key Improvements

  • Standardized layout components (TStack, TGrid, etc.) across the kit.
  • Integrated AI Chat components including sidebar, composer, and specialized renderers.
  • Added a Nuxt playground for live previewing components with realistic sample data.

💡 Minor Suggestions

  • Consolidate fill utility usage across all components (several still define it locally).
  • Standardize on sv-SE or a local date helper for defaulting YYYY-MM-DD inputs instead of toISOString().

🚨 Critical Issues

  • SCSS nesting bug in TFormField.vue prevents styles from applying to label/error elements.
  • Required field indicator in RegisterCard.vue checks for the wrong key for the Last Name field.
  • Inconsistent date/time defaults in TransferForm.vue mixing UTC and local time.

@sourceant sourceant Bot 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.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@sourceant sourceant Bot 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.

Review complete. See the overview comment for a summary.

@@ -0,0 +1,142 @@
<script setup>
import { computed, ref, h, onErrorCaptured, markRaw } from 'vue';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Move this import to the top of the script block to follow standard style guides and improve readability.

Suggested change
import { watch } from 'vue';
watch(selectedKey, () => { renderError.value = null; });

Comment thread components/CategoryForm.vue Outdated
<script setup>
import { ref, computed, watch } from 'vue';
import IconPicker from './IconPicker.vue';
import * as lucideIcons from 'lucide-vue-next';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
import * as lucideIcons from 'lucide-vue-next';
// Consider importing specific icons or using a dynamic resolution helper
import { ImagePlus, X } from 'lucide-vue-next';

Comment thread components/AIChat.vue
const chatWindow = ref<HTMLElement | null>(null);
const composerEl = ref<InstanceType<typeof ChatComposer> | null>(null);

watch(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
watch(
watch(
() => props.currentSession?.messages,
() => scrollToBottom(),
{ deep: true }
);

@nfebe nfebe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@sourceant sourceant Bot 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.

Review complete. See the overview comment for a summary.

Comment thread components/TTableComponent.vue Outdated
Comment thread components/TTopCard.vue

defineEmits(['add', 'navigate-home']);

const iconMap = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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}').

Suggested change
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);

Comment on lines +34 to +36
import { Bot, User } from 'lucide-vue-next';

interface ChatMessageResult {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
import { Bot, User } from 'lucide-vue-next';
interface ChatMessageResult {
import type { ChatMessage, ChatSession, ChatMessageResult } from '../types/chat';

@Lantum-Brendan
Lantum-Brendan requested a review from nfebe July 23, 2026 13:47

@nfebe nfebe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@sourceant sourceant Bot 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.

Review complete. See the overview comment for a summary.

message.value = '';
isError.value = false;

if (oldPassword.value !== 'current123') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
if (oldPassword.value !== 'current123') {
if (!oldPassword.value) {


import { Plus, Trash2, MessageSquare } from 'lucide-vue-next';

const fill = (t: string, v: Record<string, string | number>) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This fill function is redundant. Use the shared utility defined in utils/fill.ts to reduce duplication and improve maintainability.

Suggested change
const fill = (t: string, v: Record<string, string | number>) =>
import { fill } from '../utils/fill';

@sourceant sourceant Bot 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.

Review complete. See the overview comment for a summary.

Comment thread components/SparkLine.vue
height: { type: Number, default: 36 }
});

const gradId = computed(() => `spark-grad-${Math.random().toString(36).slice(2, 8)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
const gradId = computed(() => `spark-grad-${Math.random().toString(36).slice(2, 8)}`);
const gradId = useId();

Comment on lines +100 to +105
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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'
});

@sourceant sourceant Bot 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.

Review complete. See the overview comment for a summary.

Comment thread components/SankeyFlow.vue
const containerRef = ref(null);
const hoverNode = ref(null);
const hoveredLinkIdx = ref(-1);
const uid = Math.random().toString(36).slice(2, 8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
const uid = Math.random().toString(36).slice(2, 8);
const uid = useId();

Comment thread utils/fill.ts
vars: Record<string, string | number>
): string =>
Object.entries(vars).reduce(
(acc, [key, value]) => acc.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
(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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
const modules = import.meta.glob('../../components/*.vue', { eager: true });
const modules = import.meta.glob('../../components/*.vue');

@sourceant sourceant Bot 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.

Review complete. See the overview comment for a summary.

type Party = any;
type SuggestionWithDuplicate = any;

const fill = (t: string, v: Record<string, any>) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
type Wallet = any;
import type { Wallet, Category, Party, SuggestionWithDuplicate } from '../types';

Comment on lines +202 to +205
const formatDisplayCurrency = (value) => {
const currency = props.defaultCurrency;
const symbol = props.getCurrencySymbol(currency);
const formatted = new Intl.NumberFormat('en-US', {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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.

@Lantum-Brendan
Lantum-Brendan requested a review from nfebe July 25, 2026 11:27
- 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
Copilot AI review requested due to automatic review settings July 29, 2026 14:20

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 wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@sourceant sourceant Bot 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.

Review complete. See the overview comment for a summary.

Comment thread components/TTopCard.vue
</template>

<script setup>
import { computed } from 'vue';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Import the shared interpolation utility instead of defining it locally.

Suggested change
import { computed } from 'vue';
import { computed } from 'vue';
import { fill } from '../utils/fill';

</template>

<script setup>
import { ref, computed, watch } from 'vue';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Add shared utility import.

Suggested change
import { ref, computed, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import { fill } from '../utils/fill';

Comment thread components/BudgetForm.vue
amount: 0,
currency: defaultCurrency.value,
period_type: (props.defaults?.period ?? 'monthly') as string,
start_date: new Date().toISOString().slice(0, 10),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Defaulting to now.toISOString() uses UTC, which might result in the wrong date for users in many timezones. Use local date instead.

Suggested change
start_date: new Date().toISOString().slice(0, 10),
start_date: new Date().toLocaleDateString('sv-SE'),

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.

3 participants