Skip to content

feat: Milestone 4 — Components Integration (#68–#72)#142

Merged
adnaan merged 17 commits intomainfrom
milestone4-components
Feb 28, 2026
Merged

feat: Milestone 4 — Components Integration (#68–#72)#142
adnaan merged 17 commits intomainfrom
milestone4-components

Conversation

@adnaan
Copy link
Contributor

@adnaan adnaan commented Feb 28, 2026

Summary

  • [4.1] Move Components into lvt Monorepo #68: Move external components library (21 UI components) into monorepo via git subtree, update module paths, make base.TemplateSet a type alias for livetemplate.TemplateSet, add CI workflow for component independence
  • [4.2] Integrate Modal Component #69: Integrate modal component — replace browser confirm() dialogs with ConfirmModal component, add RequestDelete/ConfirmDelete/CancelDelete action handlers in generated code
  • [4.3] Integrate Toast Component #70: Integrate toast component — add toast notifications on Add/Update/Delete success with DismissToastNotifications action, register toast container in layout templates
  • [4.4] Integrate Dropdown Component #71: Add select field type (name:select:opt1,opt2,opt3) — parse select syntax in field parser, render <select> in form templates (add + edit), register dropdown.Templates()
  • [4.5] Add Component Usage Detection #72: Component usage detection — conditionally include component imports, state fields, init, and action methods using generation-time [[ if .Components.UseX ]] guards (modal/toast always on, dropdown only when select fields present)

Key design decisions

  • base.TemplateSet is a Go type alias for livetemplate.TemplateSet, so component templates can be passed directly to WithComponentTemplates() without conversion
  • WithFuncs converted from method to standalone function (can't define methods on type alias)
  • Integration test updated with replace directive for components sub-module
  • Golden files updated for new generated handler output

Test plan

  • go test ./... — all 32 main module packages pass
  • cd components && go test ./... — all 21 component packages pass independently
  • TestGeneratedAppFullFlow — generated app with components compiles and builds
  • TestResourceHandlerGolden / TestResourceTemplateGolden — golden files updated
  • TestParseFieldsSelectProperties / TestParseFieldsSelectWithOtherFields — select field parsing
  • TestDetectUsedComponents_* — component usage detection (3 test cases)

🤖 Generated with Claude Code

adnaan and others added 3 commits February 28, 2026 07:33
git-subtree-dir: components
git-subtree-split: b3d1351c7d64686e059e1dd510f45272b943524a
Move the external components library into the monorepo and wire modal,
toast, and dropdown components into the code generator, eliminating
template drift and enabling the evolution feedback loop.

- #68: Git subtree import of 21 UI components, update module paths,
  make base.TemplateSet a type alias for livetemplate.TemplateSet,
  add CI workflow for component independence
- #69: Integrate modal component for delete confirmation — replace
  browser confirm dialogs with ConfirmModal component, add
  RequestDelete/ConfirmDelete/CancelDelete action handlers
- #70: Integrate toast component for CRUD feedback — add toast
  notifications on Add/Update/Delete success with dismiss support
- #71: Add select field type (name:select:opt1,opt2) — parse select
  syntax in field parser, render <select> elements in form templates,
  register dropdown.Templates() in component templates
- #72: Component usage detection — conditionally include component
  imports, state fields, and action methods based on resource fields
  using generation-time [[ if .Components.UseX ]] guards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 28, 2026 07:17
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Integrates a standalone components/ Go module into the monorepo with embedded templates and component APIs, plus CI checks to ensure the components module can build/test independently.

Changes:

  • Added multiple UI component packages (templates + Go APIs) under components/ with centralized registration via components.All().
  • Introduced components/base.TemplateSet as an alias for livetemplate.TemplateSet, plus base helpers/tests.
  • Added a GitHub Actions workflow to validate components/ independence and forbid imports from the parent module.

Reviewed changes

Copilot reviewed 112 out of 160 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
.github/workflows/components-independence.yml Adds CI job to ensure components/ builds/tests independently and avoids forbidden imports.
commands/component.go Updates CLI output to reference new components module path.
commands/help.go Updates help text to reference new components module path.
commands/new_component.go Updates scaffolded imports and contribution guidance to the new components module path.
components/.gitignore Ignores .worktrees/ within the components module.
components/CONTRIBUTING.md Adds contribution guidelines for the components library and templates.
components/README.md Adds documentation and usage examples for the components library.
components/all.go Registers all component template sets via components.All().
components/accordion/accordion.go Adds accordion component state/model implementation.
components/accordion/options.go Adds functional options for accordion configuration.
components/accordion/templates.go Embeds accordion templates and exposes Templates().
components/accordion/templates/default.tmpl Adds default accordion template.
components/accordion/templates/single.tmpl Adds single-open accordion template variant.
components/autocomplete/options.go Adds functional options for autocomplete configuration.
components/autocomplete/templates.go Embeds autocomplete templates and exposes Templates().
components/autocomplete/templates/default.tmpl Adds default autocomplete template.
components/autocomplete/templates/multi.tmpl Adds multi-select autocomplete template variant.
components/base/action.go Adds ActionContext helpers and action provider types.
components/base/action_test.go Adds tests for ActionContext accessors and copying behavior.
components/base/base.go Adds shared Base component struct and basic helpers.
components/base/base_test.go Adds tests for Base behavior (ID, namespace, styled default, action naming).
components/base/templates.go Adds TemplateSet alias, NewTemplateSet, and WithFuncs.
components/base/templates_test.go Adds tests for TemplateSet creation and WithFuncs copy semantics.
components/base/testdata/test.tmpl Adds embedded template used by template set tests.
components/breadcrumbs/breadcrumbs.go Adds breadcrumbs component state/model implementation.
components/breadcrumbs/options.go Adds functional options for breadcrumbs and breadcrumb items.
components/breadcrumbs/templates.go Embeds breadcrumbs templates and registers extra funcs for templates.
components/breadcrumbs/templates/default.tmpl Adds breadcrumbs template with collapsing support.
components/datatable/options.go Adds functional options for datatable configuration.
components/datepicker/options.go Adds functional options for datepicker configuration.
components/datepicker/templates.go Embeds datepicker templates and exposes Templates().
components/datepicker/templates/inline.tmpl Adds inline calendar datepicker template variant.
components/datepicker/templates/range.tmpl Adds date-range datepicker template variant.
components/datepicker/templates/single.tmpl Adds single-date datepicker template variant (and shared calendar partial).
components/drawer/drawer.go Adds drawer component state/model implementation.
components/drawer/options.go Adds functional options for drawer configuration.
components/drawer/templates.go Embeds drawer templates and exposes Templates().
components/drawer/templates/default.tmpl Adds drawer template.
components/dropdown/dropdown.go Adds dropdown/searchable/multi dropdown component implementations.
components/dropdown/options.go Adds functional options for dropdown variants.
components/dropdown/templates.go Embeds dropdown templates and exposes Templates().
components/dropdown/templates/default.tmpl Adds base dropdown template.
components/dropdown/templates/multi.tmpl Adds multi-select dropdown template variant.
components/dropdown/templates/searchable.tmpl Adds searchable dropdown template variant.
components/go.mod Introduces a standalone Go module for components/.
components/menu/options.go Adds functional options for menu and nav menu configuration.
components/menu/templates.go Embeds menu templates and exposes Templates().
components/menu/templates/context.tmpl Adds context menu template.
components/menu/templates/default.tmpl Adds dropdown menu template.
components/menu/templates/nav.tmpl Adds navigation menu template.
components/modal/options.go Adds functional options for modal/confirm/sheet variants.
components/modal/templates.go Embeds modal templates and exposes Templates().
components/modal/templates/confirm.tmpl Adds confirm modal template variant.
components/modal/templates/default.tmpl Adds default modal template.
components/modal/templates/sheet.tmpl Adds sheet modal template variant.
components/popover/options.go Adds functional options for popover configuration.
components/popover/popover.go Adds popover component state/model implementation.
components/popover/templates.go Embeds popover templates and exposes Templates().
components/popover/templates/default.tmpl Adds popover template.
components/progress/options.go Adds functional options for linear/circular/spinner progress components.
components/progress/templates.go Embeds progress templates and exposes Templates().
components/progress/templates/circular.tmpl Adds circular progress template.
components/progress/templates/default.tmpl Adds linear progress template.
components/progress/templates/spinner.tmpl Adds spinner template.
components/rating/options.go Adds functional options for rating configuration.
components/rating/rating.go Adds star rating component state/model implementation.
components/rating/templates.go Embeds rating templates and exposes Templates().
components/rating/templates/default.tmpl Adds interactive rating template.
components/rating/templates/readonly.tmpl Adds read-only rating template variant.
components/skeleton/options.go Adds functional options for skeleton variants.
components/skeleton/skeleton.go Adds skeleton component state/model implementation (line/avatar/card).
components/skeleton/templates.go Embeds skeleton templates and exposes Templates().
components/skeleton/templates/avatar.tmpl Adds skeleton avatar template.
components/skeleton/templates/card.tmpl Adds skeleton card template.
components/skeleton/templates/default.tmpl Adds skeleton line template.
components/tabs/options.go Adds functional options for tabs configuration.
components/tabs/tabs.go Adds tabs component state/model implementation.
components/tabs/templates.go Embeds tabs templates and exposes Templates().
components/tabs/templates/horizontal.tmpl Adds horizontal tabs template.
components/tabs/templates/pills.tmpl Adds pill-style tabs template.
components/tabs/templates/vertical.tmpl Adds vertical tabs template.
components/tagsinput/options.go Adds functional options for tags input configuration.
components/tagsinput/tagsinput.go Adds tags input component state/model implementation.
components/tagsinput/templates.go Embeds tags input templates and exposes Templates().
components/tagsinput/templates/default.tmpl Adds tags input template.
components/timeline/options.go Adds functional options for timelines and timeline items.
components/timeline/templates.go Embeds timeline templates and exposes Templates().
components/timeline/templates/default.tmpl Adds timeline template with vertical/horizontal layouts.

Comment on lines +48 to +76
type SearchableOption func(*Searchable)

// WithMinChars sets the minimum characters required before filtering starts.
func WithMinChars(minChars int) SearchableOption {
return func(s *Searchable) {
s.MinChars = minChars
}
}

// WithQuery sets the initial search query.
func WithQuery(query string) SearchableOption {
return func(s *Searchable) {
s.Query = query
}
}

// MultiOption is a functional option for configuring multi-select dropdowns.
type MultiOption func(*Multi)

// WithMaxSelections limits how many items can be selected (0 = unlimited).
func WithMaxSelections(max int) MultiOption {
return func(m *Multi) {
m.MaxSelections = max
}
}

// WithSelectedValues pre-selects multiple items by their values.
func WithSelectedValues(values []string) MultiOption {
return func(m *Multi) {
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The variant-specific option types (SearchableOption, MultiOption) can’t be passed into NewSearchable / NewMulti because those constructors currently accept ...Option (for *Dropdown). As a result, callers cannot use WithMinChars, WithQuery, WithMaxSelections, or WithSelectedValues as intended. Consider either: (1) changing constructors to accept both sets (e.g., NewSearchable(id, options, dropdownOpts ...Option, searchableOpts ...SearchableOption)), or (2) making these variant options return the base Option type by targeting the embedded Dropdown / using setters on the concrete type.

Suggested change
type SearchableOption func(*Searchable)
// WithMinChars sets the minimum characters required before filtering starts.
func WithMinChars(minChars int) SearchableOption {
return func(s *Searchable) {
s.MinChars = minChars
}
}
// WithQuery sets the initial search query.
func WithQuery(query string) SearchableOption {
return func(s *Searchable) {
s.Query = query
}
}
// MultiOption is a functional option for configuring multi-select dropdowns.
type MultiOption func(*Multi)
// WithMaxSelections limits how many items can be selected (0 = unlimited).
func WithMaxSelections(max int) MultiOption {
return func(m *Multi) {
m.MaxSelections = max
}
}
// WithSelectedValues pre-selects multiple items by their values.
func WithSelectedValues(values []string) MultiOption {
return func(m *Multi) {
// It is an alias of Option so it can be passed anywhere a base dropdown Option is expected.
type SearchableOption = Option
// WithMinChars sets the minimum characters required before filtering starts.
// It returns a base Option so it can be passed to NewSearchable (and other constructors
// that accept ...Option) while still configuring Searchable-specific behavior when
// applied to a *Searchable instance.
func WithMinChars(minChars int) Option {
return func(d *Dropdown) {
if s, ok := any(d).(*Searchable); ok {
s.MinChars = minChars
}
}
}
// WithQuery sets the initial search query.
// Like WithMinChars, it returns a base Option to be compatible with constructors
// that accept ...Option.
func WithQuery(query string) Option {
return func(d *Dropdown) {
if s, ok := any(d).(*Searchable); ok {
s.Query = query
}
}
}
// MultiOption is a functional option for configuring multi-select dropdowns.
// It is an alias of Option so it can be passed anywhere a base dropdown Option is expected.
type MultiOption = Option
// WithMaxSelections limits how many items can be selected (0 = unlimited).
// It returns a base Option so it can be passed to NewMulti (and other constructors
// that accept ...Option) while still configuring Multi-specific behavior when
// applied to a *Multi instance.
func WithMaxSelections(max int) Option {
return func(d *Dropdown) {
if m, ok := any(d).(*Multi); ok {
m.MaxSelections = max
}
}
}
// WithSelectedValues pre-selects multiple items by their values.
// It also returns a base Option for compatibility with constructors that accept ...Option.
func WithSelectedValues(values []string) Option {
return func(d *Dropdown) {
m, ok := any(d).(*Multi)
if !ok {
return
}

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +85
type SearchableOption func(*Searchable)

// WithMinChars sets the minimum characters required before filtering starts.
func WithMinChars(minChars int) SearchableOption {
return func(s *Searchable) {
s.MinChars = minChars
}
}

// WithQuery sets the initial search query.
func WithQuery(query string) SearchableOption {
return func(s *Searchable) {
s.Query = query
}
}

// MultiOption is a functional option for configuring multi-select dropdowns.
type MultiOption func(*Multi)

// WithMaxSelections limits how many items can be selected (0 = unlimited).
func WithMaxSelections(max int) MultiOption {
return func(m *Multi) {
m.MaxSelections = max
}
}

// WithSelectedValues pre-selects multiple items by their values.
func WithSelectedValues(values []string) MultiOption {
return func(m *Multi) {
valueSet := make(map[string]bool)
for _, v := range values {
valueSet[v] = true
}

m.SelectedItems = make([]Item, 0)
for _, opt := range m.Options {
if valueSet[opt.Value] {
m.SelectedItems = append(m.SelectedItems, opt)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The variant-specific option types (SearchableOption, MultiOption) can’t be passed into NewSearchable / NewMulti because those constructors currently accept ...Option (for *Dropdown). As a result, callers cannot use WithMinChars, WithQuery, WithMaxSelections, or WithSelectedValues as intended. Consider either: (1) changing constructors to accept both sets (e.g., NewSearchable(id, options, dropdownOpts ...Option, searchableOpts ...SearchableOption)), or (2) making these variant options return the base Option type by targeting the embedded Dropdown / using setters on the concrete type.

Suggested change
type SearchableOption func(*Searchable)
// WithMinChars sets the minimum characters required before filtering starts.
func WithMinChars(minChars int) SearchableOption {
return func(s *Searchable) {
s.MinChars = minChars
}
}
// WithQuery sets the initial search query.
func WithQuery(query string) SearchableOption {
return func(s *Searchable) {
s.Query = query
}
}
// MultiOption is a functional option for configuring multi-select dropdowns.
type MultiOption func(*Multi)
// WithMaxSelections limits how many items can be selected (0 = unlimited).
func WithMaxSelections(max int) MultiOption {
return func(m *Multi) {
m.MaxSelections = max
}
}
// WithSelectedValues pre-selects multiple items by their values.
func WithSelectedValues(values []string) MultiOption {
return func(m *Multi) {
valueSet := make(map[string]bool)
for _, v := range values {
valueSet[v] = true
}
m.SelectedItems = make([]Item, 0)
for _, opt := range m.Options {
if valueSet[opt.Value] {
m.SelectedItems = append(m.SelectedItems, opt)
// It is an alias of Option so it can be passed anywhere an Option is expected.
type SearchableOption = Option
// WithMinChars sets the minimum characters required before filtering starts.
func WithMinChars(minChars int) SearchableOption {
return func(d *Dropdown) {
d.MinChars = minChars
}
}
// WithQuery sets the initial search query.
func WithQuery(query string) SearchableOption {
return func(d *Dropdown) {
d.Query = query
}
}
// MultiOption is a functional option for configuring multi-select dropdowns.
// It is an alias of Option so it can be passed anywhere an Option is expected.
type MultiOption = Option
// WithMaxSelections limits how many items can be selected (0 = unlimited).
func WithMaxSelections(max int) MultiOption {
return func(d *Dropdown) {
d.MaxSelections = max
}
}
// WithSelectedValues pre-selects multiple items by their values.
func WithSelectedValues(values []string) MultiOption {
return func(d *Dropdown) {
valueSet := make(map[string]bool)
for _, v := range values {
valueSet[v] = true
}
d.SelectedItems = make([]Item, 0)
for _, opt := range d.Options {
if valueSet[opt.Value] {
d.SelectedItems = append(d.SelectedItems, opt)

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +37
class="px-4 py-2 cursor-pointer hover:bg-blue-50 {{if .Disabled}}opacity-50 cursor-not-allowed{{end}} {{if eq $.Selected.Value .Value}}bg-blue-100{{end}}"
lvt-click="select_{{$.ID}}"
lvt-data-value="{{.Value}}"
role="option"
{{if .Disabled}}aria-disabled="true"{{end}}
{{if eq $.Selected.Value .Value}}aria-selected="true"{{end}}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

This template dereferences $.Selected.Value without guarding against $.Selected being nil. When no item is selected, rendering the open menu can error. Wrap these conditions with a nil-check (e.g., {{if and $.Selected (eq $.Selected.Value .Value)}}...{{end}}) for both the class and aria-selected.

Suggested change
class="px-4 py-2 cursor-pointer hover:bg-blue-50 {{if .Disabled}}opacity-50 cursor-not-allowed{{end}} {{if eq $.Selected.Value .Value}}bg-blue-100{{end}}"
lvt-click="select_{{$.ID}}"
lvt-data-value="{{.Value}}"
role="option"
{{if .Disabled}}aria-disabled="true"{{end}}
{{if eq $.Selected.Value .Value}}aria-selected="true"{{end}}
class="px-4 py-2 cursor-pointer hover:bg-blue-50 {{if .Disabled}}opacity-50 cursor-not-allowed{{end}} {{if and $.Selected (eq $.Selected.Value .Value)}}bg-blue-100{{end}}"
lvt-click="select_{{$.ID}}"
lvt-data-value="{{.Value}}"
role="option"
{{if .Disabled}}aria-disabled="true"{{end}}
{{if and $.Selected (eq $.Selected.Value .Value)}}aria-selected="true"{{end}}

Copilot uses AI. Check for mistakes.
Comment on lines +292 to +303
// Helper function
func formatFloat(f float64, decimals int) string {
if decimals == 0 {
return string(rune('0' + int(f)))
}
whole := int(f)
frac := int((f - float64(whole)) * 10)
if whole >= 10 {
return string(rune('0'+whole/10)) + string(rune('0'+whole%10)) + "." + string(rune('0'+frac))
}
return string(rune('0'+whole)) + "." + string(rune('0'+frac))
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

formatFloat builds strings via rune arithmetic and only works reliably for single-digit values (and only partially for 10–99). It will produce incorrect output for values like 0, 10+, or any negative values, and it also assumes f is an integer when decimals == 0. Prefer strconv.FormatFloat (or fmt.Sprintf) to format both integer and 1-decimal outputs correctly.

Copilot uses AI. Check for mistakes.

// EmptyColorClass returns CSS color classes for empty stars.
func (r *Rating) EmptyColorClass() string {
return "text-gray-300"
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The Rating struct has an EmptyColor field, but EmptyColorClass() ignores it and always returns a constant class. This makes the API misleading and prevents customization. Either implement a switch based on r.EmptyColor (similar to ColorClass()), or remove the field if it’s intentionally unsupported.

Suggested change
return "text-gray-300"
switch r.EmptyColor {
case "red":
return "text-red-200"
case "blue":
return "text-blue-200"
case "green":
return "text-green-200"
case "yellow":
return "text-yellow-200"
default:
// Default to a neutral gray when EmptyColor is not set or unrecognized.
return "text-gray-300"
}

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +13
aria-expanded="{{$.IsOpen .ID}}"
aria-controls="accordion-content-{{$.ID}}-{{.ID}}"
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

aria-labelledby references accordion-header-..., but there is no element in this template assigned that id (the header button lacks id=\"accordion-header-...\"). Add a matching id to the button (or another appropriate header element) so assistive tech can resolve the relationship.

Copilot uses AI. Check for mistakes.
id="accordion-content-{{$.ID}}-{{.ID}}"
class="px-4 pb-4 text-gray-600"
role="region"
aria-labelledby="accordion-header-{{$.ID}}-{{.ID}}"
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

aria-labelledby references accordion-header-..., but there is no element in this template assigned that id (the header button lacks id=\"accordion-header-...\"). Add a matching id to the button (or another appropriate header element) so assistive tech can resolve the relationship.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +15
// Templates returns the timeline template set.
func Templates() *base.TemplateSet {
return base.NewTemplateSet(templateFS, "templates/*.tmpl", "timeline")
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

components/timeline/templates/default.tmpl uses non-standard template funcs like sub (e.g., to detect the last item). Unlike breadcrumbs, this template set doesn’t register any extra funcs via base.WithFuncs, so rendering can fail if sub isn’t provided globally by the framework. Register the needed func(s) for this component’s TemplateSet (or rewrite the template to avoid them).

Copilot uses AI. Check for mistakes.
- **Full Customization** - Override templates or eject for complete control

## Installation

Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The README examples reference the old module path (github.com/livetemplate/components) and option function names (dropdown.Placeholder, dropdown.Selected) that don’t match the code in this PR (module is github.com/livetemplate/lvt/components, options are WithPlaceholder, WithSelected, etc.). Update the installation command, import paths, and example option names so the docs compile as-written.

Copilot uses AI. Check for mistakes.
@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration (#68#72)

Overall this is a well-structured milestone. The architecture decisions are sound (type alias for TemplateSet, conditional component guards in generated code, component independence CI), and the test coverage for the new parsing/detection logic is solid. Below are issues ranging from a blocking bug to minor quality nits.


🔴 Bug: toast.Container.counter resets to zero on every action

File: components/toast/toast.go:76

State is JSON round-tripped on every WebSocket action via livetemplate.AsState. Because counter is unexported with no JSON tag, it serializes as 0 and deserializes back to 0. After any state mutation, the next AddSuccess/AddInfo/AddError call increments from 0 to 1 and assigns ID "1" — the same ID assigned to the first toast in the previous action. The Dismiss(id) method then removes the wrong toast.

Fix: Export the counter with a JSON tag, or generate IDs from a source that survives serialization (e.g., base it on len(Messages) at insertion time, or use a timestamp):

Counter int `json:"counter"` // exported + tagged

🔴 Bug: Page-mode post-delete navigation no longer fires

File: internal/generator/templates/components/layout.tmpl:60

if (action === 'delete') {

With the modal integration (#69), the confirmed delete action is now confirm_delete, not delete. The JS intercept that navigates back to the list view after a deletion will never fire in the modal flow. In page mode, after confirming a delete, the user stays on the now-stale detail/edit URL instead of being returned to the list.

Fix: Change action === 'delete'action === 'confirm_delete'.


🟡 Potential Issue: Select field values fail min=3 validation

File: internal/generator/templates/resource/handler.go.tmpl:36

IsSelect fields have GoType: "string", so they inherit the validate:"required,min=3" rule. Any option value shorter than 3 characters ("NY", "CA", "ok", "no") will be rejected at runtime with a confusing validation error.

Fix: Add a branch for select fields before the string branch to use only validate:"required".


🟡 Debug log leaks input data in production

File: internal/generator/templates/resource/handler.go.tmpl:118

log.Printf("[lvt debug] add input: %+v", input)

This is in the generated Add handler and will print all field values (including any sensitive data) to stdout in every generated app. Remove or gate behind a debug flag before shipping.


🟡 Orphaned Delete handler bypasses confirmation

File: internal/generator/templates/resource/handler.go.tmpl:266

The Delete method (direct delete, no modal) is still unconditionally generated. No UI element calls it anymore — the templates now use request_delete — but it remains a reachable WebSocket action. A client can send {"action":"delete","data":{"id":"..."}} to delete a record without going through the confirmation modal. If intentional, document it. If not, consider guarding it:

[[- if not .Components.UseModal]]
func (c *[[.ResourceName]]Controller) Delete(...)
[[- end]]

🟢 Minor: TemplateProvider interface is unused

File: components/base/templates.go:52-57

Defined but never referenced anywhere in the codebase. Either add a usage (e.g., a helper that accepts it) or remove it to avoid dead-code confusion. If it is intended as a documentation-only contract for component authors, a comment to that effect would clarify the intent.


🟢 Minor: Custom contains helper instead of strings.Contains

File: internal/parser/fields_test.go:245-257

There is a hand-rolled contains/containsHelper pair. strings.Contains from the standard library is identical and already available.


🟢 Minor: No minimum option count for select fields

File: internal/parser/fields.go:48-64

status:select:single (one option) is accepted without error. A select with a single option is almost certainly a user mistake. Consider requiring at least 2 options with a clear error message.


✅ What is working well

  • Type alias design (base.TemplateSet = livetemplate.TemplateSet) cleanly solves the conversion problem; the WithFuncs standalone-function trade-off is well-documented.
  • DetectUsedComponents + conditional guards keep generated code lean — only importing/registering what each resource actually needs.
  • Components independence CI (GOWORK=off go test ./...) is exactly the right approach to prevent accidental coupling.
  • Golden file tests for handler output catch regressions in generated code.
  • Select field pre-selection in edit form (form.tmpl lines 78-85) correctly reflects the current saved value — easy to get wrong, done right here.

🤖 Generated with Claude Code

@claude
Copy link

claude bot commented Feb 28, 2026

Code Review — Milestone 4: Components Integration

Overall this is a well-structured milestone. The monorepo subtree approach is clean, the CI enforcement of module independence is solid, and the conditional generation guards ([[ if .Components.UseX ]]) are a good design. Below are observations by category.


Correctness / Potential Bugs

1. WithFuncs silently discards existing Funcs (components/base/templates.go:43–50)

func WithFuncs(ts *TemplateSet, funcs template.FuncMap) *TemplateSet {
    return &TemplateSet{
        FS:        ts.FS,
        Pattern:   ts.Pattern,
        Namespace: ts.Namespace,
        Funcs:     funcs, // any pre-existing ts.Funcs are lost
    }
}

If a caller chains WithFuncs twice, or if the incoming TemplateSet already has funcs (e.g., from NewTemplateSet in the future), the first set of funcs is silently dropped. The safer pattern is to merge:

merged := make(template.FuncMap, len(ts.Funcs)+len(funcs))
for k, v := range ts.Funcs { merged[k] = v }
for k, v := range funcs    { merged[k] = v }

2. Delete action is dead code when UseModal=true (internal/generator/templates/resource/handler.go.tmpl:266–290)

The Delete action is always generated. When UseModal=true, RequestDelete/ConfirmDelete are also generated and the template presumably wires buttons to request_delete. The plain Delete handler then becomes an unreachable (but callable via WebSocket) action that bypasses confirmation. It should either be guarded with [[ if not .Components.UseModal ]] or left intentionally with a comment explaining the dual-path design.

3. toLower in dropdown.go is ASCII-only (components/dropdown/dropdown.go:303–313)

The hand-rolled toLower only handles A-Z. Dropdown option labels with accented or non-Latin characters (e.g., "Ångström", "Ñoño") won't match on search. Using strings.ToLower from stdlib handles Unicode correctly — see note on stdlib usage below.


Code Quality / Style

4. Custom stdlib reimplementations in dropdown.go

toLower, contains, and itoa are reimplemented with a comment saying "to avoid importing strings/strconv". strings and strconv are Go stdlib — they have zero external dependency cost. The itoa implementation is also O(n²) due to prepend-appending single bytes. These should just use strings.ToLower, strings.Contains, and strconv.Itoa.

5. Import order in components/all.go:46–68

accordionbreadcrumbsautocompleteautocomplete should sort before breadcrumbs. goimports would catch this; worth fixing for consistency, especially since this file is the public-facing aggregator.

6. Hardcoded Tailwind classes in ConfirmModal methods (components/modal/modal.go:214–237)

ConfirmButtonClass() and IconClass() return raw Tailwind utility strings, but the rest of the framework supports multiple CSS kits (bulma, pico, none). These methods will produce incorrect output for non-Tailwind apps. Consider either making them configurable via options (e.g., WithConfirmButtonClass) or returning semantic tokens that templates resolve via kit helpers.

7. DataBool whitelist is hand-enumerated (components/base/action.go:81–87)

case "true", "1", "yes", "on", "True", "TRUE", "Yes", "YES", "On", "ON":

This could use strconv.ParseBool for the standard subset, then layer "yes"/"on" on top. The current list is missing mixed-case variants like "YeS" or "oN", and is fragile to extend. Tests only cover a subset of the listed values ("YES", "On", "ON" aren't tested).


Design / Architecture

8. Duplicate state initialization in page mode (handler.go.tmpl:697–719)

initialState and resourceState (in the page-mode URL handler) are initialized with identical field/option patterns — including duplicated modal.NewConfirm(...) and toast.New(...) blocks. If a new component is added or a field changes, both blocks must be updated. Extracting a newState(queries) *[[.ResourceName]]State factory helper would eliminate this duplication.

9. DismissToastNotifications uses ctx.GetString instead of BindAndValidate (handler.go.tmpl:345–351)

All other action handlers use ctx.BindAndValidate. DismissToastNotifications reads the toast ID via ctx.GetString("toast") directly, skipping validation. While a missing/invalid toast ID is handled gracefully (Dismiss will no-op), the inconsistency makes the template harder to reason about.


Security

10. Select field options not validated at generate time

ParseFields splits select options on commas (strings.Split(parts[2], ",")) but doesn't validate individual option values. An option with a colon (e.g., status:select:ac:tive,inactive) would silently truncate the option to "active" — the colon in the value isn't supported because the whole field is split on : first. Worth either documenting this limitation or rejecting options containing :.


Minor / Nits

  • The components/go.mod lists many AWS SDK packages as indirect dependencies. These come from the livetemplate base library. For a components sub-module that should be lightweight, it's worth opening an upstream issue to slim down livetemplate's transitive surface — though this is outside the scope of this PR.
  • ConfirmModal.IsDestructive() is a one-liner wrapper for c.Destructive. Keeping or removing it is fine, but it adds API surface without adding value.
  • The Version() function in all.go returns a hardcoded string "0.1.0". Consider sourcing this from a constant or build-time variable to prevent drift.

Summary

Category Count
Correctness / potential bugs 3
Code quality / style 4
Design / architecture 2
Security 1
Nits 3

The core integration is solid and the CI independence check is a great guard rail. The most impactful items to address before merging are #1 (WithFuncs data loss), #2 (dead Delete action with modal), and #6 (hardcoded Tailwind in ConfirmButtonClass/IconClass).

🤖 Generated with Claude Code

adnaan and others added 3 commits February 28, 2026 08:33
The components sub-module isn't published yet, so Docker-based e2e
tests fail when go mod tidy tries to resolve component imports. Add
injectComponentsForTest helper that copies the local components dir
and adds a replace directive before Docker builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Export toast Counter field with JSON tag for serialization survival
- Fix page-mode JS to check 'confirm_delete' instead of 'delete'
- Add IsSelect validation branch without min=3 constraint
- Remove debug log.Printf from Add handler
- Guard Delete handler with UseModal conditional to avoid duplicate methods
- Replace custom contains helpers with strings.Contains in tests
- Update golden files to reflect handler changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration (#68#72)

This is a well-structured PR that brings a significant architectural improvement. Overall the design is sound and the test coverage is solid. A few issues worth addressing before merge.


Bugs

1. &testing.T{} in shared test setup silently swallows errors

File: e2e/shared_test.go:119

injectComponentsForTest(&testing.T{}, appDir)

injectComponentsForTest calls t.Fatal / t.Logf on the passed-in *testing.T. When you pass &testing.T{}, those calls go to a throwaway object — the test goroutine is not marked as failed and the real test won't report the error. If the components copy or go.mod injection fails, the e2e tests will proceed with a broken setup and produce misleading failures downstream.

This pattern is used in multiple places (shared_test.go:79,101,107,113,119). Since this runs inside sync.Once, the cleanest fix is to capture the error and propagate it to setupErr:

// instead of injectComponentsForTest(&testing.T{}, appDir)
if err := injectComponentsForTestErr(appDir); err != nil {
    setupErr = fmt.Errorf("failed to inject components: %w", err)
    return
}

Or at minimum, use a log.Fatalf so the process actually stops.


Design / Safety Concerns

2. Nil pointer risk on deserialized state for Toasts and DeleteConfirm

File: internal/generator/templates/resource/handler.go.tmpl (lines 142, 217, 305, 322, 333, 341, 352)

state.Toasts.AddSuccess("Created", "...")   // panics if Toasts is nil
state.DeleteConfirm.Show()                    // panics if DeleteConfirm is nil

These fields are pointer types initialized in initialState. If the framework deserializes session state that pre-dates this PR (before delete_confirm/toasts fields existed), or if JSON deserialization results in a null value, the generated app will panic at runtime with no helpful error message.

A nil guard in loadResources or an explicit state migration check would prevent this:

// In load[Resource]s or a newState() helper:
if state.DeleteConfirm == nil {
    state.DeleteConfirm = modal.NewConfirm("delete", ...)
}
if state.Toasts == nil {
    state.Toasts = toast.New("notifications", ...)
}

The template already nil-guards in the HTML template ({{if .DeleteConfirm}}...) which is good, but the handler side isn't protected.

3. Duplicate state initialization for page mode

File: internal/generator/templates/resource/handler.go.tmpl (lines 638–647 vs 711–720)

The DeleteConfirm and Toasts initialization code is copy-pasted verbatim between initialState and resourceState (page mode). This is fragile — future changes to initialization options need to be applied in two places.

A generated newState() helper in the handler would eliminate the duplication:

func new[[.ResourceName]]State() *[[.ResourceName]]State {
    s := &[[.ResourceName]]State{
        Title: "...",
        // ...
    }
    s.DeleteConfirm = modal.NewConfirm("delete", ...)
    s.Toasts = toast.New("notifications", ...)
    return s
}

Minor

4. Select field minimum option count not validated

File: internal/parser/fields.go

if len(parts) < 3 || strings.TrimSpace(parts[2]) == "" {
    return nil, fmt.Errorf("...")
}
options := strings.Split(parts[2], ",")

status:select:active (a single option) passes validation and generates a <select> with one non-empty choice. This is almost certainly a mistake from the user. Consider requiring at least 2 options:

if len(options) < 2 {
    return nil, fmt.Errorf("field '%s': select type requires at least 2 options", name)
}

5. UseModal and UseToast are always true — add a clarifying comment

File: internal/generator/components.go

usage := ComponentUsage{
    UseModal: true, // always: delete confirmation
    UseToast: true, // always: CRUD feedback
}

The comments explain the intent, which is good. But the conditional import guards in the handler template ([[- if .Components.UseModal]]) look like opt-in guards when they're actually always-on. A brief comment in the template explaining these are reserved for future opt-out configuration would prevent confusion for contributors.


Positive Notes

  • The base.TemplateSet = livetemplate.TemplateSet type alias is clean — it eliminates conversion boilerplate while keeping the components module independent. Good call.
  • The components independence CI workflow (.github/workflows/components-independence.yml) is well-designed: it checks for forbidden imports, verifies go.mod independence, and runs tests with GOWORK=off. This will catch regressions effectively.
  • Replacing browser confirm() with ConfirmModal is the right move — browser dialogs are inconsistent across environments and can be blocked entirely.
  • The generation-time [[ if .Components.UseX ]] guards correctly scope imports and component registrations, preventing dead imports in the generated code.
  • Test coverage is thorough: 3 DetectUsedComponents_* cases, select field parsing tests, and updated golden files.

Summary: The architecture is solid. The two main items I'd want addressed before merge are the &testing.T{} issue (which can silently hide e2e setup failures) and the nil pointer risk on Toasts/DeleteConfirm in deserialized state. The duplicate initialization is worth fixing but less urgent.

@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration (Code-verified findings)

Three reviews have already been posted; this one focuses on correcting false positives from those reviews and confirming real issues against the actual code.


Corrections to earlier reviews

False positive — toast counter bug (Review 1)

Review 1 claimed counter is unexported with no JSON tag. This is wrong. components/toast/toast.go:76 shows Counter int with a json:"counter" tag — it is exported and survives serialization correctly.

False positive — "Orphaned Delete handler" (Review 1)

Review 1 stated the Delete handler is still unconditionally generated. This is wrong. handler.go.tmpl:267 wraps it in [[\- if not .Components.UseModal]]...[[\- end]]. Since UseModal is always true, Delete is never emitted.

Unconfirmed — "Debug log leaks input data" (Review 1)

The review cites handler.go.tmpl:118. In the actual file line 118 is if err := ctx.BindAndValidate(&input, validate); err != nil { — no debug log present. This finding cannot be confirmed against the current code.


Confirmed real issues (with line references)

1. WithFuncs silently drops existing funcs (components/base/templates.go:43–50)

The returned struct assigns Funcs: funcs directly, discarding any pre-existing ts.Funcs. Callers who chain WithFuncs or pass a TemplateSet with existing funcs will silently lose the earlier entries.

2. stdlib reimplementations (components/dropdown/dropdown.go:302–343, components/toast/toast.go:224–238)

toLower, contains, and itoa are reimplemented to "avoid importing strings/strconv". Both are Go stdlib with zero cost. The itoa implementation is O(n²) due to byte-slice prepend, and the same function is copy-pasted into both packages.

3. DataBool doc says case-insensitive but code is case-sensitive (components/base/action.go:79–88)

The godoc comment reads: "Values true, 1, yes, on are considered true (case-insensitive)." But the switch lists explicit variants — "oN" and "yEs" return false. Either use strings.EqualFold or correct the doc comment. This will confuse component authors.

4. &testing.T{} in e2e shared setup (e2e/shared_test.go:79,101,107,113,119)

Errors are propagated via if err != nil guards so setupErr does fire on failure. However, any t.Logf/t.Helper calls inside helpers silently drop output, making failures harder to diagnose.

5. Duplicate state initialization (handler.go.tmpl:628–650 vs 700–723)

DeleteConfirm and Toasts initialization is copy-pasted verbatim between initialState (list view) and resourceState (page mode). A future option change must be applied in both blocks.

6. CI path filter is too narrow (.github/workflows/components-independence.yml:5–7)

The workflow only runs when components/** files change. A main-module change that accidentally couples to components/ internals will not trigger this check. Adding a scheduled run would catch drift between PRs.


Minor

  • components/all.go import order: accordion -> breadcrumbs -> autocomplete — autocomplete should sort before breadcrumbs per goimports
  • ConfirmModal.IsDestructive() (components/modal/modal.go:209–211) wraps a single field with no added behavior

Summary

Architecture is sound, test coverage is solid. Most actionable: WithFuncs data loss (#1) and the DataBool documentation mismatch (#3). The stdlib reimplementations (#2) should be replaced before contributors copy the pattern.

🤖 Generated with Claude Code

- Call injectComponentsForTest in buildAndRunNative before build step
- Add both require and replace directives for components module
- Run go mod tidy after injection to resolve dependency graph
- Fixes TestPageModeRendering, TestPageModeURLRouting, TestTutorialFlow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration

I read the code directly from the working tree to verify findings. Two previous reviews already cover the high-level picture well, so I'll focus on precision, corrections, and a few new issues I found.


Critical: &testing.T{} at e2e/shared_test.go:119 is a silent goroutine exit, not just missed logging

Both prior reviews flagged this, but the actual failure mode is worse than described.

injectComponentsForTest calls t.Fatalf on failure (lines 321, 328, 347). t.Fatalf calls runtime.Goexit() on the current goroutine. When called on a bare &testing.T{} struct (not one wired up by the test runner), runtime.Goexit() exits the sync.Once closure goroutine before setupErr is assigned. The setupSharedResources function then returns nil, so e2e tests silently proceed against a broken app. Unlike the other four call sites (lines 79, 101, 107, 113) which check if err != nil and set setupErr, line 119 is a bare void call with no error handling at all:

// line 119 — no if-err wrapper, no setupErr assignment
injectComponentsForTest(&testing.T{}, appDir)

The fix is to make injectComponentsForTest return an error and check it, or convert the function to use log.Fatalf.


Bug: WithFuncs silently discards existing funcs (components/base/templates.go:43–50)

func WithFuncs(ts *TemplateSet, funcs template.FuncMap) *TemplateSet {
    return &TemplateSet{
        FS:        ts.FS,
        Pattern:   ts.Pattern,
        Namespace: ts.Namespace,
        Funcs:     funcs,  // ts.Funcs discarded
    }
}

Chaining WithFuncs silently loses previously-set funcs. The existing test (TestTemplateSet_WithFuncs) only tests a single call from a fresh TemplateSet with no funcs, so it doesn't catch this. Merging into ts.Funcs (new entries win) would be the expected behavior.


Bug: toLower and contains in dropdown are ASCII-only (components/dropdown/dropdown.go:303–328)

The searchable dropdown's filter uses custom helpers that only handle bytes 'A'–'Z':

func toLower(s string) string {
    b := make([]byte, len(s))
    for i := 0; i < len(s); i++ {
        c := s[i]
        if c >= 'A' && c <= 'Z' { c += 'a' - 'A' }
        b[i] = c
    }
    return string(b)
}

Labels like "Ångström", "München", or "東京" will not search/match correctly — the multi-byte UTF-8 sequences get byte-scrambled. strings.ToLower and strings.Contains handle Unicode correctly and add no measurable overhead. The stated reason in the comment ("avoid importing strings/strconv") is not a valid reason to reimport a broken implementation into a library that users will copy.


Bug: itoa is duplicated and O(n²) (dropdown/dropdown.go:330–343, toast/toast.go:224–238)

Identical function copy-pasted into both packages. The prepend pattern append([]byte{...}, digits...) allocates a new slice on every digit, making this O(n²) in the number of digits. At display scale it's harmless, but the same pattern will be copied into user code and into future components. strconv.Itoa is a single import and O(n).


Doc/code mismatch: DataBool is not truly case-insensitive (components/base/action.go:79–88)

// Values "true", "1", "yes", "on" are considered true (case-insensitive).
func (ctx *ActionContext) DataBool(key string) bool {
    switch v {
    case "true", "1", "yes", "on", "True", "TRUE", "Yes", "YES", "On", "ON":
        return true
    }
}

"tRue", "oN", "yEs", "TrUe" etc. return false. Either use strings.EqualFold per variant or correct the doc comment to list the exact accepted strings. Component authors reading the godoc will be confused when e.g. a checkbox value of "True" from some HTML frameworks is not recognized (it is, in this case), but custom variations aren't.


New: Empty options not filtered in select parser (internal/parser/fields.go:52–55)

options := strings.Split(parts[2], ",")
for i := range options {
    options[i] = strings.TrimSpace(options[i])
}

status:select:active,,inactive passes validation (non-empty third part) and produces an empty string option in the generated <select>. This should filter out empty strings after trimming. Combined with the minimum-options check flagged in the prior review (single option is valid today), both would be fixed by:

var validOptions []string
for _, o := range options {
    if s := strings.TrimSpace(o); s != "" {
        validOptions = append(validOptions, s)
    }
}
if len(validOptions) < 2 {
    return nil, fmt.Errorf("field '%s': select requires at least 2 options", name)
}

New: Searchable.Search always opens dropdown before MinChars (components/dropdown/dropdown.go:158–161)

func (s *Searchable) Search(query string) {
    s.Query = query
    s.Open = true  // unconditional
    if len(query) < s.MinChars {
        s.FilteredOptions = nil
        return
    }
    ...

A user typing their first character (when MinChars=2) sees the dropdown open but empty, which is unexpected. s.Open = true should be inside the len(query) >= s.MinChars branch, or set to len(query) >= s.MinChars.


Minor

  • components/all.go import order: accordion → breadcrumbs → autocomplete breaks goimports alphabetical order; autocomplete should precede breadcrumbs.
  • CI workflow path trigger (.github/workflows/components-independence.yml:5–7): Only fires on components/** changes. A main-module change that accidentally adds a coupling to components/ internals won't trigger the check. Adding a schedule: trigger (or extending paths to include go.mod) would catch drift.
  • Duplicate state initialization (handler.go.tmpl:638–650 vs 711–723): Confirmed verbatim duplication of DeleteConfirm and Toasts init between initialState and resourceState. A shared newState() generator function would avoid future divergence.

Confirmed correct (corrections to Review 1 false positives)

  • Counter int in toast.go:76 is exported with json:"counter" — no serialization issue.
  • Delete handler is gated by [[\- if not .Components.UseModal]] at handler.go.tmpl:267 — not unconditionally generated.
  • UseModal: true and UseToast: true are intentionally always-on (confirmed in internal/generator/components.go).

Summary

The architecture is solid and the test coverage is good. Items I'd want addressed before merge, in priority order:

  1. injectComponentsForTest(&testing.T{}, appDir) — silent goroutine exit masks e2e setup failures
  2. WithFuncs func loss — will surprise component authors chaining calls
  3. Unicode-unsafe search in Searchable — broken for non-ASCII labels
  4. Duplicate itoa — replace with strconv.Itoa in both packages
  5. DataBool doc mismatch — low risk but will confuse contributors

🤖 Generated with Claude Code

- Replace lvt-click="delete" with lvt-click="request_delete" in all selectors
- After clicking request_delete, wait for confirm modal and click confirm_delete
- Remove window.confirm overrides (no longer needed with modal flow)
- Remove lvt-confirm attribute checks (replaced by modal component)
- Add verification for confirm_delete and cancel_delete buttons in modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration (#68#72)

This is a solid, well-structured milestone. The git subtree approach, type alias design, and conditional component detection are all good architectural decisions. The CI workflow for component independence is an excellent safety net. Overall this is close to merge-ready — a few things to address first.


Bugs / Correctness

1. Empty select options not validated after trim

internal/parser/fields.go splits options by comma and trims spaces, but doesn't reject empty strings after trimming:

options := strings.Split(parts[2], ",")
for i := range options {
    options[i] = strings.TrimSpace(options[i])
}

status:select:active,,inactive or status:select:active, ,inactive produce ["active", "", "inactive"]. The empty option will appear in the generated <select> and can be submitted as a valid value. A simple validation loop after trimming would fix this:

for _, opt := range options {
    if opt == "" {
        return nil, fmt.Errorf("field '%s': select options cannot be empty", name)
    }
}

2. RequestDelete doesn't update LastUpdated

Every other state-mutating action sets state.LastUpdated = formatTime() before returning, but RequestDelete returns without it (handler template, line ~307). Depending on how LiveTemplate detects changes, this could silently suppress re-renders when the modal opens.

3. Dead code: the non-modal Delete branch is unreachable

Since UseModal and UseToast are hardcoded to true in DetectUsedComponents, the [[- if not .Components.UseModal]] delete handler block will never be generated. This leaves a maintenance foothole — if someone later makes UseModal conditional (e.g. for headless/API resources), the plain Delete handler won't get toast feedback and won't clear PendingDeleteID.

Either make this a real escape hatch (add UseToast guard inside the plain Delete handler and add state cleanup) or add a comment explaining why it's currently dead.

4. Duplicate modal.NewConfirm / toast.New initializers in page-mode handler

In the page-mode section of handler.go.tmpl, DeleteConfirm and Toasts are initialized again inside the if urlPath != "" block (resourceState). If the options ever diverge from the top-level initialState block (e.g. someone changes the message), only one will be updated. Consider extracting a newInitialState() helper function that both paths share.


Select field in sort / validation

5. Select fields participate in sort-by, which may not be desirable

select fields have GoType: "string", so applySorting will generate case "status_asc": / case "status_desc": entries. Alphabetically sorting an enum column (e.g. active/inactive/pending) could be confusing UX. This is a design question rather than a bug, but worth a conscious decision.

6. Validation gap: select fields aren't constrained to their options

Generated code only emits validate:"required" for select fields. Nothing prevents submitting an arbitrary value not in SelectOptions. For short-lived internal tools this is fine, but it's worth noting — at minimum, a validate:"oneof=opt1 opt2 opt3" tag could be generated.


Code Quality / Minor Nits

7. itoa reimplementation in toast/toast.go

// Helper to convert int to string without importing strconv
func itoa(n int) string { ... }

strconv.Itoa is a zero-dependency standard library call. The current implementation also has a subtle redundancy: Add() always sets msg.Dismissible = true regardless of the caller's intent, and AddInfo/AddSuccess/... also hardcode Dismissible: true. The Dismissible field on Message is effectively read-only from the public API (can never be false). That's probably intentional, but the dead branch in Add is confusing.

8. DeleteInput reused as view/edit identifier

View and Edit handlers both bind to DeleteInput (which has field ID). Semantically wrong — reading an item to edit it isn't a delete operation. A shared IDInput struct would be clearer.

9. time.Sleep(2 * time.Second) in integration test to avoid timestamp collision

integration_test.go:310 — migration files use timestamps in their names. A monotonic counter or UUID suffix would be more reliable than sleeping. This was pre-existing but is now more visible.

10. go-version: '1.26' in CI workflow

Check that this matches what's actually in the root go.mod. go 1.26 is very recent and actions/setup-go@v5 may not support it yet depending on release cadence.


Test Coverage

The new tests are good and cover the main paths. A few gaps:

  • No golden file / template test for a resource with a select field — would catch regressions in the generated <select> HTML, oneof validation, and sort case generation.
  • No test for RequestDeleteConfirmDelete happy path at the handler level — currently only component detection and field parsing are unit-tested. A handler-level test (even using httptest) for the 3-step modal flow would add meaningful coverage.
  • ParseFields test for comma-adjacent empty options ("status:select:,") — should be an error case.

What's working well

  • The type alias pattern for base.TemplateSet is the right call — avoids conversion boilerplate and keeps the component boundary clean.
  • DetectUsedComponents is clean, easy to extend (just add a new boolean field and a loop condition), and well-tested for its current scope.
  • The CI independence workflow is exactly the right guardrail for a subtree setup.
  • Conditional import guards prevent unused imports in generated code without adding runtime cost.
  • The e2e injectComponentsForTest approach is pragmatic for a not-yet-published module.

Summary

Category Verdict
Architecture ✅ Solid
Code quality ⚠️ A few nits (itoa, DeleteInput naming)
Correctness ❌ Empty select option validation, dead code, missing LastUpdated
Security ✅ No issues for a code-gen tool
Test coverage ⚠️ Gaps in template golden tests and handler flow tests

The two must-fix items before merge: empty-option validation (#1) and RequestDelete missing LastUpdated (#2). The dead-code issue (#3) should at least get a comment. Everything else can be follow-up issues.

🤖 Generated with Claude Code

…validation

- Add {{template "lvt:modal:confirm:v1"}} and {{template "lvt:toast:container:v1"}}
  to multi and single kit template_components.tmpl.tmpl files
- Add InjectComponentsForTest to e2e/helpers for use by ValidateCompilation
- Call InjectComponentsForTest before go mod tidy in ValidateCompilation
- Update golden template file with component template calls
- Fixes TestMigration_*, TestResourceGen_*, and modal confirm visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration (#68#72)

Solid milestone overall. The subtree monorepo approach, base.TemplateSet type alias, conditional component detection, and the CI independence workflow are all well-designed. Below is my review focusing on things that should be addressed before merge, followed by minor nits.


Must-fix before merge

1. Empty select options pass validation (internal/parser/fields.go:52-55)

status:select:active,,inactive splits to ["active", "", "inactive"] — the empty string passes the strings.TrimSpace(parts[2]) == "" guard and ends up in SelectOptions. The generated <select> will render a blank option that can be submitted as a valid value. Fix: filter empty strings after trimming:

options := strings.Split(parts[2], ",")
var valid []string
for _, o := range options {
    if s := strings.TrimSpace(o); s != "" {
        valid = append(valid, s)
    }
}
if len(valid) < 2 {
    return nil, fmt.Errorf("field '%s': select requires at least 2 non-empty options", name)
}

2. RequestDelete doesn't set LastUpdated (handler.go.tmpl:296-307)

Every other mutating action sets state.LastUpdated = formatTime() before returning, but RequestDelete returns without it. Depending on how the framework detects dirty state, this could suppress a re-render when the confirm modal opens — meaning users may not see the dialog appear.

3. WithFuncs silently discards existing funcs (components/base/templates.go:43-50)

func WithFuncs(ts *TemplateSet, funcs template.FuncMap) *TemplateSet {
    return &TemplateSet{
        FS:        ts.FS,
        Pattern:   ts.Pattern,
        Namespace: ts.Namespace,
        Funcs:     funcs,  // ts.Funcs is dropped
    }
}

If a TemplateSet already has Funcs and a component author calls WithFuncs to add more, the original functions are silently lost. This will cause template execution errors that are hard to trace. The merge should combine maps:

merged := make(template.FuncMap, len(ts.Funcs)+len(funcs))
for k, v := range ts.Funcs {
    merged[k] = v
}
for k, v := range funcs {
    merged[k] = v
}
return &TemplateSet{..., Funcs: merged}

Should fix (correctness issues)

4. Searchable.Search opens dropdown before MinChars check (components/dropdown/dropdown.go:158-161)

func (s *Searchable) Search(query string) {
    s.Query = query
    s.Open = true          // always opens
    if len(query) < s.MinChars {
        s.FilteredOptions = nil
        return             // but no options yet
    }

A user typing the first character (with MinChars=2) sees the dropdown open but empty — surprising UX. s.Open = true should move inside the >= MinChars branch, or be s.Open = len(query) >= s.MinChars.

5. Unicode-unsafe string operations in dropdown.go (components/dropdown/dropdown.go:303-327)

toLower operates byte-by-byte on ASCII only — it produces incorrect output for non-ASCII labels (e.g., "École" becomes "École" instead of "école"). contains also does raw byte-slice comparison. Use strings.ToLower and strings.Contains from the standard library; they handle Unicode correctly and the import overhead is zero relative to the correctness gain.

6. Dismissible field is effectively a dead API knob (components/toast/toast.go:111-113)

// Default to dismissible
if !msg.Dismissible {
    msg.Dismissible = true
}

Since bool zero-value is false, this if is always true. Dismissible can never be false through the public Add() API — callers who set Dismissible: false explicitly will have it overridden. Either: (a) remove the if block and document that all toasts are dismissible, (b) remove the Dismissible field from Message if it's intentionally always true, or (c) fix the logic: msg.Dismissible = msg.Dismissible || true is also wrong; just remove the block and let callers opt out.


Minor / nits

7. itoa duplicated in toast and dropdown — use strconv.Itoa

Both packages define a private itoa with comment "without importing strconv". The packages already transitively import the standard library extensively. strconv.Itoa is idiomatic, tested, and has no meaningful overhead. The base package already imports strconv directly.

8. DeleteInput reused for Edit handler semantics (handler.go.tmpl:152)

The Edit handler binds to DeleteInput{ID string}. Functionally correct (same shape), but reading an item to edit it via a struct named Delete is confusing for anyone reading generated code. A shared IDInput struct would be semantically clearer.

9. all.go import order breaks goimports (components/all.go:47-50)

"github.com/livetemplate/lvt/components/accordion"
"github.com/livetemplate/lvt/components/breadcrumbs"
"github.com/livetemplate/lvt/components/autocomplete"  // should be before breadcrumbs

autocomplete sorts before breadcrumbs alphabetically. goimports/gofmt will reorder this on next run, creating noisy diffs.

10. Duplicate state initialization in page-mode handler (handler.go.tmpl:638-650 vs 711-723)

modal.NewConfirm(...) and toast.New(...) calls are copied verbatim into both initialState and resourceState. A follow-up divergence (e.g. changing the confirm message or max visible count) in one block but not the other will create a subtle per-URL inconsistency. Consider extracting newResourceState() helper.

11. Dead code: non-modal Delete branch is currently unreachable

Since UseModal: true and UseToast: true are hardcoded in DetectUsedComponents, the [[\- if not .Components.UseModal]] delete handler block can never be generated today. At minimum add a comment explaining this is a future escape hatch, and note that the plain Delete branch would also need toast feedback and PendingDeleteID cleanup if it ever becomes active.

12. time.Sleep(2 * time.Second) in integration test (integration_test.go:310)

Pre-existing but now more visible. Migration filenames are timestamp-based, so a sleep avoids collisions. A monotonic counter suffix or UUID would make this both faster and more reliable in slow CI environments.


Already confirmed correct

  • Counter int with json:"counter" in toast.go — serialization is fine
  • UseModal: true / UseToast: true always-on is intentional and documented
  • go-version: '1.26' in CI — Go 1.26 was released February 2026, actions/setup-go@v5 supports it

Summary

Category Verdict
Architecture ✅ Solid — type alias, subtree, CI independence workflow
Correctness ❌ 3 bugs: empty options, missing LastUpdated, WithFuncs func loss
UX ⚠️ Searchable dropdown opens before MinChars
Code quality ⚠️ Unicode safety, itoa duplication, Dismissible dead branch
Test coverage ⚠️ No golden test for select-field resource, no handler-level modal flow test
Security ✅ No issues for a code-gen tool

Must-fix before merge: items 1, 2, and 3. Everything else can be follow-up issues or addressed in this PR at the author's discretion.

🤖 Generated with Claude Code

Must-fix:
- Filter empty select options after trimming, require >= 2 valid options
- Add LastUpdated to RequestDelete handler for proper re-render
- Merge existing funcs in WithFuncs instead of silently dropping them

Should-fix:
- Fix Searchable.Search to only open dropdown when MinChars met
- Replace custom toLower/contains/itoa with strings/strconv stdlib
- Fix toast Dismissible dead logic (always set to true)
- Replace formatFloat with fmt.Sprintf for correctness
- Implement EmptyColorClass switch based on EmptyColor field
- Fix nil dereference in dropdown template when no item selected
- Add aria-labelledby ID to accordion button for accessibility
- Register sub template func for timeline component
- Fix README module paths and option function names
- Rename DeleteInput to IDInput for semantic clarity
- Add dead code comment on non-modal Delete branch
- Fix all.go import ordering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration (Code-verified)

Six reviews have already been posted on this PR. This review focuses on correcting false positives in those reviews and confirming what's real in the actual checked-out code, plus a few new findings.


Corrections to earlier reviews

Several bugs cited in earlier reviews no longer exist in the current working tree:

False positive — WithFuncs silently discards funcs (flagged by Reviews 2, 4, 5, 6)

The current components/base/templates.go:43–57 already merges maps correctly:

merged := make(template.FuncMap, len(ts.Funcs)+len(funcs))
for k, v := range ts.Funcs { merged[k] = v }
for k, v := range funcs    { merged[k] = v }

This bug does not exist in the code on disk. The existing TestTemplateSet_WithFuncs covers the non-chained case; a chained-call test would still be a good addition.

False positive — toLower/contains/itoa reimplementations in dropdown.go (flagged by Reviews 2, 4, 5, 6)

components/dropdown/dropdown.go:171–176 already uses strings.ToLower and strings.Contains from stdlib. There are no custom helpers in the current file.

False positive — Searchable.Search opens before MinChars (flagged by Reviews 5, 6)

components/dropdown/dropdown.go:163 is already s.Open = len(query) >= s.MinChars — conditional, not unconditional.

False positive — empty select options not validated (flagged by Reviews 5, 6)

internal/parser/fields.go:52–61 already filters empty options and enforces len(options) < 2 → error. This is well-tested.


Confirmed real issues

1. Dismissible is a dead API knob (components/toast/toast.go:113)

Add() unconditionally sets msg.Dismissible = true, regardless of what the caller passes:

// All toasts are dismissible by default
msg.Dismissible = true

Every convenience method (AddInfo, AddSuccess, etc.) also passes Dismissible: true. The field on Message can never be false through the public API — callers who explicitly set Dismissible: false (e.g., for a sticky error) will be silently overridden. Either remove the field and document that all toasts are always dismissible, or change line 113 to only set the default when !msg.Dismissible is ambiguous (hard with a bool; use a pointer or tristate).

2. DataBool godoc says case-insensitive but code is case-sensitive (components/base/action.go:79–87)

The comment reads: Values true, 1, yes, on are considered true (case-insensitive). The implementation uses an explicit switch with hand-enumerated variants. "oN", "TrUe", "yEs" all return false. Fix by using strings.ToLower before the switch, or correct the godoc to list the exact accepted strings. This will mislead component authors.

3. TemplateProvider interface is defined but never used (components/base/templates.go:59–64)

type TemplateProvider interface {
    Templates() *TemplateSet
}

Nothing in the codebase references this interface — not components/all.go, not the livetemplate framework call sites. If it documents a convention for component authors, a comment to that effect would help. If it was intended for use by WithComponentTemplates, it should be wired up; otherwise remove it to avoid dead-code confusion.

4. Hardcoded Tailwind classes in framework methods (components/modal/modal.go:214–237, components/toast/toast.go:177–208)

ConfirmButtonClass(), IconClass(), GetPositionClasses(), and GetTypeClasses() return raw Tailwind utility strings. Existing kits include Bulma, PicoCSS, and "none". Applications using a non-Tailwind kit will get broken/nonsensical output from these methods. This is the same concern as the kit-specific class helpers elsewhere in the codebase. At minimum, these should be gated on .IsStyled() or accept a kit-aware renderer.

5. all.go import order breaks goimports (components/all.go:47–50)

autocomplete (alpha) sorts after breadcrumbsgoimports will reorder this on next run and create noisy diffs. Trivial to fix now while the file is new.


Architecture notes

The core design decisions are sound and well-executed:

  • Type alias (base.TemplateSet = livetemplate.TemplateSet) elegantly eliminates conversion boilerplate. The WithFuncs standalone-function trade-off is correctly documented.
  • DetectUsedComponents is clean, easy to extend, and properly unit-tested. The always-on UseModal/UseToast pattern is intentional and the inline comments explain it.
  • CI independence workflow (GOWORK=off, forbidden-import grep, go.mod check) is an excellent guardrail for the subtree setup.
  • Conditional import guards ([[ if .Components.UseX ]]) keep generated code lean without runtime cost.
  • Searchable.Search and select parsing are cleanly implemented in the current code.

Summary

Category Status
WithFuncs bug ✅ Already fixed — false positive
toLower/stdlib reimplementations ✅ Already fixed — false positive
Searchable.Search opens before MinChars ✅ Already fixed — false positive
Empty option validation ✅ Already fixed — false positive
Dismissible dead API knob ❌ Real issue, new finding
DataBool doc/code mismatch ❌ Real issue, confirmed
TemplateProvider unused ⚠️ Confirmed dead code
Hardcoded Tailwind in kit-unaware methods ⚠️ Real concern for non-Tailwind apps
all.go import order 🔧 Trivial fix

The PR is in better shape than many of the prior reviews suggest. The two items worth addressing before merge: Dismissible always-true override and the DataBool doc mismatch. The Tailwind-in-kit issue is a larger design question that could be tracked as a follow-up.

🤖 Generated with Claude Code

adnaan and others added 2 commits February 28, 2026 13:33
…iming

Poll for detail view content with retries instead of checking once after
WebSocket ready. Handles transient re-render during WebSocket handshake
in CI Docker Chrome environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

Code Review — Milestone 4: Components Integration (#68#72)

This is a substantial milestone — 21 UI components into the monorepo, modal/toast/dropdown integration into generated code, and the new select field type. The overall architecture is solid and well-tested. Here are my findings.


Bugs

1. dtID template function uses wrong map key (datatable/templates.go:124)

base.Base declares ComponentID string with tag json:"id", so after a JSON round-trip the map key is "id" (lowercase). The dtID helper does m["Id"] (capital I) and will silently return "" for any JSON-decoded datatable. Fix: change to m["id"].


2. toast.Container.Add unconditionally forces Dismissible = true (toast/toast.go:113)

This makes WithDismissible(false) a dead code path — any message passed to Add() will always be dismissible regardless of how it was constructed. Either remove the override (relying on NewMessage's default of true) or make it conditional. There is also no test covering this case.


3. DataTable.GetFilteredRows() does not actually filter (datatable/datatable.go:263-276)

When FilterValue != "", the method returns all rows unchanged with the comment "actual filtering done server-side." The problem: TotalRows(), TotalPages(), IsEmpty(), and GetPageRows() all call GetFilteredRows(), so their return values will be wrong whenever a filter is active. If client-side filtering is intentionally out of scope, these dependent methods need to account for the server-side count separately (e.g., via a TotalFiltered int field). As written, TotalPages() will not update when FilterValue changes.


Code Quality

4. debugType template function left in production (datatable/templates.go:316-318)

Debug utilities should not ship in production. Remove it (or move to a test helper if needed for datatable template testing).


5. ActionContext.DataBool uses an incomplete explicit case list (base/action.go:83)

The explicit list handles specific capitalizations but misses mixed-case inputs like "tRuE". Use strings.ToLower(v) with a normalized switch instead.


6. DataTable.PageInfo() is an unimplemented stub (datatable/datatable.go:470-487)

The method returns "" with a comment "Would format 'Showing 1-10 of 100' but keeping simple." Either implement it or delete the method. Leaving a public method that always returns "" is surprising to callers.


7. Doc comment shows non-existent generic syntax (datatable/datatable.go:132-133)

The example shows datatable.New[User]("users", ...) but the actual signature is func New(id string, opts ...Option) *DataTable — non-generic. This mismatch will confuse users trying to follow the example.


Minor Notes

8. Missing blank line before RequestDelete comment (resource_handler.go.golden:221)

The Back function's closing brace is immediately followed by // RequestDelete handles... with no blank line separator. Minor style nit.

9. components/go.mod inherits a heavy transitive dependency tree

The library only directly needs github.com/livetemplate/livetemplate, but the framework brings in AWS SDK, Redis, etc. transitively. Not a blocker, but worth tracking if components/ is ever meant to be a standalone, lightweight import.


What Works Well

  • The type alias (base.TemplateSet = livetemplate.TemplateSet) is exactly the right tool here — preserves type identity so component templates can be passed directly to WithComponentTemplates() without conversion.
  • The CI independence workflow is a great addition. Verifying no forbidden imports and that the submodule builds and tests in isolation (GOWORK=off) is the right guard for a subtree-managed module.
  • DetectUsedComponents is clean and covered by three well-scoped test cases.
  • The RequestDelete → ConfirmDelete → CancelDelete generated handler pattern is a major UX improvement over browser confirm().
  • DataTable.MarshalJSON correctly serializes computed fields (PageRows, VisibleColumns, HasNextPage, etc.) so templates work properly over JSON RPC.
  • Per-component test coverage is strong (e.g., 548 lines for modal, 628 for dropdown, 720 for autocomplete).

Summary

Must-fix before merge:

  1. dtID map key case mismatch — silently broken for JSON-decoded datatables
  2. Add() overriding Dismissible — makes WithDismissible(false) dead code
  3. Remove debugType from production template functions

Can track as follow-up:

  • Implement or remove GetFilteredRows / PageInfo stubs
  • Fix DataBool case handling (strings.ToLower)
  • Fix datatable.New doc comment (no generics)

@claude
Copy link

claude bot commented Feb 28, 2026

PR Review: Milestone 4 — Components Integration

This is a substantial PR bringing 21 UI components into the monorepo. Overall the architecture is clean and the test coverage for the base/ package is solid. Here are my findings:


Bugs

1. dtID template function uses wrong JSON key (datatable/templates.go:124)

The dtID template function in the map representation branch looks for m["Id"] but base.Base.ComponentID has the JSON tag json:"id" (lowercase). When DataTable is serialized via its custom MarshalJSON, the embedded Base fields produce key "id" in the resulting map — not "Id". This means dtID will always return an empty string in the map/RPC representation, breaking any template that needs the datatable ID after a server round-trip.

Fix: Change m["Id"] to m["id"].


2. GetFilteredRows silently does no filtering (datatable/datatable.go:273)

When FilterValue is non-empty and the cache is nil, the method stores all rows in filteredRows without actually filtering them. The comment says "actual filtering done server-side" but the cache write means subsequent calls will also return all rows. More importantly, the public SetFilter() method and FilterValue field imply filtering works — but it silently does nothing client-side.

Suggestion: Either implement basic client-side filtering, rename the field to signal its server-side-only purpose (e.g. ServerFilterHint), or remove the cache-write so the stub at least stays non-caching rather than actively caching the wrong result.


3. toast.Container.Add() unconditionally overrides Dismissible (toast/toast.go:113)

The line msg.Dismissible = true always overrides the caller's value, making it impossible to add a non-dismissible toast via Add(). The Message.Dismissible field exists but can never be set to false through this API. Either remove the field from Message if always-dismissible is the intent, or respect the caller's value.


Documentation Issues

4. Package doc shows non-compilable generic syntax (datatable/datatable.go:11)

The package-level doc example shows datatable.New[User]("users", ...) with a generic type parameter, but the actual New() function is not generic. This example won't compile and will confuse users. The referenced datatable.WithData(users) option also does not appear in the options file.


5. PageInfo() is a stub that always returns empty string (datatable/datatable.go:486)

The method body returns "" with a comment acknowledging it would show "Showing 1-10 of 100". If this is intentional for now, it should either be removed or clearly documented as always-empty to avoid confusing callers.


Code Quality

6. Debug helper registered in production (datatable/templates.go:316)

A debugType template function is registered inside the production Templates() call with the comment "Print for debugging". This should be removed before merge.

7. Import grouping in all.go

base is imported between breadcrumbs and datatable in a single import block. As a foundational utility package, it should be in a separate group above the other component packages, consistent with standard Go import conventions.

8. Hardcoded version string (all.go:107)

Version() returns the hardcoded string "0.1.0" which will drift from the module's actual semver. Consider removing it entirely (callers can use debug.ReadBuildInfo()) or deriving it from a build-time constant.

9. AllSelected() stale-ID edge case

AllSelected() compares len(dt.SelectedIDs) to the count of non-disabled rows, but SelectedIDs can contain stale IDs from a previous dataset if rows are replaced by direct field assignment rather than through SetData() (which properly calls DeselectAll()). Since both Rows and SelectedIDs are exported fields, direct mutation is possible.


What's Working Well

  • Type alias base.TemplateSet = livetemplate.TemplateSet is an elegant solution that avoids conversion boilerplate when calling WithComponentTemplates().
  • The explicit warning in base.Base against adding custom MarshalJSON is an excellent guardrail for component authors.
  • The CI workflow using GOWORK=off is a solid architectural safety net for verifying module independence.
  • Consistent functional-options pattern across all 21 components makes the API predictable and extensible.
  • The dual struct/map representation in datatable template functions handles RPC serialization round-trips thoughtfully.
  • Test coverage in base/ is thorough and well-structured.

Bugs 1, 2, and 3 should be fixed before merge. The rest can be addressed in a follow-up iteration.

Generated with Claude Code

- Fix dtID template function using wrong JSON key (m["Id"] -> m["id"])
- Remove debugType debug helper from production template functions
- Fix DataBool to use strings.ToLower for true case-insensitive matching
- Remove unconditional Dismissible override in toast.Add()
- Implement PageInfo() stub (was always returning empty string)
- Fix datatable doc comment removing non-existent generic syntax

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

test comment

@claude
Copy link

claude bot commented Feb 28, 2026

Code Review: Milestone 4 — Components Integration

This is a substantial and well-architected milestone. The type-alias design for TemplateSet, the conditional component guards, and the independence CI are all solid. The existing Copilot review covers several important issues; here are additional findings from my pass.


Bug 1: SearchableOption and MultiOption are unreachable from constructors

Files: components/dropdown/dropdown.go, components/dropdown/options.go

NewSearchable and NewMulti both accept ...Option (operating on Dropdown), not ...SearchableOption / ...MultiOption. The search-specific and multi-specific options defined in options.go CANNOT be passed at construction time and are silently unusable. Concretely: dropdown.WithMinChars(2) returns SearchableOption, not Option, so passing it to NewSearchable is a compile error. Same issue for WithMaxSelections and WithSelectedValues on NewMulti. The constructors should accept their respective option types.


Bug 2: RemoveTab can leave ActiveID pointing to a removed tab

File: components/tabs/tabs.go lines 187-189

SetActive only assigns ActiveID when the target is found AND is not disabled. If Items[0] is disabled, the fallback t.SetActive(t.Items[0].ID) is a no-op and ActiveID remains equal to the just-removed tab's ID. Subsequent calls to IsActive, ActiveTab, Next, and Previous all read a stale ID. The fix: iterate forward from index 0 to find the first non-disabled tab when falling back.


Design concern: NewVertical / NewPills have no type-level distinction from New

File: components/tabs/tabs.go lines 89-102

Both are exact pass-throughs to New(). The styling difference is purely in which template name the caller writes. A user who calls NewVertical but renders with 'lvt:tabs:horizontal:v1' gets horizontal styling with no error. At minimum, document that constructor names are cosmetic. Longer-term, embedding a Style field so the template picks its own variant would eliminate the mismatch.


Concern: Heavy transitive dependencies in components/go.mod

The module inherits AWS SDK v2 (10+ packages), Redis, WebSocket, and Minify as indirect deps from github.com/livetemplate/livetemplate. A developer importing only components for UI will have all of those in their go.sum. Not blocking, but worth tracking for supply-chain hygiene.


Concern: findActiveIndex silently defaults to index 0 for unknown ActiveID

File: components/tabs/tabs.go lines 163-170

Used by both Next() and Previous(). If ActiveID does not match any tab (e.g. after the RemoveTab bug), navigation silently starts from index 0. Returning -1 and guarding in callers would surface the inconsistency earlier.


Minor: CI is missing the race detector

GOWORK=off go test -race ./... is low-cost in CI and catches real data-race issues early. Components hold mutable state exercised by the LiveTemplate action loop.


Minor: Version() hardcodes 0.1.0

File: components/all.go lines 105-108. Will drift from the actual module version. debug.ReadBuildInfo() at runtime or a go generate step would keep it in sync.


Minor: SheetModal.Position should be a typed constant

File: components/modal/modal.go lines 250-261. An invalid value like 'center' silently falls back to a right-side sheet via the default case. Following the same pattern as Size, a Position type with four exported constants would make invalid values clearly visible.


What stands out positively

  • The base.Base comment warning against custom MarshalJSON (lines 11-14) is exactly the kind of footgun documentation that prevents real bugs for contributors.
  • AllData() returning a copy rather than the live map is correct defensive design.
  • The independence CI using GOWORK=off correctly tests module isolation.
  • ConfirmModal.ConfirmButtonClass() / IconClass() cleanly keep styling logic out of templates.
  • Test coverage across base, dropdown, and modal is thorough with solid edge-case handling (nil data, negative values, case-insensitive bool parsing).

- Add WithSearchOptions/WithMultiOptions chainable methods so variant-
  specific options (WithMinChars, WithMaxSelections) are reachable
- Fix RemoveTab to find first non-disabled tab instead of blindly
  trying Items[0] which may be disabled (leaving stale ActiveID)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 28, 2026

Code Review: Milestone 4 — Components Integration

Overall this is a well-structured PR. The monorepo migration of 21 UI components follows consistent patterns (functional options, base.Base embedding, embedded template FS), has strong test coverage per component, and the CI independence workflow is a nice guardrail. Here are my observations:


Bugs / Correctness

1. DataTable.GetFilteredRows() is a stub — affects all pagination/count methods

components/datatable/datatable.go:262-277 — The filter logic is not implemented:

// Filter implementation would go here
// For now, return all rows (actual filtering done server-side)
dt.filteredRows = dt.Rows

This means FilterValue/FilterColumn have no effect on counts. But TotalRows(), TotalPages(), PageInfo(), StartIndex(), EndIndex(), IsEmpty() all delegate to GetFilteredRows(), and their computed values are baked into MarshalJSON and sent to templates. If a user calls SetFilter("foo") expecting page counts to reflect filtered data, they'll get incorrect totals. Consider removing the filter fields until implemented, or adding a clear doc comment that client-side filtering is not yet supported.

2. filteredRows cache invalidated only via methods, but fields are exported

components/datatable/datatable.go:128 — The filteredRows field is a private cache invalidated by Sort(), SetFilter(), SetData(), etc. However, users can directly mutate dt.Rows, dt.FilterValue, or dt.FilterColumn (all exported) without triggering cache invalidation, leading to stale paginated/sorted data. Consider making the cache-invalidating fields unexported, or documenting that direct field assignment bypasses caching.

3. DataTable.MarshalJSON creates an embedding footgun

components/base/base.go:10-15 explicitly warns:

Do NOT add custom MarshalJSON/UnmarshalJSON to Base — it will break parent struct serialization

DataTable itself adds a MarshalJSON at datatable.go:533. If a user embeds *DataTable in their own app state struct, json.Marshal will promote DataTable.MarshalJSON to the parent, serializing only DataTable fields — the exact problem the base doc comment warns about. Worth calling this out explicitly in DataTable's own doc comment.


Design Issues

4. Autocomplete.filterFunc is silently lost after JSON round-trips

components/autocomplete/autocomplete.go:88 — The custom filterFunc is unexported and cannot survive JSON serialization/deserialization (which happens on every LiveTemplate RPC tick). After any state round-trip, it silently falls back to defaultFilter. This is a fundamental limitation that should be prominently documented on the Autocomplete struct and on any WithFilterFunc option.

5. SheetModal.Position uses untyped string — invalid values fail silently

components/modal/modal.go:254 — An invalid position like "center" silently falls through to the default "right-0 top-0 h-full" in PositionClass(). This is inconsistent with Size which uses a typed constant. A Position type with PositionLeft/Right/Top/Bottom constants would catch this at the call site.

6. NewVertical / NewPills are identical to New — no enforcement of template pairing

components/tabs/tabs.go:89-102 — Both variants just call New() and return *Tabs. The visual difference (horizontal/vertical/pills) is determined solely by which template name you use in the template file. A user calling NewVertical() has no compile-time or runtime guarantee they'll use the vertical template. Consider adding a Variant field or at minimum a doc comment explaining this convention.


Performance

7. Multi.ToggleItem() is O(n×m)

components/dropdown/dropdown.go:251-272 — Two nested linear searches: first through SelectedItems, then through Options. For a large option set like a country picker, this is noticeable. A map[string]struct{} for selected IDs (similar to DataTable.SelectedIDs) would make the membership check O(1).


Minor

8. Version() is hardcoded

components/all.go:106return "0.1.0" will drift from the actual module version. Either generate this at build time, remove it, or add a comment to update it on each release.

9. CI: tests run without -race

.github/workflows/components-independence.yml:57-60 — Consider adding -race to go test ./... to catch data races, especially given the filteredRows cache and stateful component methods.

10. Heavy transitive dependencies in components/go.mod

The components module pulls in AWS SDK, Redis, WebSocket, and validation libraries as transitives via github.com/livetemplate/livetemplate. The PR summary acknowledges this design tradeoff. For a future iteration, a lighter local TemplateSet struct (not aliasing the parent) could remove these transitive dependencies entirely.


Positives

  • Consistent functional-options pattern across all 21 components
  • base.Base embedding cleanly handles ID/namespace/styled concerns
  • TemplateSet type alias is a clean solution for the WithComponentTemplates() compatibility problem — and the doc comment explaining why WithFuncs is a standalone function is appreciated
  • components-independence.yml is an excellent guardrail for module boundary enforcement
  • Every component has a _test.go with meaningful coverage
  • AllData() returning a map copy (not the underlying map) in ActionContext is good defensive practice

Items 1 and 3 are worth addressing before merging as they can cause subtle runtime bugs for users embedding DataTable or relying on filter-accurate pagination counts.

@claude
Copy link

claude bot commented Feb 28, 2026

Code Review — Milestone 4: Components Integration

I reviewed the final merged state of this PR. Overall this is a well-thought-out milestone with a clean architecture. A few items worth addressing before this lands in a release.


Note on the existing Copilot review

Several findings in the Copilot review do not match the actual code in this PR. To avoid confusion:

  • Toast counter field: Counter int \json:"counter"`` is already exported with a JSON tag — no bug.
  • action === 'delete': The layout template correctly uses 'confirm_delete' already.
  • Select min=3 validation: Select fields already branch to validate:"required" (no min=3); the IsSelect branch comes before the generic string branch.
  • Debug log.Printf: No debug log leaking input data exists in the handler template — only proper error-path logging.
  • Orphaned Delete handler: The direct Delete method is guarded by [[- if not .Components.UseModal]]. Since UseModal is always true in DetectUsedComponents, it is never emitted. No bypass is possible.
  • No minimum option count: len(options) < 2 is already enforced with a clear error message.

All six items from the Copilot review were already addressed in this branch. Worth keeping that in mind so reviewers don't re-open closed issues.


🟡 DetectUsedComponents always forces modal + toast on

File: internal/generator/components.go

func DetectUsedComponents(data ResourceData) ComponentUsage {
    usage := ComponentUsage{
        UseModal: true, // always: delete confirmation
        UseToast: true, // always: CRUD feedback
    }
    ...
}

The function is named "detect" but two of the three components are unconditionally forced. Users who want a lightweight resource without modal/toast have no generation-time opt-out and must manually edit the generated code. This is fine for now, but the function name implies detection rather than policy — consider DefaultComponentUsage or a comment explaining that modal/toast are always included by design.


🟡 Both TemplateProvider and ActionProvider interfaces are dead code

Files: components/base/templates.go:52–64, components/base/action.go:117–124

Neither interface is referenced, implemented, or used anywhere outside its own definition file. The doc comment on ActionProvider says "The framework will automatically match…" which implies framework integration that doesn't exist yet. If these are forward-looking design contracts for component authors, mark them clearly:

// TemplateProvider documents the convention for components that provide templates.
// This interface is not yet enforced by the framework but serves as a guide
// for component authors.

Otherwise, remove them to avoid confusion about what the framework actually does.


🟢 CI workflow Go version may be too aggressive

File: .github/workflows/components-independence.yml:14

go-version: '1.26'

Go 1.26 may be newly released or not yet available on GitHub-hosted runners at the time of this PR. Using 'stable' instead of a pinned version keeps CI green without requiring manual bumps on each Go release, and avoids the risk of using a not-yet-distributed toolchain.


🟢 ActionName is susceptible to ID collisions

File: components/base/base.go

func (b *Base) ActionName(action string) string {
    return action + "_" + b.ComponentID
}

If a user creates two components — one with ID "my" and action "select_sub" and another with ID "my_sub" and action "select" — both produce the action name "select_my_sub". The router presumably splits on the last _, but this is worth documenting or enforcing at construction time (e.g., reject IDs containing _ in NewBase).


✅ What is done well

  • Type alias design (base.TemplateSet = livetemplate.TemplateSet) is the right call — zero-cost, no conversion friction, and the WithFuncs standalone-function trade-off is clearly documented.
  • Conditional component guards ([[ if .Components.UseModal ]] etc.) in all generated files keep output lean and avoid unnecessary imports.
  • Components independence CI (GOWORK=off go test ./...) is exactly the right approach to prevent accidental coupling to the parent module.
  • Select field pre-selection in edit form correctly reflects the current saved value — subtle and easy to get wrong, handled correctly here.
  • Golden file tests provide reliable regression coverage for generated handler output.

@adnaan adnaan merged commit c00a45c into main Feb 28, 2026
4 checks passed
This was referenced Feb 28, 2026
adnaan added a commit to livetemplate/tinkerdown that referenced this pull request Mar 1, 2026
Move all imports from github.com/livetemplate/components/* to
github.com/livetemplate/lvt/components/* since components were
consolidated into the lvt monorepo in Milestone 4 (PR livetemplate/lvt#142).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
adnaan added a commit to livetemplate/tinkerdown that referenced this pull request Mar 1, 2026
* refactor: migrate component imports from standalone repo to lvt monorepo

Move all imports from github.com/livetemplate/components/* to
github.com/livetemplate/lvt/components/* since components were
consolidated into the lvt monorepo in Milestone 4 (PR livetemplate/lvt#142).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: sync dependency versions from workspace

Run go work sync to align dependency versions across the workspace
after the component import migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add missing go.sum entries for Docker build

The go work sync command upgraded transitive dependency versions but
only added go.sum entries that the workspace needed. Docker builds
run without the workspace, so they need the complete go.sum with all
module checksums.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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