package app

import (
	"strings"
	"time"

	. "kjol/vdom"
	"kjol/wasmruntime"
	ui "kjol/webui"
)

// The whole kit, on one page.
//
// It used to be three pages — "UI kit", "Overlays", "AutoTable" — which is a split
// along the lines of the SOURCE FILES rather than along anything a reader wants. A
// person looking for a date picker does not know, and should not have to guess,
// whether it was filed under forms or under overlays. So: one page, one scroll, and a
// sidebar of groups that jumps you to the one you are after.
//
// The cost is a long page and a lot of live components on it at once. That is the
// right trade for documentation you search with ctrl-F.

// componentGroup is one section of the page AND one line in the sidebar. Declaring
// them once, as data, is what keeps those two in step — the sidebar cannot list a
// section that does not exist, and a section cannot go missing from the sidebar.
type componentGroup struct {
	ID    string // the anchor, and the docSection's element id
	Label string
	Icon  string
	Blurb string // shown on the /wasm index; too long for the sidebar
}

func componentGroups() []componentGroup {
	return []componentGroup{
		{ID: "buttons", Label: "Buttons", Icon: "check",
			Blurb: "Colour, outline, size, icon. Segmented controls and link-buttons."},
		{ID: "badges", Label: "Badges & alerts", Icon: "circle-info",
			Blurb: "Solid badges, tinted alerts, and the environment badge that shows nothing in production."},
		{ID: "cards", Label: "Cards & layout", Icon: "squares",
			Blurb: "Cards, headers, dividers, crumb trails, a spinner, a code box."},
		{ID: "icons", Label: "Icons", Icon: "cube",
			Blurb: "The kit draws its own: stroke paths in Go, no FontAwesome anywhere near it."},
		{ID: "forms", Label: "Forms & inputs", Icon: "pencil",
			Blurb: "Controlled inputs, and masks that are idempotent because they are fed their own output."},
		{ID: "selects", Label: "Selects & comboboxes", Icon: "filter",
			Blurb: "Combobox, multi-select with collapsing pills, and an async one that discards stale responses."},
		{ID: "toggles", Label: "Toggles & signature", Icon: "check",
			Blurb: "Switches, and a signature pad whose value IS the SVG you drew."},
		{ID: "dates", Label: "Dates", Icon: "calendar",
			Blurb: "A picker you can type into — 7/4/26, Jul 4 2026 and 2026-07-04 all work."},
		{ID: "tables", Label: "Tables", Icon: "table",
			Blurb: "PrettyTable prints, CellGrid edits, AutoTable filters, sorts, calculates and exports."},
		{ID: "overlays", Label: "Overlays", Icon: "layers",
			Blurb: "Tooltips, popovers, menus, modals — measured, portaled, flipped to fit."},
		{ID: "feedback", Label: "Toasts & tours", Icon: "bolt",
			Blurb: "Self-dismissing toasts, a spotlight tour, and a flash for data somebody else changed."},
		{ID: "navigation", Label: "Tabs & navigation", Icon: "bars",
			Blurb: "Tabs, CRM tabs, accordions, and a sidebar of jump links."},
		{ID: "search", Label: "Fuzzy search", Icon: "magnifying-glass",
			Blurb: "Subsequence matching with typo tolerance, scored and highlighted."},
		{ID: "charts", Label: "Charts", Icon: "chart-column",
			Blurb: "SVG drawn in Go, on the server — in the HTML before any WebAssembly loads."},
		{ID: "theming", Label: "Theming", Icon: "sun",
			Blurb: "The token contract, and the switch. No component names a colour."},
	}
}

//gowasm:page /wasm/components layout=app
func ComponentsPage(d Deps) func() *VNode {
	// Shared across sections, so both the overlay menus and the toast buttons raise the
	// same toasts — and there is one queue rather than two arguing about the corner.
	toaster := ui.NewToaster(ui.ToasterOptions{Position: ui.ToastBottomRight})
	push := func(kind ui.ToastType, msg string) { toaster.Push(ui.Toast{Message: msg, Type: kind}) }

	// The tour targets sections BY CSS SELECTOR — the same ids the sidebar jumps to.
	tour := ui.NewTutorial(ui.TutorialOptions{
		Steps: []ui.TutorialStep{
			{Title: "Buttons", Target: "#buttons",
				Content: func() *VNode { return Text("The presentational end: a props struct in, a VNode out.") }},
			{Title: "Overlays", Target: "#overlays",
				Content: func() *VNode { return Text("Measured against the real viewport, portaled out of the tree.") }},
			{Title: "Tables", Target: "#tables",
				Content: func() *VNode { return Text("Filter, sort, calculate, export — from a column list.") }},
			{Title: "That's the tour",
				Content: func() *VNode { return Text("Escape ends it. Arrow keys and Enter move between steps.") }},
		},
	})

	// Every section is a CLOSURE built once, here. The controllers inside them (menus,
	// modals, pickers, the table) own refs, timers and open state — build one inside the
	// render and it is thrown away and rebuilt every frame, so it can never stay open.
	sections := []func() *VNode{
		buttonsSection(),
		badgesSection(),
		cardsSection(),
		iconsSection(),
		formsSection(),
		selectsSection(),
		togglesSection(),
		datesSection(),
		tablesSection(),
		overlaysSection(push),
		feedbackSection(toaster, tour),
		navigationSection(),
		searchSection(),
		chartsSection(),
		themingSection(),
	}

	return func() *VNode {
		body := []*VNode{
			docSection("using", "Using a component",
				prose("A component is a function taking a props struct. There is no class hierarchy and "+
					"nothing to register: a component is a value, so you can build one, store it, pass it "+
					"around, and the compiler will tell you when you got it wrong."),
				prose("The floating ones — menus, modals, tooltips, pickers, the table — are CONTROLLERS "+
					"instead. They own refs, timers and open state, so you build them once alongside your "+
					"signals. Build one inside a render closure and it is discarded and rebuilt on every "+
					"frame, which means it can never stay open long enough for you to see it."),
				code("app/components.go", kitSnippet),
				note("Styling is Tailwind, compiled from your Go",
					"The Tailwind engine scans .go files for class names, because that is where the markup "+
						"is. There is no JavaScript build anywhere in Kjøl Wasm Web — the CSS is "+
						"compiled by a Go program, from Go source."),
				row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
			),
		}
		for _, s := range sections {
			body = append(body, s())
		}

		// Fixed-position hosts. Where they sit in the tree makes no difference to where
		// they appear, so they go last, once.
		body = append(body, toaster.Render(), tour.Render())

		return docPage("Kjøl Wasm Web", "Components",
			"Every component in kjol/webui, running. Not a screenshot of one anywhere: each block below "+
				"is the real component, rendered by the same Go that renders it in an application. Use the "+
				"sidebar to jump to a group.",
			body...)
	}
}

// ---- buttons -------------------------------------------------------------

