Add landing page for kjol, documentation

This commit is contained in:
2026-07-13 16:51:21 -04:00
parent 5230bd6702
commit fec8ef4a3e
54 changed files with 3529 additions and 547 deletions

View File

@@ -11,14 +11,14 @@ import "kjol/vdom"
// The TSX startOpen prop collapses into the caller-supplied initial IsOpen /
// open[] / openIndex.
const accordionRootCls = "ui-accordion border border-neutral-200 rounded-default overflow-hidden"
const accordionItemCls = "ui-accordion-item border-b border-neutral-200 last:border-b-0"
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50"
const accordionRootCls = "ui-accordion border border-line rounded-default overflow-hidden"
const accordionItemCls = "ui-accordion-item border-b border-line last:border-b-0"
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-ink bg-surface-muted cursor-pointer border-none transition-colors hover:bg-surface-raised active:bg-surface-strong focus:outline-hidden disabled:text-ink-faint disabled:cursor-not-allowed disabled:bg-surface-muted"
const accordionTitleCls = "ui-accordion-title flex-1"
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700"
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-ink-soft"
func accordionIconCls(open bool) string {
c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200"
c := "ui-accordion-icon text-ink-muted leading-none transition-transform duration-200"
if open {
c += " rotate-180"
}

View File

@@ -17,12 +17,12 @@ const (
const alertBase = "p-4 rounded-default shadow-xs border"
var alertColors = map[string]string{
"white": "bg-white border-neutral-100",
"gray": "bg-neutral-50 border-neutral-200",
"blue": "bg-sky-50 border-sky-200",
"green": "bg-green-50 border-green-200",
"red": "bg-red-50 border-red-200",
"yellow": "bg-yellow-50 border-yellow-200",
"white": "bg-surface border-line",
"gray": "bg-surface-muted border-line",
"blue": "bg-sky-50 dark:bg-sky-950 border-sky-200 dark:border-sky-900",
"green": "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900",
"red": "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-900",
"yellow": "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-900",
}
// Alert renders a colored callout. header is optional (rendered as a bold title

View File

@@ -80,7 +80,7 @@ const (
// HEADER_COLOR_CLS is the background + text color per header color.
var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50",
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-surface-muted",
AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white",
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
@@ -89,7 +89,7 @@ var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
// atHeaderSortHoverCls is the sortable-hover override per color.
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
@@ -107,7 +107,7 @@ var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
// atHeaderSortIconCls is the sort-icon color (matches header text) per color.
var atHeaderSortIconCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black",
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-ink",
AUTOTABLE_HEADER_COLOR_BLUE: "text-white",
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
@@ -137,11 +137,11 @@ var atPaginationPaddingCls = map[AutoTableSize]string{
// atRowHoverCls is the body-row hover background per color (when hover is on).
var atRowHoverCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100",
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100 dark:bg-green-900",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-surface-strong",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
}
// POS_CLS is the text alignment per ColumnPosition.
@@ -160,7 +160,7 @@ var HEADER_INNER_POS = map[ColumnPosition]string{
// -- Class string constants for parts that don't vary by config ------------
const (
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
TBL_WRAPPER = "overflow-x-auto w-full"
TBL_BASE = "min-w-full"
@@ -169,20 +169,20 @@ const (
atSortIconWrap = "leading-none shrink-0 opacity-50"
atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse"
atErrorCell = "text-center text-red-600"
atEmptyCell = "text-center text-neutral-500"
atSkeleton = "h-4 bg-surface-strong rounded-default animate-pulse"
atErrorCell = "text-center text-red-600 dark:text-red-400"
atEmptyCell = "text-center text-ink-muted"
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
atPaginationBase = "flex justify-between items-center border-t border-line-strong"
atPaginationInfo = "hidden sm:flex items-center text-sm text-ink-muted"
atPaginationControls = "flex items-center"
atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2"
atPaginationPage = "text-sm text-neutral-500 px-3"
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"
atPaginationLabel = "hidden sm:block text-sm text-ink-muted mr-2"
atPaginationPage = "text-sm text-ink-muted px-3"
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed disabled:hover:bg-transparent"
// thead sticky classes are the STATIC replacement for the TSX's JS-driven
// header pinning (transform tracking on scroll). See file-level NOTE.
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300"
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-line-strong"
)
// AutoTableColumn describes one column: its header, alignment, width, whether it
@@ -475,7 +475,7 @@ func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdo
containerCls := TBL_CONTAINER
if cfg.surroundingBorder {
containerCls = cx(containerCls, "border border-neutral-300")
containerCls = cx(containerCls, "border border-line-strong")
}
if cfg.shadow {
containerCls = cx(containerCls, "shadow-sm")
@@ -754,7 +754,7 @@ func atResizeHandle(key string, h *atColumnHooks) *vdom.VNode {
func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode {
bodyCls := BODY_PADDING_CLS[cfg.size]
if cfg.borderY {
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-line-strong")
}
colspan := atTotalColumns(cols, cfg)
@@ -768,7 +768,7 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
for r := 0; r < 5; r++ {
tr := vdom.Tr()
if cfg.alternate && r%2 == 1 {
tr.Attrs["class"] = "bg-neutral-100"
tr.Attrs["class"] = "bg-surface-raised"
}
if cfg.accordion {
tr.Children = append(tr.Children, vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL)))
@@ -799,11 +799,11 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
isLast := rowIdx == len(rows)-1
rowCls := ""
if cfg.alternate && rowIdx%2 == 1 {
rowCls = cx(rowCls, "bg-neutral-100")
rowCls = cx(rowCls, "bg-surface-raised")
}
rowCls = cx(rowCls, hoverCls)
if cfg.borderX && !isLast {
rowCls = cx(rowCls, "border-b border-neutral-300")
rowCls = cx(rowCls, "border-b border-line-strong")
}
if cfg.highlight != nil && cfg.highlight(row) {
rowCls = cx(rowCls, AUTOTABLE_HIGHLIGHT_ROW)
@@ -2182,14 +2182,14 @@ func (s *AutoTableState) FiltersToggle() *vdom.VNode {
const (
AUTOTABLE_SEARCH_FIELDS = "flex flex-wrap items-end gap-2"
AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 grow sm:grow-0 sm:w-48"
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-neutral-300 bg-white px-3 py-2 text-sm"
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-neutral-300 px-3 py-2 text-sm"
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-line-strong bg-surface px-3 py-2 text-sm"
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-line-strong px-3 py-2 text-sm"
AUTOTABLE_FILTERS_BADGE = "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-xs font-semibold text-white"
AUTOTABLE_TOOLBAR = "flex flex-wrap items-end justify-between gap-3 pb-3"
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-neutral-300 bg-white p-3"
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-line-strong bg-surface p-3"
AUTOTABLE_ACCORDION_CELL = "w-10 text-center"
AUTOTABLE_ACCORDION_ROW = "bg-neutral-50"
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
AUTOTABLE_ACCORDION_ROW = "bg-surface-muted"
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100 dark:bg-amber-900! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
)
// ==========================================================================
@@ -2602,7 +2602,7 @@ func (s *AutoTableState) pendingSkeleton() *vdom.VNode {
vdom.Attr("aria-label", "Loading table"),
vdom.Div(
kids([]vdom.Mod{
vdom.Attr("class", cx(TBL_CONTAINER, "border border-neutral-300 p-4 flex flex-col gap-3")),
vdom.Attr("class", cx(TBL_CONTAINER, "border border-line-strong p-4 flex flex-col gap-3")),
}, bars)...,
),
)
@@ -2718,6 +2718,7 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
if len(options) == 0 {
return nil
}
total := len(options)
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
Options: options,
Value: selected,
@@ -2725,6 +2726,11 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
Searchable: true,
ShowSelectAll: true,
FieldWidth: "w-52",
// Not "5 items selected": this picker is about COLUMNS, and the count is only
// meaningful next to how many there are to choose from.
CollapsedLabel: func(n int) string {
return strconv.Itoa(n) + " of " + strconv.Itoa(total) + " columns"
},
OnChange: func(visible []string) {
show := map[string]bool{}
for _, k := range visible {
@@ -2779,7 +2785,7 @@ func (s *AutoTableState) ResetMenu() *vdom.VNode {
}
if len(items) == 0 {
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-neutral-500"),
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-ink-muted"),
vdom.Text("Nothing to reset"),
))
} else {
@@ -3091,7 +3097,7 @@ func (s *AutoTableState) summaryFoot(ctx *CalcContext, colCount int) *vdom.VNode
}
// AUTOTABLE_TFOOT styles the summary footer.
const AUTOTABLE_TFOOT = "border-t-2 border-neutral-300 bg-neutral-50 [&_td]:p-3"
const AUTOTABLE_TFOOT = "border-t-2 border-line-strong bg-surface-muted [&_td]:p-3"
// ---- the calculated-column editor ----
@@ -3127,16 +3133,16 @@ func isBinaryCalcFn(fn CalculatedFunction) bool {
// their metrics line up to the pixel; if they ever drift, the caret stops landing on
// the glyph it appears to be on.
const (
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100 py-1 px-3"
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised py-1 px-3"
FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre"
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-neutral-800"
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-neutral-300 focus:border-sky-500 outline-hidden"
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-ink"
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden"
formulaPlaceholder = `<span class="text-neutral-400">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
formulaPlaceholder = `<span class="text-ink-faint">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-neutral-300 bg-neutral-50 px-2 py-1 text-xs leading-none text-neutral-700 cursor-pointer hover:bg-neutral-100"
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-line-strong bg-surface-muted px-2 py-1 text-xs leading-none text-ink-soft cursor-pointer hover:bg-surface-raised"
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
)
// HighlightFormula renders a formula as HTML with syntax colouring: cell refs
@@ -3145,6 +3151,20 @@ const (
// Purely visual, and deliberately LEXICAL rather than a real parse — it has to
// colour a half-typed formula that does not compile yet, which is exactly when the
// colours are worth the most.
// The formula highlighter's token colours.
//
// Named, rather than written inline, because they are asserted on in tests — and a
// colour spelled out in two places is a colour that gets changed in one. (It already
// happened: adding the dark: variants broke every highlighter test, none of which cared
// about the colour, all of which had a copy of it.)
const (
fmlCellClass = "text-sky-600 dark:text-sky-400" // [Cell]
fmlColumnClass = "text-violet-600 dark:text-violet-400" // {Column}
fmlNumberClass = "text-amber-600 dark:text-amber-400" // 42, PI
fmlFuncClass = "text-emerald-700 dark:text-emerald-400 font-semibold" // SUM(
fmlOpClass = "text-ink-faint" // + - * /
)
func HighlightFormula(src string) string {
var b strings.Builder
i := 0
@@ -3159,7 +3179,7 @@ func HighlightFormula(src string) string {
if j >= 0 {
end = i + j + 1
}
b.WriteString(`<span class="text-sky-600">`)
b.WriteString(`<span class="` + fmlCellClass + `">`)
b.WriteString(html.EscapeString(src[i:end]))
b.WriteString(`</span>`)
i = end
@@ -3176,7 +3196,7 @@ func HighlightFormula(src string) string {
}
j++
}
b.WriteString(`<span class="text-violet-600">`)
b.WriteString(`<span class="` + fmlColumnClass + `">`)
b.WriteString(html.EscapeString(src[i:j]))
b.WriteString(`</span>`)
i = j
@@ -3186,7 +3206,7 @@ func HighlightFormula(src string) string {
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
j++
}
b.WriteString(`<span class="text-amber-600">`)
b.WriteString(`<span class="` + fmlNumberClass + `">`)
b.WriteString(html.EscapeString(src[i:j]))
b.WriteString(`</span>`)
i = j
@@ -3205,11 +3225,11 @@ func HighlightFormula(src string) string {
}
switch {
case k < len(src) && src[k] == '(':
b.WriteString(`<span class="text-emerald-700 font-semibold">`)
b.WriteString(`<span class="` + fmlFuncClass + `">`)
b.WriteString(html.EscapeString(word))
b.WriteString(`</span>`)
case isFormulaConstant(word):
b.WriteString(`<span class="text-amber-600">`)
b.WriteString(`<span class="` + fmlNumberClass + `">`)
b.WriteString(html.EscapeString(word))
b.WriteString(`</span>`)
default:
@@ -3218,7 +3238,7 @@ func HighlightFormula(src string) string {
i = j
case strings.IndexByte("+-*/^%=<>(),:", c) >= 0:
b.WriteString(`<span class="text-neutral-400">`)
b.WriteString(`<span class="` + fmlOpClass + `">`)
b.WriteString(html.EscapeString(string(c)))
b.WriteString(`</span>`)
i++
@@ -3590,7 +3610,7 @@ func (s *AutoTableState) calcChooser(e *calcEditor) *vdom.VNode {
))
}
if len(existing) > 0 {
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-neutral-200 pb-2 mb-1")}, existing)...))
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-line pb-2 mb-1")}, existing)...))
}
children = append(children,
@@ -3607,19 +3627,19 @@ func calcChooserButton(icon, title, subtitle string, onClick func()) *vdom.VNode
return vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", calcChooserItem),
vdom.On(vdom.EVENT_CLICK, onClick),
Icon(icon, 16, "mt-0.5 text-neutral-500"),
Icon(icon, 16, "mt-0.5 text-ink-muted"),
vdom.Span(vdom.Attr("class", "flex flex-col"),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(subtitle)),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(subtitle)),
),
)
}
func calcListRow(title, subtitle string, onEdit, onDelete func()) *vdom.VNode {
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-neutral-50"),
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-surface-muted"),
vdom.Div(vdom.Attr("class", "min-w-0 grow"),
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-neutral-500"), vdom.Text(subtitle)),
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-ink"), vdom.Text(title)),
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-ink-muted"), vdom.Text(subtitle)),
),
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "pencil", Title: "Edit", OnClick: onEdit}),
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "trash", Title: "Delete", OnClick: onDelete}),
@@ -3712,14 +3732,14 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
title = "Edit " + strings.ToLower(title)
}
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-neutral-200 px-3 py-2"),
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-line px-3 py-2"),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent"),
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-ink-muted hover:bg-surface-raised border-0 bg-transparent"),
vdom.Attr("aria-label", "Back"),
vdom.On(vdom.EVENT_CLICK, e.reset),
Icon("chevron-left", 14, ""),
),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
// Basic vs Advanced.
vdom.Div(vdom.Attr("class", "ml-auto"),
SegmentedButtons([]SegmentedButtonOption{
@@ -3773,7 +3793,7 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
}
if msg := e.errorMsg.Get(); msg != "" {
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(msg)))
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(msg)))
}
saveLabel := "Add"
@@ -3877,7 +3897,7 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
})))
}
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
vdom.Text(calcBasicHint(e, summary))))
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2")}, children)...)
@@ -3908,12 +3928,12 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
label := o.Label
colItems = append(colItems,
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("[" + label + "]") }},
vdom.Span(vdom.Attr("class", "font-mono text-sky-600"), vdom.Text("["+label+"]")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("this row")),
vdom.Span(vdom.Attr("class", "font-mono text-sky-600 dark:text-sky-400"), vdom.Text("["+label+"]")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("this row")),
),
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("{" + label + "}") }},
vdom.Span(vdom.Attr("class", "font-mono text-violet-600"), vdom.Text("{"+label+"}")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("whole column")),
vdom.Span(vdom.Attr("class", "font-mono text-violet-600 dark:text-violet-400"), vdom.Text("{"+label+"}")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("whole column")),
),
)
}
@@ -3925,7 +3945,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
groups := filterFormulaGroups(e.fnSearch.Get())
if len(groups) == 0 {
fnItems = append(fnItems, vdom.Div(
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
vdom.Attr("class", "px-2 py-3 text-center text-xs text-ink-muted"),
vdom.Text("No functions match"),
))
}
@@ -3940,7 +3960,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
// The signature is syntax-highlighted with the same highlighter the
// formula box uses, so the menu and the editor speak one language.
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(f.Desc)),
),
))
}
@@ -3953,8 +3973,8 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
OnClick: func() { e.insertAtCaret(cc.Name) },
},
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600 dark:text-amber-400"), vdom.Text(cc.Name)),
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(cc.Desc)),
),
))
}
@@ -4008,7 +4028,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
),
)
help := vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
help := vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
vdom.Text("[Column] is this row's cell · {Column} is the whole column · {Column:1:ROW()} is a running total."))
children := []*vdom.VNode{menus, editor, help}
@@ -4024,11 +4044,11 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
func (e *calcEditor) fnSearchBox() *vdom.VNode {
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-line bg-surface p-1.5"),
vdom.Input(
vdom.WithRef(e.fnSearchRef),
vdom.Attr("type", "text"),
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
vdom.Attr("class", "w-full rounded-default border border-line-strong p-1 text-xs focus:outline-2 focus:outline-sky-500"),
vdom.Attr("placeholder", "Search functions…"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", e.fnSearch.Get()),
@@ -4069,12 +4089,12 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
return nil
}
if _, err := CompileFormula(src); err != nil {
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
}
rows := s.FilteredRows()
if len(rows) == 0 {
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text("Formula is valid."))
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text("Formula is valid."))
}
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
@@ -4087,10 +4107,10 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
n, err := EvalFormula(src, target)
if err != nil {
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
}
out := FormatCalcResult(n, CalculatedDataType(e.dataType.Get()), calcPrecisionOf(e.precision.Get()), "", "", "")
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(prefix+out))
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(prefix+out))
}
// insertAtCaret drops text where the cursor is, rather than at the end — which is
@@ -4121,7 +4141,7 @@ func (e *calcEditor) insertAtCaretOffset(text string, back int) {
func calcField(label string, children ...*vdom.VNode) *vdom.VNode {
nodes := []*vdom.VNode{
vdom.Span(vdom.Attr("class", "text-xs font-medium text-neutral-600"), vdom.Text(label)),
vdom.Span(vdom.Attr("class", "text-xs font-medium text-ink-soft"), vdom.Text(label)),
}
nodes = append(nodes, children...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1")}, nodes)...)

View File

@@ -362,17 +362,17 @@ func TestHighlightFormula(t *testing.T) {
src string
want []string // fragments that must appear
}{
{"cell ref", "[Revenue]", []string{`<span class="text-sky-600">[Revenue]</span>`}},
{"column ref", "{Revenue}", []string{`<span class="text-violet-600">{Revenue}</span>`}},
{"cell ref", "[Revenue]", []string{`<span class="` + fmlCellClass + `">[Revenue]</span>`}},
{"column ref", "{Revenue}", []string{`<span class="` + fmlColumnClass + `">{Revenue}</span>`}},
{"nested braces stay one span", "{Revenue:1:ROW()}",
[]string{`<span class="text-violet-600">{Revenue:1:ROW()}</span>`}},
[]string{`<span class="` + fmlColumnClass + `">{Revenue:1:ROW()}</span>`}},
{"function", "SUM(",
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
{"function with a space before the paren", "SUM (",
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
{"number", "12.5", []string{`<span class="text-amber-600">12.5</span>`}},
{"constant", "PI", []string{`<span class="text-amber-600">PI</span>`}},
{"operators", "1 + 2", []string{`<span class="text-neutral-400">+</span>`}},
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
{"number", "12.5", []string{`<span class="` + fmlNumberClass + `">12.5</span>`}},
{"constant", "PI", []string{`<span class="` + fmlNumberClass + `">PI</span>`}},
{"operators", "1 + 2", []string{`<span class="` + fmlOpClass + `">+</span>`}},
{"a bare word is not coloured", "Revenue", []string{"Revenue"}},
}
for _, c := range cases {
@@ -455,7 +455,7 @@ func TestEditorOpensOnTheChooser(t *testing.T) {
e.advanced.Set(true)
e.formula.Set("[Gross revenue] * 12")
adv := renderNode(s.calcForm(e, false))
if !strings.Contains(adv, `<span class="text-sky-600">[Gross revenue]</span>`) {
if !strings.Contains(adv, `<span class="`+fmlCellClass+`">[Gross revenue]</span>`) {
t.Errorf("the formula is not syntax-highlighted:\n%s", adv)
}
for _, want := range []string{"Column", "Function", "Constant"} {

View File

@@ -21,7 +21,7 @@ var badgeColors = map[string]string{
"blue": "text-white bg-sky-800",
"amber": "text-white bg-amber-700",
"neutral": "text-white bg-neutral-500",
"muted": "text-neutral-400 bg-transparent",
"muted": "text-ink-faint bg-transparent",
}
// BadgeProps configures Badge. When OnClick is set the badge renders as a

View File

@@ -25,8 +25,8 @@ const btnBase = "inline-flex items-center justify-center gap-2 cursor-pointer te
var btnColors = map[string]string{
"neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800",
"white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50",
"light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100",
"white": "shadow-xs bg-surface text-ink border border-line-strong hover:bg-surface-muted",
"light-neutral": "shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised",
"blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800",
"dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950",
"green": "shadow-xs bg-green-700 text-white hover:bg-green-800",
@@ -36,23 +36,23 @@ var btnColors = map[string]string{
"yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800",
"orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700",
"primary": "shadow-xs bg-primary text-white hover:bg-primary-hover",
"secondary": "shadow-none bg-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200",
"ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100",
"secondary": "shadow-none bg-surface-raised text-ink-soft border border-line-strong hover:bg-surface-strong",
"ghost": "shadow-none bg-transparent text-ink-soft border-none hover:bg-surface-raised",
}
var btnOutlineColors = map[string]string{
"neutral": "text-neutral-700",
"white": "text-neutral-300",
"light-neutral": "text-neutral-300",
"blue": "text-sky-700",
"neutral": "text-ink-soft",
"white": "text-ink-faint",
"light-neutral": "text-ink-faint",
"blue": "text-sky-700 dark:text-sky-400",
"dark-blue": "text-sky-900",
"green": "text-green-700",
"green": "text-green-700 dark:text-green-400",
"dark-green": "text-green-900",
"red": "text-red-700",
"dark-red": "text-red-900",
"yellow": "text-yellow-700",
"orange": "text-orange-600",
"primary": "text-primary",
"primary": "text-accent",
}
const btnOutlineBase = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]"
@@ -139,7 +139,7 @@ func iconSize(small bool) int {
func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 hover:underline"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 dark:text-sky-400 hover:underline"),
}
if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
@@ -151,7 +151,7 @@ func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
func ButtonLinkRed(onClick func(), children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 hover:underline"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 dark:text-red-400 hover:underline"),
}
if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
@@ -174,10 +174,10 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
sizeCls = "py-0.5 px-2 text-xs"
}
const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
const activeCls = "bg-white text-text-heading shadow-sm"
const inactiveCls = "text-neutral-500 hover:text-neutral-700"
const activeCls = "bg-surface text-text-heading shadow-sm"
const inactiveCls = "text-ink-muted hover:text-ink-soft"
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-neutral-100 p-0.5", class))}
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-surface-raised p-0.5", class))}
for _, opt := range options {
state := inactiveCls
if opt.Value == value {
@@ -201,7 +201,7 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
// BackLink is an inline chevron-left anchor. onDark switches to on-dark colors.
func BackLink(href, text string, onDark bool) *vdom.VNode {
color := "text-neutral-600 hover:text-neutral-900"
color := "text-ink-soft hover:text-ink"
if onDark {
color = "text-text-on-dark-muted hover:text-text-on-dark"
}

View File

@@ -26,18 +26,18 @@ var calMonthsShort = []string{
// exported from Calendar.tsx; here they stay unexported with a cal prefix so the
// two ported files can share them without widening the package's public API. --
const calPickerRoot = "p-2 min-w-[240px]"
const calMonthRoot = "p-0 min-w-0 w-full bg-white border border-neutral-200 rounded-default shadow-sm overflow-hidden"
const calMonthRoot = "p-0 min-w-0 w-full bg-surface border border-line rounded-default shadow-sm overflow-hidden"
const calHeaderPicker = "flex items-center justify-between mb-2 gap-1"
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-neutral-200 bg-neutral-50"
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-line bg-surface-muted"
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body"
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-surface-raised hover:text-text-body"
const calMyPicker = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap"
const calMyMonth = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading whitespace-nowrap"
const calWeekdaysPicker = "grid grid-cols-7 gap-[2px] mb-1"
const calWeekdaysMonth = "grid grid-cols-7 border-b border-neutral-200"
const calWeekdaysMonth = "grid grid-cols-7 border-b border-line"
const calWeekdayPicker = "text-center text-xs font-semibold text-text-muted p-1"
const calWeekdayMonth = "text-center text-xs font-semibold text-text-muted p-2 uppercase tracking-wider"
@@ -45,9 +45,9 @@ const calDaysPicker = "grid grid-cols-7 gap-[2px]"
const calDaysMonth = "grid grid-cols-7"
const calDayPickerBase = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-text-body p-0"
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-neutral-200 text-left gap-1 text-xs bg-transparent cursor-pointer"
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-line text-left gap-1 text-xs bg-transparent cursor-pointer"
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-neutral-200 rounded-sm bg-white text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-line rounded-sm bg-surface text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
// Calendar variants.
const (
@@ -123,10 +123,10 @@ func calDayClassPicker(empty, selected, today bool) string {
if empty {
c = cx(c, "cursor-default")
} else {
c = cx(c, "hover:bg-neutral-100")
c = cx(c, "hover:bg-surface-raised")
}
if today {
c = cx(c, "font-bold text-primary")
c = cx(c, "font-bold text-accent")
}
if selected {
c = cx(c, "!bg-primary !text-white")
@@ -142,9 +142,9 @@ func calDayClassMonth(idx int, empty, selected bool) string {
c = cx(c, "border-r-0")
}
if empty {
c = cx(c, "bg-neutral-50 cursor-default")
c = cx(c, "bg-surface-muted cursor-default")
} else {
c = cx(c, "hover:bg-neutral-50")
c = cx(c, "hover:bg-surface-muted")
}
if selected {
c = cx(c, "bg-primary/10 text-text-body")

View File

@@ -5,15 +5,15 @@ import "kjol/vdom"
// Port of web/kit/Cards.tsx. The `ui-card`/`no-flex` marker classes are kept so
// any page CSS targeting them still applies; baseline styling is Tailwind.
const cardBase = "ui-card bg-white shadow-sm rounded-default w-full"
const cardBase = "ui-card bg-surface shadow-sm rounded-default w-full"
const cardWithPadding = cardBase + " p-5 flex-1"
const cardNoFlex = cardBase + " no-flex p-5"
const cardNoPaddingNoFlex = cardBase + " no-padding no-flex"
const borderCard = "border border-neutral-300 rounded-default p-5 w-full"
const borderCard = "border border-line-strong rounded-default p-5 w-full"
// cutCornerCard uses two clip-path pseudo-elements to notch opposite corners.
const cutCornerCard = "relative isolate p-5 w-full " +
"before:content-[''] before:absolute before:inset-0 before:bg-neutral-300 before:-z-20 " +
"before:content-[''] before:absolute before:inset-0 before:bg-surface-strong before:-z-20 " +
"before:[clip-path:polygon(16px_0,100%_0,100%_calc(100%_-_16px),calc(100%_-_16px)_100%,0_100%,0_16px)] " +
"after:content-[''] after:absolute after:inset-[1px] after:bg-white after:-z-10 " +
"after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]"
@@ -47,7 +47,7 @@ func BorderCutCornerCard(class string, children ...*vdom.VNode) *vdom.VNode {
return cardDiv(cutCornerCard, class, children)
}
const cardHeader = "text-xl tracking-tight text-black mb-5"
const cardHeader = "text-xl tracking-tight text-ink mb-5"
const cardHeaderHR = "text-neutral-200 mt-1 mb-3"
// CardHeader renders a card title followed by a divider.
@@ -66,7 +66,7 @@ func CardHeaderTextCenter(class string, children ...*vdom.VNode) *vdom.VNode {
// CardSubheader renders a smaller secondary heading.
func CardSubheader(class string, children ...*vdom.VNode) *vdom.VNode {
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-ink mb-2", class))}, children)...)
}
// CardSpacer is vertical spacing between cards.

View File

@@ -35,7 +35,7 @@ import (
// GridHeaderCls is the base <th> class for CellGrid headers (exported, matching
// the TSX GRID_HEADER_CLS).
const GridHeaderCls = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0"
const GridHeaderCls = "border-b border-r border-line-strong bg-surface-muted px-1.5 py-1.5 text-left text-xs font-bold uppercase text-ink whitespace-nowrap last:border-r-0"
var cgLeadingDigits = regexp.MustCompile(`^\d+`)
var cgLeadingFloat = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`)
@@ -185,7 +185,7 @@ type SortableHeaderProps struct {
// column and calls OnSort(SortKey) on click.
func SortableHeader(p SortableHeaderProps) *vdom.VNode {
isActive := p.SortKey != "" && p.Current == p.SortKey
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-neutral-200"
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-surface-strong"
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
cls += " " + sz
}
@@ -311,9 +311,9 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if p.Dense {
rowHCls = "h-6"
}
readonlyTdCls := "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls
const editableTdCls = "border-b border-r border-neutral-300 p-0 relative align-middle"
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
readonlyTdCls := "border-b border-r border-line-strong bg-black/5 px-2 text-ink-soft align-middle " + rowHCls
const editableTdCls = "border-b border-r border-line-strong p-0 relative align-middle"
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-ink-faint focus:bg-red-50 dark:bg-red-950 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
conflicts := cellGridConflictSets(p)
isConflict := func(field string, value any) bool {
@@ -417,7 +417,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if inList {
tdCls += " relative"
if hasConflict {
tdCls += " bg-amber-100"
tdCls += " bg-amber-100 dark:bg-amber-900"
}
}
im := col.InputMode
@@ -441,7 +441,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
)
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
if inList && hasConflict {
mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600 dark:text-amber-400"),
vdom.Attr("title", "Duplicate value"),
Icon("triangle-exclamation", 12, ""),
))
@@ -461,7 +461,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
for _, col := range p.Columns {
cells = append(cells, renderCell(row, col))
}
bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-surface even:bg-surface-raised")}, cells)...))
}
tbody := vdom.Tbody(kids(nil, bodyRows)...)
@@ -469,7 +469,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if p.Dense {
tableCls = "min-w-full w-max border-collapse text-xs"
}
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-line-strong rounded-default bg-surface tabular-nums"),
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
)
}

174
go/webui/code.go Normal file
View File

@@ -0,0 +1,174 @@
package webui
import (
"html"
"strings"
)
// Go syntax highlighting, for the code samples a documentation page shows.
//
// It is a LEXER, not a parser: it classifies tokens and gives up gracefully on anything
// it does not understand, because a highlighter that can fail to render is worse than
// one that occasionally paints an identifier the wrong colour. Unterminated strings and
// comments run to the end of the input rather than throwing.
//
// Output is HTML, and every run of source text passes through html.EscapeString on the
// way out — the input is Go source, which is full of `<`, `>` and `&`, and one of the
// snippets this is meant to display is literally a block of HTML.
// A code block is dark in BOTH themes — a light code block on a light page is a
// different kind of thing, and switching it with the theme means the snippet you were
// reading changes colour under you. So these are fixed on-dark colours, not theme
// tokens: the surface they sit on never changes.
const (
goCommentClass = "text-neutral-400"
goStringClass = "text-emerald-300"
goKeywordClass = "text-sky-300"
goNumberClass = "text-amber-300"
goFuncClass = "text-violet-300"
)
var goKeywords = map[string]bool{
"break": true, "case": true, "chan": true, "const": true, "continue": true,
"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
"func": true, "go": true, "goto": true, "if": true, "import": true,
"interface": true, "map": true, "package": true, "range": true, "return": true,
"select": true, "struct": true, "switch": true, "type": true, "var": true,
// Not keywords to the Go spec — predeclared identifiers — but every editor colours
// them, and a reader looking for `nil` is looking for the same kind of thing.
"nil": true, "true": true, "false": true, "iota": true,
"string": true, "int": true, "int64": true, "float64": true, "bool": true,
"byte": true, "rune": true, "any": true, "error": true,
}
// HighlightGo turns Go source into HTML with the tokens wrapped in coloured spans.
//
// The result is meant for vdom.Raw inside a <pre>: it contains no block elements and
// preserves the source's whitespace exactly, so the <pre> does the layout.
func HighlightGo(src string) string {
var b strings.Builder
b.Grow(len(src) * 2)
i := 0
for i < len(src) {
c := src[i]
switch {
// Line comment — including the //gowasm: directives, which are the most
// important line in several of these snippets.
case c == '/' && i+1 < len(src) && src[i+1] == '/':
end := strings.IndexByte(src[i:], '\n')
if end < 0 {
end = len(src)
} else {
end += i
}
span(&b, goCommentClass, src[i:end])
i = end
// Block comment.
case c == '/' && i+1 < len(src) && src[i+1] == '*':
end := strings.Index(src[i+2:], "*/")
if end < 0 {
end = len(src)
} else {
end = i + 2 + end + 2
}
span(&b, goCommentClass, src[i:end])
i = end
// Interpreted string. Ends at the closing quote or the line's end — an
// unterminated string is a typo in a snippet, not a reason to paint the rest of
// the file green.
case c == '"':
i = quoted(&b, src, i, '"', true)
// Raw string: no escapes, and it may span lines.
case c == '`':
i = quoted(&b, src, i, '`', false)
// Rune literal.
case c == '\'':
i = quoted(&b, src, i, '\'', true)
case isDigit(c):
j := i
for j < len(src) && (isDigit(src[j]) || isHexish(src[j])) {
j++
}
span(&b, goNumberClass, src[i:j])
i = j
case isIdentStart(c):
j := i
for j < len(src) && isIdentPart(src[j]) {
j++
}
word := src[i:j]
switch {
case goKeywords[word]:
span(&b, goKeywordClass, word)
case callAhead(src, j):
// An identifier immediately followed by "(" is being called (or is a type
// being converted to). Colouring it is what makes the shape of a snippet
// readable at a glance.
span(&b, goFuncClass, word)
default:
b.WriteString(html.EscapeString(word))
}
i = j
default:
b.WriteString(html.EscapeString(string(c)))
i++
}
}
return b.String()
}
// quoted consumes a quoted literal starting at i and writes it as a string span.
// escapes reports whether a backslash escapes the next byte (false for raw strings).
func quoted(b *strings.Builder, src string, i int, quote byte, escapes bool) int {
j := i + 1
for j < len(src) {
if escapes && src[j] == '\\' && j+1 < len(src) {
j += 2
continue
}
if src[j] == quote {
j++
break
}
if escapes && src[j] == '\n' {
break // unterminated: stop at the line end rather than eating the file
}
j++
}
span(b, goStringClass, src[i:j])
return j
}
// callAhead reports whether the next non-space byte at or after i is an opening paren.
func callAhead(src string, i int) bool {
for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
i++
}
return i < len(src) && src[i] == '('
}
func span(b *strings.Builder, class, text string) {
b.WriteString(`<span class="`)
b.WriteString(class)
b.WriteString(`">`)
b.WriteString(html.EscapeString(text))
b.WriteString(`</span>`)
}
// isDigit already exists in the package (autotable.go) — reused rather than shadowed.
func isHexish(c byte) bool {
return c == '.' || c == 'x' || c == 'X' || c == '_' ||
(c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) }

99
go/webui/code_test.go Normal file
View File

@@ -0,0 +1,99 @@
package webui
import (
"strings"
"testing"
)
// The highlighter emits HTML, and its input is Go source — which is full of <, > and &.
// Anything that reaches the page unescaped is markup injection into your own docs page:
// a snippet containing `<div>` would render a div.
func TestHighlightGoEscapes(t *testing.T) {
got := HighlightGo(`s := "<div class=\"x\">" // a & b`)
if strings.Contains(got, "<div") {
t.Errorf("a `<div>` in the SOURCE reached the output as markup:\n%s", got)
}
if !strings.Contains(got, "&lt;div") {
t.Errorf("the angle bracket was not escaped:\n%s", got)
}
if !strings.Contains(got, "&amp;") {
t.Errorf("the ampersand was not escaped:\n%s", got)
}
}
func TestHighlightGoClassifies(t *testing.T) {
got := HighlightGo("func main() { x := 42 // note\n}")
for _, want := range []struct{ what, class, text string }{
{"keyword", goKeywordClass, "func"},
{"call", goFuncClass, "main"},
{"number", goNumberClass, "42"},
{"comment", goCommentClass, "// note"},
} {
if !strings.Contains(got, `<span class="`+want.class+`">`+want.text+`</span>`) {
t.Errorf("%s %q was not highlighted:\n%s", want.what, want.text, got)
}
}
}
// The //gowasm: directives are the most important line in half these snippets. They are
// comments, and must survive as such.
func TestHighlightGoKeepsDirectives(t *testing.T) {
got := HighlightGo("//gowasm:page / static layout=public\nfunc HomePage() {}")
if !strings.Contains(got, `<span class="`+goCommentClass+`">//gowasm:page / static layout=public</span>`) {
t.Errorf("the directive was not kept whole as a comment:\n%s", got)
}
}
// A lexer that can hang or eat the rest of the file on malformed input would take the
// whole page down with it. Unterminated literals stop; they do not run away.
func TestHighlightGoSurvivesMalformedInput(t *testing.T) {
for _, src := range []string{
`x := "unterminated`,
"y := `unterminated raw",
"/* unterminated block",
`z := '`,
"",
} {
got := HighlightGo(src)
// The text must all still be there — mangling is not an acceptable failure mode
// either. Compare on the visible characters, ignoring the spans.
if plain := stripTags(got); plain != src {
t.Errorf("input %q came out as %q", src, plain)
}
}
}
// Nothing is dropped: every byte of the source is still on the page, in order.
func TestHighlightGoIsLossless(t *testing.T) {
src := "package app\n\nimport \"strings\"\n\nfunc f(n int) string {\n\treturn strings.Repeat(\"x\", n) // pad\n}\n"
if plain := stripTags(HighlightGo(src)); plain != src {
t.Errorf("the highlighter changed the source.\n got: %q\nwant: %q", plain, src)
}
}
// stripTags removes the spans and unescapes, recovering the original source.
func stripTags(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
if s[i] == '<' {
j := strings.IndexByte(s[i:], '>')
if j < 0 {
break
}
i += j + 1
continue
}
b.WriteByte(s[i])
i++
}
out := b.String()
// Reverse html.EscapeString, innermost last.
out = strings.ReplaceAll(out, "&lt;", "<")
out = strings.ReplaceAll(out, "&gt;", ">")
out = strings.ReplaceAll(out, "&#34;", `"`)
out = strings.ReplaceAll(out, "&#39;", "'")
out = strings.ReplaceAll(out, "&amp;", "&")
return out
}

View File

@@ -49,9 +49,9 @@ func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
const crmTabRow = "flex w-full overflow-x-auto text-sm"
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"
const crmTabInactive = "border-b text-neutral-500 hover:text-neutral-800"
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-primary"
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-line-strong transition-colors"
const crmTabInactive = "border-b text-ink-muted hover:text-ink"
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-accent"
const crmTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"
// CrmTabGroup renders boxed tabs with a sky-blue top accent; content sits flat
@@ -79,7 +79,7 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
}
row = append(row, vdom.Button(btn...))
}
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-neutral-300")))
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-line-strong")))
return vdom.Div(vdom.Attr("class", "w-full"),
vdom.Div(row...),
@@ -89,12 +89,12 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
// --- CrmSubTabGroup: segmented control --------------------------------------
const crmSubTabWrap = "flex pb-3 border-b border-neutral-300 overflow-x-auto"
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"
const crmSubTabWrap = "flex pb-3 border-b border-line-strong overflow-x-auto"
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-line-strong overflow-hidden text-sm select-none"
const crmSubTabBase = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"
const crmSubTabDivider = "border-l border-neutral-300"
const crmSubTabDivider = "border-l border-line-strong"
const crmSubTabActive = "bg-neutral-500 text-white"
const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
const crmSubTabInactive = "bg-surface text-ink-soft hover:bg-surface-raised hover:text-ink"
const crmSubTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"
// CrmSubTabGroup renders a left-aligned segmented control (interlocking

View File

@@ -30,7 +30,7 @@ import (
const datePickerWrap = "relative w-full min-w-0"
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
const datePickerDropdown = "bg-surface border border-line rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"

View File

@@ -403,6 +403,13 @@ type FloatingTriggerProps struct {
// OnClick runs in addition to the toggle (which is suppressed when the trigger
// opens on hover).
OnClick func()
// NoToggle anchors the panel to this element without making it a switch.
//
// A text field is not a switch: the panel opens because you typed, and closes
// because you chose something. Toggling on click means clicking back into the field
// to fix a typo dismisses the results you were reading.
NoToggle bool
}
// Trigger renders the element the panel is anchored to.
@@ -436,6 +443,10 @@ func (f *Floating) Trigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdo
if p.OnClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
}
} else if p.NoToggle {
if p.OnClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
}
} else {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if p.OnClick != nil {

View File

@@ -23,28 +23,28 @@ import (
// -- shared Tailwind class strings (verbatim from Forms.tsx) -------------------
const formInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
const formInputBase = "bg-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised disabled:cursor-not-allowed"
const formInputBaseDark = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
const formErrorCls = "block text-red-600 text-xs mt-1"
const formErrorCls = "block text-red-600 dark:text-red-400 text-xs mt-1"
const formSuccessCls = "block text-green-600 text-xs mt-1"
const formInputGroupCls = "flex flex-row items-stretch w-full text-sm"
const formTriggerBase = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed"
const formTriggerBase = "bg-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised disabled:cursor-not-allowed"
const formTriggerBaseDark = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed"
const formDropdown = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdown = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdownDark = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdownSearchWrap = "sticky top-0 bg-white border-b border-neutral-200 p-2"
const formDropdownSearchWrap = "sticky top-0 bg-surface border-b border-line p-2"
const formDropdownSearchWrapDark = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"
const formDropdownSearchInput = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownSearchInput = "w-full bg-surface border border-line-strong rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownSearchInputDark = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownOptionDark = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownNoResults = "p-2 text-sm text-neutral-500 text-center"
const formDropdownNoResults = "p-2 text-sm text-ink-muted text-center"
const formDropdownNoResultsDark = "p-2 text-sm text-text-on-dark-muted text-center"
const formSelectAllWrap = "border-b border-neutral-200"
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"
const formSelectAllWrap = "border-b border-line"
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"
// -- shared class builders -----------------------------------------------------
@@ -59,7 +59,7 @@ func formControlH(small bool) string {
// formFieldBorder picks the border/focus-outline color: error > success > normal.
// error/success are the message strings; non-empty means "present" (truthy).
func formFieldBorder(errMsg, successMsg string, onDark bool) string {
borderNormal := "border-neutral-300 focus:outline-sky-500"
borderNormal := "border-line-strong focus:outline-sky-500"
if onDark {
borderNormal = "border-border-on-dark focus:outline-sky-500"
}
@@ -100,8 +100,8 @@ func formTextareaCls(small bool, errMsg, extra string, onDark bool) string {
}
func formPrefixCls(small bool, errMsg string, onDark bool) string {
base := "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal := "border-neutral-300"
base := "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal := "border-line-strong"
if onDark {
base = "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal = "border-border-on-dark"
@@ -662,7 +662,7 @@ func FormLabel(p FormLabelProps, children ...*vdom.VNode) *vdom.VNode {
if p.Inline {
display = "inline"
}
color := "text-neutral-700"
color := "text-ink-soft"
if p.OnDark {
color = "text-text-on-dark"
}
@@ -685,8 +685,8 @@ func FormFileInput(p FormInputProps) *vdom.VNode {
filePad = "file:py-[2px] file:px-3"
}
class := cx(formInputBase,
"p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer",
"file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200",
"p-1 border-line-strong focus:outline-sky-500 cursor-pointer",
"file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong",
filePad, p.Class)
mods := []vdom.Mod{
vdom.Attr("type", "file"),
@@ -717,8 +717,8 @@ func FormSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-3")) }
// FormFieldset wraps children in a bordered <fieldset> with an optional legend.
func FormFieldset(legend, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("class", cx("border border-neutral-300 rounded-default py-3 px-4", class)),
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-neutral-600"), vdom.Text(legend)),
vdom.Attr("class", cx("border border-line-strong rounded-default py-3 px-4", class)),
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-ink-soft"), vdom.Text(legend)),
}
mods = kids(mods, children)
return vdom.Fieldset(mods...)
@@ -954,7 +954,7 @@ func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark,
cls = cx(cls, "py-1.5 px-2")
}
if idx == d.active.Get() {
cls = cx(cls, "bg-neutral-100")
cls = cx(cls, "bg-surface-raised")
}
mods := []vdom.Mod{
@@ -1073,7 +1073,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
}
display := pick(p.Placeholder, "Select an option")
placeholderCls := "text-neutral-500"
placeholderCls := "text-ink-muted"
if p.OnDark {
placeholderCls = "text-text-on-dark-muted"
}
@@ -1082,7 +1082,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
placeholderCls = ""
}
chevronCls := "text-neutral-400"
chevronCls := "text-ink-faint"
if p.OnDark {
chevronCls = "text-text-on-dark-muted"
}
@@ -1178,6 +1178,12 @@ type FormMultiSelectProps struct {
Small bool
Class string
FieldWidth string
// CollapsedLabel names the selection once the pills no longer fit. It defaults to
// "N items selected", which is right for a list of things and wrong for anything
// that is not — a column picker reading "5 items selected" tells you the count and
// hides what the count is OF.
CollapsedLabel func(n int) string
}
// Render draws the field and its panel.
@@ -1211,7 +1217,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
AriaHasPopup: "listbox",
},
vdom.Div(face...),
IconInline(chevron, 16, "text-neutral-400"),
IconInline(chevron, 16, "text-ink-faint"),
)
if p.Disabled {
trigger.Attrs["disabled"] = "disabled"
@@ -1227,27 +1233,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
// user is looking at and asked for; quietly selecting the hidden ones too would be
// a nasty surprise.
if p.ShowSelectAll && len(filtered) > 0 {
all := formAllSelected(filtered, p.Value)
label := "Select All"
if all {
label = "Deselect All"
}
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if p.OnChange != nil {
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if allNow {
next := cur
for _, o := range opts {
next = formRemoveStr(next, o.Value)
}
oc(next)
return
}
oc(formSelectAllValues(opts, cur))
}))
}
rows = append(rows, vdom.Button(mods...))
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
}
if len(filtered) == 0 {
@@ -1276,6 +1262,34 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
)
}
// selectAllButton is the row at the top of the panel.
//
// It acts on what is VISIBLE. Selecting all of a filtered list is what the user is
// looking at and asked for; quietly selecting the hidden ones too would be a nasty
// surprise, and one they would not discover until they saved.
func (m *MultiSelect) selectAllButton(filtered []FormSelectOption, value []string, onChange func([]string)) *vdom.VNode {
all := formAllSelected(filtered, value)
label := "Select All"
if all {
label = "Deselect All"
}
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if onChange != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if all {
next := value
for _, o := range filtered {
next = formRemoveStr(next, o.Value)
}
onChange(next)
return
}
onChange(formSelectAllValues(filtered, value))
}))
}
return vdom.Button(mods...)
}
// triggerFace is what the closed field shows: a placeholder, a row of removable pills,
// or "N items selected" when the pills will not fit.
//
@@ -1286,7 +1300,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectOption, maxTags int) []vdom.Mod {
if len(selected) == 0 {
return []vdom.Mod{vdom.Span(
vdom.Attr("class", "text-neutral-500 truncate"),
vdom.Attr("class", "text-ink-muted truncate"),
vdom.Text(pick(p.Placeholder, "Select options")),
)}
}
@@ -1315,7 +1329,7 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
ov, label := opt.Value, opt.Label
remove := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-ink"),
vdom.Attr("aria-label", "Remove "+label),
IconInline("xmark", 10, ""),
}
@@ -1334,10 +1348,14 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
))
}
label := itemsSelected
if p.CollapsedLabel != nil {
label = p.CollapsedLabel
}
count := vdom.Span(
vdom.WithRef(m.countRef),
vdom.Attr("class", faceCls("truncate", !tooMany)),
vdom.Text(itemsSelected(len(selected))),
vdom.Text(label(len(selected))),
)
m.collapseFace(tooMany)
@@ -1390,7 +1408,7 @@ func itemsSelected(n int) string {
return strconv.Itoa(n) + " items selected"
}
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-neutral-200 py-0.5 text-neutral-700 whitespace-nowrap leading-none"
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-surface-strong py-0.5 text-ink-soft whitespace-nowrap leading-none"
func truncateRunes(s string, max int) string {
if max <= 0 {
@@ -1543,18 +1561,15 @@ var formTimezones = []FormSelectOption{
{Value: "Pacific/Honolulu", Label: "Hawaii"},
}
// -- intentionally omitted (no neutral-runtime equivalent) ---------------------
// -- the rest of the TSX kit ---------------------------------------------------
//
// NOTE: the following TSX exports are omitted. They depend on capabilities the
// neutral vdom runtime does not provide, and have no meaningful static form:
// These four were once listed here as unportable — "no neutral-runtime equivalent".
// That was true before the host API existed. It is not true now, and they all live in
// forms_async.go and signaturepad.go:
//
// - FormSignaturePad — freehand drawing on a <canvas> 2D context with
// mouse/touch tracking and SVG serialization.
// - FormAsyncCombobox — debounced async option loading via Promises/timers.
// - FormMultiSelectTrigger — a custom-trigger multi-select popover (Portal-free
// but still driven by document listeners + refs); its
// selection model is already covered by FormMultiSelect.
// - handleTaxIdInput / handleRateInput — standalone oninput handlers that mutate
// a DOM element in place; with no component consumer in
// this file and no DOM handle in handlers, they don't
// port. (Their masking logic mirrors formClean* above.)
// - SignaturePad — draws into an SVG rather than a canvas, so the thing you
// sign IS the value the caller stores.
// - AsyncCombobox — debounced, and it discards out-of-order responses.
// - MultiSelectTrigger — MultiSelect behind a trigger of your own.
// - MaskTaxID / MaskRate — the TSX's oninput handlers, as pure functions of the
// string. Testable, reusable, and they run on the server.

394
go/webui/forms_async.go Normal file
View File

@@ -0,0 +1,394 @@
package webui
import (
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// The three form controls the first pass left out, plus the two input masks. All of
// them were listed as "no neutral-runtime equivalent" — which was true before the host
// API existed, and is not true now.
// ---- AsyncCombobox: options fetched as you type --------------------------
// AsyncCombobox is a combobox whose options come from somewhere else: a search endpoint,
// a database, a third-party API. You type, it asks, the answers appear.
//
// The asking is DEBOUNCED and the answers are ORDERED. Both matter and both are easy to
// get wrong: without debouncing, a six-letter query is six requests; without ordering, a
// slow response to "ab" can land after a fast one to "abcdef" and overwrite it, leaving
// the user staring at results for a query they finished typing a second ago. Every
// response carries the query it was for, and one that no longer matches the box is
// dropped.
//
// Create it once, alongside your signals — never inside a render.
type AsyncCombobox struct {
f *Floating
query *vdom.Signal[string]
results *vdom.Signal[[]FormSelectOption]
loading *vdom.Signal[bool]
active *vdom.Signal[int]
inputRef *vdom.Ref
timer int
minChars int
debounce int
search func(query string, done func([]FormSelectOption))
}
// AsyncComboboxOptions configures NewAsyncCombobox.
type AsyncComboboxOptions struct {
// Search is asked for options. Call done with the results — from a goroutine, from a
// fetch callback, whenever. Call it exactly once; calling it late is fine, since a
// stale answer is discarded rather than shown.
Search func(query string, done func([]FormSelectOption))
MinChars int // don't search below this many characters (default 2)
DebounceMs int // wait this long after the last keystroke (default 200)
Placement string
OnOpenChange func(bool)
}
// NewAsyncCombobox creates an async combobox.
func NewAsyncCombobox(o AsyncComboboxOptions) *AsyncCombobox {
c := &AsyncCombobox{
query: vdom.NewSignal(""),
results: vdom.NewSignal([]FormSelectOption{}),
loading: vdom.NewSignal(false),
active: vdom.NewSignal(-1),
inputRef: vdom.NewRef(),
minChars: o.MinChars,
debounce: o.DebounceMs,
search: o.Search,
}
if c.minChars == 0 {
c.minChars = 2
}
if c.debounce == 0 {
c.debounce = 200
}
c.f = NewFloating(FloatingOptions{
Placement: pick(o.Placement, PlacementBottomStart),
Offset: 4,
ConstrainToViewport: true,
Standalone: true,
OnOpenChange: func(open bool) {
if !open {
c.active.Set(-1)
}
if o.OnOpenChange != nil {
o.OnOpenChange(open)
}
},
})
return c
}
// IsOpen / Close drive the panel.
func (c *AsyncCombobox) IsOpen() bool { return c.f.IsOpen() }
func (c *AsyncCombobox) Close() { c.f.Hide() }
// Dispose cancels a pending search and removes the panel's listeners.
func (c *AsyncCombobox) Dispose() {
wasmruntime.ClearTimeout(c.timer)
c.f.Dispose()
}
// FormAsyncComboboxProps configures a render.
type FormAsyncComboboxProps struct {
Placeholder string
// OnSelect fires when an option is chosen. The box shows the option's label.
OnSelect func(opt FormSelectOption)
Disabled bool
Small bool
Class string
FieldWidth string
// EmptyMessage is shown when a search returns nothing (default "No matches").
EmptyMessage string
}
// Render draws the field and its panel.
func (c *AsyncCombobox) Render(p FormAsyncComboboxProps) *vdom.VNode {
input := vdom.Input(
vdom.WithRef(c.inputRef),
vdom.Attr("type", "text"),
vdom.Attr("class", cx(formInputBase, formControlH(p.Small), "border-none bg-transparent p-0 shadow-none focus-visible:outline-none")),
vdom.Attr("placeholder", pick(p.Placeholder, "Search…")),
vdom.Attr("autocomplete", "off"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", c.query.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { c.onInput(e.Value()) }),
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { c.onKey(e, p) }),
)
if p.Disabled {
input.Attrs["disabled"] = "disabled"
}
pad := "p-2"
if p.Small {
pad = "p-1"
}
trigger := c.f.Trigger(FloatingTriggerProps{
Tag: "div",
Class: cx(formTriggerBase, formControlH(p.Small), pad),
AriaHasPopup: "listbox",
// The field is not a switch — see FloatingTriggerProps.NoToggle.
NoToggle: true,
},
vdom.Div(vdom.Attr("class", "min-w-0 flex-1"), input),
c.statusIcon(),
)
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
trigger,
c.panel(p),
)
}
func (c *AsyncCombobox) statusIcon() *vdom.VNode {
if c.loading.Get() {
return vdom.Span(vdom.Attr("class", "shrink-0 animate-spin text-ink-faint"), IconInline("circle-notch", 14, ""))
}
return IconInline("magnifying-glass", 14, "shrink-0 text-ink-faint")
}
func (c *AsyncCombobox) panel(p FormAsyncComboboxProps) *vdom.VNode {
rows := []*vdom.VNode{}
switch {
case c.loading.Get():
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("Searching…")))
case len(c.results.Get()) == 0:
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults),
vdom.Text(pick(p.EmptyMessage, "No matches"))))
default:
for i, opt := range c.results.Get() {
i, opt := i, opt
cls := formDropdownOption
if i == c.active.Get() {
cls = cx(cls, "bg-surface-raised")
}
rows = append(rows, vdom.Button(
vdom.Attr("type", "button"),
vdom.Attr("class", cls),
vdom.Attr("role", "option"),
vdom.On(vdom.EVENT_CLICK, func() { c.choose(opt, p) }),
vdom.Text(opt.Label),
))
}
}
return c.f.Panel(FloatingPanelProps{Class: formDropdown, Role: "listbox"}, rows...)
}
func (c *AsyncCombobox) choose(opt FormSelectOption, p FormAsyncComboboxProps) {
c.query.Set(opt.Label)
c.f.Hide()
if p.OnSelect != nil {
p.OnSelect(opt)
}
}
// onInput debounces. A search per keystroke is a search per keystroke — and if the
// search costs a network round-trip, typing "engineering" is twelve of them.
func (c *AsyncCombobox) onInput(q string) {
c.query.Set(q)
c.active.Set(-1)
wasmruntime.ClearTimeout(c.timer)
if len([]rune(strings.TrimSpace(q))) < c.minChars {
c.loading.Set(false)
c.results.Set([]FormSelectOption{})
c.f.Hide()
return
}
c.loading.Set(true)
c.f.Show()
c.timer = wasmruntime.SetTimeout(c.debounce, func() { c.run(q) })
}
// run performs the search, and DISCARDS an answer that arrived for a query the user has
// since typed past. Out-of-order responses are the classic bug in this control.
func (c *AsyncCombobox) run(q string) {
if c.search == nil {
c.loading.Set(false)
return
}
c.search(q, func(opts []FormSelectOption) {
if c.query.Get() != q {
return // the box has moved on; this answer is for a question nobody is asking
}
c.loading.Set(false)
c.results.Set(opts)
if len(opts) > 0 {
c.f.Show()
}
})
}
func (c *AsyncCombobox) onKey(e vdom.Event, p FormAsyncComboboxProps) {
res := c.results.Get()
switch e.Key() {
case vdom.KEY_ARROW_DOWN:
e.PreventDefault()
if len(res) > 0 {
c.active.Set((c.active.Get() + 1) % len(res))
}
case vdom.KEY_ARROW_UP:
e.PreventDefault()
if len(res) > 0 {
next := c.active.Get() - 1
if next < 0 {
next = len(res) - 1
}
c.active.Set(next)
}
case vdom.KEY_ENTER:
if i := c.active.Get(); i >= 0 && i < len(res) {
e.PreventDefault()
c.choose(res[i], p)
}
}
}
// ---- MultiSelectTrigger: a multi-select behind your own trigger ----------
// MultiSelectTrigger is MultiSelect with the field replaced by whatever you like — a
// button, an icon, a table header. The selection model is identical; only the thing you
// click on differs. The AutoTable's column picker is one of these in spirit.
type MultiSelectTrigger struct{ *MultiSelect }
// NewMultiSelectTrigger creates one.
func NewMultiSelectTrigger(o DropdownOptions) *MultiSelectTrigger {
return &MultiSelectTrigger{MultiSelect: NewMultiSelect(o)}
}
// FormMultiSelectTriggerProps configures a render.
type FormMultiSelectTriggerProps struct {
// Trigger is your element. It is wrapped in the floating trigger, so it needs no
// open/close wiring of its own.
Trigger *vdom.VNode
Options []FormSelectOption
Value []string
OnChange func([]string)
Searchable bool
SearchPlaceholder string
ShowSelectAll bool
Small bool
Class string
PanelClass string
}
// Render draws the caller's trigger and the panel.
func (m *MultiSelectTrigger) Render(p FormMultiSelectTriggerProps) *vdom.VNode {
trigger := m.f.Trigger(FloatingTriggerProps{
Tag: "div",
Class: cx("inline-flex cursor-pointer items-center", p.Class),
AriaHasPopup: "listbox",
}, p.Trigger)
filtered := m.filter(p.Options)
rows := make([]*vdom.VNode, 0, len(filtered)+2)
if p.Searchable && m.IsOpen() {
rows = append(rows, m.searchBox(p.SearchPlaceholder, false, len(filtered)))
}
if p.ShowSelectAll && len(filtered) > 0 {
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
}
if len(filtered) == 0 {
rows = append(rows, m.noResults(false))
}
for i := range filtered {
opt := filtered[i]
on := formContainsStr(p.Value, opt.Value)
rows = append(rows, m.optionButton(opt, i, on, false, p.Small, func() {
if p.OnChange != nil {
p.OnChange(formToggleStr(p.Value, opt.Value))
}
}))
}
return vdom.Div(vdom.Attr("class", "relative inline-flex"),
trigger,
m.f.Panel(FloatingPanelProps{Class: cx(formDropdown, pick(p.PanelClass, "min-w-48")), Role: "listbox"}, rows...),
)
}
// ---- input masks --------------------------------------------------------
// MaskTaxID formats a US tax ID as the user types: 12-3456789.
//
// The TSX equivalents (handleTaxIdInput / handleRateInput) were oninput handlers that
// reached into the event and rewrote the element's value in place. These are pure
// functions of the string instead — they can be tested, reused on the server, and
// composed — and the caller wires one into an input the ordinary way:
//
// ui.FormInput(ui.FormInputProps{
// Value: taxID.Get(),
// OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
// })
func MaskTaxID(s string) string {
digits := keepDigits(s)
if len(digits) > 9 {
digits = digits[:9]
}
if len(digits) <= 2 {
return digits
}
return digits[:2] + "-" + digits[2:]
}
// MaskRate formats a rate as it is typed: digits, one decimal point, at most three
// decimal places, no leading zeros.
func MaskRate(s string) string {
var b strings.Builder
seenDot := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
b.WriteByte(c)
case c == '.' && !seenDot:
seenDot = true
b.WriteByte(c)
}
}
out := b.String()
if i := strings.IndexByte(out, '.'); i >= 0 {
whole, frac := out[:i], out[i+1:]
if len(frac) > 3 {
frac = frac[:3]
}
out = trimLeadingZeros(whole) + "." + frac
} else {
out = trimLeadingZeros(out)
}
return out
}
func keepDigits(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] >= '0' && s[i] <= '9' {
b.WriteByte(s[i])
}
}
return b.String()
}
// trimLeadingZeros drops leading zeros but keeps a lone "0" — "007" is 7, and "0" is 0,
// but "" is not a number the user meant to type.
func trimLeadingZeros(s string) string {
i := 0
for i < len(s)-1 && s[i] == '0' {
i++
}
return s[i:]
}

