diff --git a/go/jsbundler/faicons.go b/go/jsbundler/faicons.go index a9dfe2bc..670202d7 100644 --- a/go/jsbundler/faicons.go +++ b/go/jsbundler/faicons.go @@ -119,6 +119,15 @@ func generateFAIcons() error { } b.WriteString("};\n") + // Skip the write when content is unchanged so the file's mtime doesn't + // advance on every SPA source edit — the dev watcher treats any mtime bump + // as a real change and hot-reloads Icons.tsx (the nearest HMR boundary that + // imports this file), which is wasteful when nothing about the icon set + // actually changed. + if existing, err := os.ReadFile(faOutPath()); err == nil && string(existing) == b.String() { + return nil + } + if err := os.MkdirAll(filepath.Dir(faOutPath()), 0755); err != nil { return err } diff --git a/go/jsruntime/uikit/AutoTable.tsx b/go/jsruntime/uikit/AutoTable.tsx index 0640cfa9..1268b8c4 100644 --- a/go/jsruntime/uikit/AutoTable.tsx +++ b/go/jsruntime/uikit/AutoTable.tsx @@ -4144,7 +4144,7 @@ export function AutoTable(props: AutoTableProps) {
- Page {displayPagination().CurrentPage} of {displayPagination().TotalPages} + Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
(small ? CONTROL_H_SM : CONTROL_H); function _fieldBorder(error?: unknown, success?: unknown, onDark?: boolean): string { - const borderNormal = onDark ? "border-border-on-dark focus:outline-sky-500" : "border-line-strong focus:outline-sky-500"; + const borderNormal = onDark ? "border-border-on-dark focus:outline-sky-500" : "border-neutral-300 focus:outline-sky-500"; return error ? "border-red-500 focus:outline-red-500" : success ? "border-green-500 focus:outline-green-500" : borderNormal; @@ -37,33 +38,33 @@ function _textareaCls(small?: boolean, error?: unknown, extra?: string, onDark?: function _prefixCls(small?: boolean, error?: unknown, onDark?: boolean): string { const base = onDark ? "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default" - : "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"; - const borderNormal = onDark ? "border-border-on-dark" : "border-line-strong"; + : "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"; + const borderNormal = onDark ? "border-border-on-dark" : "border-neutral-300"; return base + (error ? " border-red-500" : " " + borderNormal) + (small ? " p-1 text-sm" : " p-2"); } -const FORM_ERROR = "block text-red-600 dark:text-red-400 text-xs mt-1"; -const FORM_SUCCESS = "block text-green-600 dark:text-green-400 text-xs mt-1"; +const FORM_ERROR = "block text-red-600 text-xs mt-1"; +const FORM_SUCCESS = "block text-green-600 text-xs mt-1"; const INPUT_GROUP = "flex flex-row items-stretch w-full text-sm"; -const TRIGGER_BASE = "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 TRIGGER_BASE = "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 TRIGGER_BASE_DARK = "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 DROPDOWN = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"; +const DROPDOWN = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"; const DROPDOWN_DARK = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"; -const DROPDOWN_SEARCH_WRAP = "sticky top-0 bg-surface border-b border-line p-2"; +const DROPDOWN_SEARCH_WRAP = "sticky top-0 bg-white border-b border-neutral-200 p-2"; const DROPDOWN_SEARCH_WRAP_DARK = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"; -const DROPDOWN_SEARCH_INPUT = "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 DROPDOWN_SEARCH_INPUT = "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 DROPDOWN_SEARCH_INPUT_DARK = "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 DROPDOWN_OPTION = "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 DROPDOWN_OPTION = "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 DROPDOWN_OPTION_DARK = "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 DROPDOWN_OPTION_HIGHLIGHT = "bg-surface-raised"; +const DROPDOWN_OPTION_HIGHLIGHT = "bg-neutral-100"; const DROPDOWN_OPTION_HIGHLIGHT_DARK = "bg-white/10"; -const DROPDOWN_NO_RESULTS = "p-2 text-sm text-ink-muted text-center"; +const DROPDOWN_NO_RESULTS = "p-2 text-sm text-neutral-500 text-center"; const DROPDOWN_NO_RESULTS_DARK = "p-2 text-sm text-text-on-dark-muted text-center"; -const SELECT_ALL_WRAP = "border-b border-line"; -const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"; +const SELECT_ALL_WRAP = "border-b border-neutral-200"; +const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"; // -- Shared types -------------------------------------------------- type CommonInputAttrs = @@ -373,7 +374,7 @@ export function FormCurrencyInput(props: {showIcon?: boolean} & FormInputProps) } }; - return ; + return ; } @@ -393,7 +394,7 @@ export function FormPercentInput(props: {showIcon?: boolean} & FormInputProps) { } }; - return ; + return ; } export function FormPhoneInput(props: {showIcon?: boolean; icon?: string} & FormInputProps) { @@ -404,6 +405,9 @@ export function FormPhoneInput(props: {showIcon?: boolean; icon?: string} & Form const input = e.currentTarget; let digits = input.value.replace(/\D/g, ""); + if (digits[0] === "0" || digits[0] === "1") { + digits = digits.slice(1); + } digits = digits.slice(0, 10); let formatted = ""; if (digits.length > 0) { @@ -634,7 +638,7 @@ export function FormSignaturePad(props: { value?: () => string; onchange?: (svg: }; return
-
+
string; onchange?: (svg: onTouchMove={draw} onTouchEnd={stopDrawing} /> -
- {isEmpty() ? "Sign above" : ""} - +
+ {isEmpty() ? "Sign above" : ""} +
; @@ -765,12 +769,12 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) { return `position:fixed;top:${p.top}px;left:${p.left}px;min-width:${p.width}px;width:max-content;max-width:${maxW}px;z-index:200;`; }; - const triggerCls = () => "w-full flex items-center justify-between gap-2 bg-surface border border-line-strong rounded-default shadow-xs text-sm cursor-pointer text-left focus:outline-2 focus:outline-sky-500 focus:outline-offset-1" + " " + _controlH(local.small) + (local.small ? " p-1" : " p-2"); + const triggerCls = () => "w-full flex items-center justify-between gap-2 bg-white border border-neutral-300 rounded-default shadow-xs text-sm cursor-pointer text-left focus:outline-2 focus:outline-sky-500 focus:outline-offset-1" + " " + _controlH(local.small) + (local.small ? " p-1" : " p-2"); const ssBtnCls = (index, optionValue) => { - const base = "w-full p-2 text-left text-sm border-none cursor-pointer disabled:text-ink-faint disabled:cursor-not-allowed"; - if (optionValue === local.value) return base + " bg-sky-100 dark:bg-sky-950/50 font-medium hover:bg-sky-100 dark:bg-sky-950/50"; - if (index() === highlightedIndex()) return base + " bg-sky-50 dark:bg-sky-950/40"; - return base + " hover:bg-sky-50 dark:bg-sky-950/40"; + const base = "w-full p-2 text-left text-sm border-none cursor-pointer disabled:text-neutral-400 disabled:cursor-not-allowed"; + if (optionValue === local.value) return base + " bg-sky-100 font-medium hover:bg-sky-100"; + if (index() === highlightedIndex()) return base + " bg-sky-50"; + return base + " hover:bg-sky-50"; }; return
containerRef = el} class={cls()}> @@ -780,14 +784,14 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) { onclick={(_e) => { updatePos(); setIsOpen(!isOpen()); }} class={triggerCls()} > - + {selectedOption()?.label || local.placeholder || "Select an option"} -
dropdownRef = el} data-floating-content="true" class="bg-surface border border-line-strong rounded-default shadow-lg overflow-hidden" style={dropdownStyle()}> +
dropdownRef = el} data-floating-content="true" class="bg-white border border-neutral-300 rounded-default shadow-lg overflow-hidden" style={dropdownStyle()}> searchInputRef = el} type="text" @@ -795,7 +799,7 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) { oninput={(e) => setSearchQuery(e.currentTarget.value)} onKeyDown={handleKeyDown} placeholder={local.searchPlaceholder || "Search..."} - class="w-full p-2 border-0 border-b border-b-neutral-200 text-sm outline-hidden focus:bg-sky-50 dark:bg-sky-950/40" + class="w-full p-2 border-0 border-b border-b-neutral-200 text-sm outline-hidden focus:bg-sky-50" />
@@ -845,7 +849,7 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string title={props.title} class={ (props.inline ? "inline" : "block") + - (props.onDark ? " text-text-on-dark" : " text-ink") + + (props.onDark ? " text-text-on-dark" : " text-neutral-700") + " text-sm font-medium mb-2" + (props.class ? " " + props.class : "")} > @@ -854,8 +858,8 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string } export function FormFileInput(props: FormInputProps) { - const cls = () => INPUT_BASE + " 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" + const cls = () => INPUT_BASE + " 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" + (props.small ? " file:py-[2px] file:px-3" : " file:py-[3px] file:px-4") + (props.class ? " " + props.class : ""); return
@@ -877,8 +881,8 @@ export function FormSpacer() { } export function FormFieldset(props: { legend?: string; class?: string; children: JSXElement }) { - return
- {props.legend} + return
+ {props.legend} {props.children}
; } @@ -1115,8 +1119,8 @@ export function FormCombobox( _controlH(props.small) + (props.small ? " p-1" : " p-2"); const placeholderCls = () => - !selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-ink-muted") : ""; - const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-ink-faint"); + !selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-neutral-500") : ""; + const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-neutral-400"); const dropdownCls = () => (props.onDark ? DROPDOWN_DARK : DROPDOWN); const searchWrapCls = () => (props.onDark ? DROPDOWN_SEARCH_WRAP_DARK : DROPDOWN_SEARCH_WRAP); const searchInputCls = () => (props.onDark ? DROPDOWN_SEARCH_INPUT_DARK : DROPDOWN_SEARCH_INPUT); @@ -1352,7 +1356,7 @@ export function FormAsyncCombobox( onclick={() => handleSelect(option)} onMouseEnter={() => setHighlightedIndex(index())} class={ - DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-surface-raised" : "") + DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "") } > {props.renderOption ? props.renderOption(option) : option.label} @@ -1512,21 +1516,21 @@ export function FormMultiSelect(props: FormMultiSelectProps) { }; const triggerCls = () => TRIGGER_BASE + " overflow-hidden " + _controlH(props.small) + (props.small ? " p-1" : " p-2"); - const optionCls = (index) => DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-surface-raised" : ""); + const optionCls = (index) => DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : ""); const dropdownStyle = () => _floatingDropdownStyle(dropdownPos()); const renderTriggerContent = () => { if (selectedOptions().length === 0) { - return {props.placeholder || "Select options"}; + return {props.placeholder || "Select options"}; } if (isOverflowing() || selectedOptions().length > maxTags()) { return {selectedOptions().length + " item" + (selectedOptions().length !== 1 ? "s" : "") + " selected"}; } return
tagsContainerRef = el} class="flex flex-nowrap gap-1 overflow-hidden items-center"> - {(option) => + {(option) => {option.label} - } @@ -1546,12 +1550,12 @@ export function FormMultiSelect(props: FormMultiSelectProps) {
{renderTriggerContent()}
- + -
dropdownRef = el} data-floating-content="true" class="bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto" style={dropdownStyle()}> +
dropdownRef = el} data-floating-content="true" class="bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto" style={dropdownStyle()}>
"absolute z-50 mt-1 bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto " + (props.align === "right" ? "right-0" : "left-0"); + const dropdownCls = () => "absolute z-50 mt-1 bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto " + (props.align === "right" ? "right-0" : "left-0"); // Rounded, inset items (within the p-1.5 list below) to match the Menu component. - const optionCls = (index) => DROPDOWN_OPTION + " rounded-default" + (index() === highlightedIndex() ? " bg-surface-raised" : "") + (props.small ? " py-1.5 px-2" : ""); + const optionCls = (index) => DROPDOWN_OPTION + " rounded-default" + (index() === highlightedIndex() ? " bg-neutral-100" : "") + (props.small ? " py-1.5 px-2" : ""); return
containerRef = el} class={"relative inline-block " + (props.class || "")}>
; } + +// Builds a
handler for the common "validate on submit" flow: touch every +// validated field (so a never-blurred required field surfaces its error too), report + +// scroll/focus the first one with an error, otherwise run `save`. Fields that were only +// force-touched to check them (and turned out fine) are un-touched again afterward, so a field +// the user never actually interacted with doesn't keep showing state once submission is handled +// (e.g. a field hidden again by an unrelated toggle). +export function makeFormSubmitHandler(opts: { + errorFields: ErorrField[]; + touched: Accessor>; + setTouched: Setter>; + onInvalid: (msg: string) => void; + save: () => Promise; +}): (e: Event) => Promise { + return async (e: Event) => { + e.preventDefault(); + const forcedKeys = opts.errorFields + .map((field) => field.touchKey ?? field.id) + .filter((key) => !opts.touched()[key]); + opts.setTouched((prev) => { + const next = { ...prev }; + for (const key of forcedKeys) next[key] = true; + return next; + }); + + try { + for (const field of opts.errorFields) { + const msg = field.error(); + if (msg) { + opts.onInvalid(msg); + const el = document.getElementById(field.id); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + (el as HTMLElement).focus({ preventScroll: true }); + } + return; + } + } + await opts.save(); + } finally { + opts.setTouched((prev) => { + const next = { ...prev }; + for (const field of opts.errorFields) { + const key = field.touchKey ?? field.id; + if (forcedKeys.includes(key) && !field.error()) { + delete next[key]; + } + } + return next; + }); + } + }; +} diff --git a/go/jsruntime/uikit/Icons.tsx b/go/jsruntime/uikit/Icons.tsx index 468a4266..28bc945a 100644 --- a/go/jsruntime/uikit/Icons.tsx +++ b/go/jsruntime/uikit/Icons.tsx @@ -18,6 +18,25 @@ interface IconProps { style?: Partial; } +// Vite-style HMR API the dev bundler injects into every module (see +// internal/bundler/hmr_server.go + docs/hmr.md); undefined in production. +// `declare global` is required here — a bare `interface ImportMeta` in a +// module file wouldn't merge with the ambient one `import.meta`'s type +// actually resolves against. +declare global { + interface ImportMeta { + hot?: { + readonly data: Record; + accept(cb?: (mod: any) => void): void; + accept(deps: string | string[], cb?: (mod: any) => void): void; + dispose(cb: (data: Record) => void): void; + prune(cb: (data: Record) => void): void; + invalidate(): void; + decline(): void; + }; + } +} + // The FontAwesome family + default weight are theme-driven so this component // stays identical across projects. Each project's CSS theme sets `--fa-style` // (classic → far/fas, sharp → fasr/fass) and `--fa-default-solid` (0/1). Read @@ -49,7 +68,19 @@ const ICON_INLINE = "inline-block align-middle"; // lookup for the same name. This shared component ships with it EMPTY: each // project registers its own SVGs from its app entry (see frontend/src/appIcons.ts) // via registerIcon, so Icons.tsx stays identical across projects. -const customIcons: Record = {}; +// Persisted across this module's own HMR reloads (see docs/hmr.md). Icons.tsx +// is an HMR boundary, and its FA icon subset import churns on every SPA source +// edit (generateFAIcons regenerates it each time), so this module reloads far +// more often than one would expect from editing Icons.tsx itself. A fresh +// module instance would otherwise start with an empty registry, since +// registerIcon() only runs once — from appIcons.ts's side-effect import at +// initial page load — blanking custom icons (playground, e-cd, ...) until a +// full page refresh. import.meta.hot.data survives across reloads of this +// module, so stash the registry there instead of a plain module-scoped const. +const customIcons: Record = + (import.meta.hot?.data.customIcons as Record | undefined) ?? {}; +if (import.meta.hot) import.meta.hot.data.customIcons = customIcons; + // Register a custom icon under `name`, overriding FontAwesome for that name. // Call from the app's own icon module (e.g. appIcons.ts), imported for side diff --git a/go/jsruntime/uikit/Toast.tsx b/go/jsruntime/uikit/Toast.tsx index 3d313c6d..fd5a8b88 100644 --- a/go/jsruntime/uikit/Toast.tsx +++ b/go/jsruntime/uikit/Toast.tsx @@ -7,7 +7,7 @@ export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom- export interface ToastConfig { message: string; type?: ToastType; - duration?: number | null; + duration?: number | null; // in milliseconds dismissible?: boolean; showProgress?: boolean; } @@ -90,7 +90,7 @@ interface ToastItemProps { function ToastItem(props: ToastItemProps) { const type = () => props.toast.type ?? "info"; - const duration = () => props.toast.duration ?? DEFAULT_DURATION; + const duration = () => (props.toast.duration !== undefined ? props.toast.duration : DEFAULT_DURATION); const dismissible = () => props.toast.dismissible !== false; const showProgress = () => props.toast.showProgress !== false; diff --git a/go/jsruntime/uikit/Validation.ts b/go/jsruntime/uikit/Validation.ts index 8b7b2446..151c4bba 100644 --- a/go/jsruntime/uikit/Validation.ts +++ b/go/jsruntime/uikit/Validation.ts @@ -1,4 +1,4 @@ -import { Accessor, createMemo } from "solid-js"; +import {Accessor, createMemo} from "solid-js"; export interface Validation { id: string; // snake case identifier that is "touched" @@ -11,8 +11,14 @@ export interface Validation { invalidMsg?: string; } +export interface ErorrField { + error: Accessor; + id: string; + touchKey?: string; // specify if different from id +} + export function createValidation(validation: Validation): Accessor { - const { id, name, required, touched, isValidFunc, invalidMsg } = validation; + const {id, name, required, touched, isValidFunc, invalidMsg} = validation; const field = () => validation.field().trim(); // When no blur accessor is provided, fall back to the live value so the // "has value but not blurred yet" guard is always false and validation @@ -29,15 +35,22 @@ export function createValidation(validation: Validation): Accessor= 200; } export function isEmailValid(email: string): boolean { - const regex = /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; + const regex = + /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; if (!email) return false; - let emailParts = email.split('@'); + let emailParts = email.split("@"); if (emailParts.length !== 2) return false; @@ -45,27 +58,29 @@ export function isEmailValid(email: string): boolean { let address = emailParts[1]; if (account.length > 64) return false; - else if (address.length > 255) return false; - let domainParts = address.split('.'); + let domainParts = address.split("."); - if (domainParts.some(function (part) { - return part.length > 63; - })) return false; + if ( + domainParts.some(function (part) { + return part.length > 63; + }) + ) + return false; return regex.test(email); } export function isUrlValid(url: string): boolean { - const regex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/; + const regex = /^(https?:\/\/)?(www\.)?[a-zA-Z0-9@:%._\+~#-]{1,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&\/=]*)$/; return regex.test(url); } export function isZipCodeValid(zip: string): boolean { const rawZip = String(zip).replace(/\D/g, ""); - return rawZip.length == 5 || rawZip.length == 9 + return rawZip.length == 5 || rawZip.length == 9; } export function isTaxIdValid(id: string): boolean { @@ -75,13 +90,13 @@ export function isTaxIdValid(id: string): boolean { // isAtLeastMinChars checks if the input string is at least "min" characters long // and returns a boolean, true if valid, false if not. -export function isAtLeastMinChars(input: string, min:number):boolean { +export function isAtLeastMinChars(input: string, min: number): boolean { return input.length >= min; } // isWithinMaxChars checks if the input string is at most "max" characters long // and returns a boolean, true if valid, false if not. -export function isWithinMaxChars(input: string, max:number):boolean { +export function isWithinMaxChars(input: string, max: number): boolean { return input.length <= max; } @@ -89,17 +104,17 @@ export function isWithinMaxChars(input: string, max:number):boolean { // and returns a boolean, true if valid, false if not. export function isNameValid(name: string): boolean { const regex = /^[\p{L}]*[\p{L} '\-]*[\p{L}]$/u; - + return regex.test(name); } // isUsernameValid checks if the username contains 5-50 characters and only consists of alphanumeric // characters. Returns an error message if invalid, empty string if valid. export function isUsernameValid(username: string): string { - if (username.length < 5 || username.length > 50) return "Username must have 5-50 characters" - + if (username.length < 5 || username.length > 50) return "Username must have 5-50 characters"; + const regex = /^[A-Za-z0-9]*$/; - if (!regex.test(username)) return "Username must only contain alphanumeric characters" + if (!regex.test(username)) return "Username must only contain alphanumeric characters"; return ""; // Valid } \ No newline at end of file diff --git a/go/tw/tailwind.go b/go/tw/tailwind.go index 95b7c3f7..533c83a8 100644 --- a/go/tw/tailwind.go +++ b/go/tw/tailwind.go @@ -1261,8 +1261,7 @@ func compileCandidates(rawCandidates []string, ds *DesignSystem, onInvalid func( // the candidate list. // // @INCOMPLETE Only static @utility blocks are supported (no functional -// @utility/--value()). @custom-variant IS wired (both the shorthand and block -// forms) — see parseCustomVariant. -mta +// @utility/--value()); @custom-variant is not yet wired. -mta //go:embed tw_theme.css var defaultThemeCSS string @@ -1305,7 +1304,8 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) var keyframes []*AstNode var passthrough []*AstNode var customUtilities []*AstNode - var customVariants []*AstNode + var safelistAdd []string + var safelistRemove []string hasPreflight := false hasUtilities := false @@ -1361,8 +1361,13 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) processTheme(node) case node.Kind == nAtRule && node.Name == "@utility": customUtilities = append(customUtilities, node) - case node.Kind == nAtRule && node.Name == "@custom-variant": - customVariants = append(customVariants, node) + case node.Kind == nAtRule && node.Name == "@source": + literals, negate := parseSourceDirective(node.Params) + if negate { + safelistRemove = append(safelistRemove, literals...) + } else { + safelistAdd = append(safelistAdd, literals...) + } default: passthrough = append(passthrough, node) } @@ -1375,21 +1380,24 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) } processInput(inAst, baseDir) - ds := buildDesignSystem(theme) - - // Register @custom-variant blocks. This is how a project defines `dark:` as a CLASS - // toggle rather than a media query — the built-in dark variant follows the OS, which - // a site with a theme switch cannot use: - // - // @custom-variant dark (&:where(.dark, .dark *)); - // - // Both of Tailwind's forms are accepted: the shorthand above, and the block form - // with an explicit @slot. - for _, cv := range customVariants { - if name, body, ok := parseCustomVariant(cv); ok { - ds.variants.fromAst(name, body, ds) - } + if len(safelistAdd) > 0 { + candidates = append(candidates, safelistAdd...) } + if len(safelistRemove) > 0 { + remove := make(map[string]bool, len(safelistRemove)) + for _, c := range safelistRemove { + remove[c] = true + } + filtered := candidates[:0:0] + for _, c := range candidates { + if !remove[c] { + filtered = append(filtered, c) + } + } + candidates = filtered + } + + ds := buildDesignSystem(theme) // Register @utility blocks as static utilities. for _, u := range customUtilities { @@ -1438,14 +1446,6 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) if e != nil { return "", 0, e } - // The preflight is written against Tailwind's compile-time CSS functions — - // `font-family: --theme(--default-font-family, …)` and five more like it. They - // have to be resolved here, exactly as the theme's own declarations are above. - // Left in, `--theme(…)` reaches the browser verbatim, which cannot parse it and - // so DROPS THE WHOLE DECLARATION: html ends up with no font-family at all and - // falls back to the browser default, and no @theme override of --font-sans can - // ever take effect. - substituteFunctions(pfAst, ds) out = append(out, atRule("@layer", "base", pfAst...)) } @@ -1459,6 +1459,95 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) return toCss(out), len(astNodes), nil } +// parseSourceDirective parses the params of an `@source` at-rule, supporting +// the safelist form `@source inline("")` / `@source not inline("")` +// (mirrors upstream Tailwind v4's inline source safelist). Each quoted string +// literal is brace-expanded (e.g. `{text,bg}-{red,blue}-{100,200}`) into the +// literal candidate classes it names. File-glob `@source "./path/**/*.html"` +// directives are not supported and are ignored. +func parseSourceDirective(params string) (literals []string, negate bool) { + trimmed := strings.TrimSpace(params) + if rest, ok := strings.CutPrefix(trimmed, "not "); ok { + negate = true + trimmed = strings.TrimSpace(rest) + } + if !strings.HasPrefix(trimmed, "inline(") || !strings.HasSuffix(trimmed, ")") { + return nil, false + } + inner := trimmed[len("inline(") : len(trimmed)-1] + for _, lit := range extractQuotedLiterals(inner) { + literals = append(literals, expandBraces(lit)...) + } + return literals, negate +} + +// extractQuotedLiterals returns the contents of every single- or +// double-quoted string literal found in s. +func extractQuotedLiterals(s string) []string { + var out []string + for i := 0; i < len(s); i++ { + quote := s[i] + if quote != '\'' && quote != '"' { + continue + } + j := i + 1 + for j < len(s) && s[j] != quote { + if s[j] == '\\' && j+1 < len(s) { + j++ + } + j++ + } + out = append(out, s[i+1:min(j, len(s))]) + i = j + } + return out +} + +// expandBraces expands shell-style brace groups in pattern, e.g. +// "{text,bg}-red-{100,200}" -> ["text-red-100", "text-red-200", +// "bg-red-100", "bg-red-200"]. A brace group may also be a numeric range +// ("{1..3}" -> "1", "2", "3"). Groups are expanded left to right; a pattern +// with no braces expands to itself. +func expandBraces(pattern string) []string { + start := strings.IndexByte(pattern, '{') + if start == -1 { + return []string{pattern} + } + relEnd := strings.IndexByte(pattern[start:], '}') + if relEnd == -1 { + return []string{pattern} + } + end := start + relEnd + prefix, inner, suffix := pattern[:start], pattern[start+1:end], pattern[end+1:] + + var parts []string + if a, b, ok := strings.Cut(inner, ".."); ok && !strings.Contains(inner, ",") { + lo, loErr := strconv.Atoi(strings.TrimSpace(a)) + hi, hiErr := strconv.Atoi(strings.TrimSpace(b)) + if loErr == nil && hiErr == nil { + step := 1 + if lo > hi { + step = -1 + } + for n := lo; ; n += step { + parts = append(parts, strconv.Itoa(n)) + if n == hi { + break + } + } + } + } + if parts == nil { + parts = strings.Split(inner, ",") + } + + var out []string + for _, p := range parts { + out = append(out, expandBraces(prefix+p+suffix)...) + } + return out +} + // scanSources scans the given glob/** patterns (relative to baseDir) for // candidate class names using the bundler's scanner. func scanSources(baseDir string, patterns []string) []string { @@ -8831,95 +8920,6 @@ func (v *Variants) compare(a, z *Variant) int { return 1 } -// parseCustomVariant reads an @custom-variant at-rule into a name and the AST body that -// fromAst expects (a body whose rules contain an @slot where the utility goes). -// -// Two forms, both from Tailwind: -// -// @custom-variant dark (&:where(.dark, .dark *)); // shorthand -// -// @custom-variant dark { // block, explicit slot -// &:where(.dark, .dark *) { @slot; } -// } -// -// In the shorthand, a parenthesised selector starting with '@' is an at-rule -// (`@custom-variant any-hover (@media (any-hover: hover))`), and anything else is a -// selector. Several may be given, comma-separated at the top level. -func parseCustomVariant(node *AstNode) (name string, body []*AstNode, ok bool) { - params := strings.TrimSpace(node.Params) - if params == "" { - return "", nil, false - } - - // The name is the first token; whatever follows is the shorthand's parenthesised part. - i := strings.IndexAny(params, " \t(") - if i < 0 { - // No shorthand: it must be the block form, which carries its own @slot. - if len(node.Nodes) == 0 { - return "", nil, false - } - return params, node.Nodes, true - } - name = strings.TrimSpace(params[:i]) - rest := strings.TrimSpace(params[i:]) - - if rest == "" { - if len(node.Nodes) == 0 { - return "", nil, false - } - return name, node.Nodes, true - } - if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") { - return "", nil, false - } - inner := strings.TrimSpace(rest[1 : len(rest)-1]) - if inner == "" { - return "", nil, false - } - - for _, sel := range splitTopLevel(inner, ',') { - sel = strings.TrimSpace(sel) - if sel == "" { - continue - } - slot := atRule("@slot", "") - if strings.HasPrefix(sel, "@") { - // "@media (any-hover: hover)" -> name "@media", params "(any-hover: hover)" - at, params, _ := strings.Cut(sel, " ") - body = append(body, atRule(at, strings.TrimSpace(params), slot)) - continue - } - body = append(body, styleRule(sel, slot)) - } - if len(body) == 0 { - return "", nil, false - } - return name, body, true -} - -// splitTopLevel splits on sep, ignoring separators nested inside brackets — a selector -// list like `&:where(.dark, .dark *)` is ONE selector, and splitting it on its inner -// comma would produce two broken halves. -func splitTopLevel(s string, sep byte) []string { - var parts []string - depth := 0 - start := 0 - for i := 0; i < len(s); i++ { - switch s[i] { - case '(', '[': - depth++ - case ')', ']': - depth-- - case sep: - if depth == 0 { - parts = append(parts, s[start:i]) - start = i + 1 - } - } - } - return append(parts, s[start:]) -} - // fromAst registers a variant whose body comes from CSS (@custom-variant). func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) { var selectors []string diff --git a/go/tw/tailwind_test.go b/go/tw/tailwind_test.go index 6d142282..43590cc8 100644 --- a/go/tw/tailwind_test.go +++ b/go/tw/tailwind_test.go @@ -42,6 +42,71 @@ func TestEngineEdgeCases(t *testing.T) { } } +// @source inline(...) safelists candidates that aren't present in the scanned +// source files at all (brace expansion covers the color/shade cross product). +func TestSourceInlineSafelist(t *testing.T) { + css, _, err := twCompile( + `@import "tailwindcss"; +@source inline("{text,bg}-{red,blue}-{100,500}");`, + ".", nil) + if err != nil { + t.Fatalf("twCompile error: %v", err) + } + for _, want := range []string{ + "color: var(--color-red-100)", + "color: var(--color-red-500)", + "color: var(--color-blue-100)", + "color: var(--color-blue-500)", + "background-color: var(--color-red-100)", + "background-color: var(--color-blue-500)", + } { + if !strings.Contains(css, want) { + t.Errorf("expected safelisted output to contain %q\n---\n%s", want, css) + } + } +} + +// `not inline(...)` removes a candidate even if the scanner found it in source. +func TestSourceNotInlineRemoves(t *testing.T) { + css, _, err := twCompile( + `@import "tailwindcss"; +@source not inline("bg-red-500");`, + ".", []string{"bg-red-500", "bg-blue-500"}) + if err != nil { + t.Fatalf("twCompile error: %v", err) + } + if strings.Contains(css, "background-color: var(--color-red-500)") { + t.Errorf("expected bg-red-500 to be excluded by `not inline`\n---\n%s", css) + } + if !strings.Contains(css, "background-color: var(--color-blue-500)") { + t.Errorf("expected bg-blue-500 to remain\n---\n%s", css) + } +} + +func TestExpandBraces(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"flex", []string{"flex"}}, + {"{a,b}", []string{"a", "b"}}, + {"{1..3}", []string{"1", "2", "3"}}, + {"{text,bg}-{red,blue}", []string{"text-red", "text-blue", "bg-red", "bg-blue"}}, + } + for _, c := range cases { + got := expandBraces(c.in) + if len(got) != len(c.want) { + t.Errorf("expandBraces(%q) = %v, want %v", c.in, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("expandBraces(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} + func TestSegmentTopLevel(t *testing.T) { cases := []struct { in string diff --git a/go/tw/tw.go b/go/tw/tw.go index dfa6a5f9..6f2de312 100644 --- a/go/tw/tw.go +++ b/go/tw/tw.go @@ -20,11 +20,11 @@ import ( mincss "github.com/tdewolff/minify/v2/css" ) -var min *minify.M +var minifier *minify.M func init() { - min = minify.New() - min.AddFunc("text/css", mincss.Minify) + minifier = minify.New() + minifier.AddFunc("text/css", mincss.Minify) } // Scan extracts candidate utility class names from the files matched by patterns @@ -56,4 +56,4 @@ func CompileFiles(entryCSS, baseDir string, sourceGlobs []string) (string, error } // Minify shrinks compiled CSS. -func Minify(css string) (string, error) { return min.String("text/css", css) } +func Minify(css string) (string, error) { return minifier.String("text/css", css) }