func buttonsSection() func() *VNode {
	clicks := NewSignal(0)
	span := NewSignal("week")

	return func() *VNode {
		return docSection("buttons", "Buttons",
			prose("Colour, outline, size and an optional icon, from one props struct. A button with an "+
				"icon and no text gets square padding rather than the wide pill a text button gets — so "+
				"an icon button is a square, not a lozenge with a picture rattling around in it."),

			demo("Colours",
				row("flex flex-wrap items-center gap-2",
					ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "White"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Light"}),
				),
			),

			demo("Outline, size, icon, disabled",
				row("flex flex-wrap items-center gap-2",
					ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
				),
			),

			demo("Click it — clicked "+itoa(clicks.Get())+" times",
				row("flex flex-wrap items-center gap-3",
					ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Count",
						OnClick: func() { clicks.Set(clicks.Get() + 1) }}),
					ui.ButtonLink(func() { clicks.Set(0) }, Text("Reset (a link that is a button)")),
					ui.BackLink("#components", "Back link", false),
				),
			),

			demo("Segmented control — selected: "+span.Get(),
				ui.SegmentedButtons([]ui.SegmentedButtonOption{
					{Value: "day", Label: "Day"},
					{Value: "week", Label: "Week"},
					{Value: "month", Label: "Month"},
				}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
			),
		)
	}
}

// ---- badges & alerts -----------------------------------------------------

func badgesSection() func() *VNode {
	return func() *VNode {
		return docSection("badges", "Badges & alerts",
			prose("A badge is a solid fill with white text, so it stays legible in both themes without "+
				"a variant on it. An alert is the opposite: a wash of colour behind dark text, which is "+
				"exactly the case a token cannot carry into dark mode — a red-50 tint is invisible on a "+
				"near-black surface — so alerts are one of only two places in the kit with a dark: rule."),

			demo("Badges",
				row("flex flex-wrap items-center gap-2",
					ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
					ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
					ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
					ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
					ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
					ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
				),
			),

			demo("Alerts",
				row("flex flex-col gap-3",
					ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
					ui.Alert(ui.AlertGreen, "", Text("A success alert, with no header.")),
					ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
					ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
					ui.Alert(ui.AlertGray, "", Text("And a neutral one.")),
				),
			),

			demo("Environment badge",
				row("flex flex-wrap items-center gap-3",
					ui.EnvBadge("development"),
					ui.EnvBadge("staging"),
					// Production deliberately renders NOTHING — the badge exists to tell you that
					// you are NOT in production, and a badge that is always there says nothing.
					Span(Attr("class", "text-xs text-ink-muted"),
						Text("(production renders nothing — that is the point of it)")),
				),
			),
		)
	}
}

// ---- cards & layout ------------------------------------------------------

func cardsSection() func() *VNode {
	return func() *VNode {
		return docSection("cards", "Cards & layout",
			prose("The structural furniture: cards, headings, dividers, crumb trails, a spinner, and a "+
				"code box. Nothing here holds state — they are containers, and they take their children."),

			demo("Cards",
				row("grid gap-4 sm:grid-cols-3",
					ui.Card("", ui.CardHeader("", Text("Card")),
						P(Attr("class", "text-sm text-ink-soft"), Text("Padded, and grows to fill its row."))),
					ui.BorderCard("", ui.CardSubheader("", Text("BorderCard")),
						P(Attr("class", "text-sm text-ink-soft"), Text("A border instead of a shadow."))),
					ui.BorderCutCornerCard("", ui.CardSubheader("", Text("Cut corner")),
						P(Attr("class", "text-sm text-ink-soft"), Text("The same, with a clipped corner."))),
				),
			),

			demo("Headers, dividers, crumbs",
				row("flex flex-col gap-2",
					ui.Breadcrumbs([]ui.BreadcrumbItem{
						{URL: "/", DisplayText: "kjol"},
						{URL: "/wasm", DisplayText: "Kjøl Wasm Web"},
						{URL: "/wasm/components", DisplayText: "Components"},
					}),
					ui.PageHeader("A page header", ""),
					ui.Divider(),
					ui.ManagerPageHeader("With a description and an action", "The three-part page heading.",
						ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New"})),
				),
			),

			demo("Loader, code box, links",
				row("flex flex-wrap items-center gap-6",
					ui.Loader(),
					ui.PageLink("https://go.dev", true, "text-accent underline underline-offset-4",
						Text("An external link (new tab)")),
				),
				ui.CodeBox("go run ./server -build\ngo run ./server", "mt-3"),
			),
		)
	}
}

// ---- icons ---------------------------------------------------------------

func iconsSection() func() *VNode {
	return func() *VNode {
		names := []string{
			"check", "xmark", "plus", "minus", "pencil", "trash", "filter", "download", "print",
			"calendar", "envelope", "globe", "user", "book-open", "chart-column", "server", "table",
			"code", "bolt", "cube", "shield-check", "sun", "moon", "magnifying-glass", "circle-info",
			"circle-check", "circle-xmark", "triangle-exclamation", "chevron-down", "bars", "layers",
		}
		cells := []*VNode{}
		for _, n := range names {
			cells = append(cells, Div(Attr("class", "flex flex-col items-center gap-1.5 rounded-default border border-line px-2 py-3"),
				ui.Icon(n, 18, "text-ink-soft"),
				Span(Attr("class", "font-mono text-[10px] text-ink-faint"), Text(n)),
			))
		}

		return docSection("icons", "Icons",
			prose("The Go kit draws its own icons: a small registry of stroke paths, in Go, with no "+
				"FontAwesome anywhere near it. That is a deliberate difference from the Solid kit, which "+
				"generates a tree-shaken FontAwesome subset at build time — this side has no build step "+
				"to hang that off, so it carries the few dozen glyphs it actually uses."),
			prose("An icon the registry has never heard of renders an empty box, and a test fails the "+
				"build over it rather than letting it ship."),

			demo("The registry",
				row("grid grid-cols-4 gap-2 sm:grid-cols-8", cells...),
			),

			demo("Variants",
				row("flex flex-wrap items-center gap-5",
					ui.IconContainer(ui.Icon("check", 16, ""), Span(Attr("class", "text-sm text-ink-soft"), Text("IconContainer"))),
					ui.IconSuccess("circle-check", 18, ""),
					ui.IconError("circle-xmark", 18, ""),
					Span(Attr("class", "inline-flex items-center gap-1.5 text-sm text-ink-soft"),
						ui.IconInline("bolt", 14, ""), Text("IconInline sits on the text baseline")),
				),
			),
			apiTable(
				apiRow{"ui.Icon(name, size, class)", "The icon. Size is height in px; the width follows the viewBox."},
				apiRow{"ui.RegisterIcon", "Add your own. The kit ships a small set; an app brings the rest."},
			),
		)
	}
}

// ---- forms ---------------------------------------------------------------

func formsSection() func() *VNode {
	name := NewSignal("")
	email := NewSignal("")
	notes := NewSignal("")
	plan := NewSignal("pro")
	taxID := NewSignal("")
	rate := NewSignal("")
	money := NewSignal("")
	pct := NewSignal("")
	phone := NewSignal("")
	zip := NewSignal("")

	return func() *VNode {
		field := func(label string, input *VNode) *VNode {
			return row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text(label)), input)
		}

		return docSection("forms", "Forms & inputs",
			prose("Inputs are controlled: the value goes in as a prop, the change comes out as a "+
				"callback, and the caller owns the state. There is no two-way binding and nothing hidden "+
				"inside the component — which is why the same input works on the server, where there is "+
				"no one to type into it."),

			demo("Text, email, textarea, select",
				row("grid gap-4 sm:grid-cols-2",
					field("Name", ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace",
						OnInput: func(v string) { name.Set(v) }})),
					field("Email", ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com",
						OnInput: func(v string) { email.Set(v) }}, true)),
					field("Plan", ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
						ui.FormOption("free", "Free", false),
						ui.FormOption("pro", "Pro", false),
						ui.FormOption("enterprise", "Enterprise", false))),
					field("Notes", ui.FormTextarea(ui.FormTextareaProps{Value: notes.Get(), Rows: "3",
						Placeholder: "Anything worth remembering…", OnInput: func(v string) { notes.Set(v) }})),
				),
			),

			demo("Masked inputs — type letters into them",
				row("grid gap-4 sm:grid-cols-3",
					// A mask is a pure function of the string, applied on every keystroke. It must
					// be IDEMPOTENT — it is fed its own output — or the field corrupts itself as
					// you type, one character at a time, and only for fast typists.
					field("Tax ID", ui.FormInput(ui.FormInputProps{Value: taxID.Get(), Placeholder: "12-3456789",
						OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) }})),
					field("Rate", ui.FormInput(ui.FormInputProps{Value: rate.Get(), Placeholder: "5.25",
						OnInput: func(v string) { rate.Set(ui.MaskRate(v)) }})),
					field("Currency", ui.FormCurrencyInput(ui.FormInputProps{Value: money.Get(),
						OnInput: func(v string) { money.Set(v) }}, false)),
					field("Percent", ui.FormPercentInput(ui.FormInputProps{Value: pct.Get(),
						OnInput: func(v string) { pct.Set(v) }})),
					field("Phone", ui.FormPhoneInput(ui.FormInputProps{Value: phone.Get(),
						OnInput: func(v string) { phone.Set(v) }})),
					field("Zip", ui.FormZipCodeInput(ui.FormInputProps{Value: zip.Get(),
						OnInput: func(v string) { zip.Set(v) }})),
				),
			),

			demo("Fieldset, file input, and a validation error",
				ui.FormFieldset("Account", "",
					row("grid gap-4 sm:grid-cols-2",
						field("Attachment", ui.FormFileInput(ui.FormInputProps{})),
						field("Email (validated)", ui.FormEmailInput(ui.FormInputProps{
							Value:   email.Get(),
							Error:   emailError(email.Get()),
							OnInput: func(v string) { email.Set(v) },
						}, true)),
					),
				),
			),
		)
	}
}