View File

@@ -0,0 +1,163 @@
package webui
import (
"strings"
"testing"
"kjol/vdom"
)
// ---- input masks ----
func TestMaskTaxID(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", ""},
{"1", "1"},
{"12", "12"},
{"123", "12-3"},
{"123456789", "12-3456789"},
{"12-3456789", "12-3456789"}, // idempotent: re-masking its own output
{"1234567890123", "12-3456789"}, // nine digits, no more
{"ab12cd3456789xy", "12-3456789"},
} {
if got := MaskTaxID(tc.in); got != tc.want {
t.Errorf("MaskTaxID(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestMaskRate(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", ""},
{"5", "5"},
{"5.25", "5.25"},
{"5.2555", "5.255"}, // three decimals, no more
{"5.2.5", "5.25"}, // one decimal point, no more
{"007", "7"}, // no leading zeros...
{"0", "0"}, // ...but a lone zero is a number
{"0.5", "0.5"}, // and so is a leading zero before a point
{"$1,2a3.45", "123.45"},
{"5.25", "5.25"}, // idempotent
} {
if got := MaskRate(tc.in); got != tc.want {
t.Errorf("MaskRate(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// A mask is applied to its own output on every keystroke, so anything that is not
// idempotent corrupts the field as you type.
func TestMasksAreIdempotent(t *testing.T) {
for _, in := range []string{"123456789", "12-345", "1", ""} {
once := MaskTaxID(in)
if twice := MaskTaxID(once); twice != once {
t.Errorf("MaskTaxID is not idempotent: %q -> %q -> %q", in, once, twice)
}
}
for _, in := range []string{"5.255", "0.5", "007", "12"} {
once := MaskRate(in)
if twice := MaskRate(once); twice != once {
t.Errorf("MaskRate is not idempotent: %q -> %q -> %q", in, once, twice)
}
}
}
// ---- async combobox ----
func asyncOpts(labels ...string) []FormSelectOption {
out := make([]FormSelectOption, len(labels))
for i, l := range labels {
out[i] = FormSelectOption{Value: strings.ToLower(l), Label: l}
}
return out
}
// The classic bug in a search-as-you-type box: a SLOW answer to an early query lands
// after a FAST answer to the query the user actually finished typing, and overwrites it.
// The user is then reading results for "ab" while the box says "abcdef".
//
// Every response carries the query it was for; one that no longer matches is dropped.
func TestAsyncComboboxDropsStaleResponses(t *testing.T) {
var pending []func([]FormSelectOption) // answers we have not given yet
var queries []string
c := NewAsyncCombobox(AsyncComboboxOptions{
MinChars: 2,
Search: func(q string, done func([]FormSelectOption)) {
queries = append(queries, q)
pending = append(pending, done)
},
})
// Type "ab", then "abcdef". (run() is called directly: the debounce timer is a
// browser timer, and this test has no browser.)
c.query.Set("ab")
c.run("ab")
c.query.Set("abcdef")
c.run("abcdef")
if len(pending) != 2 {
t.Fatalf("expected 2 searches, got %d (%v)", len(pending), queries)
}
// The answer to "abcdef" comes back first...
pending[1](asyncOpts("Abcdef Ltd"))
if got := c.results.Get(); len(got) != 1 || got[0].Label != "Abcdef Ltd" {
t.Fatalf("the current query's results were not shown: %v", got)
}
// ...and the slow answer to "ab" arrives afterwards. It must be DISCARDED.
pending[0](asyncOpts("Ab Corp", "Abacus"))
got := c.results.Get()
if len(got) != 1 || got[0].Label != "Abcdef Ltd" {
t.Errorf("a stale response overwrote the current results: %v", got)
}
}
// Below MinChars there is no search at all — a one-character query against a big table
// is a scan, and every user who ever types starts by typing one character.
func TestAsyncComboboxRespectsMinChars(t *testing.T) {
searched := 0
c := NewAsyncCombobox(AsyncComboboxOptions{
MinChars: 3,
Search: func(string, func([]FormSelectOption)) { searched++ },
})
c.onInput("a")
c.onInput("ab")
if searched != 0 {
t.Errorf("searched %d times below MinChars", searched)
}
if c.IsOpen() {
t.Error("the panel opened with nothing to show")
}
}
// Choosing an option closes the box and reports the whole option, not just its value —
// the caller usually needs the label back to display it.
func TestAsyncComboboxSelect(t *testing.T) {
var picked FormSelectOption
c := NewAsyncCombobox(AsyncComboboxOptions{
Search: func(_ string, done func([]FormSelectOption)) { done(asyncOpts("Ada Lovelace")) },
})
c.query.Set("ada")
c.run("ada")
c.f.Show()
node := c.Render(FormAsyncComboboxProps{OnSelect: func(o FormSelectOption) { picked = o }})
if !findAndClick(node, "Ada Lovelace") {
t.Fatalf("no result row to click:\n%s", vdom.RenderHTML(node))
}
if picked.Value != "ada lovelace" {
t.Errorf("OnSelect got %+v", picked)
}
if c.IsOpen() {
t.Error("choosing an option should close the panel")
}
if c.query.Get() != "Ada Lovelace" {
t.Errorf("the field should show the chosen label, got %q", c.query.Get())
}
}

View File

@@ -230,12 +230,12 @@ const (
FuzzyDisplayNone = "none" // render only the input (headless)
)
const fuzzyInputCls = "bg-white block w-full border border-neutral-300 rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
const fuzzyInputCls = "bg-surface block w-full border border-line-strong rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
// Mirror FormCombobox's dropdown styling: neutral hover / highlight.
const fuzzyDropdownCls = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-neutral-100 whitespace-nowrap"
const fuzzyDropdownOptionHighlightCls = "bg-neutral-100"
const fuzzyDropdownCls = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-surface-raised whitespace-nowrap"
const fuzzyDropdownOptionHighlightCls = "bg-surface-raised"
// FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted
// are caller-held state (read at the call site); the On* callbacks report changes.
@@ -262,7 +262,7 @@ func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode {
out := make([]*vdom.VNode, 0, len(segments))
for _, seg := range segments {
if seg.Match {
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 dark:text-sky-400 font-semibold"), vdom.Text(seg.Text)))
} else {
out = append(out, vdom.Span(vdom.Text(seg.Text)))
}
@@ -275,7 +275,7 @@ func fuzzyScoreBadge(show bool, score int) *vdom.VNode {
if !show {
return nil
}
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-ink-faint"), vdom.Text(strconv.Itoa(score)))
}
// fuzzyListView renders the inline "list" display. Before the user types, all
@@ -296,12 +296,12 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
for _, r := range items {
r := r
li := []vdom.Mod{
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-neutral-100"),
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-surface-raised"),
}
if p.OnSelect != nil {
li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
}
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-ink")}, fuzzyHighlight(r.Segments))...))
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
li = append(li, badge)
}
@@ -309,7 +309,7 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
}
outer = append(outer, vdom.Ul(ul...))
} else if strings.TrimSpace(p.Query) != "" {
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-neutral-500 italic"),
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-ink-muted italic"),
vdom.Text(`No matches for "`+p.Query+`".`)))
}
return vdom.Div(outer...)
@@ -338,7 +338,7 @@ func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode
if p.OnSelect != nil {
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
}
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-ink")}, fuzzyHighlight(r.Segments))...))
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
btnMods = append(btnMods, badge)
}

View File

@@ -24,7 +24,7 @@ func CodeBox(code, class string) *vdom.VNode {
func PageHeader(text, class string) *vdom.VNode {
return vdom.Header(vdom.Attr("class", class),
vdom.Div(vdom.Attr("class", "mt-1"),
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-ink mb-2"), vdom.Text(text)),
vdom.Hr(vdom.Attr("class", "text-neutral-200 mb-2")),
),
)
@@ -34,7 +34,7 @@ func PageHeader(text, class string) *vdom.VNode {
func PageLink(href string, newTab bool, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("href", href),
vdom.Attr("class", cx("text-sky-700 hover:text-sky-800 hover:underline hover:decoration-1", class)),
vdom.Attr("class", cx("text-sky-700 dark:text-sky-400 hover:text-sky-800 hover:underline hover:decoration-1", class)),
}
if newTab {
mods = append(mods, vdom.Attr("target", "_blank"), vdom.Attr("rel", "noopener noreferrer"))
@@ -58,17 +58,17 @@ type BreadcrumbItem struct {
// Breadcrumbs renders a chevron-separated crumb trail; the last item is bold and
// not linked.
func Breadcrumbs(items []BreadcrumbItem) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-neutral-400 text-xs")}
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-ink-faint text-xs")}
for i, crumb := range items {
if i != len(items)-1 {
mods = append(mods, vdom.Span(vdom.Attr("class", "flex items-center"),
vdom.A(vdom.Attr("href", crumb.URL),
vdom.Attr("class", "text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline"),
vdom.Attr("class", "text-ink-muted cursor-pointer no-underline hover:text-ink-soft hover:underline"),
vdom.Text(crumb.DisplayText)),
Icon("chevron-right", 12, "mx-[0.15rem] opacity-50"),
))
} else {
mods = append(mods, vdom.Span(vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
mods = append(mods, vdom.Span(vdom.Attr("class", "text-ink-soft font-medium"), vdom.Text(crumb.DisplayText)))
}
}
return vdom.Div(mods...)

View File

@@ -24,6 +24,18 @@ func RegisterIcon(name, viewBox, content string) {
iconRegistry[name] = iconDef{viewBox: viewBox, content: content}
}
// HasIcon reports whether a name resolves to an icon.
//
// It exists so an app can TEST that the names it uses are real. An unknown name renders
// a correctly-sized but empty box — the layout survives, which is the right runtime
// behaviour and a terrible debugging experience: the icon is simply not there, and
// nothing anywhere says why. A one-line test over the names you reference turns that
// into a build failure.
func HasIcon(name string) bool {
_, ok := iconRegistry[name]
return ok
}
// Icon renders a named icon at the given pixel size (0 => 16). Extra classes are
// appended. Unknown names render an empty, correctly-sized box (never blanks the
// layout). Mirrors Icons.tsx's <svg fill=currentColor width height>innerHTML</svg>.
@@ -59,7 +71,7 @@ func IconSuccess(name string, size int, class string) *vdom.VNode {
return Icon(name, size, cx("inline-block align-middle text-green-600", class))
}
func IconError(name string, size int, class string) *vdom.VNode {
return Icon(name, size, cx("inline-block align-middle text-red-600", class))
return Icon(name, size, cx("inline-block align-middle text-red-600 dark:text-red-400", class))
}
// IconContainer is a flex row that vertically centers an icon + text.
@@ -136,6 +148,32 @@ func init() {
reg("globe", "M12 21a9 9 0 0 0 8.72-6.75M12 21a9 9 0 0 1-8.72-6.75M12 21c2.49 0 4.5-4.03 4.5-9S14.49 3 12 3m0 18c-2.49 0-4.5-4.03-4.5-9S9.51 3 12 3m0 0a9 9 0 0 1 7.84 4.58M12 3a9 9 0 0 0-7.84 4.58m15.68 0A11.95 11.95 0 0 1 12 10.5c-3 0-5.74-1.1-7.84-2.92m15.68 0A8.96 8.96 0 0 1 21 12c0 .78-.1 1.53-.28 2.25m0 0A17.92 17.92 0 0 1 12 16.5c-3.16 0-6.13-.82-8.72-2.25m0 0A9.02 9.02 0 0 1 3 12c0-1.6.42-3.11 1.16-4.42")
reg("user", "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.5 20.12a7.5 7.5 0 0 1 15 0A17.93 17.93 0 0 1 12 21.75c-2.68 0-5.22-.58-7.5-1.63Z")
// --- documentation / section icons ---
reg("book-open", "M12 6.04A8.97 8.97 0 0 0 6 3.75c-1.05 0-2.06.18-3 .51v14.25A8.99 8.99 0 0 1 6 18c2.3 0 4.4.87 6 2.29m0-14.25a8.97 8.97 0 0 1 6-2.29c1.05 0 2.06.18 3 .51v14.25A8.99 8.99 0 0 0 18 18a8.97 8.97 0 0 0-6 2.29m0-14.25v14.25")
reg("chart-column", "M3.75 20.25h16.5M6.75 20.25v-6.75m4.5 6.75V8.25m4.5 12V4.5m4.5 15.75v-9")
reg("server", "M3.75 6.75h16.5v4.5H3.75v-4.5Zm0 6h16.5v4.5H3.75v-4.5ZM6.75 9h.01M6.75 15h.01")
reg("cloud-arrow-down", "M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.78 5.25 5.25 0 0 1 10.23-2.33 3 3 0 0 1 3.76 3.85A3.75 3.75 0 0 1 18 19.5H6.75Z")
reg("squares", "M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 8.25 20.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z")
reg("layers", "m2.25 12 9 5.25L20.25 12M2.25 7.5l9-5.25 9 5.25-9 5.25-9-5.25Zm0 9 9 5.25 9-5.25")
reg("table", "M3.75 5.25h16.5v13.5H3.75V5.25Zm0 4.5h16.5m-16.5 4.5h16.5M9.75 9.75v9")
reg("code", "m6.75 7.5-4.5 4.5 4.5 4.5m10.5-9 4.5 4.5-4.5 4.5M14.25 3.75l-4.5 16.5")
reg("bolt", "M14.25 2.25 4.5 13.5h6l-.75 8.25L19.5 10.5h-6l.75-8.25Z")
// A keel is the spine of a hull — the thing everything else is built on. Which is
// the whole idea behind the name, so the boat is not merely decorative.
reg("sailboat", "M11.25 3.75v12M11.25 15.75H4.5l6.75-12M14.25 15.75h4.5l-4.5-7.5zM2.25 18.75h19.5l-2.4 3H4.65z")
reg("cube", "m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9")
reg("shield-check", "M9 12.75 11.25 15 15 9.75m-3-7.04A11.96 11.96 0 0 1 3.6 6 12 12 0 0 0 3 9.75c0 5.59 3.82 10.29 9 11.62 5.18-1.33 9-6.03 9-11.62 0-1.31-.21-2.57-.6-3.75h-.15a11.96 11.96 0 0 1-8.25-3.29Z")
// --- theme ---
reg("sun", "M12 3v1.5m0 15V21m9-9h-1.5m-15 0H3m15.36-6.36-1.06 1.06M6.7 17.3l-1.06 1.06m12.72 0-1.06-1.06M6.7 6.7 5.64 5.64M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z")
reg("moon", "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z")
// --- search / loading ---
reg("magnifying-glass", "M21 21l-5.2-5.2M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z")
// An open arc, so that spinning it actually reads as motion — a full ring rotating
// looks like a ring standing still.
reg("circle-notch", "M21 12a9 9 0 0 0-9-9")
// --- status (the toast set) ---
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("circle-info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")

View File

@@ -57,11 +57,11 @@ import "kjol/vdom"
// MenuDivider, MenuSection and MenuGroup stay free functions: they hold no state
// and have nothing to close.
const menuCls = "bg-white rounded-default shadow-lg border border-neutral-200 p-1.5 min-w-48 max-h-96 overflow-y-auto"
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-neutral-200"
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 uppercase tracking-wide text-left"
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900"
const menuCls = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto"
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-line"
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left"
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft cursor-pointer hover:bg-surface-raised hover:text-ink"
// defaultHoverCloseDelay is the TSX's 150ms grace period after the cursor leaves.
const defaultHoverCloseDelay = 150
@@ -375,7 +375,7 @@ func (m *Menu) Anchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
}
mods = kids(mods, children)
if target == "_blank" {
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-ink-faint"))
}
return vdom.A(mods...)
}
@@ -413,7 +413,7 @@ func (m *Menu) Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
OnClick: m.f.Toggle,
},
vdom.Span(label...),
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
Icon("chevron-right", 16, "shrink-0 ml-auto text-ink-faint"),
)
menuSetAttr(trigger, "role", "menuitem")
// Entering the trigger means entering the submenu; it must not let an ancestor

View File

@@ -98,7 +98,7 @@ const modalContainerCenter = "items-center"
const modalBackdrop = "fixed inset-0 bg-black/30"
const modalBase = "relative bg-white shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
const modalBase = "relative bg-surface shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
var modalSizes = map[ModalSize]string{
ModalSmall: "max-w-md",
@@ -113,19 +113,19 @@ var modalSizes = map[ModalSize]string{
ModalFull: "max-w-none",
}
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-neutral-200 text-lg font-semibold text-text-heading"
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-line text-lg font-semibold text-text-heading"
const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1"
const modalCloseBtn = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700"
const modalCloseBtn = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink-soft"
const modalBody = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]"
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-neutral-200 bg-neutral-50 rounded-b-default"
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-line bg-surface-muted rounded-b-default"
const modalFooterSpacer = "h-2"
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 border-t border-red-200"
const modalWizardErrorIcon = "shrink-0 text-red-500"
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 dark:bg-red-950 border-t border-red-200 dark:border-red-900"
const modalWizardErrorIcon = "shrink-0 text-red-500 dark:text-red-400"
// Confirm modal
const modalConfirmWrap = "flex justify-end gap-2"
const modalConfirmCancel = "py-2 px-4 text-sm border border-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50"
const modalConfirmCancel = "py-2 px-4 text-sm border border-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted"
const modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"
var modalConfirmOkVariants = map[string]string{
@@ -137,20 +137,20 @@ var modalConfirmOkVariants = map[string]string{
const modalWizardHeader = "flex flex-col items-center gap-2 flex-1"
const modalWizardTitleRow = "flex items-center justify-between w-full"
const modalWizardTitle = "text-xl"
const modalWizardStepName = "text-xs font-semibold text-neutral-600 uppercase tracking-wider"
const modalWizardStepName = "text-xs font-semibold text-ink-soft uppercase tracking-wider"
const modalWizardSteps = "flex items-center justify-between relative w-full max-w-64"
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-neutral-200 -translate-y-1/2"
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-surface-strong -translate-y-1/2"
const modalWizardTrackFill = "h-full bg-primary transition-[width] duration-300 ease-in-out"
const modalWizardStepWrap = "relative z-[1]"
const modalStepIndicatorBase = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"
const modalStepIndicatorPending = "border-neutral-300 text-neutral-400 bg-white"
const modalStepIndicatorPending = "border-line-strong text-ink-faint bg-surface"
const modalStepIndicatorActive = "bg-primary text-white border-primary"
const modalStepIndicatorCompleted = "bg-primary text-white border-primary"
// Wizard footer
const modalWizardFooter = "flex items-center justify-between w-full gap-2"
const modalWizardBtnBase = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed"
const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50"
const modalWizardBtnBack = "bg-transparent border border-line-strong text-ink-soft enabled:hover:bg-surface-muted"
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"

View File

@@ -29,7 +29,7 @@ package webui
import "kjol/vdom"
const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200"
const popoverCls = "bg-surface rounded-default shadow-lg border border-line"
// TSX defaults: offset ?? 8, hoverDelay ?? 0, hoverCloseDelay ?? 150.
const popoverOffset = 8

View File

@@ -45,14 +45,14 @@ const (
PrettyTableSizeSuperCompact PrettyTableSize = 2
)
const ptTblContainer = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
const ptTblContainer = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
const ptTblWrapper = "overflow-x-auto w-full"
const ptTblBase = "min-w-full"
const ptHeaderContent = "transition-transform duration-150 ease-in-out"
const ptHeaderInnerBase = "flex justify-between gap-2 items-center"
var ptHeaderColorCls = map[PrettyTableHeaderColor]string{
PrettyTableColorDefault: "bg-neutral-50",
PrettyTableColorDefault: "bg-surface-muted",
PrettyTableColorBlue: "bg-sky-700 text-white",
PrettyTableColorGreen: "bg-green-700 text-white",
PrettyTableColorGray: "bg-neutral-600 text-white",
@@ -70,11 +70,11 @@ var ptHeaderTextCls = map[PrettyTableHeaderColor]string{
// ptRowHoverCls is applied to the tbody when Hover is on (matches the TSX
// PT_ROW_HOVER_CLS map).
var ptRowHoverCls = map[PrettyTableHeaderColor]string{
PrettyTableColorDefault: "[&_tr:hover]:!bg-neutral-200",
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100",
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100",
PrettyTableColorGray: "[&_tr:hover]:!bg-neutral-200",
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100",
PrettyTableColorDefault: "[&_tr:hover]:!bg-surface-strong",
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100 dark:bg-green-900",
PrettyTableColorGray: "[&_tr:hover]:!bg-surface-strong",
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
}
var ptHeaderPaddingCls = map[PrettyTableSize]string{
@@ -127,13 +127,13 @@ type PrettyTableOptions struct {
func prettyTableBodyClass(o PrettyTableOptions) string {
c := ptBodyPaddingCls[o.Size]
if o.BorderY {
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-line-strong")
}
if o.Alternate {
c = cx(c, "[&_tr:nth-child(even)]:bg-neutral-100")
c = cx(c, "[&_tr:nth-child(even)]:bg-surface-raised")
}
if o.BorderX {
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-neutral-300")
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-line-strong")
}
if o.Hover {
c = cx(c, ptRowHoverCls[o.Color])
@@ -146,7 +146,7 @@ func prettyTableBodyClass(o PrettyTableOptions) string {
func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children ...*vdom.VNode) *vdom.VNode {
containerCls := ptTblContainer
if opts.SurroundingBorder {
containerCls = cx(containerCls, "border border-neutral-300")
containerCls = cx(containerCls, "border border-line-strong")
}
if opts.Shadow {
containerCls = cx(containerCls, "shadow-sm")
@@ -178,7 +178,7 @@ func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children
))
}
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-line-strong"),
vdom.Tr(kids(nil, headerCells)...),
)

View File

@@ -32,7 +32,7 @@ func (f *RemoteFlash) Fire() { f.visible.Set(true) }
// Clear hides the flash.
func (f *RemoteFlash) Clear() { f.visible.Set(false) }
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700"
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-900 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700 dark:text-emerald-400"
// RemoteUpdateFlash is a small pill that briefly shows "Updated". It renders
// nothing (nil) when when is false, mirroring the TSX <Show when=…>.

View File

@@ -4,10 +4,10 @@ package webui
import "kjol/vdom"
const sidebarNavRoot = "bg-white rounded-default shadow-sm border border-neutral-200 py-2"
const sidebarNavRoot = "bg-surface rounded-default shadow-sm border border-line py-2"
const sidebarNavList = "list-none m-0 p-0"
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-neutral-600 border-0 hover:text-neutral-900 hover:bg-neutral-50"
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-neutral-500 border-0 hover:text-neutral-900 hover:bg-neutral-50"
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-ink-soft border-0 hover:text-ink hover:bg-surface-muted"
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-ink-muted border-0 hover:text-ink hover:bg-surface-muted"
const sidebarNavIcon = "mr-2"
const sidebarLayoutRoot = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12"

265
go/webui/signaturepad.go Normal file
View File

@@ -0,0 +1,265 @@
package webui
import (
"strconv"
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of FormSignaturePad (web/uikit/Forms.tsx) — freehand signing.
//
// Two deliberate departures from the TSX.
//
// It draws into an SVG, not a <canvas>. The component's OUTPUT is SVG — the TSX kept
// strokes in memory, painted them onto a canvas for the user, and serialized a separate
// SVG string for the caller. That is two renderers for one drawing, and they can
// disagree. Here the SVG the user is looking at IS the value the caller gets, so it
// cannot be wrong. It also means a stored signature renders on the SERVER: the same
// markup, no client needed to see it.
//
// And the in-flight stroke never touches a signal. A pointer moves sixty times a second,
// and a signal write re-renders the whole page; the live stroke is written straight at
// the element with SetHTML, and only the FINISHED stroke is committed. Without that,
// signing your name would re-render the document a few hundred times.
//
// Create it once, alongside your signals — never inside a render.
type SignaturePad struct {
svgRef *vdom.Ref
// strokes are the finished ones: committed, re-rendered, part of the value.
strokes *vdom.Signal[[]sigStroke]
// live is the stroke being drawn right now. NOT a signal — see above.
live sigStroke
drawing bool
unsubs []wasmruntime.Unsub
onChange func(svg string)
width float64
height float64
}
type sigPoint struct{ X, Y float64 }
type sigStroke []sigPoint
// SignaturePadOptions configures NewSignaturePad.
type SignaturePadOptions struct {
// Width and Height are the SVG's coordinate space (its viewBox), not its size on
// screen — the element scales to its container and the strokes scale with it.
// Default 600x120.
Width, Height float64
// OnChange receives the signature as an SVG document, or "" when it is cleared.
OnChange func(svg string)
}
// NewSignaturePad creates a signature pad.
func NewSignaturePad(o SignaturePadOptions) *SignaturePad {
if o.Width <= 0 {
o.Width = 600
}
if o.Height <= 0 {
o.Height = 120
}
return &SignaturePad{
svgRef: vdom.NewRef(),
strokes: vdom.NewSignal([]sigStroke{}),
onChange: o.OnChange,
width: o.Width,
height: o.Height,
}
}
// SignaturePadProps configures a render.
type SignaturePadProps struct {
Class string
Hint string // shown while empty; default "Sign above"
ClearText string // default "Clear"
Disabled bool
HideFooter bool // no hint / Clear button — for showing a signature back, read-only
}
// IsEmpty reports whether anything has been drawn.
func (s *SignaturePad) IsEmpty() bool { return len(s.strokes.Get()) == 0 }
// SVG is the signature as a standalone SVG document — the value to store. It is "" when
// the pad is empty, so an empty pad is an empty string rather than a blank drawing.
func (s *SignaturePad) SVG() string {
strokes := s.strokes.Get()
if len(strokes) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 `)
b.WriteString(sigNum(s.width) + " " + sigNum(s.height))
b.WriteString(`" width="` + sigNum(s.width) + `" height="` + sigNum(s.height) + `">`)
b.WriteString(sigPaths(strokes))
b.WriteString(`</svg>`)
return b.String()
}
// Clear empties the pad.
func (s *SignaturePad) Clear() {
s.endDrag()
s.live = nil
s.strokes.Set([]sigStroke{})
if s.onChange != nil {
s.onChange("")
}
}
// Dispose removes any listeners left behind by an interrupted drag.
func (s *SignaturePad) Dispose() { s.endDrag() }
// Render draws the pad.
func (s *SignaturePad) Render(p SignaturePadProps) *vdom.VNode {
svgMods := []vdom.Mod{
vdom.WithRef(s.svgRef),
vdom.Attr("viewBox", "0 0 "+sigNum(s.width)+" "+sigNum(s.height)),
// touch-none: without it, drawing on a phone scrolls the page instead.
vdom.Attr("class", "block h-auto w-full cursor-crosshair touch-none"),
vdom.Attr("role", "img"),
vdom.Raw(sigPaths(s.strokes.Get())),
}
if !p.Disabled {
svgMods = append(svgMods, vdom.OnEvent(vdom.EVENT_POINTERDOWN, s.onDown))
}
kids := []*vdom.VNode{vdom.Svg(svgMods...)}
if !p.HideFooter {
hint := ""
if s.IsEmpty() {
hint = pick(p.Hint, "Sign above")
}
clear := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "text-xs text-ink-muted hover:text-ink disabled:opacity-50"),
vdom.Text(pick(p.ClearText, "Clear")),
}
if p.Disabled || s.IsEmpty() {
clear = append(clear, vdom.Attr("disabled", "disabled"))
} else {
clear = append(clear, vdom.On(vdom.EVENT_CLICK, s.Clear))
}
kids = append(kids, vdom.Div(
vdom.Attr("class", "flex items-center justify-between border-t border-line bg-surface-muted px-2 py-1"),
vdom.Span(vdom.Attr("class", "text-xs italic text-ink-faint"), vdom.Text(hint)),
vdom.Button(clear...),
))
}
mods := []vdom.Mod{vdom.Attr("class", cx("overflow-hidden rounded-default border border-line-strong bg-surface", p.Class))}
for _, k := range kids {
mods = append(mods, k)
}
return vdom.Div(mods...)
}
// ---- drawing ------------------------------------------------------------
func (s *SignaturePad) onDown(e vdom.Event) {
e.PreventDefault()
s.endDrag() // a previous drag that never got its pointerup (alt-tab, say)
s.drawing = true
s.live = sigStroke{s.point(e)}
// The listeners go on the DOCUMENT, not the element. Drag off the edge of the pad
// and the stroke should follow the cursor and finish when you let go — with element
// listeners the pointer simply escapes and the stroke is left half-drawn.
s.unsubs = append(s.unsubs,
wasmruntime.OnDocument(vdom.EVENT_POINTERMOVE, false, s.onMove),
wasmruntime.OnDocument(vdom.EVENT_POINTERUP, false, s.onUp),
wasmruntime.OnDocument(vdom.EVENT_POINTERCANCEL, false, s.onUp),
)
}
func (s *SignaturePad) onMove(e vdom.Event) {
if !s.drawing {
return
}
s.live = append(s.live, s.point(e))
// Imperative. This runs on every pointer move; a signal here would re-render the
// entire application between one pixel of ink and the next.
wasmruntime.SetHTML(s.svgRef, sigPaths(append(append([]sigStroke{}, s.strokes.Get()...), s.live)))
}
func (s *SignaturePad) onUp(vdom.Event) {
if !s.drawing {
return
}
s.endDrag()
// A stroke of one point is a click, not a mark. Dropping it keeps a stray tap from
// counting as a signature.
if len(s.live) >= 2 {
s.strokes.Set(append(append([]sigStroke{}, s.strokes.Get()...), s.live))
if s.onChange != nil {
s.onChange(s.SVG())
}
}
s.live = nil
}
func (s *SignaturePad) endDrag() {
s.drawing = false
for _, u := range s.unsubs {
u()
}
s.unsubs = nil
}
// point maps a pointer's viewport coordinates into the SVG's coordinate space. The
// element is fluid and the viewBox is fixed, so the two differ by whatever the browser
// scaled the SVG to — measuring is the only way to know.
func (s *SignaturePad) point(e vdom.Event) sigPoint {
r := wasmruntime.Measure(s.svgRef)
if r.Width == 0 || r.Height == 0 {
return sigPoint{}
}
return sigPoint{
X: (float64(e.ClientX()) - r.X) * (s.width / r.Width),
Y: (float64(e.ClientY()) - r.Y) * (s.height / r.Height),
}
}
// ---- serialization ------------------------------------------------------
// sigPaths renders strokes as SVG <path> elements, smoothed.
//
// Straight lines between raw pointer samples look like a seismograph, not handwriting.
// Each segment is a quadratic curve THROUGH the sampled point and ending at the midpoint
// of the next one, which is the standard trick for turning a polyline into something
// that reads as a pen stroke.
func sigPaths(strokes []sigStroke) string {
var b strings.Builder
for _, st := range strokes {
if len(st) < 2 {
continue
}
b.WriteString(`<path d="M`)
b.WriteString(sigNum(st[0].X) + "," + sigNum(st[0].Y))
if len(st) == 2 {
b.WriteString(" L" + sigNum(st[1].X) + "," + sigNum(st[1].Y))
} else {
for i := 1; i < len(st)-1; i++ {
mx := (st[i].X + st[i+1].X) / 2
my := (st[i].Y + st[i+1].Y) / 2
b.WriteString(" Q" + sigNum(st[i].X) + "," + sigNum(st[i].Y) + "," + sigNum(mx) + "," + sigNum(my))
}
last := st[len(st)-1]
b.WriteString(" L" + sigNum(last.X) + "," + sigNum(last.Y))
}
b.WriteString(`" fill="none" stroke="#1a1a2e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`)
}
return b.String()
}
// sigNum formats a coordinate to one decimal, without a trailing ".0" — the markup is
// the value the caller stores, and there is no reason to store 600.0 as six bytes.
func sigNum(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}

View File

@@ -0,0 +1,88 @@
package webui
import (
"strings"
"testing"
"kjol/vdom"
)
// The pad's value IS the markup it shows — that is the whole reason it draws into an SVG
// rather than a canvas. If the two could differ, the user would sign one thing and the
// caller would store another.
func TestSignaturePadValueIsWhatIsDrawn(t *testing.T) {
var got string
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
pad.strokes.Set([]sigStroke{{{X: 10, Y: 20}, {X: 30, Y: 40}, {X: 50, Y: 20}}})
value := pad.SVG()
shown := vdom.RenderHTML(pad.Render(SignaturePadProps{}))
// Every path in the value is present in the rendered element.
paths := sigPaths(pad.strokes.Get())
if paths == "" {
t.Fatal("no path was generated for a three-point stroke")
}
if !strings.Contains(value, paths) {
t.Error("the emitted SVG does not contain the strokes it was built from")
}
if !strings.Contains(shown, `d="M10,20`) {
t.Errorf("the rendered pad is not showing the stroke:\n%s", shown)
}
_ = got
}
// An empty pad is an empty string, not a blank drawing. A caller storing the value must
// be able to ask "did they sign?" without parsing SVG.
func TestSignaturePadEmptyIsEmptyString(t *testing.T) {
pad := NewSignaturePad(SignaturePadOptions{})
if !pad.IsEmpty() {
t.Error("a fresh pad should be empty")
}
if pad.SVG() != "" {
t.Errorf("an empty pad produced %q, want \"\"", pad.SVG())
}
}
// Clear reports the emptiness to the caller. Without the callback a cleared pad would
// leave the last signature sitting in whatever the caller stored it in.
func TestSignaturePadClearNotifies(t *testing.T) {
got := "not called"
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
pad.strokes.Set([]sigStroke{{{X: 1, Y: 1}, {X: 2, Y: 2}}})
pad.Clear()
if got != "" {
t.Errorf("OnChange got %q on clear, want \"\"", got)
}
if !pad.IsEmpty() {
t.Error("the pad is not empty after Clear")
}
}
// A single point is a tap, not a mark: it has no length, produces no path, and must not
// count as a signature.
func TestSignaturePadIgnoresSinglePointStrokes(t *testing.T) {
if got := sigPaths([]sigStroke{{{X: 5, Y: 5}}}); got != "" {
t.Errorf("a one-point stroke produced a path: %q", got)
}
}
// Two points are a straight line; three or more are smoothed into curves. Handwriting
// drawn as raw polylines looks like a seismograph.
func TestSignaturePadSmoothsLongStrokes(t *testing.T) {
line := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}}})
if !strings.Contains(line, " L10,10") {
t.Errorf("a two-point stroke should be a straight line: %q", line)
}
if strings.Contains(line, "Q") {
t.Errorf("a two-point stroke has nothing to smooth: %q", line)
}
curve := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}, {X: 20, Y: 0}}})
if !strings.Contains(curve, "Q") {
t.Errorf("a three-point stroke should be smoothed: %q", curve)
}
}

View File

@@ -17,8 +17,8 @@ import (
// defaultIndex prop likewise collapses into the caller-supplied ActiveIndex.
const tabsBase = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150"
const tabsInactive = "text-text-muted border-neutral-200 hover:text-text-body"
const tabsActive = "text-primary border-primary"
const tabsInactive = "text-text-muted border-line hover:text-text-body"
const tabsActive = "text-accent border-primary"
// TabItem is one tab: a title, an optional numeric badge (shown only when > 0),
// and optional inline panel content.
@@ -119,7 +119,7 @@ func TabGroup(p TabGroupProps) *vdom.VNode {
header = append(header, vdom.Button(btn...))
}
if p.Actions != nil {
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-line flex items-center justify-end pb-1"),
vdom.Div(vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
))
}

207
go/webui/theme.go Normal file
View File

@@ -0,0 +1,207 @@
package webui
import (
"kjol/vdom"
"kjol/wasmruntime"
)
// Dark mode.
//
// The kit is themed by TOKENS, not by a dark: variant on every class. A component says
// bg-surface, text-ink, border-line; the theme decides what those mean. Flipping the
// theme is therefore one class on <html> and a block of CSS variables — not four hundred
// class strings, each of which is a chance to forget one and leave a white card sitting
// in the middle of a dark page.
//
// Only genuinely COLOURED things (an alert's red tint, a validation message) carry dark:
// variants, because those are not surfaces or ink and there is no token that means "red,
// but for a dark background".
//
// The app must define the tokens and the variant in its stylesheet. See the example's
// css/app.css; the required set is listed in ThemeTokens below.
// ThemeTokens is the contract between the kit and an app's stylesheet: the CSS custom
// properties every component assumes exist, with light values in :root and dark values
// under .dark.
//
// It is a documentation constant, not code — but it is here, next to the components that
// depend on it, rather than in a README nobody opens when a card comes out the wrong
// colour.
const ThemeTokens = `
--color-surface page and card backgrounds
--color-surface-muted a subtle fill (table stripes, footers)
--color-surface-raised a hovered or filled row
--color-surface-strong the strongest neutral fill
--color-line ordinary borders and dividers
--color-line-strong an input's border — the one you must be able to see
--color-ink body text and headings
--color-ink-soft secondary text
--color-ink-muted labels, captions
--color-ink-faint placeholders, disabled text
--color-accent accent TEXT and icons (readable on the surface)
--color-primary accent FILLS, which carry white text
--color-primary-hover
--color-primary-subtle a tinted panel
--color-primary-border
`
// ThemeMode is what the user chose. System is the default: follow the OS until told
// otherwise, because a site that ignores the OS setting is a site that flashes white at
// someone who asked their whole computer not to.
type ThemeMode string
const (
ThemeSystem ThemeMode = "system"
ThemeLight ThemeMode = "light"
ThemeDark ThemeMode = "dark"
)
// themeStorageKey is where the choice is kept. The bootstrap script in the document head
// reads the SAME key — see ThemeBootScript.
const themeStorageKey = "kjol-theme"
// Theme is the site-wide theme controller. Create one, call Init once after mount, and
// render its Toggle wherever the switch belongs.
type Theme struct {
mode *vdom.Signal[ThemeMode]
// dark is the RESOLVED answer: what "system" actually means right now.
dark *vdom.Signal[bool]
stop wasmruntime.Unsub
}
// NewTheme creates the controller. It reads no storage and touches no DOM — Init does
// that, once the document exists.
func NewTheme() *Theme {
return &Theme{
mode: vdom.NewSignal(ThemeSystem),
dark: vdom.NewSignal(false),
}
}
// Init adopts the stored choice and starts following the OS while the mode is "system".
// Call it once, after the app has mounted.
//
// It does not FLASH, because it is not what puts the class on <html> for the first
// paint: ThemeBootScript already did that, before any of this code existed. Init only
// takes over.
func (t *Theme) Init() {
if s, ok := wasmruntime.StorageGet(themeStorageKey); ok {
switch ThemeMode(s) {
case ThemeLight:
t.mode.Set(ThemeLight)
case ThemeDark:
t.mode.Set(ThemeDark)
}
}
t.apply()
// Follow the OS, but only while the user has not overridden it.
t.stop = wasmruntime.OnMediaChange("(prefers-color-scheme: dark)", func(bool) {
if t.mode.Get() == ThemeSystem {
t.apply()
}
})
}
// Dispose stops following the OS.
func (t *Theme) Dispose() {
if t.stop != nil {
t.stop()
}
}
// Mode is the user's choice; IsDark is what that currently resolves to.
func (t *Theme) Mode() ThemeMode { return t.mode.Get() }
func (t *Theme) IsDark() bool { return t.dark.Get() }
// Set changes the theme. "system" forgets the choice entirely rather than storing the
// current resolution — otherwise "follow my OS" would quietly freeze at whatever the OS
// happened to say the day you chose it.
func (t *Theme) Set(m ThemeMode) {
t.mode.Set(m)
if m == ThemeSystem {
wasmruntime.StorageRemove(themeStorageKey)
} else {
wasmruntime.StorageSet(themeStorageKey, string(m))
}
t.apply()
}
// Toggle flips between light and dark. It resolves "system" first, so the first click
// does what it looks like it will do — the opposite of what you are looking at.
func (t *Theme) Toggle() {
if t.resolve() {
t.Set(ThemeLight)
return
}
t.Set(ThemeDark)
}
// resolve turns the mode into a yes or no.
func (t *Theme) resolve() bool {
switch t.mode.Get() {
case ThemeDark:
return true
case ThemeLight:
return false
default:
return wasmruntime.PrefersDark()
}
}
func (t *Theme) apply() {
dark := t.resolve()
t.dark.Set(dark)
wasmruntime.SetRootClass("dark", dark)
}
// ThemeToggleProps configures the switch.
type ThemeToggleProps struct {
Class string
// Small renders an icon-only button.
Small bool
}
// ThemeToggle is the switch: one button, showing the theme you would get by pressing it.
func (t *Theme) ThemeToggle(p ThemeToggleProps) *vdom.VNode {
icon, label := "moon", "Dark"
if t.IsDark() {
icon, label = "sun", "Light"
}
cls := "inline-flex items-center gap-2 rounded-default border border-line px-2.5 py-1.5 text-sm text-ink-soft hover:bg-surface-raised hover:text-ink"
if p.Small {
cls = "inline-flex h-8 w-8 items-center justify-center rounded-default border border-line text-ink-soft hover:bg-surface-raised hover:text-ink"
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cx(cls, p.Class)),
vdom.Attr("aria-label", "Switch to "+label+" theme"),
vdom.Attr("title", "Switch to "+label+" theme"),
vdom.On(vdom.EVENT_CLICK, t.Toggle),
IconInline(icon, 15, ""),
}
if !p.Small {
mods = append(mods, vdom.Span(vdom.Text(label)))
}
return vdom.Button(mods...)
}
// ThemeBootScript is the inline script an app puts in its <head>, BEFORE any stylesheet
// or markup.
//
// It exists to prevent the flash. The server cannot read localStorage, so it cannot know
// which theme to render; if the class were applied by the WebAssembly after it loads,
// every dark-mode user would be shown a white page for as long as the binary takes to
// download, and then have it yanked out from under them. This runs first, synchronously,
// and the first paint is already correct.
//
// It is ten lines of JavaScript in a project that has none. That is the price of the
// browser giving the page no way to ask about localStorage before it paints, and it is
// worth paying — the alternative is a white flash on every single load.
const ThemeBootScript = `<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>`

View File

@@ -72,7 +72,7 @@ var toastContainerPositions = map[ToastPosition]string{
ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
}
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-line border-l-4 bg-surface min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
var toastTypeBorder = map[ToastType]string{
ToastSuccess: "border-l-green-700",
@@ -84,9 +84,9 @@ var toastTypeBorder = map[ToastType]string{
var toastIconColor = map[ToastType]string{
ToastSuccess: "text-green-600",
ToastError: "text-red-600",
ToastError: "text-red-600 dark:text-red-400",
ToastWarning: "text-yellow-600",
ToastInfo: "text-sky-700",
ToastInfo: "text-sky-700 dark:text-sky-400",
ToastGeneric: "",
}
@@ -108,10 +108,10 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
if icon != "" {
row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
}
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message)))
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-ink"), vdom.Text(t.Message)))
if t.Dismissible {
dismiss := []vdom.Mod{
vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"),
vdom.Attr("class", "shrink-0 cursor-pointer text-ink-faint hover:text-ink-soft bg-transparent border-0 p-0 transition-colors"),
vdom.Attr("aria-label", "Dismiss"),
}
if onDismiss != nil {
@@ -133,13 +133,13 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
// string on every render is what stops the reconciler's attribute diff from
// resetting it back to 100% mid-countdown.
bar := []vdom.Mod{
vdom.Attr("class", "h-full bg-neutral-300"),
vdom.Attr("class", "h-full bg-surface-strong"),
vdom.Attr("style", "width:100%"),
}
if progressRef != nil {
bar = append(bar, vdom.WithRef(progressRef))
}
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-neutral-100"),
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-surface-raised"),
vdom.Div(bar...),
))
}

