26 KiB
Frontend Componentization — Tracker & Playbook
Owner: maintained by Claude as the single source of truth for the componentization effort. Supersedes the "Component Updates Tracker" spreadsheet (whose status column is stale — the taxonomy, naming, prop vocabulary, and priorities are kept).
Goal
Route all of Leantime's HTML through a central component layer so that a future design overhaul (e.g. daisyUI) becomes a one-file change instead of an N-thousand-call-site change.
The rules (how we do this safely)
- No-op first. Every component renders byte-for-byte what the page renders today —
same Bootstrap/
lt-/forms.cssclasses. Zero visual change. We insert the abstraction layer without touching the output. - The prop API is the durable contract; the rendered classes are the swappable implementation. Call-sites are written against the canonical prop vocabulary (below) now. At design time, only each component's internal class-map + the CSS change — restyling the whole app from one place. This is the entire point.
- One component at a time, tested each step. Build no-op component → verify identical
render (compile + Playwright before/after) → migrate call-sites in small batches → test →
commit → next. No big-bang merges (that's what broke
feature/ui-components). - Defer the design engine. No daisyUI, no
tw--prefix churn, no JS rewrite during the no-op phase. The design update (daisyUI or otherwise) is a later, separate phase that becomes trivial because the component layer exists. - Old branches are API reference only, never a merge source (see Branch Landscape).
Taxonomy & naming
Category-namespaced anonymous Blade components — resolves today with no ServiceProvider change (nested folders already work):
<x-global::{category}.{name}> → app/Views/Templates/components/{category}/{name}.blade.php
Six categories: elements · forms · actions · navigation · feedback · layout.
Domain-specific components live under their domain namespace, e.g.
<x-tickets::ticket-card> → app/Domain/Tickets/Templates/components/ticket-card.blade.php.
Prop vocabulary (the IDL — the durable contract)
| Prop | Options | Default | Notes |
|---|---|---|---|
contentRole |
default · primary · secondary · tertiary(=ghost) · accent · link | primary (actions) | semantic role |
state |
default · info · warning · danger · success | default | |
variant |
component-specific | '' |
behavior/shape variant |
scale |
xs · s · m · l · xl | m | size |
position |
left · right · top · bottom · inner · outer · start · end | bottom | |
tag (element) |
a · input · button · … | component-specific | polymorphic element |
align |
start · end | ||
labelText |
text | '' |
|
labelPosition |
top · left · right · bottom · inside | ||
caption |
text | '' |
helper text under the control |
validationText / validationState |
text / state | '' |
|
leadingVisual / trailingVisual |
icon class | '' |
|
items |
array | [] |
for list-driven components |
Props are camelCase in
@props(contentRole); Blade normalizescontent-role="…"attributes to the same variable, so call-sites may use either.
No-op mapping principle (worked example: button)
The canonical vocabulary maps to today's classes so output is unchanged:
| canonical | renders today | (at design time →) |
|---|---|---|
contentRole="primary" |
btn btn-primary |
dui-btn dui-btn-primary |
contentRole="secondary" |
btn btn-secondary |
… |
contentRole="default" |
btn btn-default |
… |
contentRole="tertiary"/ghost |
btn btn-transparent |
… |
contentRole="link" |
btn btn-link |
… |
state="danger" |
btn btn-danger |
… |
scale="s" / scale="l" |
btn btn-small / btn btn-large |
… |
Extra/legacy classes pass through via $attributes->merge (e.g. class="addCanvasLink").
JS-coupled buttons (dropdown-toggle) are migrated in the dropdown component phase, not here.
Component registry
Status: ⬜ todo · 🟡 in progress · ✅ no-op done (on master) · 🎨 design-updated. "Ref" = branch to crib the prop API from (reference only — do not merge).
P0 — primitives & core
| Component | Tag | Cat | Status | Ref | Notes |
|---|---|---|---|---|---|
| button | forms.button |
forms | ✅ | refactor/table-component | merged #3531: no-op migration + 3-tier role model |
| text-input | forms.text-input |
forms | ✅ | refactor/table-component | merged #3558: no-op; 146 call-sites / 56 files; variants headline/large/small (dropped form/legacy as CSS-redundant); HTML-native type prop; defer JS-coupled (datepickers/tags/inline-edit/color/sorter/hourCell) + legacy <?php echo ?>-in-attr |
| textarea | forms.textarea |
forms | 🟡 | selectsComponentUpdates | PR #3562: thin no-op (attrs + inner-content slot); 10 plain migrated / 6 files; defer Tiptap editors (.tiptapSimple/.tiptapComplex/.wiki-editor-textarea) |
| select (native) | forms.select |
forms | ⬜ | refactor/table-component | native no-op first; JS-enhanced later |
| form-field | forms.field-row |
forms | ⬜ | refactor/table-component | label-row + caption + validation wrapper |
| card (content-box) | elements.card |
elements | ⬜ | ui-components | replaces .maincontentinner (167 sites) |
| chip | actions.chip |
actions | ⬜ | selectsComponentUpdates | |
| dropdown-menu | actions.dropdown |
actions | ⬜ | refactor/table-component | JS-coupled (Bootstrap dropdown) |
| modal | actions.modal |
actions | ⬜ | modal line | unify 3 legacy modal systems; HxComponent-aligned |
| tabs | navigation.tabs |
navigation | ✅ | ui-components | ARIA button-tablist (roving tabindex, Arrow/Home/End, storage prop, lt:tabs:changed event); vanilla JS, htmx.onLoad-aware; variants attached/floating; tab+panel sub-components (no raw contract HTML in consumers); jQuery-UI wrapper retired (deliberate markup change, called out) |
| text-editor | forms.text-editor |
forms | ⬜ | (Tiptap core) | wrap Tiptap (already HTMX-aware) |
| date-picker | forms.date-picker |
forms | ⬜ | selectsComponentUpdates | jQuery-UI datepicker; needs htmx.onLoad re-init |
P1
| Component | Tag | Cat | Status | Notes |
|---|---|---|---|---|
| checkbox | forms.checkbox |
forms | ⬜ | |
| radio | forms.radio |
forms | ⬜ | |
| toggle | forms.toggle |
forms | ⬜ | |
| button-group | forms.button-group |
forms | ⬜ | |
| badge | elements.badge |
elements | ⬜ | flat badge exists on master — migrate to category |
| avatar | elements.avatar |
elements | ⬜ | flat avatar exists on master |
| accordion | elements.accordion |
elements | ⬜ | flat accordion exists on master |
| table | elements.table |
elements | ⬜ | DataTables-coupled; class-backed (Table.php) |
| empty-state | elements.empty-state |
elements | ⬜ | wraps undrawSvg |
| date-info | elements.date-info |
elements | ⬜ | relative-time |
| statistic / code | elements.statistic / elements.code |
elements | ⬜ | |
| steps / breadcrumbs / pagination | navigation.* |
navigation | ⬜ | |
| alert / progress / skeleton / loading / indicator | feedback.* |
feedback | ⬜ | loader/loadingText exist on master |
| page-header | layout.page-header |
layout | ⬜ | flat pageheader exists on master |
| color-picker / select-panel / context-menu | various | ⬜ |
Domain-specific
| Component | Tag | Status | Notes |
|---|---|---|---|
| ticket-card | tickets::ticket-card |
⬜ | = the tile from refactor/card-column-components |
| ticket-column | tickets::ticket-column |
⬜ | = column from refactor/card-column-components |
| milestone-card | tickets::milestone-card |
⬜ | |
| project-card | projects::project-card |
⬜ | |
| comments list | comments::list |
⬜ | HxController-backed |
Card naming resolution (decided)
elements.card= the glass content-box that replaces.maincontentinner.- The small tile I shipped on
refactor/card-column-componentsbecomestickets::ticket-card. - My
columnbecomestickets::ticket-column. refactor/card-column-componentsis superseded — its work folds into the above; the Logic Model board will consumetickets::*+elements.card.
Branch landscape (reference only — DO NOT merge)
| Branch | Age | Use as | Verdict |
|---|---|---|---|
feature/ui-components |
fresh (Feb 2026) | richest reference: daisyUI theme, full category layer, 11/12 P0, domain cards, JS modules | reference; broke features as a big-bang — harvest APIs, don't merge |
refactor/table-component |
~2024 | best forms/table/form-field + prop IDL + Table.php |
reference |
selectsComponentUpdates |
Jan 2025 | superset forms incl. chip/datepicker/select + 113 call-site examples | reference |
feature/leantime-design-tokens |
2024 | daisyUI theme + Material-3 palette token values | reference (for design phase) |
modal line (feature/modal-component) |
2024 | <dialog> + hash-routed global page-modal pattern |
reference (rebuild on HxComponent) |
refactor/javascript-to-modules-… |
2024 | full domain-JS ESM conversion (still pending eventually) | reference |
feature/card-component, feature/table-component, left-nav-design-fix, file-component, button/text-input/checkbox-radio-component, commentsComponent |
2024 | stale/subsumed | reference at most |
JS-backed component pattern (the standard)
Copy Tiptap (public/assets/js/app/core/tiptap/index.js) — the only widget already correct:
- markup carries a
data-lt-*initializer attribute (never an inline<script>), - one central idempotent registry per widget type (
WeakMap,data-…-initializedguard), - wired to
htmx.onLoad(init on first paint + every swap) and, where teardown is needed,htmx:beforeSwap/htmx:afterSwap, - heavy bundles lazy-loaded via
Template::requireComponents([...])/needsComponent().
This fixes the SlimSelect / Chosen / jQuery-UI-datepicker / tabs / inlineSelect bug where
inline jQuery(document).ready init runs only on first paint and breaks after HTMX swaps.
⚠️ Gotcha: no double-quotes inside a component attribute value
Blade parses component attributes more strictly than plain HTML. A " inside a {{ }}
expression within an attribute value terminates the attribute early and breaks the tag —
even though the same markup works as a raw <a href="...">. So when migrating:
href="{{ $x["key"] }}"→ use{{ $x['key'] }}(single-quote the array key), or:link="$x['key']".href="{{ BASE_URL . "/path/$id" }}"→ uselink="{{ BASE_URL }}/path/{{ $id }}"(Blade interpolation).class="{{ $c ? "a" : "b" }}"→ single-quote the strings, or compute in@php. Run the brace/quote-aware scan (forms.button opening tags with a"inside any{{ }}) after any button migration batch —view:cachedoes NOT catch these (they fail at render, not compile).
⚠️ Gotcha: no legacy <?php echo ?> / <?= ?> inside a component attribute value
Raw PHP echo tags work in a plain <input placeholder="<?php echo … ?>"> (PHP executes at render),
but Laravel's component-tag compiler treats a non-bound attribute value as a literal string, so
<?php … ?> inside a <x-…> attribute does NOT reliably execute. Leave such inputs RAW (or first
modernize the echo to {{ … }} / {!! $tpl->escape(…) !!} in a separate step, then migrate). Found in
Auth/userInvite (placeholders use <?php echo $tpl->language->__('…') ?>) — deferred. Scan migrated
tags for <?php / <?= before committing.
Per-component playbook (repeatable)
- Read what the primitive renders today (classes, JS hooks, every call-site shape).
- Build the no-op component under the right category, full prop IDL, mapping to today's classes.
php bin/leantime view:cache+vendor/bin/pint --test(syntactic gate).- Migrate a small pilot batch of call-sites; Playwright before/after to prove zero visual diff.
- Migrate the rest in batches, re-verifying; commit per batch.
- Update this tracker (status, gotchas, call-site count migrated).
Button migration — deferral backlog (handle in later passes)
The no-op migration deliberately defers buttons it can't migrate without changing the rendered class set / behavior. Categories found (to revisit, some need a design decision):
— DONE (#3563): a CSS audit foundclass="button"(notbtn).buttonhas no rule at all;input[type='submit']is styled by the.btn-primaryelement-selector group (forms.css:313), so these 44 submits already render as primary buttons. Migrated all 44 to<x-global::forms.button tag="input" inputType="submit" contentRole="primary">(no-op). Also cleaned up a few pre-existing duplicateclass="button" class="button"attrs. Follow-up: ~16 aredel*confirmation submits that look primary today — candidates forstate="danger"in a later semantic pass (a visual change, not a no-op).Unstyled— DONE (#3564, round 2): NOT a design change after all —<input type="submit">(no class)input[type='submit']is in the.btn-primaryelement-selector group (forms.css:313), so bare submits already looked primary. Migrated tocontentRole="primary"(~30 of them). Intended visual no-op, not strictly byte-identical: the component adds the shared.btnbase (input.btn { vertical-align: top; … }) which a bare submit lacked — imperceptible, but worth stating precisely.- Unmapped btn variants —
btn-sm/btn-lg(vs Leantimebtn-small/btn-large),btn-danger-outline,btn-circle,btn-inverse,btn-file. Add mappings (after confirming CSS) or keep deferred. - role+state combo (
btn btn-default btn-success) — component currently emits one color; allow coexistence. — DONE: component emits<a onclick>withouthrefhrefonly whenlinkis set; migrate these by omitting thelinkprop.- dropdown-toggle / data-toggle / fileupload / span.btn — handled in the dropdown / file-upload / later phases.
Text-input migration — scope & defer rubric
forms.text-input is a thin no-op: it emits a plain <input> with today's class (default = no
class) and passes all attributes through; the label/validation IDL props are declared but not rendered
(a wrapper would change markup — that's the design phase). Pass the HTML-native type= (it is a
declared @prop, so Blade extracts it from the attribute bag — emits exactly one type, never a duplicate).
- ✅ Migrate (146 done in PR #3558; more in follow-ups): standard inputs (bare), headline title inputs
(
main-title-input→variant="headline"), search inputs. Map source class →variant; any extra non-variant class (tw-utilities,pull-left, …) passes throughclass=..form-controlAND.input→ bare (NOT variants): both are pure Bootstrap cruft — forms.css element selectors override.form-control, and.inputhas no backing CSS rule at all; a bare input renders identically (the entry-page width that.form-controlgave comes from.regpanelinner input{width:100%}).
Variant taxonomy (evidence-backed — 4-agent CSS audit)
Only visually-distinct treatments earn a variant. Verdicts:
| variant | class | real? | what it actually is |
|---|---|---|---|
headline |
.main-title-input |
✅ | large 24/26px (--font-size-xxxl) title font + box-shadow:none; keeps border/bg |
large |
.input-large |
✅ (width-only) | fixed width:210px — forms.css never sets width, so it survives |
small |
.input-small |
✅ (width-only) | fixed width:90px |
ghost (planned) |
.secretInput |
✅ | inline-edit "looks like text until touched": transparent, no border/shadow, hover/focus reveal box. Pending its async-save JS migration. |
form |
.form-control |
❌ removed | overridden by forms.css element selectors |
legacy |
.input |
❌ removed | no .input CSS rule exists anywhere |
- ⛔ Leave RAW — do-not-touch signals (JS-coupled; breaking these regresses behavior):
- datepickers (jQuery-UI):
.dates .duedates .quickDueDates .dateFrom .dateTo .editFrom .editTo .startDate .endDate .projectDateFrom .projectDateTo .week-picker .hasDatepicker+ ids#deadline #sprintStart #sprintEnd #event_date_* #date #startDate #endDate #timesheetdate #invoiced* #paidDate(many init via inline<script>in the template + an a11y pass on.hasDatepicker). - time:
.timepicker,type="time",#dueTime #timeFrom #timeTo. - tags:
#tags(+#tags_tag/#tags_tagsinput),.tagsinputField,data-role="tagsinput",#wikiTagsInput. - inline-edit / async-save:
.secretInput,.asyncInputUpdate(+data-label/data-id). - color:
.simpleColorPicker. honeypot:.ohnohoney. - JS grids / clone-templates:
.hourCell(timesheet grid),.sorter+name/idclone markers likeXXNEWKEYXXor pipe-keyedname="new|GENERAL_BILLABLE|…". - dynamic
class/idbuilt with{{ }}/{!! !!}(can't statically classify → defer). - legacy
<?php echo ?>/<?= ?>in an attribute value (see gotcha above). - any inline
onchange/onblur/onkeyup/oninput/onfocushandler.
- datepickers (jQuery-UI):
Progress log
- Phase 0: tracker created;
feature/componentizationbranched off master; card-naming resolved. - button: no-op
forms.buttonbuilt + 2 correctness fixes (native button-type, no default color). - button pilot:
Auth/loginmigrated; Playwright before/after = byte-identical (proven). - button batch 1: ~65 plain buttons migrated across 46 core form/admin/CRUD templates (9-agent fan-out, disjoint files); ~70 deferred per the backlog above. Verified: view:cache compiles, audit shows no JS-coupled class swallowed, real before/after on /users/showAll = identical class set.
- button href tweak: component emits href only when
linkis set (so<a onclick>w/o href migrates). - button batch 2: ~100 plain buttons migrated across 43 JS-heavy templates (Tickets, Dashboard, Widgets, Canvas/Blueprints/Goalcanvas/Logicmodel, Ideas, Wiki, Calendar, Sprints); the rest deferred (dropdown-toggles, fc-* calendar, file-uploads, class="button", unmapped variants, role+state). Verified: compile clean, audit clean, live no-op spot-check on /goalcanvas/showCanvas. Core plain-button migration is now essentially complete — remaining work = the deferral backlog (dropdowns get migrated in the dropdown-component phase; class="button"/unstyled = design decisions).
- button role sanity pass: 15 Back/Cancel/"Go Back" buttons that were hard-coded btn-primary in the original markup demoted to contentRole="secondary" (alternative/navigate-away actions). Only the role VALUE changed. This is intentionally NOT a no-op (appearance changes; secondary is unstyled until the design phase).
- button role promotions: 5 main-action submits that were
defaultpromoted toprimaryfor consistency with siblings — Ideas board create/save (advancedBoards + showBoards, ×4) and the Comments/showAll reply (generalComment's reply was already primary). Genuinely-secondarydefaultbuttons (Back, Export, Copy, Reset Logo, Resend Invite, Close, Activate) left as-is. - button outline variant: added
variant="outline"to forms.button (emits btn-outline / btn-{state}-outline). All "Save & Close" buttons set to variant="outline" to match the edit-ticket save style (7 sites: 5 canvas/idea dialogs + the ticketDetails/articleDialog inputs componentized). - action-links -> secondary: ~35 standalone Cancel/Back/Close/Delete/Remove links that were bare
<a>text-links (no btn class) converted to<x-global::forms.button ... contentRole="secondary">, preserving onclick + JS-hook classes (delete/formModal/editTimeModal/...). Strictly skipped: dropdown<li>menu-items (incl. menu delete/edit), accordion + inline|-separated toggles, add/create toggles, nav, timers, and already-btnlinks. Still bare (flagged, not converted): inline per-commentdeleteCommentlinks + per-row table delete actions (would need a smaller-scale/inline treatment). - text-input: thin no-op
forms.text-inputbuilt onfeature/text-input-component(off master, post-#3531). Scope + datepicker/tags/inline-edit defer rubric above. PR #3558. - text-input pilot:
Projects/newProjectheadline (main-title-input→variant="headline") migrated; Playwright = byte-identical (same class/type/name/id/style/value/placeholder); the two.dateFrom/.dateTodatepickers on the same page left RAW (component never applied to JS-coupled inputs → can't regress). (Note: dev instance currently isn't loadingcompiled-app/jQuery, so runtime datepicker init couldn't be exercised — but the datepicker DOM is byte-identical to master since those lines are untouched.) - text-input sweep: 146 call-sites across 56 files migrated (63-file 2-phase workflow: per-file migrate
- adversarial diff-verify; all 63 verified ok). Diff is perfectly symmetric (202 ins / 202 del = pure
in-place swaps). Static audit of all 146: 0 problems (no
type=/inputType dup, no variant class left inclass=, no JS-coupled signal swallowed, no nested-quote, no dup attrs). Compile + Pint clean. Live render no-op confirmed on/setting/editCompanySettings(pull-leftpassthrough) +/clients/newClient(bare). Deferred to follow-ups:Auth/userInvite(3 inputs w/ legacy<?php echo ?>in attrs — see gotcha),Tickets/partials/ticketCard+partials/subtasks(HTMX inline-edit/date), and everywhere the do-not-touch signals (datepickers/tags/inline-edit/color/sorter/hourCell/dynamic-class).
- adversarial diff-verify; all 63 verified ok). Diff is perfectly symmetric (202 ins / 202 del = pure
in-place swaps). Static audit of all 146: 0 problems (no
- text-input API refinement (review feedback): two API cleanups after review.
(1)
inputType→type: renamed the prop to the HTML-nativetype(17 call-sites). It's a declared@prop, so Blade extracts it from the attribute bag → exactly onetype, no duplication. (forms.buttonkeepsinputTypebecause it's polymorphic —typeis ambiguous across a/button/input.) (2) droppedvariant="form"(theform/bordered→.form-controlarm). 3-agent CSS audit proved.form-controlis cosmetically redundant in Leantime:forms.csselement selectors (input[type=text]…, loaded after Bootstrap) override its bg/border/radius/shadow/padding/height/color, and the only residual effect (desktopwidth:100%) is already supplied by container rules (.regpanelinner input{width:100%}) for the sole 7 call-sites (login ×2, twoFA/verify ×1, install ×4 — all entry pages). No JS hooks.form-controlon inputs. Collapsed those 7 to bare; live render on/auth/login= bare inputs, singletype, noform-control. Bare IS the form look now. - text-input variant taxonomy (review feedback): 4-agent CSS audit to keep ONLY evidence-backed variants.
Findings:
headline(.main-title-input) = REAL (large--font-size-xxxlfont + shadow removed);large(.input-large)/small(.input-small) = REAL but width-only (210px/90px — the one prop forms.css doesn't set);ghost(.secretInput) = REAL inline-edit treatment (4 distinct low-chrome looks found, the canonical one being .secretInput) but its call-sites are the deferred async-save fields, so it's a planned variant;legacy(.input) = REDUNDANT (no.inputCSS rule exists anywhere). Droppedvariant="legacy"(1 call-site, TwoFA/edit → bare; removed the arm). Component now exposes onlyheadline/large/small. - textarea: thin no-op
forms.textarea(#3562). Body is<textarea {{ $attributes }}>{{ $slot }}</textarea>— attributes pass through, the field value is the slot (inner content) preserved EXACTLY (textareas are whitespace-sensitive). 10 plain textareas migrated across 6 files (Help projectDefinitionStep ×3, Ideas/Wiki newMilestone, Timesheets add/edit + Tickets timesheet description, Widgets myToDos description-input ×2). 19 Tiptap editor textareas left RAW — JS upgrades exactlytextarea.tiptapSimple/textarea.tiptapComplex(core/tiptap/index.js) plus the Wiki.wiki-editor-textarea; never route those through the component. Novariantarm (plain textareas carry no distinct style class; the only textarea classes are editor-coupled). - button + text-input completion (round 2): swept blade for buttons/inputs missed by #3531/#3558.
53 migrated across 38 files: 29 bare
<input type=submit>(no class — already looked primary via forms.css:313, socontentRole="primary"is an intended visual no-op; the.btnbase adds minor props likevertical-align, imperceptible), 4 token-UI text inputs/buttons, Errors back ×4, support sponsor, Auth token UI (create/copy/close/delete), Files cancel ×2, widgetManager reset (btn-outline→secondary), Reports chart toggles ×6, showProject delete (btn-danger-outline→state=danger variant=outline), 1 comment reply.btn-sm/btn-lg/btn-secondary(own CSS, ≠ Leantime's small/large/outline) passed throughclass=pending a design-phase scale/role mapping. Left deferred (correct): 3 commentbtn-successrole+state combos (component emits one color);partials/subtasksquickadd (nested__("…")+ HTMX file); dynamic-class links (calendarSettings, Dashboard favoriteProject);ticketFilterraw<a>(whitespace-sensitive, intentional); custom non-btnwidget buttons (Wiki collapse/panel, calendar day-button, todoItem reset); modaldata-dismiss/.close, Files.deleteicons, file-uploadpicSubmit, dropdown-toggles,<?php echoinvite variants. Verified: compile + Pint clean, 0 button-tag problems, diff is tag swaps (multiline tags collapse to 1 line). ALSO: TimesheetCest selectors that clicked.buttonrepointed toinput[type=submit]/name (the.buttonclass is removed by the migration) — see #3563.