// emailError is derived, not stored — so it cannot fall out of step with the field it
// describes. Blank is not "invalid", it is unfilled.
func emailError(v string) string {
	if v != "" && !ui.IsEmailValid(v) {
		return "That is not an email address."
	}
	return ""
}

// ---- selects & comboboxes ------------------------------------------------

func selectsSection() func() *VNode {
	langs := NewSignal([]string{"go"})
	tags := NewSignal([]string{"go"})
	one := NewSignal("")
	state := NewSignal("")
	tz := NewSignal("")
	picked := NewSignal("")

	combo := ui.NewCombobox(ui.DropdownOptions{})
	skills := ui.NewMultiSelect(ui.DropdownOptions{})
	tagPicker := ui.NewMultiSelectTrigger(ui.DropdownOptions{})

	// The search is the CALLER's. The component knows how to debounce, order and render;
	// it knows nothing at all about where options come from. Here it is a local slice; in
	// an app it would be a fetch.
	people := ui.NewAsyncCombobox(ui.AsyncComboboxOptions{
		MinChars: 2,
		Search: func(q string, done func([]ui.FormSelectOption)) {
			var out []ui.FormSelectOption
			for _, r := range employees() {
				p := emp(r)
				if strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
					out = append(out, ui.FormSelectOption{Value: p.Email, Label: p.Name})
				}
			}
			done(out)
		},
	})

	return func() *VNode {
		field := func(label string, input *VNode) *VNode {
			return row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text(label)), input)
		}

		return docSection("selects", "Selects & comboboxes",
			prose("A combobox picks one; a multi-select picks several, with checkboxes on the rows and "+
				"removable pills in the field. Past three selections — or once the pills stop fitting — "+
				"the field collapses to \"N items selected\" rather than growing until it wraps."),

			demo("Combobox, multi-select, and the built-in selectors",
				row("grid gap-4 sm:grid-cols-2",
					field("Language (searchable, one)", combo.Render(ui.FormComboboxProps{
						Options: languageOptions(), Value: one.Get(), Placeholder: "Pick one",
						Searchable: true, OnChange: func(v string) { one.Set(v) },
					})),
					field("Languages (several)", skills.Render(ui.FormMultiSelectProps{
						Options: languageOptions(), Value: langs.Get(), Placeholder: "Pick a few",
						Searchable: true, ShowSelectAll: true, OnChange: func(v []string) { langs.Set(v) },
					})),
					field("State", ui.FormStateSelector(ui.FormSelectProps{
						Value: state.Get(), OnChange: func(v string) { state.Set(v) }})),
					field("Timezone", ui.FormTimezoneSelector(ui.FormSelectProps{
						Value: tz.Get(), OnChange: func(v string) { tz.Set(v) }})),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("one="+orElse(one.Get(), "—")+"  several="+orElse(strings.Join(langs.Get(), ","), "—"))),
			),

			demo("Async combobox — picked: "+orElse(picked.Get(), "nothing yet"),
				row("max-w-sm",
					people.Render(ui.FormAsyncComboboxProps{
						Placeholder: "Search people…",
						OnSelect:    func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
					}),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("Two characters before it asks; 200 ms after you stop typing. A response for a query "+
						"you have already typed past is DISCARDED rather than shown — which is the whole bug "+
						"with hand-rolled autocompletes.")),
			),

			demo("Multi-select behind your own trigger",
				tagPicker.Render(ui.FormMultiSelectTriggerProps{
					Trigger: ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
						Icon: "filter", Text: "Tags (" + itoa(len(tags.Get())) + ")"}),
					Options:       languageOptions(),
					Value:         tags.Get(),
					Searchable:    true,
					ShowSelectAll: true,
					OnChange:      func(v []string) { tags.Set(v) },
				}),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("Same selection model as the field above; only the thing you click on differs.")),
			),
		)
	}
}

// ---- toggles & signature -------------------------------------------------

func togglesSection() func() *VNode {
	notify := NewSignal(true)
	locked := NewSignal(false)
	signed := NewSignal("")

	pad := ui.NewSignaturePad(ui.SignaturePadOptions{
		OnChange: func(svg string) { signed.Set(svg) },
	})

	return func() *VNode {
		return docSection("toggles", "Toggles & signature",
			prose("A toggle is a checkbox that admits what it is. The signature pad is an SVG rather "+
				"than a canvas, which is not a detail: the markup you draw IS the value the caller gets, "+
				"so a stored signature renders on the server like any other markup."),

			demo("Toggles",
				row("flex flex-col gap-4",
					ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) },
						"Email notifications", "At most one message a day.", false, ""),
					ui.ToggleSwitch(locked.Get(), func(v bool) { locked.Set(v) },
						"Locked", "This one is disabled.", true, ""),
				),
			),

			demo("Signature pad — "+itoa(len(signed.Get()))+" bytes of SVG",
				pad.Render(ui.SignaturePadProps{}),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("Draw in it. Long strokes are smoothed; a single stray point is not a stroke and "+
						"is dropped, so a click does not leave a dot behind.")),
			),
		)
	}
}