View File

@@ -14,7 +14,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
onChange(!checked)
}
trackState := "bg-neutral-300"
trackState := "bg-surface-strong"
if checked {
trackState = "bg-primary"
}
@@ -24,7 +24,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
if checked {
knobState = "translate-x-[18px]"
}
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform", knobState)
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-surface shadow-sm transition-transform", knobState)
ariaChecked := "false"
if checked {
@@ -47,16 +47,16 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
if label != "" || description != "" {
text := []vdom.Mod{vdom.Attr("class", "flex flex-col leading-tight")}
if label != "" {
labelColor := "text-neutral-800"
labelColor := "text-ink"
if disabled {
labelColor = "text-neutral-400"
labelColor = "text-ink-faint"
}
text = append(text, vdom.Span(vdom.Attr("class", cx("text-sm select-none", labelColor)),
vdom.On(vdom.EVENT_CLICK, toggle),
vdom.Text(label)))
}
if description != "" {
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(description)))
}
mods = append(mods, vdom.Div(text...))
}

View File

@@ -155,7 +155,7 @@ const (
// Tailwind classes, verbatim from the TSX.
const (
tutorialPopoverClass = "fixed z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm"
tutorialPopoverClass = "fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm"
tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
tutorialCatcherClass = "fixed inset-0 -z-10 cursor-pointer"
tutorialBackdropClass = "fixed inset-0 bg-black/50 z-150"
@@ -701,23 +701,23 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
// Header: optional title + "X of N", and a close button.
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
if step != nil && step.Title != "" {
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-neutral-900"),
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-ink"),
vdom.Text(step.Title)))
}
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"),
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"),
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
vdom.Div(headerLeft...),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"),
vdom.Attr("class", "cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft"),
vdom.On(vdom.EVENT_CLICK, t.End),
Icon("xmark", 18, ""),
),
)
// Body.
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-ink-soft")}
if step != nil && step.Content != nil {
if c := step.Content(); c != nil {
bodyMods = append(bodyMods, c)
@@ -749,7 +749,7 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
if total > 1 {
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
for i := 0; i < total; i++ {
state := "bg-neutral-300"
state := "bg-surface-strong"
if i == idx {
state = "bg-sky-600 scale-110"
}