// ---- dates ---------------------------------------------------------------

func datesSection() func() *VNode {
	picked := NewSignal("")
	day := NewSignal("")
	month := NewSignal(time.Time{})

	dp := ui.NewDatePicker(ui.DatePickerProps{
		Placeholder: "Pick a date",
		Clearable:   true,
		OnChange:    func(v string) { picked.Set(v) },
	})
	dob := ui.NewDateOfBirthPicker(ui.DatePickerProps{Placeholder: "Date of birth"})

	return func() *VNode {
		return docSection("dates", "Dates",
			prose("The field is TYPEABLE, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 "+
				"and 2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
				"affordance rather than the only way in. A picker you can only click is a picker that is "+
				"slower than the keyboard for everyone who knows the date already."),

			demo("Pickers — picked: "+orElse(picked.Get(), "nothing"),
				row("grid gap-4 sm:grid-cols-2",
					row("flex flex-col gap-1",
						ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled; flips near the bottom)")),
						dp.Render()),
					row("flex flex-col gap-1",
						ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
						dob.Render()),
				),
			),

			demo("The calendar on its own — selected: "+orElse(day.Get(), "none"),
				row("max-w-xs",
					ui.Calendar(ui.CalendarProps{
						Selected:   day.Get(),
						ViewMonth:  month.Get(),
						OnSelect:   func(k string) { day.Set(k) },
						OnNavigate: func(m time.Time) { month.Set(m) },
					}),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("The same grid the picker drops down, usable directly when you want it inline.")),
			),
		)
	}
}

// ---- tables --------------------------------------------------------------

func tablesSection() func() *VNode {
	highlight := NewSignal("")

	table := newEmployeeTable(highlight)
	table.SetRows(employees())

	exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
	pdfSub := ui.NewSubmenu(exportMenu)

	// CellGrid is the editable one: a spreadsheet, not a report.
	gridRows := NewSignal([]map[string]any{
		{"id": "1", "sku": "KJ-100", "qty": 12, "price": "$4.50"},
		{"id": "2", "sku": "KJ-220", "qty": 3, "price": "$18.00"},
		{"id": "3", "sku": "KJ-330", "qty": 47, "price": "$1.25"},
	})
	gridSort := NewSignal("sku")
	gridDesc := NewSignal(false)

	pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
		orientation := ui.PDF_ORIENTATION_PORTRAIT
		if landscape {
			orientation = ui.PDF_ORIENTATION_LANDSCAPE
		}
		return ui.AutoTablePDFHeader{
			Title:       "Employees",
			Subtitle:    "Exported from the kjol-website components page",
			ShowDate:    true,
			Orientation: orientation,
		}
	}

	return func() *VNode {
		return docSection("tables", "Tables",
			prose("Three of them, and the difference is what the user is allowed to do. PrettyTable "+
				"prints rows. CellGrid lets them be edited in place. AutoTable filters, sorts, pages, "+
				"reorders, resizes, computes and exports — configured with a column list and a slice of "+
				"rows, and everything the user changes about it is theirs and persists."),

			// --- PrettyTable
			demo("PrettyTable — it prints rows, and that is all",
				ui.PrettyTable(
					[]ui.PrettyTableColumn{
						{DisplayName: "Name"},
						{DisplayName: "Plan"},
						{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
					},
					ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
					ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
					ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
					ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
				),
			),

			// --- CellGrid
			demo("CellGrid — editable cells",
				ui.CellGrid(ui.CellGridProps{
					Columns: []ui.CellGridColumn{
						{Key: "sku", Label: "SKU", SortKey: "sku"},
						{Key: "qty", Label: "Qty", SortKey: "qty", SortType: ui.SortTypeNumeric, Editable: true},
						{Key: "price", Label: "Price", SortKey: "price", SortType: ui.SortTypeMoney, Editable: true},
					},
					Rows:    gridRows.Get(),
					IDField: "id",
					OnCellChange: func(id any, field string, v any) {
						rows := gridRows.Get()
						next := make([]map[string]any, len(rows))
						for i, r := range rows {
							cp := map[string]any{}
							for k, val := range r {
								cp[k] = val
							}
							if cp["id"] == id {
								cp[field] = v
							}
							next[i] = cp
						}
						gridRows.Set(next)
					},
					SortKey:     gridSort.Get(),
					SetSortKey:  func(k string) { gridSort.Set(k) },
					SortDesc:    gridDesc.Get(),
					SetSortDesc: func(b bool) { gridDesc.Set(b) },
				}),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("Click a Qty or Price cell and type. The grid does not own the rows — it tells you "+
						"which cell changed and hands the value back; what you do with it is yours.")),
			),

			// --- AutoTable
			prose("AutoTable is the big one. Search matches name or email. Sort by Salary and it parses "+
				"the currency, so $980 sorts below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes "+
				"before \"Item 10\", because numbers inside text are compared as numbers. Drag a header to "+
				"reorder it, drag its right edge to resize — reload the page and both are still where you "+
				"left them."),
			prose("Then filter it and export. You get every matching row across every page, in the column "+
				"order you dragged them into, with the calculated columns computed per row — not the five "+
				"rows that happened to be on screen."),

			table.Render(
				ui.AutoTableWithHover(),
				ui.AutoTableWithAlternate(),
				ui.AutoTableWithSurroundingBorder(),
				ui.AutoTableWithPaginationShowAll(),
				ui.AutoTableWithSearchFields(
					table.GlobalSearch("Search name or email…", "Name", "Email"),
					table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
					table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
				),
				ui.AutoTableWithToolbarActions(
					table.ColumnPicker(),
					table.CalculatedColumnEditor(),
					exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
						return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
							Icon: "download", Text: "Export"})
					}),
					exportMenu.Content("",
						exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
							OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
						pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
							pdfSub.Item(ui.MenuItemProps{
								OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
							pdfSub.Item(ui.MenuItemProps{
								OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
						),
						ui.MenuDivider(""),
						exportMenu.Item(ui.MenuItemProps{Icon: "print",
							OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
					),
				),
			),

			row("mt-4 flex flex-wrap items-center gap-2",
				ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Find Radia Perlman",
					OnClick: func() { highlight.Set("radia@example.com") }}),
				ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear highlight",
					OnClick: func() { highlight.Set("") }}),
			),

			codeLang("formulas", "syntax", formulaSnippet),
			note("The server renders a skeleton, on purpose",
				"The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
					"browser's localStorage, which the server cannot read. So the server ships a skeleton "+
					"rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
					"watch them rearrange themselves the moment the WebAssembly booted."),

			apiTable(
				apiRow{"NewAutoTableState", "The controller: the columns, and where to persist the layout."},
				apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
				apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Once, on the client."},
				apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
				apiRow{"ExportCSV / ExportPDF", "Write the bytes, in Go, stdlib only. The PDF writer builds its own xref table."},
			),
		)
	}
}

// ---- overlays ------------------------------------------------------------

func overlaysSection(push func(ui.ToastType, string)) func() *VNode {
	tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
	tipRight := ui.NewHoverTooltip(ui.PlacementRight, "")
	tipFocus := ui.NewFocusTooltip(ui.PlacementBottom, "")
	tipFast := ui.NewTooltip(ui.TooltipProps{Placement: ui.PlacementTop, Delay: -1})

	pop := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomStart})
	popEnd := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomEnd})
	hoverPop := ui.NewHoverPopover(ui.HoverPopoverProps{
		Placement: ui.PlacementTop,
		// The bridge: 300ms of grace for the cursor to cross the gap from trigger onto
		// panel. Without it the panel closes in the dead space between them — which is
		// exactly what happens once a panel is portaled and CSS :hover no longer reaches it.
		HoverCloseDelay: 300,
	})

	menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
	sub := ui.NewSubmenu(menu)
	hoverMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart, OpenOnHover: true})

	modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
	nested := ui.NewModal(ui.ModalOptions{Size: ui.ModalSmall})
	confirm := ui.NewModal(ui.ModalOptions{})
	deleted := NewSignal(false)

	wizardName := NewSignal("")
	wizard := ui.NewWizard(ui.ModalOptions{})

	return func() *VNode {
		return docSection("overlays", "Overlays",
			prose("Tooltips, popovers, menus and modals — every one measured against the real viewport. "+
				"A floating panel is portaled to document.body, positioned from its trigger's bounding "+
				"box, and flipped or shifted when it would otherwise run off the screen."),
			prose("The position is written to the element with SetStyle, NOT through a signal. A signal "+
				"write re-renders the whole tree, and this runs on every scroll and resize frame; going "+
				"through the vdom would rebuild the page sixty times a second to move one panel four "+
				"pixels."),
			code("webui/floating.go", floatingSnippet),

			demo("Tooltips — hover, focus, placement, delay",
				row("flex flex-wrap items-center gap-3",
					tipTop.Render(Span(Text("Above — the default")),
						ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
					tipRight.Render(Span(Text("To the right, unless it would run off the edge")),
						ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Right"})),
					tipFast.Render(Span(Text("No open delay")),
						ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Instant"})),
					tipFocus.Render(Span(Text("Shown on FOCUS, not hover — tab to the field")),
						ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("Narrow the window and hover \"Right\": it flips to the left, and its arrow follows "+
						"it. A tooltip that only answers to a mouse is a tooltip a keyboard user cannot read.")),
			),

			demo("Popovers — click, alignment, and a hover bridge",
				row("flex flex-wrap items-center gap-3",
					pop.Trigger(ui.PopoverTriggerProps{},
						ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
					pop.Content(ui.PopoverContentProps{Class: "w-64"},
						P(Attr("class", "text-sm text-ink-soft"),
							Text("Click outside, or press Escape. Only the TOPMOST floating panel closes per press."))),

					popEnd.Trigger(ui.PopoverTriggerProps{},
						ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
					popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
						P(Attr("class", "text-sm text-ink-soft"), Text("Placement bottom-end."))),

					hoverPop.Trigger(ui.PopoverTriggerProps{},
						ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover, then reach the panel"})),
					hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
						P(Attr("class", "text-sm text-ink-soft"),
							Text("Move the cursor across the gap and onto this panel — it stays open. Select "+
								"this text to prove it."))),
				),
			),

			demo("Menus & submenus",
				row("flex flex-wrap items-center gap-3",
					menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
						caret := " ▾"
						if open {
							caret = " ▴"
						}
						return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Actions" + caret})
					}),
					menu.Content("",
						menu.Item(ui.MenuItemProps{Icon: "check", OnClick: func() { push(ui.ToastSuccess, "Profile opened") }},
							Text("Profile")),
						menu.Item(ui.MenuItemProps{OnClick: func() { push(ui.ToastInfo, "Settings opened") }},
							Text("Settings")),
						// Portaled — it used to be clipped by the parent menu's own overflow-y-auto.
						sub.Submenu(ui.SubmenuProps{Trigger: "More", Icon: "ellipsis"},
							sub.Item(ui.MenuItemProps{OnClick: func() { push(ui.ToastInfo, "Archived") }}, Text("Archive")),
							sub.Item(ui.MenuItemProps{OnClick: func() { push(ui.ToastWarning, "Duplicated") }}, Text("Duplicate")),
						),
						ui.MenuDivider(""),
						menu.Item(ui.MenuItemProps{KeepOpen: true, OnClick: func() { push(ui.ToastGeneric, "Menu stayed open") }},
							Text("Stay open (KeepOpen)")),
						menu.Item(ui.MenuItemProps{Icon: "arrow-right-from-bracket",
							OnClick: func() { push(ui.ToastError, "Signed out") }}, Text("Sign out")),
					),

					hoverMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(bool) *VNode {
						return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Opens on hover"})
					}),
					hoverMenu.Content("",
						hoverMenu.Item(ui.MenuItemProps{}, Text("One")),
						hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
					),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("Opening one closes the other — a single-open manager. Submenus are exempt, or a "+
						"submenu would close the very menu it belongs to as it opened. The items raise toasts, "+
						"which is how you can see that an item really does close its own menu.")),
			),

			demo("Modals — deleted: "+boolStr(deleted.Get()),
				row("flex flex-wrap items-center gap-3",
					ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
						OnClick: func() {
							// No component in the tree owns this one: OpenModal hands content to the
							// shared host rendered once in the layout.
							ui.OpenModal(func() *VNode {
								return ui.ModalContent(ui.ModalContentProps{
									Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
								},
									P(Attr("class", "text-ink-soft"),
										Text("This content was rendered by no component — it was handed to ModalHost "+
											"(see AppLayout) by webui.OpenModal. That is what a confirmation raised "+
											"from inside a save handler needs.")),
								)
							}, ui.ModalOptions{Size: ui.ModalSmall})
						}}),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("Open the modal, then the nested one inside it, and press Escape twice: modals unwind "+
						"ONE LAYER per press rather than all at once.")),

				modal.Render(ui.ModalProps{
					Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
					Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
				},
					P(Attr("class", "text-ink-soft"),
						Text("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip "+
							"it. It fades and scales in over a double requestAnimationFrame — a single frame does "+
							"not give the browser time to commit the initial style, so the transition never runs.")),
					row("mt-4", ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true,
						Text: "Open a nested modal", OnClick: nested.Open})),
				),
				nested.Render(ui.ModalProps{
					Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
				},
					P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
				),
				confirm.Confirm(ui.ConfirmModalProps{
					Title:     "Delete row",
					Message:   "This cannot be undone. (Nothing is actually deleted — this is a docs page.)",
					OnConfirm: func() { deleted.Set(true); push(ui.ToastError, "Row deleted") },
				}),
				wizard.Render(ui.WizardProps{
					Title:      "Set up your account",
					FinishText: "Finish",
					OnComplete: func() { push(ui.ToastSuccess, "Wizard complete: "+wizardName.Get()) },
					Steps: []ui.WizardStep{
						{
							Title: "Your name",
							// Each step gets its own context: SetCanContinue gates THIS step's Next
							// button, which one shared bool could not express.
							Content: func(ctx ui.WizardStepContext) *VNode {
								ctx.SetCanContinue(wizardName.Get() != "")
								return row("flex flex-col gap-1",
									ui.FormLabel(ui.FormLabelProps{}, Text("Name (required to continue)")),
									ui.FormInput(ui.FormInputProps{Value: wizardName.Get(), Placeholder: "Ada Lovelace",
										OnInput: func(v string) { wizardName.Set(v) }}),
								)
							},
						},
						{
							Title: "Confirm",
							Content: func(ctx ui.WizardStepContext) *VNode {
								ctx.SetCanContinue(true)
								return P(Attr("class", "text-ink-soft"),
									Text("All set for "+orElse(wizardName.Get(), "nobody")+". Finish to close."))
							},
						},
					},
				}),
			),

			apiTable(
				apiRow{"NewFloating", "The engine behind every panel: placement, offset, flip, shift, arrow."},
				apiRow{"NewTooltip / NewPopover / NewMenu / NewModal", "Controllers. Build once, outside the render."},
				apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
				apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
				apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
			),
		)
	}
}

func boolStr(b bool) string {
	if b {
		return "true"
	}
	return "false"
}

// ---- feedback ------------------------------------------------------------

func feedbackSection(toaster *ui.Toaster, tour *ui.Tutorial) func() *VNode {
	flash := ui.NewRemoteFlash(2000)

	return func() *VNode {
		return docSection("feedback", "Toasts & tours",
			prose("Toasts dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
				"transition, written straight at the element — not a re-render per frame, which is what a "+
				"progress bar driven through a signal would cost you."),

			demo("Push, dismiss, and a sticky one",
				row("flex flex-wrap items-center gap-2",
					ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
						OnClick: func() { toaster.Success("Saved.") }}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
						OnClick: func() { toaster.Error("Something went wrong.") }}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
						OnClick: func() { toaster.Info("Just so you know.") }}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
						OnClick: func() {
							toaster.Push(ui.Toast{Message: "This one waits for you to dismiss it.",
								Type: ui.ToastWarning, Duration: ui.ToastSticky})
						}}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
						OnClick: toaster.Clear}),
				),
			),

			demo("The guided tour",
				row("flex flex-wrap items-center gap-3",
					tour.StartButton(0, "", Text("Take the tour")),
					Span(Attr("class", "text-xs text-ink-muted"),
						Text("It dims the page, cuts a hole around each target, and animates the spotlight from "+
							"one to the next. Targets are CSS SELECTORS — the same section ids the sidebar jumps to.")),
				),
			),

			demo("Remote update flash",
				row("flex flex-wrap items-center gap-3",
					ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Something changed elsewhere",
						OnClick: flash.Fire}),
					ui.RemoteUpdateFlash(flash.Visible()),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("A two-second acknowledgement that data you are looking at was changed by somebody "+
						"else. It is not a toast: it belongs next to the thing that moved, not in the corner.")),
			),
		)
	}
}

// ---- navigation ----------------------------------------------------------

func navigationSection() func() *VNode {
	tab := NewSignal(0)
	crm := NewSignal(0)
	crmSub := NewSignal(0)
	acc := NewSignal(0)
	multi := NewSignal([]bool{true, false, false})
	side := NewSignal("buttons")

	return func() *VNode {
		panel := func(s string) *VNode {
			return P(Attr("class", "pt-3 text-sm text-ink-soft"), Text(s))
		}

		return docSection("navigation", "Tabs & navigation",
			prose("Tabs, an accordion, and a sidebar of jump links. All controlled: the active index goes "+
				"in, the change comes out, and the caller decides what that means — which is what lets a "+
				"tab be driven by the URL, or persisted, without the component knowing anything about it."),

			demo("Tabs",
				ui.TabGroup(ui.TabGroupProps{
					Items: []ui.TabItem{
						{Title: "Overview", Content: panel("The overview panel.")},
						{Title: "Details", Content: panel("The details panel.")},
						{Title: "Activity", Badge: 3, Content: panel("The activity panel (3 new).")},
					},
					ActiveIndex: tab.Get(),
					OnTabChange: func(i int) { tab.Set(i) },
				}),
			),

			demo("CRM tabs — the same thing, wearing a different suit",
				row("flex flex-col gap-4",
					ui.CrmTabGroup(ui.CrmTabGroupProps{
						Items: []ui.CrmTabItem{
							{Title: "Contacts", Badge: 12, Content: panel("A contacts list would go here.")},
							{Title: "Deals", Badge: 3, Content: panel("And the deals.")},
							{Title: "Notes", Content: panel("And the notes.")},
						},
						ActiveIndex: crm.Get(),
						OnTabChange: func(i int) { crm.Set(i) },
					}),
					ui.CrmSubTabGroup(ui.CrmTabGroupProps{
						Items: []ui.CrmTabItem{
							{Title: "All", Content: panel("The sub-tab strip: smaller, for nesting inside a tab.")},
							{Title: "Mine", Content: panel("Mine.")},
						},
						ActiveIndex: crmSub.Get(),
						OnTabChange: func(i int) { crmSub.Set(i) },
					}),
				),
			),

			demo("Accordion — one open at a time, or several",
				row("grid gap-6 lg:grid-cols-2",
					row("flex flex-col gap-2",
						P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-ink-faint"),
							Text("SingleAccordion")),
						ui.SingleAccordion([]ui.AccordionItemData{
							{Title: "What is Kjøl Wasm Web?", Content: panel("kjol's Go→WebAssembly UI engine.")},
							{Title: "Is it isomorphic?", Content: panel("Yes — the same Go server-renders and then hydrates.")},
							{Title: "How is it styled?", Content: panel("Tailwind, compiled by a Go program that reads your Go.")},
						}, acc.Get(), func(i int) { acc.Set(i) }),
					),
					row("flex flex-col gap-2",
						P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-ink-faint"),
							Text("Accordion (several at once)")),
						ui.Accordion([]ui.AccordionItemData{
							{Title: "First", Content: panel("Open me.")},
							{Title: "Second", Content: panel("And me, at the same time.")},
							{Title: "Third", Content: panel("And me.")},
						}, multi.Get(), func(i int) {
							open := append([]bool{}, multi.Get()...)
							open[i] = !open[i]
							multi.Set(open)
						}),
					),
				),
			),

			demo("Sidebar nav — clicked: "+side.Get(),
				row("max-w-xs",
					ui.SidebarNav([]ui.SidebarNavItem{
						{ID: "buttons", Label: "Buttons", Icon: ui.IconInline("check", 14, "")},
						{ID: "forms", Label: "Forms", Icon: ui.IconInline("pencil", 14, "")},
						{ID: "tables", Label: "Tables", Icon: ui.IconInline("table", 14, ""),
							Children: []ui.SidebarNavItem{
								{ID: "pretty", Label: "PrettyTable"},
								{ID: "auto", Label: "AutoTable"},
							}},
					}, func(id string) { side.Set(id) }, ""),
				),
				P(Attr("class", "mt-3 text-xs text-ink-muted"),
					Text("It reports the id you clicked and nothing else. The sidebar on THIS page is the "+
						"same idea, wired to scroll the section into view.")),
			),
		)
	}
}

// ---- fuzzy search --------------------------------------------------------

func searchSection() func() *VNode {
	q := NewSignal("")
	hit := NewSignal("")

	return func() *VNode {
		options := []string{
			"AutoTable", "Accordion", "Alert", "Badge", "Button", "Calendar", "CellGrid", "Combobox",
			"DatePicker", "FormInput", "Menu", "Modal", "MultiSelect", "Popover", "PrettyTable",
			"SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip",
		}

		return docSection("search", "Fuzzy search",
			prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+
				"the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+
				"\"autotbale\", which is what you actually typed."),

			demo("Type into it — picked: "+orElse(hit.Get(), "nothing"),
				row("max-w-md",
					ui.FuzzyMatch(ui.FuzzyMatchProps{
						Options:       options,
						Query:         q.Get(),
						Placeholder:   "Search the kit…",
						MaxResults:    6,
						ShowScores:    true,
						OnQueryChange: func(v string) { q.Set(v) },
						OnSelect:      func(v string, _ ui.FuzzyRankedItem) { hit.Set(v) },
					}),
				),
			),
			apiTable(
				apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."},
				apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."},
				apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."},
			),
		)
	}
}

// ---- charts --------------------------------------------------------------

var chartDaysWasm = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
var pieLabelsWasm = []string{"Direct", "Search", "Social", "Email", "Referral"}
var regionsWasm = []string{"East", "Central", "Mountain", "Pacific"}

// A made-up per-state metric for the choropleth, and a few cities (lat/lng) — Anchorage
// and Honolulu land on albersUsa's Alaska and Hawaii insets.
var usSignups = map[string]float64{
	"CA": 4820, "TX": 3910, "NY": 3120, "FL": 2870, "IL": 1740, "PA": 1610, "OH": 1490, "GA": 1450,
	"NC": 1360, "MI": 1280, "WA": 1230, "AZ": 1180, "MA": 1120, "VA": 1090, "CO": 980, "TN": 940,
	"NJ": 910, "OR": 720, "MN": 690, "WI": 610, "MO": 560, "MD": 540, "IN": 520, "NV": 480,
	"UT": 430, "AL": 390, "SC": 360, "KY": 310, "LA": 300, "OK": 280, "CT": 260, "IA": 210,
	"KS": 180, "AK": 140, "HI": 160, "ME": 120, "MT": 90, "WY": 60, "ND": 70, "SD": 80,
}
var usCities = []ui.USHeatmapPoint{
	{Label: "Seattle", Lat: 47.6062, Lng: -122.3321, Value: 1230},
	{Label: "San Francisco", Lat: 37.7749, Lng: -122.4194, Value: 2110},
	{Label: "Denver", Lat: 39.7392, Lng: -104.9903, Value: 980},
	{Label: "Chicago", Lat: 41.8781, Lng: -87.6298, Value: 1740},
	{Label: "New York", Lat: 40.7128, Lng: -74.006, Value: 3120},
	{Label: "Miami", Lat: 25.7617, Lng: -80.1918, Value: 1460},
	{Label: "Anchorage", Lat: 61.2181, Lng: -149.9003, Value: 140},
	{Label: "Honolulu", Lat: 21.3069, Lng: -157.8583, Value: 160},
}

func chartsSection() func() *VNode {
	seed := NewSignal(0)
	threeD := NewSignal(false)
	barC, donutC, areaC, lineC := ui.NewChart(), ui.NewChart(), ui.NewChart(), ui.NewChart()
	horizC, stackC := ui.NewChart(), ui.NewChart()
	heat := ui.NewUSHeatmap()

	return func() *VNode {
		sh := seed.Get()
		shuffle := func(base []float64) []float64 {
			out := make([]float64, len(base))
			for i, b := range base {
				if sh == 0 {
					out[i] = b
					continue
				}
				m := (int(b)*7 + sh*13) % 80
				if m < 4 {
					m = 4
				}
				out[i] = float64(m)
			}
			return out
		}
		requests := ui.ChartSeries{Name: "Requests", Data: shuffle([]float64{42, 17, 63, 28, 55, 9, 71})}
		errs := ui.ChartSeries{Name: "Errors", Data: shuffle([]float64{8, 3, 12, 6, 9, 2, 14})}
		pie := ui.ChartSeries{Name: "Traffic", Data: shuffle([]float64{40, 25, 20, 15, 8})}
		threeDLabel := "3D"
		if threeD.Get() {
			threeDLabel = "Flat"
		}

		return docSection("charts", "Charts",
			prose("webui.Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, a "+
				"pointer crosshair — with no charting library. The geometry is pure Go, so the same shapes "+
				"the Solid kit draws on /js run here in the WebAssembly, against the same theme tokens. Change "+
				"the data and only the marks that moved re-render. A Title captions the plot, and the legend a "+
				"multi-series or pie/donut chart draws is interactive — clicking a key toggles that series or slice."),

			demo("Bar, donut, smooth area, two lines — one data set, and a 3D toggle",
				row("grid gap-6 lg:grid-cols-12",
					Div(Attr("class", "lg:col-span-7"), barC.Render(ui.ChartProps{Kind: ui.ChartBar, Title: "Requests & errors this week", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260, ThreeD: threeD.Get()})),
					Div(Attr("class", "lg:col-span-5"), donutC.Render(ui.ChartProps{Kind: ui.ChartDonut, Title: "Traffic by source", Labels: pieLabelsWasm, Series: []ui.ChartSeries{pie}, Height: 260, ThreeD: threeD.Get()})),
					Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Title: "Requests, smoothed", Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220})),
					Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Requests & errors", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220})),
				),
				row("mt-4 flex items-center gap-3",
					ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}),
					ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}),
					Span(Attr("class", "text-xs text-ink-muted"), Text("Each chart takes a Title; a multi-series chart also draws a legend. Click a legend key (Errors, or a donut slice) to hide it — the scale and marks recompute. 3D extrudes the bars and tilts the donut.")),
				),
			),

			demo("Horizontal bars, and stacked",
				row("grid gap-6 lg:grid-cols-2",
					horizC.Render(ui.ChartProps{Kind: ui.ChartBar, Horizontal: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260}),
					stackC.Render(ui.ChartProps{Kind: ui.ChartBar, Stacked: true, Labels: regionsWasm, Series: []ui.ChartSeries{
						{Name: "Requests", Data: shuffle([]float64{42, 55, 28, 63})},
						{Name: "Errors", Data: shuffle([]float64{8, 9, 6, 12})},
						{Name: "Retries", Data: shuffle([]float64{5, 7, 3, 9})},
					}, Height: 260}),
				),
			),

			demo("US heatmap — a value per state, with proportional lat/lng points on top",
				heat.Render(ui.USHeatmapProps{Data: usSignups, Points: usCities, Proportional: true}),
				Span(Attr("class", "mt-3 block text-xs text-ink-muted"),
					Text("webui.USHeatmap shades each state on the choropleth ramp and projects lat/lng points "+
						"with a Go albersUsa port — Anchorage and Honolulu land on the insets. Hover a state or a point.")),
			),
		)
	}
}

// ---- theming -------------------------------------------------------------

func themingSection() func() *VNode {
	tokens := []struct{ Swatch, Name, Role string }{
		{"bg-surface", "surface", "the page"},
		{"bg-surface-muted", "surface-muted", "a recessed strip"},
		{"bg-surface-raised", "surface-raised", "a panel, a hover"},
		{"bg-surface-strong", "surface-strong", "a track, a divider fill"},
		{"bg-line", "line", "an ordinary border"},
		{"bg-line-strong", "line-strong", "a border that has to be seen"},
		{"bg-ink", "ink", "body text, headings"},
		{"bg-ink-soft", "ink-soft", "secondary text"},
		{"bg-ink-muted", "ink-muted", "captions, labels"},
		{"bg-ink-faint", "ink-faint", "placeholders, disabled"},
	}

	return func() *VNode {
		rows := []*VNode{}
		for i, t := range tokens {
			cls := "flex items-center gap-4 px-4 py-2.5"
			if i > 0 {
				cls += " border-t border-line"
			}
			rows = append(rows, Div(Attr("class", cls),
				Span(Attr("class", "h-7 w-7 shrink-0 rounded border border-line-strong "+t.Swatch)),
				El("code", Attr("class", "w-40 shrink-0 font-mono text-[13px] text-ink"), Text(t.Name)),
				Span(Attr("class", "text-sm text-ink-muted"), Text(t.Role)),
			))
		}

		return docSection("theming", "Theming",
			prose("No component in this kit names a colour. They say bg-surface, text-ink, border-line — "+
				"and what those mean is decided in one place. Dark mode re-points a dozen CSS variables "+
				"and not one component knows it happened."),

			demo("The switch",
				row("flex flex-wrap items-center gap-4",
					Theme.ThemeToggle(ui.ThemeToggleProps{}),
					Span(Attr("class", "text-sm text-ink-soft"),
						Text("Press it. Every component on this page moves — none of them were told.")),
				),
			),

			demo("The contract — each swatch is drawn WITH the token it names",
				swatchTable(rows),
			),

			note("One vocabulary, two kits",
				"The Solid kit uses these exact token names, and both layers of this site read the same "+
					"kjol-theme key — so choose dark here, walk over to Kjøl JS Web, and it is still dark. "+
					"Two front-ends that share no code at all, and one preference."),
		)
	}
}

// ---- the sidebar's jump links --------------------------------------------

// navigateAnchor makes a sidebar link scroll to a section instead of loading a page.
//
// A plain <a href="#forms"> would work if the reader were already on this page, and
// would do nothing useful from anywhere else. So: intercept, route first if we are on
// another page, and scroll once the section actually exists in the DOM — which is what
// AfterRender is for, and the only moment at which it is true.
//
// On the server Navigate is nil, so this does not intercept at all and the anchor stays
// a real, working link. A crawler and a reader with JavaScript off both get the jump.
func navigateAnchor(d Deps, base, frag string) Mod {
	return OnEvent(EVENT_CLICK, func(e Event) {
		if d.Navigate == nil {
			return
		}
		e.PreventDefault()

		if d.Path() != base {
			d.Navigate(base)
			// The section does not exist yet — the page it lives on has not rendered.
			wasmruntime.AfterRender(func() { scrollToSection(frag) })
			return
		}
		// Already here: the element exists NOW, and no render is coming to hang an
		// AfterRender off.
		scrollToSection(frag)
	})
}

func scrollToSection(id string) {
	// QuerySelector always returns a Ref; ScrollIntoView is a no-op on one with no node,
	// so a stale anchor scrolls nowhere rather than panicking.
	wasmruntime.ScrollIntoView(wasmruntime.QuerySelector("#"+id), true, wasmruntime.ScrollBlockStart)
}

// ---- shared helpers ------------------------------------------------------

// orElse is a fallback for an empty string.
func orElse(s, fallback string) string {
	if s == "" {
		return fallback
	}
	return s
}

// row is a flex/grid container helper (appends *VNode children as Mods).
func row(class string, children ...*VNode) *VNode {
	mods := []Mod{Attr("class", class)}
	for _, c := range children {
		mods = append(mods, c)
	}
	return Div(mods...)
}

// kitSection is one labelled block of the gallery — a live demo panel, so that what you
// are looking at is unmistakably the component running rather than a picture of it.
func kitSection(title string, body ...*VNode) *VNode {
	return demo(title, row("flex flex-col gap-4", body...))
}

func ptRow(name, plan string, status *VNode) *VNode {
	td := func(cls string, c *VNode) *VNode { return El("td", Attr("class", "px-3 py-2 text-sm "+cls), c) }
	return El("tr",
		td("text-ink", Text(name)),
		td("text-ink-soft", Text(plan)),
		El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
	)
}

// languageOptions is deliberately longer than the pill limit, so the multi-select
// demonstrates both ways it collapses: past 3 selections it says "N items selected"
// outright, and below that it still collapses if the pills are too wide for the field.
func languageOptions() []ui.FormSelectOption {
	return []ui.FormSelectOption{
		{Value: "go", Label: "Go"},
		{Value: "rust", Label: "Rust"},
		{Value: "ts", Label: "TypeScript"},
		{Value: "python", Label: "Python"},
		{Value: "kotlin", Label: "Kotlin"},
		{Value: "swift", Label: "Swift"},
	}
}

const kitSnippet = `// A component is a function taking a props struct.
ui.Button(ui.ButtonProps{
    Color:   ui.ButtonPrimary,
    Icon:    "check",
    Text:    "Save",
    OnClick: func() { toaster.Success("Saved.") },
})

// Inputs are controlled: the caller owns the state.
name := NewSignal("")

ui.FormInput(ui.FormInputProps{
    Value:   name.Get(),
    OnInput: func(v string) { name.Set(v) },   // a write re-renders
})`

const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
pop := ui.NewPopover(ui.PopoverOptions{
    Placement: ui.PlacementBottomStart,
    Offset:    8,
})

// ...and in the render:
pop.Trigger(ui.PopoverTriggerProps{},
    ui.Button(ui.ButtonProps{Text: "Click me"}),
)
pop.Content(ui.PopoverContentProps{Class: "w-64"},
    P(Text("Outside click and Escape close me.")),
)

// The panel is portaled to document.body and positioned imperatively:
//   render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
// Never through a signal: this runs on every scroll frame.`

// swatchTable wraps the token rows. Div takes ...Mod, and a []*VNode is not that — so
// the conversion happens here rather than being repeated at every call site.
func swatchTable(rows []*VNode) *VNode {
	mods := []Mod{Attr("class", "overflow-hidden rounded-default border border-line")}
	for _, r := range rows {
		mods = append(mods, r)
	}
	return Div(mods...)
}
