From 112c13e459da74da1d2352e673d3a5c40e303ca0 Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Fri, 31 Jul 2026 13:05:43 -0400 Subject: [PATCH 1/9] Refine personal site and articles --- README.md | 2 + app/andrewmeier.slnx | 1 + app/docker-compose.yml | 9 + app/src/App/App.fsproj | 7 +- app/src/App/src/Articles/Handler.fs | 66 ++++++- app/src/App/src/Articles/View.fs | 265 ++++++++++++++++++++++++++- app/src/App/src/Common/View.fs | 61 +++--- app/src/App/src/Index/Handler.fs | 4 +- app/src/App/src/Index/View.fs | 9 - app/src/App/src/Projects/Handler.fs | 26 --- app/src/App/src/Projects/View.fs | 58 ------ app/src/App/src/Services/Handler.fs | 28 --- app/src/App/src/Services/View.fs | 99 ---------- app/src/Build/Program.fs | 59 ++++-- app/src/Domain/Notion.fs | 16 +- app/src/MockNotion/Dockerfile | 20 ++ app/src/MockNotion/MockNotion.fsproj | 14 ++ app/src/MockNotion/Program.fs | 153 ++++++++++++++++ app/src/MockNotion/paket.references | 1 + app/src/Tests/ArticleFilterTests.fs | 46 +++++ app/src/Tests/ArticleServiceTests.fs | 3 +- app/src/Tests/MockNotionTests.fs | 33 ++++ app/src/Tests/NotionServiceTests.fs | 24 ++- app/src/Tests/Tests.fsproj | 6 +- app/src/Tests/ViewTests.fs | 98 +++++++++- 25 files changed, 810 insertions(+), 298 deletions(-) delete mode 100644 app/src/App/src/Projects/Handler.fs delete mode 100644 app/src/App/src/Projects/View.fs delete mode 100644 app/src/App/src/Services/Handler.fs delete mode 100644 app/src/App/src/Services/View.fs create mode 100644 app/src/MockNotion/Dockerfile create mode 100644 app/src/MockNotion/MockNotion.fsproj create mode 100644 app/src/MockNotion/Program.fs create mode 100644 app/src/MockNotion/paket.references create mode 100644 app/src/Tests/ArticleFilterTests.fs create mode 100644 app/src/Tests/MockNotionTests.fs diff --git a/README.md b/README.md index e71b5f5..d1a5952 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ dotnet paket restore ./fake.sh Watch ``` +`Watch` starts a local MockNotion service with fictional articles, so normal local development does not require Notion credentials or a Pulumi ESC environment. + ## Testing ```bash diff --git a/app/andrewmeier.slnx b/app/andrewmeier.slnx index fb43e20..1e87bf6 100644 --- a/app/andrewmeier.slnx +++ b/app/andrewmeier.slnx @@ -3,6 +3,7 @@ + diff --git a/app/docker-compose.yml b/app/docker-compose.yml index 4fc1301..5111f45 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -1,4 +1,13 @@ services: + mock-notion: + build: + context: . + dockerfile: src/MockNotion/Dockerfile + ports: + - "5015:5015" + environment: + MOCK_NOTION_URL: http://0.0.0.0:5015 + seq: image: datalust/seq:2024.3 environment: diff --git a/app/src/App/App.fsproj b/app/src/App/App.fsproj index 3e10037..34f3a16 100644 --- a/app/src/App/App.fsproj +++ b/app/src/App/App.fsproj @@ -3,6 +3,7 @@ net10.0 $(NoWarn);NU1510 + true @@ -14,10 +15,6 @@ - - - - @@ -26,4 +23,4 @@ - + \ No newline at end of file diff --git a/app/src/App/src/Articles/Handler.fs b/app/src/App/src/Articles/Handler.fs index 0db339b..b7ddc18 100644 --- a/app/src/App/src/Articles/Handler.fs +++ b/app/src/App/src/Articles/Handler.fs @@ -7,21 +7,67 @@ open App.Common.Handler open Giraffe open Microsoft.AspNetCore.Http open StarFederation.Datastar.DependencyInjection +open System + +let private optionalQueryValue (ctx:HttpContext) name = + match ctx.Request.Query.TryGetValue name with + | true, value when not (String.IsNullOrWhiteSpace(string value)) -> Some(string value) + | _ -> None + +let private filtersFromRequest (ctx:HttpContext) = + { FilterState.search = optionalQueryValue ctx "search" + tag = optionalQueryValue ctx "tag" + publishedYear = optionalQueryValue ctx "year" |> Option.bind (fun value -> + match Int32.TryParse value with + | true, year -> Some year + | false, _ -> None) } + +let filterArticles (filters:FilterState) (articles:Domain.Article.Article list) = + let matchesSearch (article:Domain.Article.Article) = + filters.search + |> Option.forall (fun query -> + let query = query.Trim() + article.title.Contains(query, StringComparison.OrdinalIgnoreCase) + || article.summary.Contains(query, StringComparison.OrdinalIgnoreCase) + || (article.tags |> Array.exists (fun tag -> tag.Contains(query, StringComparison.OrdinalIgnoreCase)))) + + let matchesTag (article:Domain.Article.Article) = + filters.tag + |> Option.forall (fun selectedTag -> + article.tags |> Array.exists (fun tag -> String.Equals(tag, selectedTag, StringComparison.OrdinalIgnoreCase))) + + let matchesYear (article:Domain.Article.Article) = + filters.publishedYear + |> Option.forall (fun year -> article.createdAt.Year = year) + + articles |> List.filter (fun article -> matchesSearch article && matchesTag article && matchesYear article) let private getArticlesPage (services:Services) : HttpHandler = fun next ctx -> task { use _span = services.telemetry.startActiveSpan "app.articles.get_articles_page" - let! articles = services.article.listArticles () - let page = articlesPage articles - - if ctx.IsDatastar then - let ds = ctx.GetService() - do! patchSignals ds {| selectedNav = "nav-articles" |} - do! patchElement ds page - do! pushUrl ds "/articles" - return Some ctx + let filters = filtersFromRequest ctx + let canonicalUrl = FilterState.url filters + let requestedUrl = ctx.Request.Path.Value + ctx.Request.QueryString.ToString() + + if not ctx.IsDatastar && requestedUrl <> canonicalUrl then + return! redirectTo false canonicalUrl next ctx else - return! renderPage services page "nav-articles" next ctx + let! allArticles = services.article.listArticles () + let page = + { articles = filterArticles filters allArticles + filters = filters + tags = allArticles |> List.collect (fun article -> article.tags |> Array.toList) |> List.distinct |> List.sort + years = allArticles |> List.map (fun article -> article.createdAt.Year) |> List.distinct |> List.sortDescending } + |> articlesPage + + if ctx.IsDatastar then + let ds = ctx.GetService() + do! patchSignals ds {| selectedNav = "nav-articles" |} + do! patchElement ds page + do! pushUrl ds canonicalUrl + return Some ctx + else + return! renderPage services page "nav-articles" next ctx } let private getArticlePage (services:Services) (id:string) : HttpHandler = diff --git a/app/src/App/src/Articles/View.fs b/app/src/App/src/Articles/View.fs index 80fd70f..1ea7adc 100644 --- a/app/src/App/src/Articles/View.fs +++ b/app/src/App/src/Articles/View.fs @@ -3,25 +3,270 @@ module App.Articles.View open Domain.Article open FSharp.ViewEngine open App.Common.View +open System open type Datastar open type Html -let articlesPage (articles:Article list) = +type FilterState = + { search: string option + tag: string option + publishedYear: int option } + +module FilterState = + let url (filters:FilterState) = + [ "search", filters.search + "tag", filters.tag + "year", filters.publishedYear |> Option.map string ] + |> List.choose (fun (key, value) -> + value + |> Option.map _.Trim() + |> Option.filter (String.IsNullOrWhiteSpace >> not) + |> Option.map (fun value -> $"{Uri.EscapeDataString key}={Uri.EscapeDataString value}")) + |> String.concat "&" + |> function + | "" -> "/articles" + | query -> $"/articles?{query}" + +type ArticlesPageState = + { articles: Article list + filters: FilterState + tags: string list + years: int list } + +module FilterControl = + let private json (value:string) = + System.Text.Json.JsonSerializer.Serialize value + |> fun serialized -> serialized.Replace("\"", "'") + + let select (name:string) (options:(string * string) list) (selected:string) (ariaLabel:string) (onChange:string) (buttonClass:string) = + let selectedValue = + options + |> List.tryFind (fun (value, _) -> value = selected) + |> Option.orElse (options |> List.tryHead) + |> Option.map fst + |> Option.defaultValue "" + let selectedLabel = + options + |> List.tryFind (fun (value, _) -> value = selectedValue) + |> Option.map snd + |> Option.defaultValue "" + let prefix = "article_filter_" + Guid.NewGuid().ToString("N") + let valueSignal = prefix + "Value" + let labelSignal = prefix + "Label" + let openSignal = prefix + "Open" + let buttonId = prefix + "Button" + let optionsId = prefix + "Options" + + div { + _class "relative" + _data ("select-root", "") + _data ("signals", $"{{ {valueSignal}: {json selectedValue}, {labelSignal}: {json selectedLabel}, {openSignal}: false }}") + _data ("on:keydown__window", $"evt.key == 'Escape' && (${openSignal} = false)") + input { _type "hidden"; _name name; _value selectedValue } + button { + _id buttonId + _type "button" + _role "combobox" + _ariaHaspopup "listbox" + _ariaLabel ariaLabel + _attr ("aria-controls", optionsId) + _data ("select-button", "") + _data ("attr:aria-expanded", $"${openSignal} ? 'true' : 'false'") + _data ("on:click__stop", $"${openSignal} = !${openSignal}") + _class buttonClass + span { _class "block min-w-0 truncate"; _data ("text", $"${labelSignal}"); selectedLabel } + span { + _class "pointer-events-none ml-2 flex size-5 shrink-0 items-center justify-center text-gray-500 dark:text-gray-400" + _ariaHidden "true" + raw """""" + } + } + div { + _id optionsId + _role "listbox" + _attr ("aria-labelledby", buttonId) + _data ("select-options", "") + _data ("show", $"${openSignal}") + _data ("on:click__outside", $"${openSignal} = false") + _style "display:none" + _class "absolute right-0 top-full z-40 mt-1 min-w-full overflow-hidden rounded-lg border border-gray-300 bg-white py-1 shadow-xl dark:border-gray-600 dark:bg-gray-800" + for index, (value, label) in options |> List.indexed do + button { + _id $"{prefix}Option{index}" + _type "button" + _role "option" + _tabindex -1 + _data ("select-option", "") + _data ("attr:aria-selected", $"${valueSignal} == {json value} ? 'true' : 'false'") + _data ("on:click", $"${valueSignal} = {json value}; ${labelSignal} = {json label}; el.closest('[data-select-root]').querySelector('input[type=hidden]').value = {json value}; ${openSignal} = false; {onChange}") + _data ("class", $"{{ 'bg-gray-100 font-semibold dark:bg-gray-700': ${valueSignal} == {json value} }}") + _class "flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm text-gray-800 transition hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:text-gray-100 dark:hover:bg-gray-700" + span { _class "truncate"; label } + span { + _class "text-emerald-600 dark:text-emerald-400" + _ariaHidden "true" + _data ("show", $"${valueSignal} == {json value}") + "✓" + } + } + } + } + +let articlesPage (state:ArticlesPageState) = + let filters = state.filters + let hasSearch = filters.search.IsSome + let hasActiveFilters = filters.tag.IsSome || filters.publishedYear.IsSome + let hasCriteria = hasSearch || hasActiveFilters + let clearSearchUrl = FilterState.url { filters with search = None } + let clearFiltersUrl = FilterState.url { filters with tag = None; publishedYear = None } + let searchInputClass = + "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm outline-none transition placeholder:text-gray-400 focus:border-emerald-600 focus:ring-2 focus:ring-emerald-600/20 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:border-emerald-400 dark:focus:ring-emerald-400/20" + + if hasSearch then " pr-10" else "" + let content = div { - _class "mx-auto max-w-5xl py-10 px-4" + _class "mx-auto max-w-5xl px-4 py-10" header { - h1 { _class "text-4xl text-gray-900 dark:text-gray-100 font-medium"; "Articles" } - p { _class "mt-4 text-lg text-gray-600 dark:text-gray-400"; "My thoughts on finance and technology" } + h1 { _class "text-4xl font-medium text-gray-900 dark:text-gray-100"; "Articles" } + p { + _class "mt-4 max-w-2xl text-lg leading-7 text-gray-600 dark:text-gray-400" + "Personal notes by Andy Meier. Unless otherwise noted, they reflect my own views—not those of clients, collaborators, or organizations I work with." + } } - div { + section { + _ariaLabel "Article search and filters" _class "mt-10" - div { + form { + _method "get" + _action "/articles" + _class "border-b border-gray-300/60 py-4 dark:border-gray-700/60" div { - _class "flex flex-col space-y-4" - for a in articles do ArticleCard.summary a + _class "flex flex-col gap-2 sm:flex-row sm:items-center" + label { + _class "min-w-0 flex-1" + span { _class "sr-only"; "Search articles" } + div { + _class "relative" + input { + _type "text" + _role "searchbox" + _name "search" + _ariaLabel "Search articles" + _value (filters.search |> Option.defaultValue "") + _placeholder "Search articles" + _class searchInputClass + } + if hasSearch then + a { + _href clearSearchUrl + _ariaLabel "Clear search" + _class "absolute inset-y-0 right-0 inline-flex items-center px-3 text-gray-400 transition hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:text-gray-500 dark:hover:text-gray-100 dark:focus-visible:outline-emerald-400" + span { _class "sr-only"; "Clear search" } + raw """""" + } + } + } + if filters.tag.IsNone || filters.publishedYear.IsNone then + details { + _class "group relative shrink-0" + summary { + _class "flex cursor-pointer list-none items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-800 shadow-sm transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-800" + "+ Add filter" + } + div { + _class "z-30 mt-2 grid w-full gap-3 rounded-lg border border-gray-300 bg-white p-3 shadow-xl sm:absolute sm:right-0 sm:w-auto sm:min-w-64 dark:border-gray-700 dark:bg-gray-900" + if filters.tag.IsNone then + label { + _class "grid gap-1 text-sm" + span { _class "font-medium text-gray-900 dark:text-gray-100"; "Tag" } + FilterControl.select + "tag" + (("", "Select tag") :: (state.tags |> List.map (fun tag -> tag, tag))) + "" + "Tag filter" + "el.closest('form').requestSubmit()" + "inline-flex w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm outline-none transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus-visible:outline-emerald-400" + } + if filters.publishedYear.IsNone then + label { + _class "grid gap-1 text-sm" + span { _class "font-medium text-gray-900 dark:text-gray-100"; "Published" } + FilterControl.select + "year" + (("", "Select year") :: (state.years |> List.map (fun year -> string year, string year))) + "" + "Published year filter" + "el.closest('form').requestSubmit()" + "inline-flex w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm outline-none transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus-visible:outline-emerald-400" + } + } + } + button { _type "submit"; _class "sr-only"; "Search" } } + if hasActiveFilters then + div { + _class "mt-3 flex flex-wrap items-center gap-2" + match filters.tag with + | Some tag -> + div { + _class "inline-flex items-center rounded-md border border-gray-300 bg-gray-50 text-sm dark:border-gray-600 dark:bg-gray-800" + span { _class "border-r border-gray-300 px-2 py-1.5 font-medium text-gray-500 dark:border-gray-600 dark:text-gray-400"; "Tag" } + FilterControl.select + "tag" + (state.tags |> List.map (fun tagOption -> tagOption, tagOption)) + tag + "Tag filter" + "el.closest('form').requestSubmit()" + "inline-flex items-center bg-transparent py-1.5 pl-2 pr-1 font-medium text-gray-900 outline-none focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:text-gray-100 dark:focus-visible:outline-emerald-400" + a { + _href (FilterState.url { filters with tag = None }) + _ariaLabel "Remove tag filter" + _class "inline-flex self-stretch items-center border-l border-gray-300 px-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100" + "×" + } + } + | None -> () + match filters.publishedYear with + | Some year -> + div { + _class "inline-flex items-center rounded-md border border-gray-300 bg-gray-50 text-sm dark:border-gray-600 dark:bg-gray-800" + span { _class "border-r border-gray-300 px-2 py-1.5 font-medium text-gray-500 dark:border-gray-600 dark:text-gray-400"; "Published" } + FilterControl.select + "year" + (state.years |> List.map (fun yearOption -> string yearOption, string yearOption)) + (string year) + "Published year filter" + "el.closest('form').requestSubmit()" + "inline-flex items-center bg-transparent py-1.5 pl-2 pr-1 font-medium text-gray-900 outline-none focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:text-gray-100 dark:focus-visible:outline-emerald-400" + a { + _href (FilterState.url { filters with publishedYear = None }) + _ariaLabel "Remove published year filter" + _class "inline-flex self-stretch items-center border-l border-gray-300 px-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100" + "×" + } + } + | None -> () + a { + _href clearFiltersUrl + _class "px-1 py-1.5 text-sm font-medium text-gray-500 transition hover:text-gray-900 hover:underline dark:text-gray-400 dark:hover:text-gray-100" + "Clear filters" + } + } } + if state.articles.IsEmpty then + div { + _class "py-12 text-center" + h2 { _class "text-lg font-semibold text-gray-900 dark:text-gray-100"; "No articles found" } + if hasCriteria then + p { _class "mt-2 text-sm text-gray-600 dark:text-gray-400"; "Try changing or clearing your search and filters." } + else + p { _class "mt-2 text-sm text-gray-600 dark:text-gray-400"; "Articles will appear here when they are published." } + } + else + div { + _class "flex flex-col" + for article in state.articles do ArticleCard.summary article + } } } Page.primary content @@ -173,6 +418,10 @@ let articlePage (article':Article) = _class "mt-4 text-4xl font-bold tracking-tight text-gray-50" article'.title } + div { + _class "mt-5" + ArticleCard.tags article'.tags + } } } article { diff --git a/app/src/App/src/Common/View.fs b/app/src/App/src/Common/View.fs index c09cd88..2ab5c5e 100644 --- a/app/src/App/src/Common/View.fs +++ b/app/src/App/src/Common/View.fs @@ -100,6 +100,12 @@ module ArticleCard = text } + let tags (tags:string[]) = + div { + _class "flex flex-wrap gap-2" + for tag' in tags do tag tag' + } + let summary (article':Article) = let url = $"/articles/{article'.permalink}" article { @@ -127,8 +133,8 @@ module ArticleCard = } p { _class "mt-2 text-base text-gray-600 dark:text-gray-400"; article'.summary } div { - _class "mt-4 flex flex-wrap gap-2" - for t in article'.tags do tag t + _class "mt-4" + tags article'.tags } } @@ -146,8 +152,7 @@ module Footer = div { _class "text-sm flex flex-col space-y-1" a { _class "underline cursor-pointer hover:text-emerald-600 dark:hover:text-emerald-400"; _dataOn ("click", "@get('/articles')"); "Articles" } - a { _class "underline cursor-pointer hover:text-emerald-600 dark:hover:text-emerald-400"; _dataOn ("click", "@get('/services')"); "Services" } - a { _class "underline cursor-pointer hover:text-emerald-600 dark:hover:text-emerald-400"; _dataOn ("click", "@get('/projects')"); "Projects" } + a { _href "https://meiermade.com"; _class "whitespace-nowrap underline hover:text-emerald-600 dark:hover:text-emerald-400"; "Meier Made" } } } @@ -184,6 +189,13 @@ module TopNav = text label } + let private companyLink (className:string) = + a { + _href "https://meiermade.com" + _class className + text "Meier Made" + } + let private themeToggle = elDropdown { _class "relative inline-block text-left" @@ -260,8 +272,7 @@ module TopNav = _anchor "bottom end" _class "mt-2 w-56 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10" mobileItem("nav-articles", "Articles", "/articles") - mobileItem("nav-projects", "Projects", "/projects") - mobileItem("nav-services", "Services", "/services") + companyLink "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-emerald-600 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-emerald-400" } } @@ -278,8 +289,7 @@ module TopNav = div { _class "hidden md:flex items-center gap-4" item("nav-articles", text "Articles", "/articles") - item("nav-projects", text "Projects", "/projects") - item("nav-services", text "Services", "/services") + companyLink "p-2 text-sm font-semibold text-gray-800 hover:text-emerald-600 dark:text-gray-200 dark:hover:text-emerald-400" } themeToggle mobileDropdown @@ -319,35 +329,36 @@ type Document = } div { _id "cookie-consent-banner" - _class "hidden fixed inset-x-0 bottom-0 z-50 border-t border-gray-300 bg-white/95 p-4 shadow-2xl backdrop-blur dark:border-gray-700 dark:bg-gray-900/95" _role "dialog" + _ariaLabelledby "analytics-consent-title" + _ariaDescribedby "analytics-consent-description" _ariaLive "polite" + _class "pointer-events-none fixed inset-x-0 bottom-0 z-50 hidden px-4 pb-4 sm:px-6 sm:pb-6" div { - _class "mx-auto flex max-w-5xl flex-col gap-4 md:flex-row md:items-center md:justify-between" - div { - _class "max-w-3xl" - p { - _class "text-sm font-semibold text-gray-900 dark:text-gray-100" - "Analytics cookies" - } - p { - _class "mt-1 text-sm text-gray-600 dark:text-gray-300" - "I use analytics to measure how this site is used. You can accept or reject analytics cookies, and the site will work either way." - } + _class "pointer-events-auto ml-auto max-w-xl rounded-2xl border border-gray-200 bg-white p-5 shadow-2xl shadow-gray-950/15 sm:p-6 dark:border-gray-700 dark:bg-gray-900 dark:shadow-black/30" + h2 { + _id "analytics-consent-title" + _class "text-base font-semibold text-gray-950 dark:text-gray-50" + "Optional analytics" + } + p { + _id "analytics-consent-description" + _class "mt-2 text-sm/6 text-gray-600 dark:text-gray-300" + "Google Analytics and Snowplow are loaded only if you accept. They help me understand site usage; declining does not affect the site." } div { - _class "flex flex-col gap-2 sm:flex-row" + _class "mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end" button { _type "button" - _class "inline-flex items-center justify-center rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:cursor-pointer dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800" + _class "rounded-full border border-gray-300 px-4 py-2.5 text-sm font-semibold text-gray-800 transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:border-gray-600 dark:text-gray-100 dark:hover:bg-gray-800" _onclick "setAnalyticsConsent('declined')" - "Reject" + "Decline" } button { _type "button" - _class "inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 hover:cursor-pointer dark:bg-emerald-500 dark:hover:bg-emerald-400" + _class "rounded-full bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500 dark:hover:bg-emerald-400" _onclick "setAnalyticsConsent('accepted')" - "Accept" + "Accept analytics" } } } diff --git a/app/src/App/src/Index/Handler.fs b/app/src/App/src/Index/Handler.fs index c1651ba..f019c50 100644 --- a/app/src/App/src/Index/Handler.fs +++ b/app/src/App/src/Index/Handler.fs @@ -28,7 +28,7 @@ let handler (services:Services) : HttpHandler = choose [ route "/health" >=> GET >=> text "Healthy" routex "(/?)" >=> GET >=> getHomePage services - App.Services.Handler.handler services - App.Projects.Handler.handler services + route "/services" >=> GET >=> redirectTo true "https://meiermade.com/services" + route "/projects" >=> GET >=> redirectTo true "https://meiermade.com/projects" subRoute "/articles" (App.Articles.Handler.handler services) ] diff --git a/app/src/App/src/Index/View.fs b/app/src/App/src/Index/View.fs index d88fd73..a51c505 100644 --- a/app/src/App/src/Index/View.fs +++ b/app/src/App/src/Index/View.fs @@ -11,15 +11,6 @@ let aboutMe = p { _class "mt-4 text-lg text-gray-600 dark:text-gray-400" "Engineer working in finance, accounting, and capital markets." - br - br - "Currently working at " - a { - _href "https://aplazo.mx/" - _class "underline hover:text-emerald-600 dark:hover:text-emerald-400" - "Aplazo" - } - "." } let homePage (recentArticles:Article list) = diff --git a/app/src/App/src/Projects/Handler.fs b/app/src/App/src/Projects/Handler.fs deleted file mode 100644 index d8fb500..0000000 --- a/app/src/App/src/Projects/Handler.fs +++ /dev/null @@ -1,26 +0,0 @@ -module App.Projects.Handler - -open App.Infrastructure -open App.Common.Handler -open App.ServiceRegistry -open App.Projects -open Giraffe -open Microsoft.AspNetCore.Http -open StarFederation.Datastar.DependencyInjection - -let private getPage (services:Services) : HttpHandler = - fun next ctx -> task { - use _span = services.telemetry.startActiveSpan "app.projects.get_page" - let page = View.page - if ctx.IsDatastar then - let ds = ctx.GetService() - do! patchSignals ds {| selectedNav = "nav-projects" |} - do! patchElement ds page - do! pushUrl ds "/projects" - return Some ctx - else - return! renderPage services page "nav-projects" next ctx - } - -let handler (services:Services) : HttpHandler = - route "/projects" >=> GET >=> getPage services diff --git a/app/src/App/src/Projects/View.fs b/app/src/App/src/Projects/View.fs deleted file mode 100644 index 190b801..0000000 --- a/app/src/App/src/Projects/View.fs +++ /dev/null @@ -1,58 +0,0 @@ -module App.Projects.View - -open FSharp.ViewEngine -open App.Common.View -open type Html - -type ProjectCardProps = - { title: string - description: string - logoSrc: string - logoAlt: string - href: string - label: string } - -let private projectCard (props: ProjectCardProps) = - a { - _href props.href - _class "group block p-6 rounded-2xl border border-gray-300/60 dark:border-gray-700/60 transition-colors hover:bg-white dark:hover:bg-gray-800" - div { - _class "flex items-center gap-4" - img { - _class "h-12 w-12 rounded-xl object-cover bg-gray-100 dark:bg-gray-900 p-1" - _src props.logoSrc - _alt props.logoAlt - } - h2 { _class "text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"; props.title } - } - p { _class "mt-4 text-base text-gray-700 dark:text-gray-300"; props.description } - p { _class "mt-5 text-sm text-gray-600 dark:text-gray-200 transition-colors group-hover:text-emerald-600 dark:group-hover:text-emerald-400"; props.label } - } - -let page = - let content = - div { - _class "mx-auto max-w-5xl py-10 px-4" - h1 { _class "text-4xl text-gray-900 dark:text-gray-100 font-medium"; "Projects" } - p { _class "mt-4 text-lg text-gray-600 dark:text-gray-400"; "Things I am working on." } - div { - _class "mt-10 grid gap-8 md:grid-cols-2" - projectCard { - title = "FSharp.ViewEngine" - description = "A minimal, fast view engine for F# with a clean computation-expression DSL." - logoSrc = Asset.fingerprinted "/images/fsharpviewengine.svg" - logoAlt = "FSharp.ViewEngine logo" - href = "https://fsharpviewengine.meiermade.com" - label = "fsharpviewengine.meiermade.com" - } - projectCard { - title = "Geldos" - description = "A financial operating system for building modern finance and accounting workflows." - logoSrc = Asset.fingerprinted "/images/geldos.svg" - logoAlt = "Geldos logo" - href = "https://geldos.com" - label = "geldos.com" - } - } - } - Page.primary content diff --git a/app/src/App/src/Services/Handler.fs b/app/src/App/src/Services/Handler.fs deleted file mode 100644 index 06f453e..0000000 --- a/app/src/App/src/Services/Handler.fs +++ /dev/null @@ -1,28 +0,0 @@ -module App.Services.Handler - -open App.Infrastructure -open App.ServiceRegistry -open App.Common.Handler -open Giraffe -open Microsoft.AspNetCore.Http -open StarFederation.Datastar.DependencyInjection - -let private getPage (services:Services) : HttpHandler = - fun next ctx -> task { - use _span = services.telemetry.startActiveSpan "app.services.get_page" - let page = View.page - - if ctx.IsDatastar then - let ds = ctx.GetService() - do! patchSignals ds {| selectedNav = "nav-services" |} - do! patchElement ds page - do! pushUrl ds "/services" - return Some ctx - else - return! renderPage services page "nav-services" next ctx - } - -let handler (services:Services) : HttpHandler = - choose [ - route "/services" >=> GET >=> getPage services - ] diff --git a/app/src/App/src/Services/View.fs b/app/src/App/src/Services/View.fs deleted file mode 100644 index cd9578c..0000000 --- a/app/src/App/src/Services/View.fs +++ /dev/null @@ -1,99 +0,0 @@ -module App.Services.View - -open FSharp.ViewEngine -open App.Common.View -open type Html - -let private iconSecureInfrastructure = - raw """ - - - - """ - -let private iconPrivateNetwork = - raw """ - - - - """ - -let private iconIngestion = - raw """ - - - - """ - -let private iconWarehouse = - raw """ - - - - """ - -let private iconFinancialModels = - raw """ - - - - """ - -let private iconInternalAppsAi = - raw """ - - - - """ - -let private serviceCard (icon: HtmlElement) (title: string) (description: string) : HtmlElement = - div { - _class "rounded-xl border border-gray-300/60 dark:border-gray-700/60 p-4" - div { - _class "text-emerald-600 dark:text-emerald-400" - icon - } - h2 { _class "mt-3 text-xl font-semibold text-gray-900 dark:text-gray-100"; title } - p { _class "mt-2 text-base text-gray-600 dark:text-gray-400"; description } - } - -let page = - let content = - div { - _class "mx-auto max-w-5xl py-10 px-4" - h1 { _class "text-4xl text-gray-900 dark:text-gray-100 font-medium"; "Services" } - p { - _class "mt-4 text-lg text-gray-600 dark:text-gray-400" - "I help businesses build and scale their finance, accounting, and capital markets infrastructure." - } - div { - _class "mt-10 grid grid-cols-1 gap-4 md:grid-cols-2" - serviceCard iconSecureInfrastructure "Secure private infrastructure" "Design and deploy secure, cost-efficient infrastructure with Kubernetes and Pulumi. Enable private networking and access controls with Cloudflare Zero Trust." - serviceCard iconWarehouse "Data engineering and analytics" "Build reliable ingestion and orchestration pipelines with tools like Airbyte and Dagster. Model analytics layers with dbt in BigQuery, Redshift, Snowflake or other warehouses." - serviceCard iconFinancialModels "Financial models" "Develop operating models, borrowing base reports, investor reporting, and more in Excel and Google Sheets. Create bespoke F# and Python models for complex scenarios." - serviceCard iconInternalAppsAi "Internal apps and AI tooling" "Build secure internal web apps and MCP servers in F# or Python. Help set up AI coding agents and skills to automate workflows." - } - section { - _class "mt-10 max-w-3xl mx-auto rounded-xl border border-gray-300/60 dark:border-gray-700/60 p-6" - div { - _class "flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6" - div { - _class "min-w-0" - h2 { _class "text-2xl text-gray-900 dark:text-gray-100 font-medium"; "Get in touch" } - p { - _class "mt-2 text-gray-600 dark:text-gray-400" - "Schedule a quick 30-minute call to see if I can help." - } - } - a { - _href "https://calendar.app.google/wQPWAYBda4r2S7o48" - _target "_blank" - _rel "noopener noreferrer" - _class "inline-flex shrink-0 justify-center rounded-md bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 font-medium" - "Schedule 30 minutes" - } - } - } - } - - Page.primary content diff --git a/app/src/Build/Program.fs b/app/src/Build/Program.fs index bee0707..fd055cf 100644 --- a/app/src/Build/Program.fs +++ b/app/src/Build/Program.fs @@ -4,8 +4,12 @@ open Fake.IO open Fake.IO.FileSystemOperators open System open System.IO +open System.Net +open System.Net.Http +open System.Net.Sockets open System.Security.Cryptography open System.Text.Json +open System.Threading open System.Threading.Tasks Environment.GetCommandLineArgs() @@ -75,34 +79,56 @@ let exec command workDir args = |> CreateProcess.ensureExitCode |> Proc.start -let getEnvMap () = - let env = "andymeier/local" - let pulumiEnv = - CreateProcess.fromRawCommand "pulumi" ["env"; "open"; env] - |> CreateProcess.withWorkingDirectory rootDir - |> CreateProcess.redirectOutput - |> CreateProcess.ensureExitCode - |> Proc.start - let rawPulumiEnvOutput = pulumiEnv.Result.Result.Output - let pulumiEnvOutput = JsonSerializer.Deserialize<{| environmentVariables:Map |}>(rawPulumiEnvOutput) - pulumiEnvOutput.environmentVariables +let availableLocalPort () = + use listener = new TcpListener(IPAddress.Loopback, 0) + listener.Start() + (listener.LocalEndpoint :?> IPEndPoint).Port + +let waitForHttpHealth (name:string) (url:string) = + use httpClient = new HttpClient() + let mutable healthy = false + let mutable attempt = 1 + + while not healthy && attempt <= 30 do + try + use response = httpClient.GetAsync(url).GetAwaiter().GetResult() + healthy <- response.IsSuccessStatusCode + with _ -> + () + + if not healthy then Thread.Sleep 500 + attempt <- attempt + 1 + + if not healthy then failwith $"{name} did not become healthy at {url}" Target.create "StartDeps" <| fun _ -> Trace.trace "Starting dependencies (seq)" exec "docker-compose" rootDir ["up"; "-d"; "seq"] |> Task.WaitAll +Target.create "StartMockNotion" <| fun _ -> + Trace.trace "Starting MockNotion" + exec "docker-compose" rootDir ["up"; "-d"; "--build"; "mock-notion"] |> Task.WaitAll + waitForHttpHealth "MockNotion" "http://localhost:5015/healthz" + Target.create "EnsureDevCert" <| fun _ -> Trace.trace "Ensuring trusted ASP.NET Core HTTPS development certificate" exec "dotnet" rootDir ["dev-certs"; "https"; "--trust"] |> Task.WaitAll Target.create "Watch" <| fun _ -> let sqlitePath = rootDir ".data" "app.db" + let serverUrl = $"https://localhost:{availableLocalPort ()}" + Trace.trace $"Starting local server at {serverUrl}" let env = - getEnvMap() - |> Map.add "ASPNETCORE_ENVIRONMENT" "Development" - |> Map.add "SERVER_URL" "https://localhost:5000" - |> Map.add "SQLITE_PATH" sqlitePath + Map.ofList [ + "ASPNETCORE_ENVIRONMENT", "Development" + "GOOGLE_ANALYTICS_MEASUREMENT_ID", "G-LOCAL" + "NOTION_API_KEY", "mock-notion-token" + "NOTION_ARTICLES_DATABASE_ID", "mock-articles" + "NOTION_BASE_URL", "http://localhost:5015/v1" + "SERVER_URL", serverUrl + "SQLITE_PATH", sqlitePath + ] |> EnvMap.ofMap let watchCss = exec "tailwindcss" appDir ["--input"; "./input.css"; "--output"; "./wwwroot/css/compiled.css"; "--watch"] @@ -129,7 +155,8 @@ Target.create "Publish" <| fun _ -> Target.create "Default" (fun _ -> Target.listAvailable()) -"StartDeps" ==>! "EnsureDevCert" +"StartDeps" ==>! "StartMockNotion" +"StartMockNotion" ==>! "EnsureDevCert" "EnsureDevCert" ==>! "Watch" "BuildCss" ==>! "Publish" diff --git a/app/src/Domain/Notion.fs b/app/src/Domain/Notion.fs index 5c9d6ea..2c7efa6 100644 --- a/app/src/Domain/Notion.fs +++ b/app/src/Domain/Notion.fs @@ -46,12 +46,14 @@ module Cursor = type Config = { articlesDatabaseId:DatabaseId - apiKey:string } + apiKey:string + baseUrl:string } module Config = let load () = { articlesDatabaseId = Env.variable "NOTION_ARTICLES_DATABASE_ID" |> DatabaseId.ofString - apiKey = Env.variable "NOTION_API_KEY" } + apiKey = Env.variable "NOTION_API_KEY" + baseUrl = Env.variableOrDefault "NOTION_BASE_URL" "https://api.notion.com/v1" } // ============================================================ // Types @@ -354,8 +356,8 @@ module Page = [] let private NotionApiVersion = "2022-06-28" -[] -let private BaseUrl = "https://api.notion.com/v1" +let private apiUrl (config:Config) (path:string) = + config.baseUrl.TrimEnd('/') + path let private createRequest (config: Config) (method: HttpMethod) (url: string) = let req = new HttpRequestMessage(method, url) @@ -404,7 +406,7 @@ module Service = let dbId = DatabaseId.toString databaseId use span = telemetry.startActiveSpan "domain.notion.query_database" span.SetAttribute("database_id", dbId) |> ignore - let url = $"{BaseUrl}/databases/{dbId}/query" + let url = apiUrl config $"/databases/{dbId}/query" let req = createRequest config HttpMethod.Post url let body = dict [ @@ -433,7 +435,7 @@ module Service = let pgId = PageId.toString pageId use span = telemetry.startActiveSpan "domain.notion.retrieve_page" span.SetAttribute("page_id", pgId) |> ignore - let url = $"{BaseUrl}/pages/{pgId}" + let url = apiUrl config $"/pages/{pgId}" let req = createRequest config HttpMethod.Get url let! resp = httpClient.SendAsync(req) resp.EnsureSuccessStatusCode() |> ignore @@ -453,7 +455,7 @@ module Service = use span = telemetry.startActiveSpan "domain.notion.retrieve_block_children" span.SetAttribute("block_id", blkId) |> ignore - let mutable url = $"{BaseUrl}/blocks/{blkId}/children?page_size=100" + let mutable url = apiUrl config $"/blocks/{blkId}/children?page_size=100" match startCursor with | Some c -> url <- url + $"&start_cursor={Cursor.toString c}" diff --git a/app/src/MockNotion/Dockerfile b/app/src/MockNotion/Dockerfile new file mode 100644 index 0000000..35e477b --- /dev/null +++ b/app/src/MockNotion/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS builder + +WORKDIR /app + +COPY .config .config +RUN dotnet tool restore + +COPY paket.dependencies paket.lock ./ +RUN dotnet paket install && dotnet paket restore + +COPY src src +RUN dotnet publish src/MockNotion/MockNotion.fsproj --configuration Release --output /out --self-contained false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra AS runner + +WORKDIR /app +COPY --from=builder /out . + +EXPOSE 5015 +ENTRYPOINT ["dotnet", "MockNotion.dll"] diff --git a/app/src/MockNotion/MockNotion.fsproj b/app/src/MockNotion/MockNotion.fsproj new file mode 100644 index 0000000..551ed14 --- /dev/null +++ b/app/src/MockNotion/MockNotion.fsproj @@ -0,0 +1,14 @@ + + + + net10.0 + MockNotion + MockNotion + $(NoWarn);NU1510 + + + + + + + diff --git a/app/src/MockNotion/Program.fs b/app/src/MockNotion/Program.fs new file mode 100644 index 0000000..50c3d7c --- /dev/null +++ b/app/src/MockNotion/Program.fs @@ -0,0 +1,153 @@ +module MockNotion + +open Giraffe +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http +open System +open System.Threading.Tasks + +module Fixture = + [] + let apiKey = "mock-notion-token" + + [] + let databaseId = "mock-articles" + + [] + let apiVersion = "2022-06-28" + + let private annotations = + """{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"}""" + + let private richText text = + $"""{{"type":"text","text":{{"content":"{text}"}},"annotations":{annotations},"plain_text":"{text}","href":null}}""" + + let private page id title permalink summary tags createdAt updatedAt = + let tags = tags |> List.map (fun tag -> $"""{{"name":"{tag}"}}""") |> String.concat "," + + $"""{{ + "object":"page", + "id":"{id}", + "icon":null, + "cover":null, + "properties":{{ + "Title":{{"type":"title","title":[{richText title}]}}, + "Permalink":{{"type":"rich_text","rich_text":[{richText permalink}]}}, + "Summary":{{"type":"rich_text","rich_text":[{richText summary}]}}, + "Tags":{{"type":"multi_select","multi_select":[{tags}]}}, + "Created At":{{"type":"date","date":{{"start":"{createdAt}","end":null}}}}, + "Updated At":{{"type":"date","date":{{"start":"{updatedAt}","end":null}}}}, + "Status":{{"type":"status","status":{{"name":"Published"}}}} + }} + }}""" + + let private financeArticle = + page + "mock-finance-systems" + "Mock finance systems" + "mock-finance-systems" + "A fictional local article about dependable finance operations." + [ "Finance"; "Local development" ] + "2026-01-15T00:00:00.000+00:00" + "2026-01-15T00:00:00.000+00:00" + + let private engineeringArticle = + page + "mock-engineering-notes" + "Mock engineering notes" + "mock-engineering-notes" + "A fictional local article used to exercise the article index and detail view." + [ "Engineering"; "Mock" ] + "2026-02-01T00:00:00.000+00:00" + "2026-02-01T00:00:00.000+00:00" + + let queryDatabase = + $"""{{"object":"list","results":[{engineeringArticle},{financeArticle}],"has_more":false,"next_cursor":null}}""" + + let private blocks heading paragraph = + $"""{{ + "object":"list", + "results":[ + {{"object":"block","id":"{heading}-heading","type":"heading_1","has_children":false,"heading_1":{{"rich_text":[{richText heading}]}}}}, + {{"object":"block","id":"{heading}-paragraph","type":"paragraph","has_children":false,"paragraph":{{"rich_text":[{richText paragraph}]}}}} + ], + "has_more":false, + "next_cursor":null + }}""" + + let tryFindPage id = + match id with + | "mock-finance-systems" -> Some financeArticle + | "mock-engineering-notes" -> Some engineeringArticle + | _ -> None + + let tryFindBlocks id = + match id with + | "mock-finance-systems" -> Some(blocks "Mock finance systems" "This fictional article is available only through the local MockNotion service.") + | "mock-engineering-notes" -> Some(blocks "Mock engineering notes" "This fictional article verifies local article synchronization without a live Notion workspace.") + | _ -> None + +let private json (body:string) : HttpHandler = + fun _next ctx -> task { + ctx.Response.ContentType <- "application/json" + do! ctx.Response.WriteAsync body + return Some ctx + } + +let private unauthorized : HttpHandler = + fun _next ctx -> task { + ctx.SetStatusCode StatusCodes.Status401Unauthorized + return! json """{"object":"error","status":401,"code":"unauthorized","message":"Invalid mock Notion credentials"}""" _next ctx + } + +let private requireNotionRequest (handler:HttpHandler) : HttpHandler = + fun next ctx -> task { + let authorization = ctx.Request.Headers.Authorization.ToString() + let version = ctx.Request.Headers["Notion-Version"].ToString() + + if authorization = $"Bearer {Fixture.apiKey}" && version = Fixture.apiVersion then + return! handler next ctx + else + return! unauthorized next ctx + } + +let private notFound : HttpHandler = + fun next ctx -> task { + ctx.SetStatusCode StatusCodes.Status404NotFound + return! json """{"object":"error","status":404,"code":"object_not_found","message":"Mock Notion object was not found"}""" next ctx + } + +let private queryDatabase : HttpHandler = + json Fixture.queryDatabase + +let private retrievePage id : HttpHandler = + match Fixture.tryFindPage id with + | Some page -> json page + | None -> notFound + +let private retrieveBlockChildren id : HttpHandler = + match Fixture.tryFindBlocks id with + | Some blocks -> json blocks + | None -> notFound + +let app : HttpHandler = + choose [ + GET >=> route "/healthz" >=> text "ok" + requireNotionRequest ( + choose [ + POST >=> route $"/v1/databases/{Fixture.databaseId}/query" >=> queryDatabase + GET >=> routef "/v1/pages/%s" retrievePage + GET >=> routef "/v1/blocks/%s/children" retrieveBlockChildren + notFound + ]) + ] + +[] +let main _args = + let url = Environment.GetEnvironmentVariable("MOCK_NOTION_URL") |> Option.ofObj |> Option.defaultValue "http://0.0.0.0:5015" + let builder = WebApplication.CreateBuilder() + builder.Services.AddGiraffe() |> ignore + let webApp = builder.Build() + webApp.UseGiraffe app + webApp.Run url + 0 diff --git a/app/src/MockNotion/paket.references b/app/src/MockNotion/paket.references new file mode 100644 index 0000000..378494c --- /dev/null +++ b/app/src/MockNotion/paket.references @@ -0,0 +1 @@ +Giraffe diff --git a/app/src/Tests/ArticleFilterTests.fs b/app/src/Tests/ArticleFilterTests.fs new file mode 100644 index 0000000..bcb399a --- /dev/null +++ b/app/src/Tests/ArticleFilterTests.fs @@ -0,0 +1,46 @@ +module ArticleFilterTests + +open App.Articles.Handler +open Domain.Article +open Expecto +open System + +let private article title summary tags createdAt = + { id = title + permalink = title.ToLowerInvariant().Replace(" ", "-") + title = title + summary = summary + icon = "" + iconDescription = "" + cover = "" + coverDescription = "" + tags = tags + createdAt = createdAt + updatedAt = createdAt + blocks = [] + syncedAt = createdAt } + +let private articles = + [ article "Finance systems" "Notes on dependable operations." [| "Finance"; "Architecture" |] (DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero)) + article "Engineering notes" "Patterns for resilient software." [| "Engineering" |] (DateTimeOffset(2025, 6, 10, 0, 0, 0, TimeSpan.Zero)) + article "Capital planning" "Finance and engineering trade-offs." [| "Capital markets" |] (DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero)) ] + +[] +let articleFilterTests = + testList "Article filters" [ + test "searches title, summary, and tags case-insensitively" { + let titleMatches = filterArticles { search = Some "systems"; tag = None; publishedYear = None } articles + let summaryMatches = filterArticles { search = Some "resilient"; tag = None; publishedYear = None } articles + let tagMatches = filterArticles { search = Some "architecture"; tag = None; publishedYear = None } articles + + Expect.equal (titleMatches |> List.map _.title) [ "Finance systems" ] "Expected title search match" + Expect.equal (summaryMatches |> List.map _.title) [ "Engineering notes" ] "Expected summary search match" + Expect.equal (tagMatches |> List.map _.title) [ "Finance systems" ] "Expected tag search match" + } + + test "combines tag and published-year filters" { + let filtered = filterArticles { search = Some "finance"; tag = Some "capital markets"; publishedYear = Some 2025 } articles + + Expect.equal (filtered |> List.map _.title) [ "Capital planning" ] "Expected filters to combine with AND semantics" + } + ] diff --git a/app/src/Tests/ArticleServiceTests.fs b/app/src/Tests/ArticleServiceTests.fs index dd8c9b2..1dbd485 100644 --- a/app/src/Tests/ArticleServiceTests.fs +++ b/app/src/Tests/ArticleServiceTests.fs @@ -30,7 +30,8 @@ let private telemetry () = let private notionConfig: Notion.Config = { articlesDatabaseId = Notion.DatabaseId.ofString "db" - apiKey = "token" } + apiKey = "token" + baseUrl = "https://api.notion.com/v1" } let private emptyNotionService: Notion.Service = { queryDatabase = diff --git a/app/src/Tests/MockNotionTests.fs b/app/src/Tests/MockNotionTests.fs new file mode 100644 index 0000000..9f7d78c --- /dev/null +++ b/app/src/Tests/MockNotionTests.fs @@ -0,0 +1,33 @@ +module Tests.MockNotionTests + +open System.Text.Json +open Expecto +open MockNotion + +[] +let tests = + testList "MockNotion" [ + test "returns two fictional published articles" { + use doc = JsonDocument.Parse(Fixture.queryDatabase) + let articles = doc.RootElement.GetProperty("results") + + Expect.equal (articles.GetArrayLength()) 2 "Expected two local articles" + + let titles = + articles.EnumerateArray() + |> Seq.map (fun page -> + page.GetProperty("properties").GetProperty("Title").GetProperty("title").[0].GetProperty("plain_text").GetString()) + |> Set.ofSeq + + Expect.equal titles (Set.ofList [ "Mock engineering notes"; "Mock finance systems" ]) "Expected fictional article titles" + } + + test "returns deterministic blocks for each fictional article" { + let financeBlocks = Fixture.tryFindBlocks "mock-finance-systems" + let engineeringBlocks = Fixture.tryFindBlocks "mock-engineering-notes" + + Expect.isSome financeBlocks "Expected finance article blocks" + Expect.isSome engineeringBlocks "Expected engineering article blocks" + Expect.isNone (Fixture.tryFindBlocks "unknown") "Expected unknown article to have no blocks" + } + ] diff --git a/app/src/Tests/NotionServiceTests.fs b/app/src/Tests/NotionServiceTests.fs index 2025657..2e7dd60 100644 --- a/app/src/Tests/NotionServiceTests.fs +++ b/app/src/Tests/NotionServiceTests.fs @@ -55,7 +55,8 @@ let private mockHttpClient (responses: (string * string) list) = let private testConfig: Config = { articlesDatabaseId = DatabaseId.ofString "articles-db" - apiKey = "test-token" } + apiKey = "test-token" + baseUrl = "http://notion.test/v1" } let private noopTelemetry: Telemetry.Service = { startActiveSpan = @@ -73,6 +74,27 @@ let private noopTelemetry: Telemetry.Service = let queryDatabaseTests = testList "Service.queryDatabase" [ + testTask "uses the configured endpoint and Notion request headers" { + let mutable request: HttpRequestMessage option = None + let handler = + new MockHandler(fun req -> + request <- Some req + new HttpResponseMessage( + HttpStatusCode.OK, + Content = new StringContent("""{"results":[],"has_more":false,"next_cursor":null}""", Encoding.UTF8, "application/json") + )) + let httpClient = new HttpClient(handler) + let svc = Service.create testConfig noopTelemetry httpClient + + let! _ = svc.queryDatabase testConfig.articlesDatabaseId { filter = None; startCursor = None } + + let request = request |> Option.defaultWith (fun () -> failtest "Expected a Notion request") + Expect.equal request.RequestUri.AbsoluteUri "http://notion.test/v1/databases/articles-db/query" "Expected configured Notion base URL" + Expect.equal request.Headers.Authorization.Scheme "Bearer" "Expected bearer authentication" + Expect.equal request.Headers.Authorization.Parameter "test-token" "Expected configured API key" + Expect.equal (request.Headers.GetValues("Notion-Version") |> Seq.exactlyOne) "2022-06-28" "Expected Notion API version" + } + testTask "returns parsed pages" { let responseJson = $"""{{ diff --git a/app/src/Tests/Tests.fsproj b/app/src/Tests/Tests.fsproj index 41673b8..c1ac930 100644 --- a/app/src/Tests/Tests.fsproj +++ b/app/src/Tests/Tests.fsproj @@ -4,19 +4,23 @@ net10.0 Exe $(NoWarn);NU1510 + true + + + - + \ No newline at end of file diff --git a/app/src/Tests/ViewTests.fs b/app/src/Tests/ViewTests.fs index 5e4d03a..85fc48c 100644 --- a/app/src/Tests/ViewTests.fs +++ b/app/src/Tests/ViewTests.fs @@ -29,6 +29,97 @@ let tests = } ] + testList "Personal site separation" [ + test "links to Meier Made without retaining company services or projects" { + let navigation = Render.toHtmlDocString (TopNav.primary) + let profile = Render.toHtmlDocString App.Index.View.aboutMe + + Expect.stringContains navigation "https://meiermade.com" "Expected a Meier Made company link" + Expect.isFalse (navigation.Contains "Services") "Expected company services to leave personal navigation" + Expect.isFalse (navigation.Contains "Projects") "Expected company projects to leave personal navigation" + Expect.isFalse (profile.Contains "Currently working at") "Expected current-employer claim to be removed" + } + ] + + testList "Articles" [ + test "uses custom listbox controls for article filters" { + let page = + App.Articles.View.articlesPage { + articles = [] + filters = { search = None; tag = None; publishedYear = None } + tags = [ "Finance" ] + years = [ 2026 ] + } + + let html = Render.toHtmlDocString page + + Expect.stringContains html "data-select-root" "Expected custom filter select component" + Expect.isFalse (html.Contains " Render.toHtmlDocString + + Expect.stringContains page "Personal notes by Andy Meier. Unless otherwise noted, they reflect my own views—not those of clients, collaborators, or organizations I work with." "Expected personal-writing subtitle" + Expect.isFalse (page.Contains "My thoughts on finance and technology") "Expected superseded subtitle to be removed" + } + + test "shows article tags on the detail page" { + let article : Domain.Article.Article = + { id = "article" + permalink = "article" + title = "Example article" + summary = "Summary" + icon = "" + iconDescription = "" + cover = "" + coverDescription = "" + tags = [| "Engineering"; "Finance" |] + createdAt = System.DateTimeOffset(2026, 2, 1, 0, 0, 0, System.TimeSpan.Zero) + updatedAt = System.DateTimeOffset(2026, 2, 1, 0, 0, 0, System.TimeSpan.Zero) + blocks = [] + syncedAt = System.DateTimeOffset(2026, 2, 1, 0, 0, 0, System.TimeSpan.Zero) } + + let html = App.Articles.View.articlePage article |> Render.toHtmlDocString + + Expect.stringContains html "Engineering" "Expected article tag on detail page" + Expect.stringContains html "Finance" "Expected article tag on detail page" + } + + test "scopes clear actions to search or added filters" { + let searchOnly = + App.Articles.View.articlesPage { + articles = [] + filters = { search = Some "engine"; tag = None; publishedYear = None } + tags = [ "Engineering" ] + years = [ 2026 ] + } + |> Render.toHtmlDocString + + let searchAndFilter = + App.Articles.View.articlesPage { + articles = [] + filters = { search = Some "engine"; tag = Some "Engineering"; publishedYear = None } + tags = [ "Engineering" ] + years = [ 2026 ] + } + |> Render.toHtmlDocString + + Expect.stringContains searchOnly "Clear search" "Expected a dedicated search clear action" + Expect.isFalse (searchOnly.Contains "Clear filters") "Expected filter clear action only after adding a filter" + Expect.isFalse (searchOnly.Contains "type=\"search\"") "Expected no browser-native search clear control" + Expect.stringContains searchAndFilter "Clear filters" "Expected filters-only clear action" + Expect.stringContains searchAndFilter "href=\"/articles?search=engine\"" "Expected filter clearing to preserve search" + } + ] + testList "Document" [ test "includes consent banner and delayed analytics loading" { let doc = Document.primary(div { "Hello" }, "G-TEST123", "nav-home") @@ -38,8 +129,11 @@ let tests = Expect.stringContains html "Andy Meier" "Expected page to render" Expect.stringContains html "selectedNav: 'nav-home'" "Expected nav signal to render" Expect.stringContains html "cookie-consent-banner" "Expected consent banner" - Expect.stringContains html "Reject" "Expected reject action" - Expect.stringContains html "Accept" "Expected accept action" + Expect.stringContains html "analytics-consent-title" "Expected consent dialog title" + Expect.stringContains html "Optional analytics" "Expected Meier Made-style consent heading" + Expect.stringContains html "rounded-2xl" "Expected compact consent card" + Expect.stringContains html "Decline" "Expected decline action" + Expect.stringContains html "Accept analytics" "Expected accept action" Expect.stringContains html "gtag('consent','default',{analytics_storage:'denied'" "Expected denied-by-default consent mode" Expect.stringContains html "localStorage.setItem('analytics-consent',v)" "Expected consent to be persisted" Expect.stringContains html "https://www.googletagmanager.com/gtag/js?id=G-TEST123" "Expected deferred gtag script source" From 43bccda793d568a09b6ac4a976f71938dedc3ffe Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Fri, 31 Jul 2026 15:21:02 -0400 Subject: [PATCH 2/9] Add end-to-end site checks --- .github/workflows/deploy.yml | 65 ++++++++++++++++++++++--- .github/workflows/preview.yml | 49 +++++++++++++++---- .gitignore | 2 + e2e/package-lock.json | 76 +++++++++++++++++++++++++++++ e2e/package.json | 11 +++++ e2e/playwright.config.ts | 34 +++++++++++++ e2e/scripts/start-local.sh | 80 +++++++++++++++++++++++++++++++ e2e/tests/routes.spec.ts | 62 ++++++++++++++++++++++++ pulumi/src/cloudflare/record.ts | 20 +------- pulumi/src/cloudflare/redirect.ts | 26 +--------- pulumi/src/cloudflare/zone.ts | 1 - 11 files changed, 365 insertions(+), 61 deletions(-) create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/scripts/start-local.sh create mode 100644 e2e/tests/routes.spec.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 10334a2..db8c04c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,11 @@ on: - main paths-ignore: - '**.md' + +concurrency: + group: andymeier-deploy + cancel-in-progress: false + jobs: update: name: Update @@ -14,13 +19,13 @@ jobs: id-token: write pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@v3 with: install_components: gke-gcloud-auth-plugin - name: Install Packages @@ -30,24 +35,70 @@ jobs: working-directory: ./pulumi run: npm run check - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 with: version: latest - name: Expose GitHub Actions cache env - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | core.exportVariable('ACTIONS_CACHE_URL', process.env['ACTIONS_CACHE_URL']) core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']) - name: Authenticate Pulumi - uses: pulumi/auth-actions@v1 + uses: pulumi/auth-actions@v2 with: organization: meiermade requested-token-type: urn:pulumi:token-type:access_token:personal scope: user:meiermade - name: Update - uses: pulumi/actions@v6 + uses: pulumi/actions@v7 with: work-dir: ./pulumi command: up stack-name: prod + + e2e: + name: E2E + needs: update + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 24.x + cache: npm + cache-dependency-path: e2e/package-lock.json + - name: Install E2E packages + working-directory: ./e2e + run: npm ci + - name: Install Firefox + working-directory: ./e2e + run: npx playwright install --with-deps firefox + - name: Wait for deployed site + run: | + for attempt in $(seq 1 60); do + if curl --fail --silent --show-error https://andymeier.dev/health >/dev/null; then + exit 0 + fi + sleep 5 + done + echo "Andy Meier site did not become ready." >&2 + exit 1 + - name: Run E2E against deployed site + working-directory: ./e2e + run: npx playwright test --project=firefox --retries=0 + env: + E2E_START_LOCAL: '0' + SITE_E2E_BASE_URL: https://andymeier.dev + - name: Upload E2E diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: andymeier-deploy-e2e + path: | + e2e/playwright-report + e2e/test-results + if-no-files-found: ignore diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index cecdf7a..edbf5e1 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -10,9 +10,9 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: 10.x - name: Restore Tools & Dependencies @@ -23,6 +23,37 @@ jobs: - name: Run Tests working-directory: ./app run: dotnet run --project src/Tests/Tests.fsproj + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 24.x + cache: npm + cache-dependency-path: e2e/package-lock.json + - name: Install E2E packages + working-directory: ./e2e + run: npm ci + - name: Install Firefox + working-directory: ./e2e + run: npx playwright install --with-deps firefox + - name: Install Tailwind CSS + run: | + curl --fail --location --silent --show-error \ + https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.2/tailwindcss-linux-x64 \ + --output /tmp/tailwindcss + sudo install /tmp/tailwindcss /usr/local/bin/tailwindcss + - name: Run local E2E + working-directory: ./e2e + run: npx playwright test --project=firefox + - name: Upload E2E diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: andymeier-preview-e2e + path: | + e2e/playwright-report + e2e/test-results + /tmp/andymeier-e2e + if-no-files-found: ignore preview: name: Preview needs: test @@ -32,13 +63,13 @@ jobs: id-token: write pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@v3 with: install_components: gke-gcloud-auth-plugin - name: Install Packages @@ -48,23 +79,23 @@ jobs: working-directory: ./pulumi run: npm run check - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 with: version: latest - name: Expose GitHub Actions cache env - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | core.exportVariable('ACTIONS_CACHE_URL', process.env['ACTIONS_CACHE_URL']) core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']) - name: Authenticate Pulumi - uses: pulumi/auth-actions@v1 + uses: pulumi/auth-actions@v2 with: organization: meiermade requested-token-type: urn:pulumi:token-type:access_token:personal scope: user:meiermade - name: Preview - uses: pulumi/actions@v6 + uses: pulumi/actions@v7 with: work-dir: ./pulumi command: preview diff --git a/.gitignore b/.gitignore index 2ac5123..bb13511 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ node_modules .pi/ app/.data/ +e2e/playwright-report/ +e2e/test-results/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..305ea53 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "andymeier-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "andymeier-e2e", + "devDependencies": { + "@playwright/test": "1.61.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..7c57fb1 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "andymeier-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.61.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..59f9b61 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const startLocal = process.env.E2E_START_LOCAL !== '0' +const port = process.env.E2E_SERVER_PORT ?? '5051' +const baseURL = process.env.SITE_E2E_BASE_URL ?? (startLocal ? `http://127.0.0.1:${port}` : 'https://andymeier.dev') + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + trace: 'retain-on-failure', + video: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'firefox', use: { ...devices['Desktop Firefox'] } }], + webServer: startLocal + ? { + command: 'bash scripts/start-local.sh', + cwd: __dirname, + url: `${baseURL}/health`, + timeout: process.env.CI ? 300_000 : 120_000, + reuseExistingServer: process.env.E2E_REUSE_EXISTING_SERVER === '1', + } + : undefined, +}) diff --git a/e2e/scripts/start-local.sh b/e2e/scripts/start-local.sh new file mode 100644 index 0000000..1ffb14c --- /dev/null +++ b/e2e/scripts/start-local.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +e2e_dir="$(cd "$script_dir/.." && pwd)" +repo_dir="$(cd "$e2e_dir/.." && pwd)" +app_dir="$repo_dir/app" +server_port="${E2E_SERVER_PORT:-5051}" +mock_port="${E2E_MOCK_NOTION_PORT:-5052}" +log_dir="${TMPDIR:-/tmp}/andymeier-e2e" +mkdir -p "$log_dir" +mock_pid="" +app_pid="" + +cleanup() { + for pid in "$app_pid" "$mock_pid"; do + if [[ -n "$pid" ]]; then kill "$pid" 2>/dev/null || true; fi + done + for pid in "$app_pid" "$mock_pid"; do + if [[ -n "$pid" ]]; then wait "$pid" 2>/dev/null || true; fi + done +} +trap cleanup EXIT INT TERM + +cd "$app_dir" + +if [[ "${E2E_SKIP_DOTNET_BUILD:-0}" != "1" ]]; then + dotnet build src/App/App.fsproj --nologo --verbosity minimal + dotnet build src/MockNotion/MockNotion.fsproj --nologo --verbosity minimal +fi + +./fake.sh BuildCss + +MOCK_NOTION_URL="http://127.0.0.1:${mock_port}" \ + dotnet run --no-build --project src/MockNotion/MockNotion.fsproj > "$log_dir/mock-notion.log" 2>&1 & +mock_pid=$! + +for _ in $(seq 1 60); do + if curl --fail --silent "http://127.0.0.1:${mock_port}/healthz" >/dev/null; then break; fi + sleep 1 +done + +if ! curl --fail --silent "http://127.0.0.1:${mock_port}/healthz" >/dev/null; then + cat "$log_dir/mock-notion.log" >&2 || true + exit 1 +fi + +sqlite_path="$log_dir/articles-${server_port}.db" +rm -f "$sqlite_path" +ASPNETCORE_ENVIRONMENT=Development \ +GOOGLE_ANALYTICS_MEASUREMENT_ID=G-LOCAL \ +NOTION_API_KEY=mock-notion-token \ +NOTION_ARTICLES_DATABASE_ID=mock-articles \ +NOTION_BASE_URL="http://127.0.0.1:${mock_port}/v1" \ +SEQ_ENDPOINT="http://127.0.0.1:5341" \ +SERVER_URL="http://127.0.0.1:${server_port}" \ +SQLITE_PATH="$sqlite_path" \ + dotnet run --no-build --project src/App/App.fsproj > "$log_dir/app.log" 2>&1 & +app_pid=$! + +for _ in $(seq 1 60); do + if curl --fail --silent "http://127.0.0.1:${server_port}/health" >/dev/null; then break; fi + if ! kill -0 "$app_pid" 2>/dev/null; then + cat "$log_dir/app.log" >&2 || true + exit 1 + fi + sleep 1 +done + +for _ in $(seq 1 60); do + if curl --fail --silent "http://127.0.0.1:${server_port}/articles" | grep -q 'Mock engineering notes'; then break; fi + sleep 1 +done + +if ! curl --fail --silent "http://127.0.0.1:${server_port}/articles" | grep -q 'Mock engineering notes'; then + cat "$log_dir/app.log" >&2 || true + exit 1 +fi + +wait "$app_pid" diff --git a/e2e/tests/routes.spec.ts b/e2e/tests/routes.spec.ts new file mode 100644 index 0000000..a319d50 --- /dev/null +++ b/e2e/tests/routes.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test' + +const isRemote = process.env.E2E_START_LOCAL === '0' + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('analytics-consent', 'declined')) +}) + +test('homepage renders recent articles', async ({ page }) => { + const response = await page.goto('/', { waitUntil: 'domcontentloaded' }) + + expect(response?.status()).toBe(200) + await expect(page.getByRole('heading', { name: 'Andy Meier', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Recent articles', exact: true })).toBeVisible() +}) + +test('articles index renders and opens a published article when available', async ({ page }) => { + const response = await page.goto('/articles', { waitUntil: 'domcontentloaded' }) + + expect(response?.status()).toBe(200) + await expect(page.getByRole('heading', { name: 'Articles', exact: true })).toBeVisible() + + const articleLinks = page.locator('#page a[href^="/articles/"]') + if (await articleLinks.count()) { + const href = await articleLinks.first().getAttribute('href') + expect(href).toBeTruthy() + const articleResponse = await page.goto(href!, { waitUntil: 'domcontentloaded' }) + expect(articleResponse?.status()).toBe(200) + await expect(page.locator('article')).toBeVisible() + } else { + await expect(page.getByRole('heading', { name: 'No articles found', exact: true })).toBeVisible() + } +}) + +test('local article search and detail routes use MockNotion fixtures', async ({ page }) => { + test.skip(isRemote, 'Published production content is not a deterministic fixture.') + + await page.goto('/articles?search=engineering', { waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: 'Mock engineering notes', exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: 'Clear search' })).toBeVisible() + + const response = await page.goto('/articles/mock-finance-systems', { waitUntil: 'domcontentloaded' }) + expect(response?.status()).toBe(200) + await expect(page.getByRole('heading', { name: 'Mock finance systems', exact: true }).first()).toBeVisible() + await expect(page.getByText('Finance', { exact: true }).first()).toBeVisible() +}) + +test('legacy company paths permanently redirect to Meier Made', async ({ request }) => { + for (const path of ['/services', '/projects']) { + const response = await request.get(path, { maxRedirects: 0 }) + expect([301, 308], `${path} permanent redirect status`).toContain(response.status()) + expect(response.headers().location).toBe(`https://meiermade.com${path}`) + } +}) + +test('articles remain usable at a mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/articles', { waitUntil: 'domcontentloaded' }) + + await expect(page.getByRole('heading', { name: 'Articles', exact: true })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) +}) diff --git a/pulumi/src/cloudflare/record.ts b/pulumi/src/cloudflare/record.ts index b2d62f0..27add44 100644 --- a/pulumi/src/cloudflare/record.ts +++ b/pulumi/src/cloudflare/record.ts @@ -1,7 +1,7 @@ import * as cloudflare from '@pulumi/cloudflare' import { tunnelHostname } from './tunnel' import { provider } from './provider' -import { andymeierZone, andrewmeierZone, meiermadeZone } from './zone' +import { andymeierZone, andrewmeierZone } from './zone' import * as config from '../config' export const andymeier = new cloudflare.DnsRecord(config.identifier, { @@ -33,21 +33,3 @@ new cloudflare.DnsRecord(`${config.identifier}-andrewmeier-www`, { proxied: true, ttl: 1 }, { provider }) - -new cloudflare.DnsRecord(`${config.identifier}-meiermade-root`, { - name: '@', - zoneId: meiermadeZone.id, - type: 'A', - content: '192.0.2.1', - proxied: true, - ttl: 1 -}, { provider }) - -new cloudflare.DnsRecord(`${config.identifier}-meiermade-www`, { - name: 'www', - zoneId: meiermadeZone.id, - type: 'A', - content: '192.0.2.1', - proxied: true, - ttl: 1 -}, { provider }) diff --git a/pulumi/src/cloudflare/redirect.ts b/pulumi/src/cloudflare/redirect.ts index 62e55a3..6b33646 100644 --- a/pulumi/src/cloudflare/redirect.ts +++ b/pulumi/src/cloudflare/redirect.ts @@ -1,6 +1,6 @@ import * as cloudflare from '@pulumi/cloudflare' import { provider } from './provider' -import { andrewmeierZone, meiermadeZone } from './zone' +import { andrewmeierZone } from './zone' import * as config from '../config' // andrewmeier.dev -> andymeier.dev @@ -26,27 +26,3 @@ new cloudflare.Ruleset(`${config.identifier}-andrewmeier-redirect`, { } }] }, { provider }) - -// meiermade.com -> andymeier.dev/services -new cloudflare.Ruleset(`${config.identifier}-meiermade-redirect`, { - zoneId: meiermadeZone.id, - name: 'Redirect meiermade.com to andymeier.dev/services', - kind: 'zone', - phase: 'http_request_dynamic_redirect', - rules: [{ - ref: 'meiermade_to_andymeier_services', - description: 'Redirect meiermade.com and www.meiermade.com to services page', - enabled: true, - expression: '(http.host eq "meiermade.com") or (http.host eq "www.meiermade.com")', - action: 'redirect', - actionParameters: { - fromValue: { - statusCode: 301, - preserveQueryString: true, - targetUrl: { - value: 'https://andymeier.dev/services' - } - } - } - }] -}, { provider }) diff --git a/pulumi/src/cloudflare/zone.ts b/pulumi/src/cloudflare/zone.ts index 77fb789..00dcb60 100644 --- a/pulumi/src/cloudflare/zone.ts +++ b/pulumi/src/cloudflare/zone.ts @@ -14,4 +14,3 @@ const getZone = (name: string) => export const andymeierZone = getZone('andymeier.dev') export const andrewmeierZone = getZone('andrewmeier.dev') -export const meiermadeZone = getZone('meiermade.com') From ebb73a473360310d3f2d5d22e020bccc844d9f4f Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Fri, 31 Jul 2026 15:36:35 -0400 Subject: [PATCH 3/9] Improve article filter accessibility --- app/src/App/src/Articles/View.fs | 61 +++++++++----------------------- app/src/Tests/ViewTests.fs | 21 +++++++++-- e2e/tests/routes.spec.ts | 33 +++++++++++++++++ 3 files changed, 69 insertions(+), 46 deletions(-) diff --git a/app/src/App/src/Articles/View.fs b/app/src/App/src/Articles/View.fs index 1ea7adc..b1c4055 100644 --- a/app/src/App/src/Articles/View.fs +++ b/app/src/App/src/Articles/View.fs @@ -6,6 +6,7 @@ open App.Common.View open System open type Datastar open type Html +open type Tailwind type FilterState = { search: string option @@ -34,9 +35,8 @@ type ArticlesPageState = years: int list } module FilterControl = - let private json (value:string) = - System.Text.Json.JsonSerializer.Serialize value - |> fun serialized -> serialized.Replace("\"", "'") + let private attributeValue (value:string) = + System.Net.WebUtility.HtmlEncode value let select (name:string) (options:(string * string) list) (selected:string) (ariaLabel:string) (onChange:string) (buttonClass:string) = let selectedValue = @@ -50,62 +50,35 @@ module FilterControl = |> List.tryFind (fun (value, _) -> value = selectedValue) |> Option.map snd |> Option.defaultValue "" - let prefix = "article_filter_" + Guid.NewGuid().ToString("N") - let valueSignal = prefix + "Value" - let labelSignal = prefix + "Label" - let openSignal = prefix + "Open" - let buttonId = prefix + "Button" - let optionsId = prefix + "Options" - div { + elSelect { + _name name + _value (attributeValue selectedValue) + _dataOn ("change", onChange) _class "relative" - _data ("select-root", "") - _data ("signals", $"{{ {valueSignal}: {json selectedValue}, {labelSignal}: {json selectedLabel}, {openSignal}: false }}") - _data ("on:keydown__window", $"evt.key == 'Escape' && (${openSignal} = false)") - input { _type "hidden"; _name name; _value selectedValue } button { - _id buttonId _type "button" - _role "combobox" - _ariaHaspopup "listbox" _ariaLabel ariaLabel - _attr ("aria-controls", optionsId) - _data ("select-button", "") - _data ("attr:aria-expanded", $"${openSignal} ? 'true' : 'false'") - _data ("on:click__stop", $"${openSignal} = !${openSignal}") _class buttonClass - span { _class "block min-w-0 truncate"; _data ("text", $"${labelSignal}"); selectedLabel } + span { _class "block min-w-0 truncate"; selectedLabel } span { _class "pointer-events-none ml-2 flex size-5 shrink-0 items-center justify-center text-gray-500 dark:text-gray-400" _ariaHidden "true" raw """""" } } - div { - _id optionsId - _role "listbox" - _attr ("aria-labelledby", buttonId) - _data ("select-options", "") - _data ("show", $"${openSignal}") - _data ("on:click__outside", $"${openSignal} = false") - _style "display:none" - _class "absolute right-0 top-full z-40 mt-1 min-w-full overflow-hidden rounded-lg border border-gray-300 bg-white py-1 shadow-xl dark:border-gray-600 dark:bg-gray-800" - for index, (value, label) in options |> List.indexed do - button { - _id $"{prefix}Option{index}" - _type "button" - _role "option" - _tabindex -1 - _data ("select-option", "") - _data ("attr:aria-selected", $"${valueSignal} == {json value} ? 'true' : 'false'") - _data ("on:click", $"${valueSignal} = {json value}; ${labelSignal} = {json label}; el.closest('[data-select-root]').querySelector('input[type=hidden]').value = {json value}; ${openSignal} = false; {onChange}") - _data ("class", $"{{ 'bg-gray-100 font-semibold dark:bg-gray-700': ${valueSignal} == {json value} }}") - _class "flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm text-gray-800 transition hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:text-gray-100 dark:hover:bg-gray-700" + elOptions { + _popover + _anchor "bottom end" + _class "z-40 max-h-64 min-w-(--button-width) overflow-auto rounded-lg border border-gray-300 bg-white py-1 shadow-xl [--anchor-gap:4px] dark:border-gray-600 dark:bg-gray-800" + for value, label in options do + elOption { + _value (attributeValue value) + _class "group flex w-full cursor-default items-center justify-between gap-3 px-3 py-2 text-left text-sm text-gray-800 transition hover:bg-gray-100 focus:bg-gray-100 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 aria-selected:bg-gray-100 aria-selected:font-semibold dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:bg-gray-700 dark:focus-visible:outline-emerald-400 dark:aria-selected:bg-gray-700" span { _class "truncate"; label } span { - _class "text-emerald-600 dark:text-emerald-400" + _class "invisible text-emerald-600 group-aria-selected:visible dark:text-emerald-400" _ariaHidden "true" - _data ("show", $"${valueSignal} == {json value}") "✓" } } diff --git a/app/src/Tests/ViewTests.fs b/app/src/Tests/ViewTests.fs index 85fc48c..a93dd2a 100644 --- a/app/src/Tests/ViewTests.fs +++ b/app/src/Tests/ViewTests.fs @@ -42,7 +42,7 @@ let tests = ] testList "Articles" [ - test "uses custom listbox controls for article filters" { + test "uses accessible custom listbox controls for article filters" { let page = App.Articles.View.articlesPage { articles = [] @@ -53,10 +53,27 @@ let tests = let html = Render.toHtmlDocString page - Expect.stringContains html "data-select-root" "Expected custom filter select component" + Expect.stringContains html " { + test.skip(isRemote, 'Published production filters are not deterministic fixtures.') + + for (const filter of [ + { name: 'tag', ariaLabel: 'Tag filter', value: 'Engineering' }, + { name: 'year', ariaLabel: 'Published year filter', value: '2026' }, + ]) { + await page.goto('/articles', { waitUntil: 'domcontentloaded' }) + await page.getByText('+ Add filter', { exact: true }).click() + + const select = page.locator(`el-select[name="${filter.name}"]`) + const button = select.getByRole('button', { name: filter.ariaLabel }) + const options = select.locator('el-options') + const selectedOption = select.locator(`el-option[value="${filter.value}"]`) + + await button.focus() + await button.press('ArrowDown') + await expect(options).toBeVisible() + await expect(select.locator('el-option').first()).toBeFocused() + + await page.keyboard.press('Escape') + await expect(options).toBeHidden() + await expect(button).toBeFocused() + + await button.press('ArrowDown') + await page.keyboard.press('ArrowDown') + await expect(selectedOption).toBeFocused() + await page.keyboard.press('Enter') + + await expect(page).toHaveURL(new RegExp(`[?&]${filter.name}=${filter.value}(?:&|$)`)) + } +}) + test('legacy company paths permanently redirect to Meier Made', async ({ request }) => { for (const path of ['/services', '/projects']) { const response = await request.get(path, { maxRedirects: 0 }) From 89fb81d017be64db21c4f893b91cb9c4a5c7c493 Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Fri, 31 Jul 2026 15:59:38 -0400 Subject: [PATCH 4/9] Replace Tailwind Elements with Datastar controls --- app/src/App/src/Articles/View.fs | 60 +-- app/src/App/src/Common/View.fs | 351 +++++++++++++++--- .../scripts/tailwindplus-elements.1.js | 9 - app/src/Tests/ViewTests.fs | 32 +- e2e/tests/routes.spec.ts | 41 +- 5 files changed, 360 insertions(+), 133 deletions(-) delete mode 100644 app/src/App/wwwroot/scripts/tailwindplus-elements.1.js diff --git a/app/src/App/src/Articles/View.fs b/app/src/App/src/Articles/View.fs index b1c4055..6a527ab 100644 --- a/app/src/App/src/Articles/View.fs +++ b/app/src/App/src/Articles/View.fs @@ -6,7 +6,6 @@ open App.Common.View open System open type Datastar open type Html -open type Tailwind type FilterState = { search: string option @@ -34,57 +33,6 @@ type ArticlesPageState = tags: string list years: int list } -module FilterControl = - let private attributeValue (value:string) = - System.Net.WebUtility.HtmlEncode value - - let select (name:string) (options:(string * string) list) (selected:string) (ariaLabel:string) (onChange:string) (buttonClass:string) = - let selectedValue = - options - |> List.tryFind (fun (value, _) -> value = selected) - |> Option.orElse (options |> List.tryHead) - |> Option.map fst - |> Option.defaultValue "" - let selectedLabel = - options - |> List.tryFind (fun (value, _) -> value = selectedValue) - |> Option.map snd - |> Option.defaultValue "" - - elSelect { - _name name - _value (attributeValue selectedValue) - _dataOn ("change", onChange) - _class "relative" - button { - _type "button" - _ariaLabel ariaLabel - _class buttonClass - span { _class "block min-w-0 truncate"; selectedLabel } - span { - _class "pointer-events-none ml-2 flex size-5 shrink-0 items-center justify-center text-gray-500 dark:text-gray-400" - _ariaHidden "true" - raw """""" - } - } - elOptions { - _popover - _anchor "bottom end" - _class "z-40 max-h-64 min-w-(--button-width) overflow-auto rounded-lg border border-gray-300 bg-white py-1 shadow-xl [--anchor-gap:4px] dark:border-gray-600 dark:bg-gray-800" - for value, label in options do - elOption { - _value (attributeValue value) - _class "group flex w-full cursor-default items-center justify-between gap-3 px-3 py-2 text-left text-sm text-gray-800 transition hover:bg-gray-100 focus:bg-gray-100 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 aria-selected:bg-gray-100 aria-selected:font-semibold dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:bg-gray-700 dark:focus-visible:outline-emerald-400 dark:aria-selected:bg-gray-700" - span { _class "truncate"; label } - span { - _class "invisible text-emerald-600 group-aria-selected:visible dark:text-emerald-400" - _ariaHidden "true" - "✓" - } - } - } - } - let articlesPage (state:ArticlesPageState) = let filters = state.filters let hasSearch = filters.search.IsSome @@ -152,7 +100,7 @@ let articlesPage (state:ArticlesPageState) = label { _class "grid gap-1 text-sm" span { _class "font-medium text-gray-900 dark:text-gray-100"; "Tag" } - FilterControl.select + FormControl.select "tag" (("", "Select tag") :: (state.tags |> List.map (fun tag -> tag, tag))) "" @@ -164,7 +112,7 @@ let articlesPage (state:ArticlesPageState) = label { _class "grid gap-1 text-sm" span { _class "font-medium text-gray-900 dark:text-gray-100"; "Published" } - FilterControl.select + FormControl.select "year" (("", "Select year") :: (state.years |> List.map (fun year -> string year, string year))) "" @@ -184,7 +132,7 @@ let articlesPage (state:ArticlesPageState) = div { _class "inline-flex items-center rounded-md border border-gray-300 bg-gray-50 text-sm dark:border-gray-600 dark:bg-gray-800" span { _class "border-r border-gray-300 px-2 py-1.5 font-medium text-gray-500 dark:border-gray-600 dark:text-gray-400"; "Tag" } - FilterControl.select + FormControl.select "tag" (state.tags |> List.map (fun tagOption -> tagOption, tagOption)) tag @@ -204,7 +152,7 @@ let articlesPage (state:ArticlesPageState) = div { _class "inline-flex items-center rounded-md border border-gray-300 bg-gray-50 text-sm dark:border-gray-600 dark:bg-gray-800" span { _class "border-r border-gray-300 px-2 py-1.5 font-medium text-gray-500 dark:border-gray-600 dark:text-gray-400"; "Published" } - FilterControl.select + FormControl.select "year" (state.years |> List.map (fun yearOption -> string yearOption, string yearOption)) (string year) diff --git a/app/src/App/src/Common/View.fs b/app/src/App/src/Common/View.fs index 2ab5c5e..df14e64 100644 --- a/app/src/App/src/Common/View.fs +++ b/app/src/App/src/Common/View.fs @@ -7,7 +7,6 @@ open System.IO open System.Text.Json open type Html open type Datastar -open type Tailwind module Asset = let resolveWithManifest (manifest:IReadOnlyDictionary) (path:string) = @@ -93,6 +92,222 @@ module MiniIcon = let close = raw """""" +module FormControl = + let private attributeValue (value:string) = + System.Net.WebUtility.HtmlEncode value + + let select (name:string) (options:(string * string) list) (selected:string) (ariaLabel:string) (onChange:string) (buttonClass:string) = + let selectedValue = + options + |> List.tryFind (fun (value, _) -> value = selected) + |> Option.orElse (options |> List.tryHead) + |> Option.map fst + |> Option.defaultValue "" + let selectedLabel = + options + |> List.tryFind (fun (value, _) -> value = selectedValue) + |> Option.map snd + |> Option.defaultValue "" + let prefix = "select_" + System.Guid.NewGuid().ToString("N") + let openSignal = prefix + "Open" + let buttonId = prefix + "Button" + let optionsId = prefix + "Options" + + div { + _class "relative" + _data ("select-root", "") + _data ("signals", $"{{ {openSignal}: false }}") + input { + _type "hidden" + _name name + _value (attributeValue selectedValue) + } + button { + _id buttonId + _type "button" + _role "combobox" + _ariaHaspopup "listbox" + _attr ("aria-controls", optionsId) + _ariaLabel ariaLabel + _data ("select-button", "") + _data ("attr:aria-expanded", $"${openSignal} ? 'true' : 'false'") + _data ("on:click__stop", $"${openSignal} = !${openSignal}") + _class buttonClass + span { _class "block min-w-0 truncate"; selectedLabel } + span { + _class "pointer-events-none ml-2 flex size-5 shrink-0 items-center justify-center text-gray-500 dark:text-gray-400" + _ariaHidden "true" + raw """""" + } + } + div { + _id optionsId + _role "listbox" + _attr ("aria-labelledby", buttonId) + _data ("select-options", "") + _data ("show", $"${openSignal}") + _data ("on:click__outside", $"${openSignal} = false") + _style "display:none" + _class "absolute right-0 top-full z-40 mt-1 max-h-64 min-w-full overflow-auto rounded-lg border border-gray-300 bg-white py-1 shadow-xl dark:border-gray-600 dark:bg-gray-800" + for value, label in options do + let isSelected = value = selectedValue + button { + _type "button" + _role "option" + _tabindex -1 + _value (attributeValue value) + _attr ("aria-selected", if isSelected then "true" else "false") + _data ("select-option", "") + _data ("on:click", $"el.closest('[data-select-root]').querySelector('input[type=hidden]').value = el.value; ${openSignal} = false; {onChange}") + _class "group flex w-full cursor-default items-center justify-between gap-3 px-3 py-2 text-left text-sm text-gray-800 transition hover:bg-gray-100 focus:bg-gray-100 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 aria-selected:bg-gray-100 aria-selected:font-semibold dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:bg-gray-700 dark:focus-visible:outline-emerald-400 dark:aria-selected:bg-gray-700" + span { _class "truncate"; label } + span { + _class "invisible text-emerald-600 group-aria-selected:visible dark:text-emerald-400" + _ariaHidden "true" + "✓" + } + } + } + } + +module Interaction = + let script = + script { + raw """ + (() => { + const itemsFor = (root, selector) => [...root.querySelectorAll(selector)]; + const triggerFor = (root, selector) => root.querySelector(selector); + const focusAfterOpen = (root, selector, position, selectedSelector) => requestAnimationFrame(() => { + const items = itemsFor(root, selector); + if (items.length === 0) return; + const selected = selectedSelector ? items.findIndex(item => item.matches(selectedSelector)) : -1; + const index = position === 'first' ? 0 + : position === 'last' ? items.length - 1 + : selected >= 0 ? selected : 0; + items[index].focus(); + }); + const openAndFocus = (root, triggerSelector, itemSelector, position, selectedSelector) => { + const trigger = triggerFor(root, triggerSelector); + if (trigger?.getAttribute('aria-expanded') !== 'true') trigger?.click(); + focusAfterOpen(root, itemSelector, position, selectedSelector); + }; + const closeAndFocus = (root, triggerSelector) => { + const trigger = triggerFor(root, triggerSelector); + if (trigger?.getAttribute('aria-expanded') === 'true') trigger.click(); + trigger?.focus(); + }; + const moveFocus = (items, current, key) => { + if (items.length === 0) return; + const index = key === 'Home' ? 0 + : key === 'End' ? items.length - 1 + : Math.max(0, Math.min(items.length - 1, current + (key === 'ArrowDown' ? 1 : -1))); + items[index]?.focus(); + }; + const focusByTypeahead = (items, current, event) => { + if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return; + const query = event.key.toLocaleLowerCase(); + const match = items + .slice(current + 1) + .concat(items.slice(0, current + 1)) + .find(item => item.textContent.trim().toLocaleLowerCase().startsWith(query)); + if (match) { + event.preventDefault(); + match.focus(); + } + }; + + document.addEventListener('keydown', event => { + if (!(event.target instanceof Element)) return; + + const selectRoot = event.target.closest('[data-select-root]'); + if (selectRoot) { + const trigger = event.target.closest('[data-select-button]'); + const option = event.target.closest('[data-select-option]'); + const options = itemsFor(selectRoot, '[data-select-option]'); + if (trigger && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) { + event.preventDefault(); + openAndFocus(selectRoot, '[data-select-button]', '[data-select-option]', 'selected', '[aria-selected="true"]'); + } else if (trigger && event.key === 'Home') { + event.preventDefault(); + openAndFocus(selectRoot, '[data-select-button]', '[data-select-option]', 'first'); + } else if (trigger && event.key === 'End') { + event.preventDefault(); + openAndFocus(selectRoot, '[data-select-button]', '[data-select-option]', 'last'); + } else if (trigger && event.key === 'Escape') { + event.preventDefault(); + closeAndFocus(selectRoot, '[data-select-button]'); + } else if (option) { + const current = options.indexOf(option); + if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + event.preventDefault(); + moveFocus(options, current, event.key); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + option.click(); + } else if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeAndFocus(selectRoot, '[data-select-button]'); + } else if (event.key === 'Tab') { + const trigger = triggerFor(selectRoot, '[data-select-button]'); + if (trigger?.getAttribute('aria-expanded') === 'true') trigger.click(); + } else { + focusByTypeahead(options, current, event); + } + } + return; + } + + const menuRoot = event.target.closest('[data-menu-root]'); + if (!menuRoot) return; + const trigger = event.target.closest('[data-menu-button]'); + const item = event.target.closest('[data-menu-item]'); + const items = itemsFor(menuRoot, '[data-menu-item]'); + if (trigger && ['ArrowDown', 'Enter', ' '].includes(event.key)) { + event.preventDefault(); + openAndFocus(menuRoot, '[data-menu-button]', '[data-menu-item]', 'first'); + } else if (trigger && event.key === 'ArrowUp') { + event.preventDefault(); + openAndFocus(menuRoot, '[data-menu-button]', '[data-menu-item]', 'last'); + } else if (trigger && event.key === 'Escape') { + event.preventDefault(); + closeAndFocus(menuRoot, '[data-menu-button]'); + } else if (item) { + const current = items.indexOf(item); + if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + event.preventDefault(); + moveFocus(items, current, event.key); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + item.click(); + } else if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeAndFocus(menuRoot, '[data-menu-button]'); + } else if (event.key === 'Tab') { + const menuTrigger = triggerFor(menuRoot, '[data-menu-button]'); + if (menuTrigger?.getAttribute('aria-expanded') === 'true') menuTrigger.click(); + } else { + focusByTypeahead(items, current, event); + } + } + }); + + document.addEventListener('click', event => { + if (!(event.target instanceof Element)) return; + for (const [itemSelector, rootSelector, triggerSelector] of [ + ['[data-select-option]', '[data-select-root]', '[data-select-button]'], + ['[data-menu-item]', '[data-menu-root]', '[data-menu-button]'], + ]) { + const item = event.target.closest(itemSelector); + const root = item?.closest(rootSelector); + if (root) requestAnimationFrame(() => triggerFor(root, triggerSelector)?.focus()); + } + }); + })(); + """ + } + module ArticleCard = let private tag (text:string) = span { @@ -176,16 +391,19 @@ module TopNav = } let private mobileItem (id:string, label:string, href:string) = - button { + a { _id $"{id}-mobile" - _type "button" - _class "block w-full text-left px-4 py-2 text-sm cursor-pointer hover:bg-gray-100 hover:text-emerald-600 dark:hover:bg-gray-700/50 dark:hover:text-emerald-400" + _href href + _role "menuitem" + _tabindex -1 + _data ("menu-item", "") + _class "block w-full cursor-pointer px-4 py-2 text-left text-sm transition hover:bg-gray-100 hover:text-emerald-600 focus:bg-gray-100 focus:text-emerald-600 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:hover:bg-gray-700/50 dark:hover:text-emerald-400 dark:focus:bg-gray-700/50 dark:focus:text-emerald-400 dark:focus-visible:outline-emerald-400" _dataClass ("text-emerald-600", $"$selectedNav == '{id}'") _dataClass ("dark:text-emerald-400", $"$selectedNav == '{id}'") _dataClass ("font-semibold", $"$selectedNav == '{id}'") _dataClass ("text-gray-700", $"$selectedNav != '{id}'") _dataClass ("dark:text-gray-300", $"$selectedNav != '{id}'") - _dataOn ("click", $"@get('{href}')") + _dataOn ("click__prevent", $"@get('{href}')") text label } @@ -196,83 +414,102 @@ module TopNav = text "Meier Made" } + let private themeItem (value:string) (label:string) (icon:HtmlElement) = + button { + _type "button" + _role "menuitemradio" + _tabindex -1 + _data ("menu-item", "") + _data ("attr:aria-checked", $"$theme == '{value}' ? 'true' : 'false'") + _class "flex w-full items-center gap-2 px-4 py-2 text-sm transition hover:bg-gray-100 focus:bg-gray-100 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:hover:bg-gray-700/50 dark:focus:bg-gray-700/50 dark:focus-visible:outline-emerald-400" + _dataClass ("text-emerald-600", $"$theme == '{value}'") + _dataClass ("dark:text-emerald-400", $"$theme == '{value}'") + _dataClass ("font-semibold", $"$theme == '{value}'") + _dataClass ("text-gray-700", $"$theme != '{value}'") + _dataClass ("dark:text-gray-300", $"$theme != '{value}'") + _dataOn ("click", $"$theme = '{value}'; $themeMenuOpen = false; setTheme('{value}')") + icon + text label + } + let private themeToggle = - elDropdown { + div { _class "relative inline-block text-left" + _data ("menu-root", "") + _data ("signals", "{ themeMenuOpen: false }") button { _id "theme-toggle" _type "button" + _ariaLabel "Choose theme" + _ariaHaspopup "menu" + _attr ("aria-controls", "theme-menu") + _data ("menu-button", "") + _data ("attr:aria-expanded", "$themeMenuOpen ? 'true' : 'false'") + _data ("on:click__stop", "$themeMenuOpen = !$themeMenuOpen") _class [ - "inline-flex w-full items-center justify-center rounded-md p-2 text-gray-600 hover:text-emerald-600 hover:bg-gray-100" - "dark:text-gray-400 dark:hover:text-emerald-400 dark:hover:bg-gray-800" + "inline-flex w-full items-center justify-center rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-emerald-600" + "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600" + "dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-emerald-400 dark:focus-visible:outline-emerald-400" "hover:cursor-pointer" ] MiniIcon.sun MiniIcon.moon } - elMenu { - _popover - _anchor "bottom end" - _class "mt-2 w-36 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10" - button { - _type "button" - _class "flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50" - _dataClass ("text-emerald-600", "$theme == 'light'") - _dataClass ("dark:text-emerald-400", "$theme == 'light'") - _dataClass ("font-semibold", "$theme == 'light'") - _dataClass ("text-gray-700", "$theme != 'light'") - _dataClass ("dark:text-gray-300", "$theme != 'light'") - _dataOn ("click", "$theme = 'light'; setTheme('light')") - MiniIcon.sunSmall - text "Light" - } - button { - _type "button" - _class "flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50" - _dataClass ("text-emerald-600", "$theme == 'dark'") - _dataClass ("dark:text-emerald-400", "$theme == 'dark'") - _dataClass ("font-semibold", "$theme == 'dark'") - _dataClass ("text-gray-700", "$theme != 'dark'") - _dataClass ("dark:text-gray-300", "$theme != 'dark'") - _dataOn ("click", "$theme = 'dark'; setTheme('dark')") - MiniIcon.moonSmall - text "Dark" - } - button { - _type "button" - _class "flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50" - _dataClass ("text-emerald-600", "$theme == 'system'") - _dataClass ("dark:text-emerald-400", "$theme == 'system'") - _dataClass ("font-semibold", "$theme == 'system'") - _dataClass ("text-gray-700", "$theme != 'system'") - _dataClass ("dark:text-gray-300", "$theme != 'system'") - _dataOn ("click", "$theme = 'system'; setTheme('system')") - MiniIcon.monitor - text "System" - } + div { + _id "theme-menu" + _role "menu" + _ariaLabel "Theme" + _data ("menu", "") + _data ("show", "$themeMenuOpen") + _data ("on:click__outside", "$themeMenuOpen = false") + _style "display:none" + _class "absolute right-0 top-full z-40 mt-2 w-36 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10" + themeItem "light" "Light" MiniIcon.sunSmall + themeItem "dark" "Dark" MiniIcon.moonSmall + themeItem "system" "System" MiniIcon.monitor } } let private mobileDropdown = - elDropdown { + div { _class "relative inline-block text-left md:hidden" + _data ("menu-root", "") + _data ("signals", "{ mobileMenuOpen: false }") button { _id "menu-toggle" _type "button" + _ariaLabel "Open navigation" + _ariaHaspopup "menu" + _attr ("aria-controls", "mobile-menu") + _data ("menu-button", "") + _data ("attr:aria-expanded", "$mobileMenuOpen ? 'true' : 'false'") + _data ("on:click__stop", "$mobileMenuOpen = !$mobileMenuOpen") _class [ - "inline-flex w-full items-center justify-center rounded-md p-2 text-gray-600 hover:text-emerald-600 hover:bg-gray-100" - "dark:text-gray-400 dark:hover:text-emerald-400 dark:hover:bg-gray-800" + "inline-flex w-full items-center justify-center rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-emerald-600" + "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600" + "dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-emerald-400 dark:focus-visible:outline-emerald-400" "hover:cursor-pointer" ] MiniIcon.hamburger } - elMenu { + div { _id "mobile-menu" - _popover - _anchor "bottom end" - _class "mt-2 w-56 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10" + _role "menu" + _ariaLabel "Navigation" + _data ("menu", "") + _data ("show", "$mobileMenuOpen") + _data ("on:click__outside", "$mobileMenuOpen = false") + _style "display:none" + _class "absolute right-0 top-full z-40 mt-2 w-56 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10" mobileItem("nav-articles", "Articles", "/articles") - companyLink "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-emerald-600 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-emerald-400" + a { + _href "https://meiermade.com" + _role "menuitem" + _tabindex -1 + _data ("menu-item", "") + _class "block w-full px-4 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-100 hover:text-emerald-600 focus:bg-gray-100 focus:text-emerald-600 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-emerald-600 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-emerald-400 dark:focus:bg-gray-700/50 dark:focus:text-emerald-400 dark:focus-visible:outline-emerald-400" + "Meier Made" + } } } @@ -315,7 +552,6 @@ type Document = } link { _href (Asset.fingerprinted "/css/compiled.css"); _rel "stylesheet" } link { _href (Asset.fingerprinted "/css/prism.css"); _rel "stylesheet" } - script { _type "module"; _src (Asset.fingerprinted "/scripts/tailwindplus-elements.1.js") } script { _type "module"; _src (Asset.fingerprinted "/scripts/datastar.1.0.0-RC.6.js") } } body { @@ -363,6 +599,7 @@ type Document = } } } + Interaction.script script { js "function getInitialTheme(){return localStorage.getItem('theme')||'system'};function applyTheme(t){var d=document.documentElement,isDark=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);d.classList.toggle('dark',isDark)};function setTheme(t){localStorage.setItem('theme',t);applyTheme(t)}" } } } diff --git a/app/src/App/wwwroot/scripts/tailwindplus-elements.1.js b/app/src/App/wwwroot/scripts/tailwindplus-elements.1.js deleted file mode 100644 index 77a9371..0000000 --- a/app/src/App/wwwroot/scripts/tailwindplus-elements.1.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Minified by jsDelivr using Terser v5.39.0. - * Original file: /npm/@tailwindplus/elements@1.0.22/dist/index.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -/*! @tailwindplus/elements v1.0.22 | Proprietary License | https://tailwindcss.com/plus/license */ -var Pn=Object.defineProperty,So=e=>{throw TypeError(e)},Cn=(e,t,n)=>t in e?Pn(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,M=(e,t,n)=>Cn(e,"symbol"!=typeof t?t+"":t,n),Ot=(e,t,n)=>t.has(e)||So("Cannot "+n),d=(e,t,n)=>(Ot(e,t,"read from private field"),n?n.call(e):t.get(e)),L=(e,t,n)=>t.has(e)?So("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),I=(e,t,n,o)=>(Ot(e,t,"write to private field"),o?o.call(e,n):t.set(e,n),n),H=(e,t,n)=>(Ot(e,t,"access private method"),n);if(typeof globalThis.window<"u"){let e=!1;document.addEventListener("submit",(t=>{if(e)return void(e=!1);let n=t.target;if(n&&"dialog"===n.method){let o=n.closest("el-dialog");if(!o||!("beforeClose"in o))return;let r=t.submitter?.value??"",i=o.beforeClose(r);if(!0===i||(t.preventDefault(),t.stopImmediatePropagation(),!1===i))return;i.then((o=>{o&&(e=!0,n.dispatchEvent(t))})).catch(console.error)}}),!0)}var at=class extends Event{constructor(e,{oldState:t="",newState:n="",...o}={}){super(e,o),M(this,"oldState"),M(this,"newState"),this.oldState=String(t||""),this.newState=String(n||"")}},Lo=new WeakMap;function Mo(e,t,n){Lo.set(e,setTimeout((()=>{Lo.has(e)&&e.dispatchEvent(new at("toggle",{cancelable:!1,oldState:t,newState:n}))}),0))}var Nt=globalThis.ShadowRoot||function(){},On=globalThis.HTMLDialogElement||function(){},rt=new WeakMap,X=new WeakMap,q=new WeakMap,xe=new WeakMap;function it(e){return xe.get(e)||"hidden"}var st=new WeakMap;function qe(e){return[...e].pop()}function Dn(e){let t=e.popoverTargetElement;if(!(t instanceof HTMLElement))return;let n=it(t);"show"===e.popoverTargetAction&&"showing"===n||"hide"===e.popoverTargetAction&&"hidden"===n||("showing"===n?Se(t,!0,!0):pe(t,!1)&&(st.set(t,e),Rt(t)))}function pe(e,t){return!("auto"!==e.popover&&"manual"!==e.popover&&"hint"!==e.popover||!e.isConnected||t&&"showing"!==it(e)||!t&&"hidden"!==it(e)||e instanceof On&&e.hasAttribute("open")||document.fullscreenElement===e)}function Io(e){if(!e)return 0;let t=X.get(document)||new Set,n=q.get(document)||new Set;return n.has(e)?[...n].indexOf(e)+t.size+1:t.has(e)?[...t].indexOf(e)+1:0}function Mn(e){let t=Ho(e),n=Hn(e);return Io(t)>Io(n)?t:n}function Ve(e){let t,n=q.get(e)||new Set,o=X.get(e)||new Set,r=n.size>0?n:o.size>0?o:null;return r?(t=qe(r),t.isConnected?t:(r.delete(t),Ve(e))):null}function ko(e){for(let t of e||[]){if(t.isConnected)return t;e.delete(t)}return null}function Te(e){return"function"==typeof e.getRootNode?e.getRootNode():e.parentNode?Te(e.parentNode):e}function Ho(e){for(;e;){if(e instanceof HTMLElement&&"auto"===e.popover&&"showing"===xe.get(e))return e;if((e=e instanceof Element&&e.assignedSlot||e.parentElement||Te(e))instanceof Nt&&(e=e.host),e instanceof Document)return}}function Hn(e){for(;e;){let t=e.popoverTargetElement;if(t instanceof HTMLElement)return t;if((e=e.parentElement||Te(e))instanceof Nt&&(e=e.host),e instanceof Document)return}}function Po(e,t){let n=new Map,o=0;for(let e of t||[])n.set(e,o),o+=1;n.set(e,o),o+=1;let r=null;return function(t){if(!t)return;let o=!1,i=null,a=null;for(;!o;){if(i=Ho(t)||null,null===i||!n.has(i))return;("hint"===e.popover||"auto"===i.popover)&&(o=!0),o||(t=i.parentElement)}a=n.get(i),(null===r||n.get(r)"u")return;function e(e){return e?.includes(":popover-open")&&(e=e.replace(qn,"$1.\\:popover-open")),e}window.ToggleEvent=window.ToggleEvent||at,de(Document.prototype,"querySelector",e),de(Document.prototype,"querySelectorAll",e),de(Element.prototype,"querySelector",e),de(Element.prototype,"querySelectorAll",e),de(Element.prototype,"matches",e),de(Element.prototype,"closest",e),de(DocumentFragment.prototype,"querySelectorAll",e),Object.defineProperties(HTMLElement.prototype,{popover:{enumerable:!0,configurable:!0,get(){if(!this.hasAttribute("popover"))return null;let e=(this.getAttribute("popover")||"").toLowerCase();return""===e||"auto"==e?"auto":"hint"==e?"hint":"manual"},set(e){null===e?this.removeAttribute("popover"):this.setAttribute("popover",e)}},showPopover:{enumerable:!0,configurable:!0,value(e={}){Rt(this)}},hidePopover:{enumerable:!0,configurable:!0,value(){Se(this,!0,!0)}},togglePopover:{enumerable:!0,configurable:!0,value(e={}){return"boolean"==typeof e&&(e={force:e}),"showing"===xe.get(this)&&void 0===e.force||!1===e.force?Se(this,!0,!0):(void 0===e.force||!0===e.force)&&Rt(this),"showing"===xe.get(this)}}});let t=Element.prototype.attachShadow;t&&Object.defineProperties(Element.prototype,{attachShadow:{enumerable:!0,configurable:!0,writable:!0,value(e){let n=t.call(this,e);return Ht(n),n}}});let n=HTMLElement.prototype.attachInternals;n&&Object.defineProperties(HTMLElement.prototype,{attachInternals:{enumerable:!0,configurable:!0,writable:!0,value(){let e=n.call(this);return e.shadowRoot&&Ht(e.shadowRoot),e}}});let o=new WeakMap;function r(e){Object.defineProperties(e.prototype,{popoverTargetElement:{enumerable:!0,configurable:!0,set(e){if(null===e)this.removeAttribute("popovertarget"),o.delete(this);else{if(!(e instanceof Element))throw new TypeError("popoverTargetElement must be an element or null");this.setAttribute("popovertarget",""),o.set(this,e)}},get(){if("button"!==this.localName&&"input"!==this.localName||"input"===this.localName&&"reset"!==this.type&&"image"!==this.type&&"button"!==this.type||this.disabled||this.form&&"submit"===this.type)return null;let e=o.get(this);if(e&&e.isConnected)return e;if(e&&!e.isConnected)return o.delete(this),null;let t=Te(this),n=this.getAttribute("popovertarget");return(t instanceof Document||t instanceof Do)&&n&&t.getElementById(n)||null}},popoverTargetAction:{enumerable:!0,configurable:!0,get(){let e=(this.getAttribute("popovertargetaction")||"").toLowerCase();return"show"===e||"hide"===e?e:"toggle"},set(e){this.setAttribute("popovertargetaction",e)}}})}r(HTMLButtonElement),r(HTMLInputElement);let i=e=>{if(e.defaultPrevented)return;let t=e.composedPath(),n=t[0];if(!(n instanceof Element)||n?.shadowRoot)return;let o=Te(n);if(!(o instanceof Do||o instanceof Document))return;let r=t.find((e=>{var t;return null==(t=e.matches)?void 0:t.call(e,"[popovertargetaction],[popovertarget]")}));return r?(Dn(r),void e.preventDefault()):void 0},a=e=>{let t=e.key,n=e.target;!e.defaultPrevented&&n&&("Escape"===t||"Esc"===t)&&me(n.ownerDocument,!0,!0)};var l;(l=document).addEventListener("click",i),l.addEventListener("keydown",a),l.addEventListener("pointerdown",Oo),l.addEventListener("pointerup",Oo),Ht(document)}function Fo(){return typeof HTMLButtonElement<"u"&&"command"in HTMLButtonElement.prototype&&"source"in((globalThis.CommandEvent||{}).prototype||{})}function qo(){function e(e,t,n=!0){Object.defineProperty(e,t,{...Object.getOwnPropertyDescriptor(e,t),enumerable:n})}function t(e){return e&&"function"==typeof e.getRootNode?e.getRootNode():e&&e.parentNode?t(e.parentNode):e}document.addEventListener("invoke",(e=>{"invoke"==e.type&&e.isTrusted&&(e.stopImmediatePropagation(),e.preventDefault())}),!0),document.addEventListener("command",(e=>{"command"==e.type&&e.isTrusted&&(e.stopImmediatePropagation(),e.preventDefault())}),!0);let n=new WeakMap,o=new WeakMap;class r extends Event{constructor(e,t={}){super(e,t);let{source:r,command:i}=t;if(null!=r&&!(r instanceof Element))throw new TypeError("source must be an element");n.set(this,r||null),o.set(this,void 0!==i?String(i):"")}get[Symbol.toStringTag](){return"CommandEvent"}get source(){if(!n.has(this))throw new TypeError("illegal invocation");let e=n.get(this);if(!(e instanceof Element))return null;let o=t(e);return o!==t(this.target||document)?o.host:e}get command(){if(!o.has(this))throw new TypeError("illegal invocation");return o.get(this)}get action(){throw new Error("CommandEvent#action was renamed to CommandEvent#command")}get invoker(){throw new Error("CommandEvent#invoker was renamed to CommandEvent#source")}}e(r.prototype,"source"),e(r.prototype,"command");class i extends Event{constructor(e,t={}){throw super(e,t),new Error("InvokeEvent has been deprecated, it has been renamed to `CommandEvent`")}}let a=new WeakMap;let l=new WeakMap;function s(e){for(let t of e)t.oncommand=new Function("event",t.getAttribute("oncommand"))}Object.defineProperties(HTMLElement.prototype,{oncommand:{enumerable:!0,configurable:!0,get(){return u.takeRecords(),l.get(this)||null},set(e){let t=l.get(this)||null;t&&this.removeEventListener("command",t),l.set(this,"object"==typeof e||"function"==typeof e?e:null),"function"==typeof e&&this.addEventListener("command",e)}}});let u=new MutationObserver((e=>{for(let t of e){let{target:e}=t;"childList"===t.type?s(e.querySelectorAll("[oncommand]")):s([e])}}));function c(e){if(e.defaultPrevented||"click"!==e.type)return;let t=e.target.closest("button[invoketarget], button[invokeaction], input[invoketarget], input[invokeaction]");if(t&&(console.warn("Elements with `invoketarget` or `invokeaction` are deprecated and should be renamed to use `commandfor` and `command` respectively"),t.matches("input")))throw new Error("Input elements no longer support `commandfor`");let n=e.target.closest("button[commandfor], button[command]");if(!n)return;if(n.form&&"button"!==n.getAttribute("type"))throw e.preventDefault(),new Error("Element with `commandFor` is a form participant. It should explicitly set `type=button` in order for `commandFor` to work. In order for it to act as a Submit button, it must not have command or commandfor attributes");if(n.hasAttribute("command")!==n.hasAttribute("commandfor")){let e=n.hasAttribute("command")?"command":"commandfor",t=n.hasAttribute("command")?"commandfor":"command";throw new Error(`Element with ${e} attribute must also have a ${t} attribute to function.`)}if("show-popover"!==n.command&&"hide-popover"!==n.command&&"toggle-popover"!==n.command&&"show-modal"!==n.command&&"close"!==n.command&&!n.command.startsWith("--"))return void console.warn(`"${n.command}" is not a valid command value. Custom commands must begin with --`);let o=n.commandForElement;if(!o)return;let i=new r("command",{command:n.command,source:n,cancelable:!0});if(o.dispatchEvent(i),i.defaultPrevented)return;let a=i.command.toLowerCase();if(o.popover){let e=!o.matches(":popover-open");!e||"toggle-popover"!==a&&"show-popover"!==a?!e&&"hide-popover"===a&&o.hidePopover():o.showPopover({source:n})}else if("dialog"===o.localName){let e=!o.hasAttribute("open");e&&"show-modal"===a?o.showModal():!e&&"close"===a&&o.close()}}function d(e){e.addEventListener("click",c,!0)}var h;u.observe(document,{subtree:!0,childList:!0,attributeFilter:["oncommand"]}),s(document.querySelectorAll("[oncommand]")),h=HTMLButtonElement,Object.defineProperties(h.prototype,{commandForElement:{enumerable:!0,configurable:!0,set(e){if(this.hasAttribute("invokeaction"))throw new TypeError("Element has deprecated `invokeaction` attribute, replace with `command`");if(this.hasAttribute("invoketarget"))throw new TypeError("Element has deprecated `invoketarget` attribute, replace with `commandfor`");if(null===e)this.removeAttribute("commandfor"),a.delete(this);else{if(!(e instanceof Element))throw new TypeError("commandForElement must be an element or null");{this.setAttribute("commandfor","");let n=t(e);t(this)===n||n===this.ownerDocument?a.set(this,e):a.delete(this)}}},get(){if("button"!==this.localName)return null;if(this.hasAttribute("invokeaction")||this.hasAttribute("invoketarget"))return console.warn("Element has deprecated `invoketarget` or `invokeaction` attribute, use `commandfor` and `command` instead"),null;if(this.disabled)return null;if(this.form&&"button"!==this.getAttribute("type"))return console.warn("Element with `commandFor` is a form participant. It should explicitly set `type=button` in order for `commandFor` to work"),null;let e=a.get(this);if(e)return e.isConnected?e:(a.delete(this),null);let n=t(this),o=this.getAttribute("commandfor");return(n instanceof Document||n instanceof ShadowRoot)&&o&&n.getElementById(o)||null}},command:{enumerable:!0,configurable:!0,get(){let e=this.getAttribute("command")||"";if(e.startsWith("--"))return e;let t=e.toLowerCase();switch(t){case"show-modal":case"close":case"toggle-popover":case"hide-popover":case"show-popover":return t}return""},set(e){this.setAttribute("command",e)}},invokeAction:{enumerable:!1,configurable:!0,get(){throw new Error("invokeAction is deprecated. It has been renamed to command")},set(e){throw new Error("invokeAction is deprecated. It has been renamed to command")}},invokeTargetElement:{enumerable:!1,configurable:!0,get(){throw new Error("invokeTargetElement is deprecated. It has been renamed to command")},set(e){throw new Error("invokeTargetElement is deprecated. It has been renamed to command")}}}),function(e,t){let n=e.prototype.attachShadow;e.prototype.attachShadow=function(e){let o=n.call(this,e);return t(o),o};let o=e.prototype.attachInternals;e.prototype.attachInternals=function(){let e=o.call(this);return e.shadowRoot&&t(e.shadowRoot),e}}(HTMLElement,(e=>{d(e),u.observe(e,{attributeFilter:["oncommand"]}),s(e.querySelectorAll("[oncommand]"))})),d(document),Object.assign(globalThis,{CommandEvent:r,InvokeEvent:i})}function Vo(){if("function"!=typeof HTMLDialogElement)return!1;let e=!1,t=document.createElement("dialog");return t.addEventListener("beforetoggle",(t=>{e=!0,t.preventDefault()})),t.show(),e}function Wo(){let e=new WeakMap;function t(t){let n=t.open?"closed":"open",o=t.open?"open":"closed";if(e.has(t)){let n=e.get(t);o=n.oldState,clearTimeout(n.id)}e.set(t,{oldState:o,id:setTimeout((()=>{t.dispatchEvent(new ToggleEvent("toggle",{newState:n,oldState:o}))}))})}let n=HTMLDialogElement.prototype.show,o=HTMLDialogElement.prototype.showModal,r=HTMLDialogElement.prototype.close;function i(e){let n=new ToggleEvent("beforetoggle",{newState:"closed",oldState:"open",cancelable:!1});e.dispatchEvent(n),e.open&&t(e)}document.addEventListener("submit",(e=>{let t=e.target;if("dialog"===t.method){let e=t.closest("dialog");e instanceof HTMLDialogElement&&i(e)}}),!0),Object.defineProperties(HTMLDialogElement.prototype,{show:{value(){if(this.open||this.matches(":popover-open, :modal")||!this.ownerDocument)return n.apply(this,arguments);let e=new ToggleEvent("beforetoggle",{newState:"open",oldState:"closed",cancelable:!0});this.dispatchEvent(e)&&(t(this),n.apply(this,arguments))}},showModal:{value(){if(this.open||this.matches(":popover-open, :modal")||!this.isConnected||!this.ownerDocument)return o.apply(this,arguments);let e=new ToggleEvent("beforetoggle",{newState:"open",oldState:"closed",cancelable:!0});return this.dispatchEvent(e)?(t(this),o.apply(this,arguments)):void 0}},close:{value(){return this.open||this.matches(":popover-open, :modal")?(i(this),r.apply(this,arguments)):r.apply(this,arguments)}}})}function ut(e){function t(){"loading"!==document.readyState&&(e(),document.removeEventListener("DOMContentLoaded",t))}typeof window<"u"&&typeof document<"u"&&(document.addEventListener("DOMContentLoaded",t),t())}async function $n(e){await _n();for(let t of document.styleSheets)try{for(let n of t.rules)if("CSSLayerStatementRule"===n.constructor.name&&"nameList"in n&&n.nameList.includes(e))return!0}catch{}return!1}async function _n(){await Promise.all(Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((e=>e.sheet?Promise.resolve():new Promise((t=>{e.addEventListener("load",(()=>t()),{once:!0}),e.addEventListener("error",(()=>t()),{once:!0})})))))}function Le(e){"focus"in e&&e.focus({focusVisible:ct})}typeof globalThis.window<"u"&&(Bo()||(No(),ut((async()=>{if(await $n("popover-polyfill"))return;let e=document.createElement("style");e.textContent="@layer popover-polyfill;",e.setAttribute("suppressHydrationWarning",""),e.addEventListener("securitypolicyviolation",(()=>{console.log("CSP rules on this website prevented @tailwindplus/elements from defining `popover-polyfill` as the first CSS layer. This is necessary to ensure the popover polyfill behaves correctly with CSS layers. To fix this, please manually add the following CSS to the top of your first stylesheet:\n\n```\n@layer popover-polyfill;\n```")})),document.documentElement.prepend(e)}))),Fo()||qo(),Vo()||Wo());var ct=!1;if(typeof globalThis.window<"u"){let e;(e=>{e[e.Keyboard=0]="Keyboard",e[e.Mouse=1]="Mouse"})(e||(e={})),document.addEventListener("keydown",(e=>{e.metaKey||e.altKey||e.ctrlKey||(ct=!0,document.documentElement.dataset.focusVisible="")}),!0),document.addEventListener("click",(e=>{1===e.detail?(ct=!1,delete document.documentElement.dataset.focusVisible):0===e.detail&&(ct=!0,document.documentElement.dataset.focusVisible="")}),!0)}typeof globalThis.HTMLElement>"u"&&(globalThis.HTMLElement=class{});var Ie,We,se,A=class extends HTMLElement{constructor(){super(...arguments),L(this,Ie,new AbortController),L(this,We,!1),L(this,se,!1)}connectedCallback(){if("observedAttributes"in this.constructor&&"object"==typeof this.constructor.observedAttributes&&Array.isArray(this.constructor.observedAttributes))for(let e of this.constructor.observedAttributes)"string"==typeof e&&(e in this||Object.defineProperty(this,e,{get(){return this.getAttribute(e)},set(t){null!=t&&!1!==t?this.setAttribute(e,t.toString()):this.removeAttribute(e)}}));I(this,We,!0);let e=d(this,Ie).signal;queueMicrotask((()=>{if(!e.aborted)try{this.mount?.(e)}catch(e){console.error(e)}}))}disconnectedCallback(){d(this,Ie).abort(),I(this,Ie,new AbortController)}setAttributeNoCallbacks(e,t){try{I(this,se,!0),this.setAttribute(e,t)}finally{I(this,se,!1)}}removeAttributeNoCallbacks(e){try{I(this,se,!0),this.removeAttribute(e)}finally{I(this,se,!1)}}attributeChangedCallback(e,t,n){d(this,We)&&(d(this,se)||t!==n&&this.onAttributeChange?.(e,t,n))}};function x(e,t){typeof globalThis.customElements>"u"||customElements.get(e)===t||customElements.define(e,t)}function J(){let e=[],t={addEventListener:(e,n,o,r)=>(e.addEventListener(n,o,r),t.add((()=>e.removeEventListener(n,o,r)))),requestAnimationFrame(...e){let n=requestAnimationFrame(...e);return t.add((()=>cancelAnimationFrame(n)))},nextFrame:(...e)=>t.requestAnimationFrame((()=>t.requestAnimationFrame(...e))),setTimeout(...e){let n=setTimeout(...e);return t.add((()=>clearTimeout(n)))},microTask(...e){let n={current:!0};return queueMicrotask((()=>{n.current&&e[0]()})),t.add((()=>{n.current=!1}))},style(e,t,n){let o=e.style.getPropertyValue(t);return t.startsWith("--")?e.style.setProperty(t,n):Object.assign(e.style,{[t]:n}),this.add((()=>{t.startsWith("--")?e.style.setProperty(t,o):Object.assign(e.style,{[t]:o})}))},add:t=>(e.includes(t)||e.push(t),()=>{let n=e.indexOf(t);if(n>=0)for(let t of e.splice(n,1))t()}),dispose(){for(let t of e.splice(0))t()}};return t}function ke(e,t=()=>[]){let n=!1,o=null,r=J();return{start(i,a){let l=[e,...t()];n=!n&&(null!==o&&o!==i),o=i;for(let e of l)Kn(e,(()=>{n||("in"===i?(e.dataset.transition="",e.dataset.enter="",e.dataset.closed="",delete e.dataset.leave):"out"===i&&(e.dataset.transition="",e.dataset.leave="",delete e.dataset.enter))}),null!==o);r.nextFrame((()=>{for(let e of l)n?"in"===i?(delete e.dataset.enter,delete e.dataset.closed,e.dataset.leave=""):"out"===i&&(delete e.dataset.leave,e.dataset.enter="",e.dataset.closed=""):"in"===i?delete e.dataset.closed:"out"===i&&(e.dataset.closed="");r.requestAnimationFrame((()=>{r.add(Ft(e,(()=>{if(!n||!l.some((e=>qt(e)))){for(let e of l)delete e.dataset.transition,delete e.dataset.enter,delete e.dataset.closed,delete e.dataset.leave;o=null,a?.()}})))}))}))},abort(){r.dispose(),n=!1,o=null}}}function Kn(e,t,n=!1){if(n)return void t();let o=e.style.transition;e.style.transition="none",t(),e.offsetHeight,e.style.transition=o}function Ft(e,t){let n=J();if(!e)return n.dispose;let o=!1;n.add((()=>{o=!0}));let r=e.getAnimations?.({subtree:!0}).filter((e=>e instanceof CSSTransition))??[];return 0===r.length?(t(),n.dispose):(Promise.allSettled(r.map((e=>e.finished))).then((()=>{o||t()})),n.dispose)}function qt(e){return(e.getAnimations?.()??[]).some((e=>e instanceof CSSTransition&&"finished"!==e.playState))}Ie=new WeakMap,We=new WeakMap,se=new WeakMap;var Pe=Math.min,Z=Math.max,_e=Math.round,Ke=Math.floor,K=e=>({x:e,y:e}),Un={left:"right",right:"left",bottom:"top",top:"bottom"},jn={start:"end",end:"start"};function Vt(e,t,n){return Z(e,Pe(t,n))}function dt(e,t){return"function"==typeof e?e(t):e}function he(e){return e.split("-")[0]}function pt(e){return e.split("-")[1]}function Wt(e){return"x"===e?"y":"x"}function $t(e){return"y"===e?"height":"width"}var zn=new Set(["top","bottom"]);function le(e){return zn.has(he(e))?"y":"x"}function _t(e){return Wt(le(e))}function Ko(e,t,n){void 0===n&&(n=!1);let o=pt(e),r=_t(e),i=$t(r),a="x"===r?o===(n?"end":"start")?"right":"left":"start"===o?"bottom":"top";return t.reference[i]>t.floating[i]&&(a=$e(a)),[a,$e(a)]}function Uo(e){let t=$e(e);return[ft(e),t,ft(t)]}function ft(e){return e.replace(/start|end/g,(e=>jn[e]))}var $o=["left","right"],_o=["right","left"],Gn=["top","bottom"],Yn=["bottom","top"];function Qn(e,t,n){switch(e){case"top":case"bottom":return n?t?_o:$o:t?$o:_o;case"left":case"right":return t?Gn:Yn;default:return[]}}function jo(e,t,n,o){let r=pt(e),i=Qn(he(e),"start"===n,o);return r&&(i=i.map((e=>e+"-"+r)),t&&(i=i.concat(i.map(ft)))),i}function $e(e){return e.replace(/left|right|bottom|top/g,(e=>Un[e]))}function Xn(e){return{top:0,right:0,bottom:0,left:0,...e}}function zo(e){return"number"!=typeof e?Xn(e):{top:e,right:e,bottom:e,left:e}}function ge(e){let{x:t,y:n,width:o,height:r}=e;return{width:o,height:r,top:n,left:t,right:t+o,bottom:n+r,x:t,y:n}}function Go(e,t,n){let o,{reference:r,floating:i}=e,a=le(t),l=_t(t),s=$t(l),u=he(t),c="y"===a,d=r.x+r.width/2-i.width/2,h=r.y+r.height/2-i.height/2,p=r[s]/2-i[s]/2;switch(u){case"top":o={x:d,y:r.y-i.height};break;case"bottom":o={x:d,y:r.y+r.height};break;case"right":o={x:r.x+r.width,y:h};break;case"left":o={x:r.x-i.width,y:h};break;default:o={x:r.x,y:r.y}}switch(pt(t)){case"start":o[l]-=p*(n&&c?-1:1);break;case"end":o[l]+=p*(n&&c?-1:1)}return o}var Yo=async(e,t,n)=>{let{placement:o="bottom",strategy:r="absolute",middleware:i=[],platform:a}=n,l=i.filter(Boolean),s=await(null==a.isRTL?void 0:a.isRTL(t)),u=await a.getElementRects({reference:e,floating:t,strategy:r}),{x:c,y:d}=Go(u,o,s),h=o,p={},f=0;for(let n=0;ne<=0))){var I,k;let e=((null==(I=i.flip)?void 0:I.index)||0)+1,t=x[e];if(t&&("alignment"!==d||v===le(t)||L.every((e=>le(e.placement)!==v||e.overflows[0]>0))))return{data:{index:e,overflows:L},reset:{placement:t}};let n=null==(k=L.filter((e=>e.overflows[0]<=0)).sort(((e,t)=>e.overflows[1]-t.overflows[1]))[0])?void 0:k.placement;if(!n)switch(p){case"bestFit":{var P;let e=null==(P=L.filter((e=>{if(E){let t=le(e.placement);return t===v||"y"===t}return!0})).map((e=>[e.placement,e.overflows.filter((e=>e>0)).reduce(((e,t)=>e+t),0)])).sort(((e,t)=>e[1]-t[1]))[0])?void 0:P[0];e&&(n=e);break}case"initialPlacement":n=l}if(r!==n)return{reset:{placement:n}}}return{}}}},Xo=function(e){return void 0===e&&(e={}),{name:"shift",options:e,async fn(t){let{x:n,y:o,placement:r}=t,{mainAxis:i=!0,crossAxis:a=!1,limiter:l={fn:e=>{let{x:t,y:n}=e;return{x:t,y:n}}},...s}=dt(e,t),u={x:n,y:o},c=await Kt(t,s),d=le(he(r)),h=Wt(d),p=u[h],f=u[d];if(i){let e="y"===h?"bottom":"right";p=Vt(p+c["y"===h?"top":"left"],p,p-c[e])}if(a){let e="y"===d?"bottom":"right";f=Vt(f+c["y"===d?"top":"left"],f,f-c[e])}let m=l.fn({...t,[h]:p,[d]:f});return{...m,data:{x:m.x-n,y:m.y-o,enabled:{[h]:i,[d]:a}}}}}};function mt(){return typeof window<"u"}function be(e){return Zo(e)?(e.nodeName||"").toLowerCase():"#document"}function R(e){var t;return(null==e||null==(t=e.ownerDocument)?void 0:t.defaultView)||window}function U(e){var t;return null==(t=(Zo(e)?e.ownerDocument:e.document)||window.document)?void 0:t.documentElement}function Zo(e){return!!mt()&&(e instanceof Node||e instanceof R(e).Node)}function V(e){return!!mt()&&(e instanceof Element||e instanceof R(e).Element)}function j(e){return!!mt()&&(e instanceof HTMLElement||e instanceof R(e).HTMLElement)}function Jo(e){return!(!mt()||typeof ShadowRoot>"u")&&(e instanceof ShadowRoot||e instanceof R(e).ShadowRoot)}var Jn=new Set(["inline","contents"]);function Oe(e){let{overflow:t,overflowX:n,overflowY:o,display:r}=W(e);return/auto|scroll|overlay|hidden|clip/.test(t+o+n)&&!Jn.has(r)}var Zn=new Set(["table","td","th"]);function en(e){return Zn.has(be(e))}var er=[":popover-open",":modal"];function Ue(e){return er.some((t=>{try{return e.matches(t)}catch{return!1}}))}var tr=["transform","translate","scale","rotate","perspective"],or=["transform","translate","scale","rotate","perspective","filter"],nr=["paint","layout","strict","content"];function ht(e){let t=gt(),n=V(e)?W(e):e;return tr.some((e=>!!n[e]&&"none"!==n[e]))||!!n.containerType&&"normal"!==n.containerType||!t&&!!n.backdropFilter&&"none"!==n.backdropFilter||!t&&!!n.filter&&"none"!==n.filter||or.some((e=>(n.willChange||"").includes(e)))||nr.some((e=>(n.contain||"").includes(e)))}function tn(e){let t=ee(e);for(;j(t)&&!ve(t);){if(ht(t))return t;if(Ue(t))return null;t=ee(t)}return null}function gt(){return!(typeof CSS>"u"||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}var rr=new Set(["html","body","#document"]);function ve(e){return rr.has(be(e))}function W(e){return R(e).getComputedStyle(e)}function je(e){return V(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function ee(e){if("html"===be(e))return e;let t=e.assignedSlot||e.parentNode||Jo(e)&&e.host||U(e);return Jo(t)?t.host:t}function on(e){let t=ee(e);return ve(t)?e.ownerDocument?e.ownerDocument.body:e.body:j(t)&&Oe(t)?t:on(t)}function Ce(e,t,n){var o;void 0===t&&(t=[]),void 0===n&&(n=!0);let r=on(e),i=r===(null==(o=e.ownerDocument)?void 0:o.body),a=R(r);if(i){let e=bt(a);return t.concat(a,a.visualViewport||[],Oe(r)?r:[],e&&n?Ce(e):[])}return t.concat(r,Ce(r,[],n))}function bt(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}function sn(e){let t=W(e),n=parseFloat(t.width)||0,o=parseFloat(t.height)||0,r=j(e),i=r?e.offsetWidth:n,a=r?e.offsetHeight:o,l=_e(n)!==i||_e(o)!==a;return l&&(n=i,o=a),{width:n,height:o,$:l}}function jt(e){return V(e)?e:e.contextElement}function De(e){let t=jt(e);if(!j(t))return K(1);let n=t.getBoundingClientRect(),{width:o,height:r,$:i}=sn(t),a=(i?_e(n.width):n.width)/o,l=(i?_e(n.height):n.height)/r;return(!a||!Number.isFinite(a))&&(a=1),(!l||!Number.isFinite(l))&&(l=1),{x:a,y:l}}var ir=K(0);function ln(e){let t=R(e);return gt()&&t.visualViewport?{x:t.visualViewport.offsetLeft,y:t.visualViewport.offsetTop}:ir}function sr(e,t,n){return void 0===t&&(t=!1),!(!n||t&&n!==R(e))&&t}function we(e,t,n,o){void 0===t&&(t=!1),void 0===n&&(n=!1);let r=e.getBoundingClientRect(),i=jt(e),a=K(1);t&&(o?V(o)&&(a=De(o)):a=De(e));let l=sr(i,n,o)?ln(i):K(0),s=(r.left+l.x)/a.x,u=(r.top+l.y)/a.y,c=r.width/a.x,d=r.height/a.y;if(i){let e=R(i),t=o&&V(o)?R(o):o,n=e,r=bt(n);for(;r&&o&&t!==n;){let e=De(r),t=r.getBoundingClientRect(),o=W(r),i=t.left+(r.clientLeft+parseFloat(o.paddingLeft))*e.x,a=t.top+(r.clientTop+parseFloat(o.paddingTop))*e.y;s*=e.x,u*=e.y,c*=e.x,d*=e.y,s+=i,u+=a,n=R(r),r=bt(n)}}return ge({width:c,height:d,x:s,y:u})}function zt(e,t){let n=je(e).scrollLeft;return t?t.left+n:we(U(e)).left+n}function an(e,t,n){void 0===n&&(n=!1);let o=e.getBoundingClientRect();return{x:o.left+t.scrollLeft-(n?0:zt(e,o)),y:o.top+t.scrollTop}}function lr(e){let{elements:t,rect:n,offsetParent:o,strategy:r}=e,i="fixed"===r,a=U(o),l=!!t&&Ue(t.floating);if(o===a||l&&i)return n;let s={scrollLeft:0,scrollTop:0},u=K(1),c=K(0),d=j(o);if((d||!d&&!i)&&(("body"!==be(o)||Oe(a))&&(s=je(o)),j(o))){let e=we(o);u=De(o),c.x=e.x+o.clientLeft,c.y=e.y+o.clientTop}let h=!a||d||i?K(0):an(a,s,!0);return{width:n.width*u.x,height:n.height*u.y,x:n.x*u.x-s.scrollLeft*u.x+c.x+h.x,y:n.y*u.y-s.scrollTop*u.y+c.y+h.y}}function ar(e){return Array.from(e.getClientRects())}function ur(e){let t=U(e),n=je(e),o=e.ownerDocument.body,r=Z(t.scrollWidth,t.clientWidth,o.scrollWidth,o.clientWidth),i=Z(t.scrollHeight,t.clientHeight,o.scrollHeight,o.clientHeight),a=-n.scrollLeft+zt(e),l=-n.scrollTop;return"rtl"===W(o).direction&&(a+=Z(t.clientWidth,o.clientWidth)-r),{width:r,height:i,x:a,y:l}}function cr(e,t){let n=R(e),o=U(e),r=n.visualViewport,i=o.clientWidth,a=o.clientHeight,l=0,s=0;if(r){i=r.width,a=r.height;let e=gt();(!e||e&&"fixed"===t)&&(l=r.offsetLeft,s=r.offsetTop)}return{width:i,height:a,x:l,y:s}}var fr=new Set(["absolute","fixed"]);function dr(e,t){let n=we(e,!0,"fixed"===t),o=n.top+e.clientTop,r=n.left+e.clientLeft,i=j(e)?De(e):K(1);return{width:e.clientWidth*i.x,height:e.clientHeight*i.y,x:r*i.x,y:o*i.y}}function nn(e,t,n){let o;if("viewport"===t)o=cr(e,n);else if("document"===t)o=ur(U(e));else if(V(t))o=dr(t,n);else{let n=ln(e);o={x:t.x-n.x,y:t.y-n.y,width:t.width,height:t.height}}return ge(o)}function un(e,t){let n=ee(e);return!(n===t||!V(n)||ve(n))&&("fixed"===W(n).position||un(n,t))}function pr(e,t){let n=t.get(e);if(n)return n;let o=Ce(e,[],!1).filter((e=>V(e)&&"body"!==be(e))),r=null,i="fixed"===W(e).position,a=i?ee(e):e;for(;V(a)&&!ve(a);){let t=W(a),n=ht(a);!n&&"fixed"===t.position&&(r=null),(i?!n&&!r:!n&&"static"===t.position&&r&&fr.has(r.position)||Oe(a)&&!n&&un(e,a))?o=o.filter((e=>e!==a)):r=t,a=ee(a)}return t.set(e,o),o}function mr(e){let{element:t,boundary:n,rootBoundary:o,strategy:r}=e,i=[..."clippingAncestors"===n?Ue(t)?[]:pr(t,this._c):[].concat(n),o],a=i[0],l=i.reduce(((e,n)=>{let o=nn(t,n,r);return e.top=Z(o.top,e.top),e.right=Pe(o.right,e.right),e.bottom=Pe(o.bottom,e.bottom),e.left=Z(o.left,e.left),e}),nn(t,a,r));return{width:l.right-l.left,height:l.bottom-l.top,x:l.left,y:l.top}}function hr(e){let{width:t,height:n}=sn(e);return{width:t,height:n}}function gr(e,t,n){let o=j(t),r=U(t),i="fixed"===n,a=we(e,!0,i,t),l={scrollLeft:0,scrollTop:0},s=K(0);function u(){s.x=zt(r)}if(o||!o&&!i)if(("body"!==be(t)||Oe(r))&&(l=je(t)),o){let e=we(t,!0,i,t);s.x=e.x+t.clientLeft,s.y=e.y+t.clientTop}else r&&u();i&&!o&&r&&u();let c=!r||o||i?K(0):an(r,l);return{x:a.left+l.scrollLeft-s.x-c.x,y:a.top+l.scrollTop-s.y-c.y,width:a.width,height:a.height}}function Ut(e){return"static"===W(e).position}function rn(e,t){if(!j(e)||"fixed"===W(e).position)return null;if(t)return t(e);let n=e.offsetParent;return U(e)===n&&(n=n.ownerDocument.body),n}function cn(e,t){let n=R(e);if(Ue(e))return n;if(!j(e)){let t=ee(e);for(;t&&!ve(t);){if(V(t)&&!Ut(t))return t;t=ee(t)}return n}let o=rn(e,t);for(;o&&en(o)&&Ut(o);)o=rn(o,t);return o&&ve(o)&&Ut(o)&&!ht(o)?n:o||tn(e)||n}var br=async function(e){let t=this.getOffsetParent||cn,n=this.getDimensions,o=await n(e.floating);return{reference:gr(e.reference,await t(e.floating),e.strategy),floating:{x:0,y:0,width:o.width,height:o.height}}};function vr(e){return"rtl"===W(e).direction}var Gt={convertOffsetParentRelativeRectToViewportRelativeRect:lr,getDocumentElement:U,getClippingRect:mr,getOffsetParent:cn,getElementRects:br,getClientRects:ar,getDimensions:hr,getScale:De,isElement:V,isRTL:vr};function fn(e,t){return e.x===t.x&&e.y===t.y&&e.width===t.width&&e.height===t.height}function wr(e,t){let n,o=null,r=U(e);function i(){var e;clearTimeout(n),null==(e=o)||e.disconnect(),o=null}return function a(l,s){void 0===l&&(l=!1),void 0===s&&(s=1),i();let u=e.getBoundingClientRect(),{left:c,top:d,width:h,height:p}=u;if(l||t(),!h||!p)return;let f={rootMargin:-Ke(d)+"px "+-Ke(r.clientWidth-(c+h))+"px "+-Ke(r.clientHeight-(d+p))+"px "+-Ke(c)+"px",threshold:Z(0,Pe(1,s))||1},m=!0;function g(t){let o=t[0].intersectionRatio;if(o!==s){if(!m)return a();o?a(!1,o):n=setTimeout((()=>{a(!1,1e-7)}),1e3)}1===o&&!fn(u,e.getBoundingClientRect())&&a(),m=!1}try{o=new IntersectionObserver(g,{...f,root:r.ownerDocument})}catch{o=new IntersectionObserver(g,f)}o.observe(e)}(!0),i}function dn(e,t,n,o){void 0===o&&(o={});let{ancestorScroll:r=!0,ancestorResize:i=!0,elementResize:a="function"==typeof ResizeObserver,layoutShift:l="function"==typeof IntersectionObserver,animationFrame:s=!1}=o,u=jt(e),c=r||i?[...u?Ce(u):[],...Ce(t)]:[];c.forEach((e=>{r&&e.addEventListener("scroll",n,{passive:!0}),i&&e.addEventListener("resize",n)}));let d=u&&l?wr(u,n):null,h=-1,p=null;a&&(p=new ResizeObserver((e=>{let[o]=e;o&&o.target===u&&p&&(p.unobserve(t),cancelAnimationFrame(h),h=requestAnimationFrame((()=>{var e;null==(e=p)||e.observe(t)}))),n()})),u&&!s&&p.observe(u),p.observe(t));let f,m=s?we(e):null;return s&&function t(){let o=we(e);m&&!fn(m,o)&&n(),m=o,f=requestAnimationFrame(t)}(),n(),()=>{var e;c.forEach((e=>{r&&e.removeEventListener("scroll",n),i&&e.removeEventListener("resize",n)})),d?.(),null==(e=p)||e.disconnect(),p=null,s&&cancelAnimationFrame(f)}}var pn=Xo,mn=Qo,hn=(e,t,n)=>{let o=new Map,r={platform:Gt,...n},i={...r.platform,_c:o};return Yo(e,t,{...r,platform:i})};function bn(e){let t=()=>{};return function(n,o){if(t(),!n)return void requestAnimationFrame((()=>{qt(e)?Ft(e,(()=>{e.style.removeProperty("position")})):e.style.removeProperty("position")}));if(!o||!e.hasAttribute("anchor"))return;let r=e.getAttribute("anchor"),i=e.getAttribute("anchor-strategy")||"absolute";"absolute"!==i&&"fixed"!==i&&(console.warn(`[createAnchorUpdater] Invalid anchor strategy "${i}" for element:`,e),i="absolute"),t=dn(o,e,(()=>{let t=gn(window.getComputedStyle(e).getPropertyValue("--anchor-gap"),e),n=gn(window.getComputedStyle(e).getPropertyValue("--anchor-offset"),e),a={};switch(r.split(" ")[0]){case"top":case"bottom":a={top:t,left:-1*n,right:n,bottom:t};break;case"left":case"right":a={top:-1*n,bottom:n,left:t,right:t}}hn(o,e,{strategy:i,placement:r.replace(" ","-"),middleware:[mn({padding:a}),pn({padding:a})]}).then((async({x:t,y:n,placement:o})=>{if(!yr()&&"absolute"===i){let o=null;for(let t=e.parentElement;t;t=t.parentElement){let e=getComputedStyle(t).position;if("relative"===e||"absolute"===e||"fixed"===e||"sticky"===e){o=t;break}}if(o){let e=o.getBoundingClientRect();t-=e.left+window.scrollX,n-=e.top+window.scrollY}}let r=`${t}px`,a=`${n}px`;switch(o.split("-")[0]){case"top":a=`calc(${n}px - var(--anchor-gap, 0px))`,r=`calc(${t}px + var(--anchor-offset, 0px))`;break;case"right":r=`calc(${t}px + var(--anchor-gap, 0px))`,a=`calc(${n}px + var(--anchor-offset, 0px))`;break;case"bottom":a=`calc(${n}px + var(--anchor-gap, 0px))`,r=`calc(${t}px + var(--anchor-offset, 0px))`;break;case"left":r=`calc(${t}px - var(--anchor-gap, 0px))`,a=`calc(${n}px + var(--anchor-offset, 0px))`}{let t=e.getBoundingClientRect();if(0===t.x&&0===t.y&&0===t.width&&0===t.height)return}Object.assign(e.style,{left:r,top:a,position:i}),await(Gt.isRTL?.(e))&&Object.assign(e.style,{right:"unset",bottom:"unset"})}))}))}}function yr(){return"showPopover"in HTMLElement.prototype&&HTMLElement.prototype.showPopover.toString().includes("[native code]")}function gn(e,t){let n=document.createElement("div");t.appendChild(n),n.style.setProperty("margin-top","0px","important"),n.style.setProperty("margin-top",e,"important");let o=parseFloat(window.getComputedStyle(n).marginTop)||0;return t.removeChild(n),o}function ze(e){return Yt(e)&&"tabIndex"in e}function Yt(e){return Er(e)&&"tagName"in e}function Qt(e){return Yt(e)&&"accessKey"in e}function Er(e){return"object"==typeof e&&null!==e&&"nodeType"in e}function vn(e){return Yt(e)&&"style"in e}function wn(e){return Qt(e)&&"INPUT"===e.nodeName}function B(e){let t=e.getBoundingClientRect();return!(0===t.x&&0===t.y&&0===t.width&&0===t.height||"hidden"===(e.ownerDocument.defaultView||window).getComputedStyle(e).visibility)}var yn={get selectRequired(){let e=document.createElement("select");e.setAttribute("required","true");let t=e.validationMessage;return Object.defineProperty(this,"selectRequired",{value:t}),t}};function Ge(e,t,n){function o(){if(!B(e)){for(let t of e.children)if(B(t))return;n()}}if(typeof ResizeObserver<"u"){let n=new ResizeObserver(o);n.observe(e),t.addEventListener("abort",(()=>n.disconnect()))}if(typeof IntersectionObserver<"u"){let n=new IntersectionObserver(o);n.observe(e),t.addEventListener("abort",(()=>n.disconnect()))}}var ae=["[contentEditable=true]","[tabindex]","a[href]","area[href]","button:not([disabled])","iframe","input:not([disabled])","select:not([disabled])","textarea:not([disabled])"].map((e=>`${e}:not([tabindex='-1'])`)).join(",");function Y(e,t,n){let o=t?e.indexOf(t):null;switch(-1===o&&(o=null),n){case 0:for(let t=0;t=0;t--)if(B(e[t]))return e[t];return null;case 2:if(null===o)return Y(e,t,1);for(let t=o-1;t>=0;t--)if(B(e[t]))return e[t];return null;case 3:if(null===o)return Y(e,t,0);for(let t=o+1;t{function e(e){if(e.target===document.body||N[0]===e.target)return;let t=e.target;t&&"closest"in t&&(t=t.closest(ae),N.unshift(t??e.target),(N=N.filter((e=>null!=e&&e.isConnected))).splice(10))}window.addEventListener("click",e,{capture:!0}),window.addEventListener("pointerdown",e,{capture:!0}),window.addEventListener("focus",e,{capture:!0}),document.body.addEventListener("click",e,{capture:!0}),document.body.addEventListener("pointerdown",e,{capture:!0}),document.body.addEventListener("focus",e,{capture:!0})}));var Ye=!1,Xt=!1;typeof navigator<"u"&&(Ye=/^((?!chrome|android).)*safari/i.test(navigator.userAgent),Xt=navigator.userAgent.toLowerCase().includes("firefox"));var Jt=!1;function Me(e,t,n,o,r,i){xr(e.ownerDocument);let a=ke(e),l=bn(e),s=J();e.hasAttribute("popover")||e.setAttribute("popover","");let u=n();for(let t of u)t.setAttribute("type","button"),t.setAttribute("aria-haspopup","true"),t.setAttribute("aria-controls",e.id),t.setAttribute("aria-expanded","false");function c(){e.hasAttribute("open")&&e.hidePopover()}e.hasAttribute("open")&&queueMicrotask((()=>e.showPopover()));let d=new AbortController;t.addEventListener("abort",(()=>d.abort())),e.addEventListener("beforetoggle",(t=>{let n=vt(t,u);l("open"===t.newState,o?.()??n),"open"===t.newState?n&&(Ge(n,d.signal,c),Ge(e,d.signal,c)):(d.abort(),d=new AbortController);let h=e.hasAttribute("open");"open"!==t.newState||h?"closed"===t.newState&&h&&e.removeAttributeNoCallbacks("open"):e.setAttributeNoCallbacks("open",""),"open"===t.newState?(n?.setAttribute("aria-expanded","true"),n?.id&&e.setAttribute("aria-labelledby",n.id),r?.(),Jt=""===e.getAttribute("popover")):(n?.setAttribute("aria-expanded","false"),e.removeAttribute("aria-labelledby"),i?.(),Jt=!1),"closed"===t.oldState&&"open"===t.newState?(Ye&&(s.dispose(),s=J()),a.start("in")):"open"===t.oldState&&"closed"===t.newState&&(Ye&&s.style(e,"transition-property","none"),a.start("out"))}),{signal:t}),t.addEventListener("abort",(()=>a.abort()))}function vt(e,t){return e.source??N.find((e=>t.includes(e)))??t[0]??null}var En=new WeakSet;function xr(e){if(Xt||Ye||En.has(e))return;En.add(e);let t=null;e.addEventListener("mousedown",(()=>{Jt&&(e.body.setAttribute("tabindex","-1"),t&&clearTimeout(t),t=setTimeout((()=>e.body.removeAttribute("tabindex"))))}),{capture:!0})}function z(e,t,n,o){function r(){let n=e.getBoundingClientRect();o.style.setProperty(t,n.width+"px")}let i=e.ownerDocument,a=new ResizeObserver(r);a.observe(e),i.addEventListener("transitionend",r,{signal:n}),n.addEventListener("abort",(()=>a.disconnect()))}var Tr=0;function P(e){return`${e}-${Tr++}`}var Sr=200;function ue(e,t,n,o){Lr(),e.addEventListener(t,(e=>{null!==Zt&&Date.now()-Zt{Zt=Date.now()}),{capture:!0}))}var wt=class extends Map{constructor(e){super(),this.factory=e}get(e){let t=super.get(e);return void 0===t&&(t=this.factory(e,this),this.set(e,t)),t}},Tn=new wt((()=>({referenceCounter:0,d:J()})));function yt(e){let t=Tn.get(e);if(t.referenceCounter++,1===t.referenceCounter){let n=[Cr(),kr(),Ir()];n.forEach((({before:n})=>n({doc:e,d:t.d}))),n.forEach((({after:n})=>n({doc:e,d:t.d})))}let n=!1;return()=>{n||(n=!0,t.referenceCounter--,!(t.referenceCounter>0)&&(t.d.dispose(),Tn.delete(e)))}}function Ir(){return{before({doc:e,d:t}){t.style(e.documentElement,"overflow","hidden")},after(){}}}function kr(){let e;return{before({doc:t}){let n=t.documentElement,o=t.defaultView??window;e=Math.max(0,o.innerWidth-n.clientWidth),n.style.setProperty("--el-top-layer-scrollbar-offset","0px")},after({doc:t,d:n}){let o=t.documentElement,r=Math.max(0,o.clientWidth-o.offsetWidth),i=Math.max(0,e-r);n.style(o,"paddingRight",`${i}px`),n.add((()=>{o.style.setProperty("--el-top-layer-scrollbar-offset",`-${i}px`)}))}}}function Pr(){return/iPhone/gi.test(window.navigator.platform)||/Mac/gi.test(window.navigator.platform)&&window.navigator.maxTouchPoints>0}function Cr(){return Pr()?{before({doc:e,d:t}){function n(e){return!!e.closest("[popover], dialog > *")}t.microTask((()=>{if("auto"!==window.getComputedStyle(e.documentElement).scrollBehavior){let n=J();n.style(e.documentElement,"scrollBehavior","auto"),t.add((()=>t.microTask((()=>n.dispose()))))}let o=window.scrollY??window.pageYOffset,r=null;t.addEventListener(e,"click",(t=>{if(ze(t.target))try{let o=t.target.closest("a");if(!o)return;let{hash:i}=new URL(o.href),a=e.querySelector(i);ze(a)&&!n(a)&&(r=a)}catch{}}),!0),t.addEventListener(e,"touchstart",(e=>{if(ze(e.target)&&vn(e.target))if(n(e.target)){let o=e.target;for(;o.parentElement&&n(o.parentElement);)o=o.parentElement;t.style(o,"overscrollBehavior","contain")}else t.style(e.target,"touchAction","none")})),t.addEventListener(e,"touchmove",(e=>{if(ze(e.target)){if(wn(e.target))return;if(n(e.target)){let t=e.target;for(;t.parentElement&&""!==t.dataset.tailwindplusPortal&&!(t.scrollHeight>t.clientHeight||t.scrollWidth>t.clientWidth);)t=t.parentElement;""===t.dataset.tailwindplusPortal&&e.preventDefault()}else e.preventDefault()}}),{passive:!1}),t.add((()=>{let e=window.scrollY??window.pageYOffset;o!==e&&window.scrollTo(0,o),r&&r.isConnected&&(r.scrollIntoView({block:"nearest"}),r=null)}))}))},after(){}}:{before(){},after(){}}}function Et(e,t){let n=null;e.addEventListener("toggle",(t=>{"open"===t.newState?n||(n=yt(e.ownerDocument)):n&&(n(),n=null)}),{signal:t}),t.addEventListener("abort",(()=>{n&&(n(),n=null)}))}var Sn=/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g;function At(e){let t=e.innerText??"",n=e.cloneNode(!0);if(!Qt(n))return t;let o=!1;for(let e of n.querySelectorAll('[hidden],[aria-hidden],[role="img"]'))e.remove(),o=!0;let r=o?n.innerText??"":t;return Sn.test(r)&&(r=r.replace(Sn,"")),r}function Ln(e){let t=e.getAttribute("aria-label");if("string"==typeof t)return t.trim();let n=e.getAttribute("aria-labelledby");if(n){let e=n.split(" ").map((e=>{let t=document.getElementById(e);if(t){let e=t.getAttribute("aria-label");return"string"==typeof e?e.trim():At(t).trim()}return null})).filter(Boolean);if(e.length>0)return e.join(", ")}return At(e).trim()}var F,Qe,eo=class extends A{constructor(){super(...arguments),L(this,F,[]),L(this,Qe,null)}mount(e){let t=this.getInput(),n=this.getButton(),o=this.getOptions();t.id||(t.id=P("autocomplete-input")),n&&(n.id||(n.id=P("autocomplete-button"))),o.id||(o.id=P("autocomplete-listbox")),Me(o,e,(()=>{let e=this.getButton();return e?[e]:[]}),(()=>this.getInput()),(()=>this.onBeforeOpen()),(()=>this.onBeforeClose())),Et(o,e),t.setAttribute("role","combobox"),t.setAttribute("aria-autocomplete","list"),t.setAttribute("aria-expanded","false"),t.setAttribute("aria-controls",o.id),t.setAttribute("aria-activedescendant",""),t.setAttribute("autocomplete","off"),n&&(n.setAttribute("type","button"),n.setAttribute("tabindex","-1"),n.setAttribute("aria-expanded","false"),n.setAttribute("aria-haspopup","listbox"),n.setAttribute("popovertarget",o.id)),o.setAttribute("role","listbox"),o.setAttribute("popover","manual");let r=this,i=new WeakSet;function a(){for(let t of o.getItems())i.has(t)||(i.add(t),t.id||(t.id=P("option")),t.setAttribute("role","option"),t.setAttribute("aria-selected","false"),t.setAttribute("tabIndex","-1"),t.addEventListener("mousedown",(e=>{0===e.button&&(e.preventDefault(),r.selectOption(t))}),{signal:e}),ue(t,"mouseover",e,(()=>r.setActiveItem(t,!1))),ue(t,"mouseout",e,(()=>r.clearActiveItem())));r.filterOptions()}a();let l=new MutationObserver(a);l.observe(this,{attributes:!1,childList:!0,subtree:!0}),n&&z(n,"--button-width",e,this),z(t,"--input-width",e,this),t.addEventListener("input",(()=>{t.matches(":disabled")||(this.filterOptions(),d(this,F).length>0?o.hasAttribute("open")||o.showPopover():o.hidePopover())}),{signal:e});let s=()=>{t.matches(":disabled")||(t.focus(),o.hasAttribute("open")?o.hidePopover():(this.filterOptions(),d(this,F).length>0&&o.showPopover()))};t.addEventListener("pointerdown",s,{signal:e}),n&&(n.addEventListener("pointerdown",(e=>{e.preventDefault(),s()}),{signal:e}),n.addEventListener("click",(e=>{e.preventDefault(),e.stopImmediatePropagation()}),{signal:e})),t.addEventListener("blur",(({relatedTarget:e})=>{e&&this.contains(e)||o.hidePopover()}),{signal:e}),t.addEventListener("keydown",(e=>{if(!t.matches(":disabled"))switch(e.key){case"ArrowDown":e.preventDefault(),o.hasAttribute("open")||(0===d(this,F).length&&this.filterOptions(),d(this,F).length>0&&o.showPopover()),this.goToItem(3);break;case"ArrowUp":e.preventDefault(),o.hasAttribute("open")||(0===d(this,F).length&&this.filterOptions(),d(this,F).length>0&&o.showPopover()),this.goToItem(2);break;case"Home":case"PageUp":return o.hasAttribute("open")?(e.preventDefault(),e.stopPropagation(),this.goToItem(0)):void 0;case"End":case"PageDown":return o.hasAttribute("open")?(e.preventDefault(),e.stopPropagation(),this.goToItem(1)):void 0;case"Enter":{let t=this.getActiveItem();t&&(e.preventDefault(),this.selectOption(t)),o.hasAttribute("open")&&(e.preventDefault(),o.hidePopover());break}case"Escape":if(!o.hasAttribute("open"))return;e.preventDefault(),o.hidePopover();break;case"Tab":o.hidePopover()}}),{signal:e});let u=Array.from(o.querySelectorAll("el-option[disabled]"));for(let e of u)e.setAttribute("aria-disabled","true"),e.setAttribute("aria-selected","false");e.addEventListener("abort",(()=>{l.disconnect()}))}getInput(){let e=this.querySelector("input");if(!e)throw new Error("`` must contain an input element.");return e}getButton(){return this.querySelector("button")}getOptions(){let e=this.querySelector("el-options");if(!e)throw new Error("`` must contain a `` element.");return e}filterOptions(){let e=this.getInput().value.toLowerCase();d(this,Qe)!==e&&(this.clearActiveItem(),I(this,Qe,e)),I(this,F,[]);for(let t of this.getOptions().getItems()){let n=t.getAttribute("value")?.toLowerCase()||"",o=At(t)?.trim().toLowerCase()??"";""===e||n.includes(e)||o.includes(e)?(d(this,F).push(t),t.removeAttribute("hidden"),t.removeAttribute("aria-hidden")):(t.setAttribute("hidden",""),t.setAttribute("aria-hidden","true"))}}getActiveItem(){let e=this.getInput().getAttribute("aria-activedescendant");return e?document.getElementById(e):null}goToItem(e){if(0===d(this,F).length)return;let t=this.getActiveItem(),n=Y(d(this,F),t,e);n&&this.setActiveItem(n)}setActiveItem(e,t=!0){let n=this.getInput(),o=this.getActiveItem();null!==o&&o.setAttribute("aria-selected","false"),e.setAttribute("aria-selected","true"),n.setAttribute("aria-activedescendant",e.id),t&&e.scrollIntoView({block:"nearest"})}clearActiveItem(){let e=this.getInput(),t=this.getActiveItem();null!==t&&t.setAttribute("aria-selected","false"),e.setAttribute("aria-activedescendant","")}selectOption(e){let t=this.getInput(),n=e.getAttribute("value");n&&(t.value=n,t.dispatchEvent(new Event("input",{bubbles:!0,cancelable:!0})),t.dispatchEvent(new Event("change",{bubbles:!0,cancelable:!0})),this.getOptions().hidePopover())}onBeforeOpen(){let e=this.getInput(),t=this.getButton();e.setAttribute("aria-expanded","true"),t?.setAttribute("aria-expanded","true")}onBeforeClose(){let e=this.getInput(),t=this.getButton();e.setAttribute("aria-expanded","false"),t?.setAttribute("aria-expanded","false"),this.clearActiveItem()}};F=new WeakMap,Qe=new WeakMap,x("el-autocomplete",eo);var G,Xe,Je,te,xt,no,oo=class extends A{constructor(){super(...arguments),L(this,te),L(this,G,[]),L(this,Xe,null),L(this,Je,(({query:e,content:t})=>t.toLocaleLowerCase().includes(e.toLocaleLowerCase().trim())))}mount(e){let t=this.getInput(),n=this.getItems();t.id||(t.id=P("command-input")),n.id||(n.id=P("command-items")),t.setAttribute("role","combobox"),t.setAttribute("aria-autocomplete","list"),t.setAttribute("autocomplete","off"),t.setAttribute("aria-controls",n.id),n.setAttribute("role","listbox");let o=this,r=new WeakSet;function i(t=!1){var i;for(let t of n.getItems())r.has(t)||(r.add(t),t.id||(t.id=P("item")),t.setAttribute("role","option"),t.setAttribute("tabIndex","-1"),t.setAttribute("aria-selected","false"),t.hasAttribute("disabled")&&t.setAttribute("aria-disabled","true"),ue(t,"mouseover",e,(()=>{var e;return H(e=o,te,no).call(e,t,!1)})));H(i=o,te,xt).call(i,t),t||o.goToItem(0)}i(!0);let a=new MutationObserver((()=>i(!1)));a.observe(this,{attributes:!1,childList:!0,subtree:!0}),z(t,"--input-width",e,this),t.addEventListener("input",(()=>H(this,te,xt).call(this)),{signal:e}),t.addEventListener("keydown",(e=>{switch(e.key){case"ArrowDown":e.preventDefault(),this.goToItem(3);break;case"ArrowUp":e.preventDefault(),this.goToItem(2);break;case"Home":case"PageUp":return e.preventDefault(),e.stopPropagation(),this.goToItem(0);case"End":case"PageDown":return e.preventDefault(),e.stopPropagation(),this.goToItem(1);case"Enter":{let t=this.getActiveItem();t&&(e.preventDefault(),t.click());break}}}),{signal:e}),e.addEventListener("abort",(()=>{a.disconnect()}))}getInput(){let e=this.querySelector("input");if(!e)throw new Error("`` must contain an input element.");return e}getItems(){let e=this.querySelector("el-command-list");if(!e)throw new Error("`` must contain a `` element.");return e}getGroups(){return this.getItems().querySelectorAll("el-command-group")}getSuggestions(){return this.querySelector("el-defaults")}getActiveItem(){let e=this.getInput().getAttribute("aria-activedescendant");return e?document.getElementById(e):null}goToItem(e){if(0===d(this,G).length)return;let t=this.getActiveItem(),n=Y(d(this,G),t,e);n&&H(this,te,no).call(this,n)}clearActiveItem(){let e=this.getInput(),t=this.getActiveItem();if(null!==t){t.setAttribute("aria-selected","false");let e=this.querySelector(`el-command-preview[for="${t.id}"]`);e&&e.setAttribute("hidden","")}e.removeAttribute("aria-activedescendant"),this.dispatchEvent(new CustomEvent("change",{detail:{relatedTarget:null},bubbles:!1,cancelable:!1}))}reset(){let e=this.getInput();e.value="",e.dispatchEvent(new Event("input",{bubbles:!0,cancelable:!0})),e.dispatchEvent(new Event("change",{bubbles:!0,cancelable:!0})),H(this,te,xt).call(this,!0),this.clearActiveItem()}setFilterCallback(e){I(this,Je,e)}};G=new WeakMap,Xe=new WeakMap,Je=new WeakMap,te=new WeakSet,xt=function(e=!1){let t=this.getItems(),n=this.getInput().value??"";I(this,G,[]);for(let e of t.getItems()){if(e.closest("el-defaults"))continue;let t=Ln(e)??"";""!==n&&d(this,Je).call(this,{query:n,node:e,content:t})?(d(this,G).push(e),e.removeAttribute("hidden"),e.removeAttribute("aria-hidden")):(e.setAttribute("hidden",""),e.setAttribute("aria-hidden","true"))}for(let e of this.getGroups())e.getItems().some((e=>!e.hasAttribute("hidden")))?e.removeAttribute("hidden"):e.setAttribute("hidden","");let o=this.getSuggestions();o&&(""===n?(o.removeAttribute("hidden"),I(this,G,o.getItems())):o.setAttribute("hidden",""));let r=this.querySelector("el-no-results");r&&(""===n||d(this,G).length>0?r.setAttribute("hidden",""):r.removeAttribute("hidden")),0===d(this,G).length?t.setAttribute("hidden",""):t.removeAttribute("hidden"),(!e||""!==n)&&(0===d(this,G).length?this.clearActiveItem():d(this,Xe)!==n&&this.goToItem(0),I(this,Xe,n))},no=function(e,t=!0){let n=this.getInput(),o=this.getActiveItem();if(e===o)return;if(null!==o){o.setAttribute("aria-selected","false");let e=this.querySelector(`el-command-preview[for="${o.id}"]`);e&&e.setAttribute("hidden","")}e.setAttribute("aria-selected","true"),n.setAttribute("aria-activedescendant",e.id);let r=this.querySelector(`el-command-preview[for="${e.id}"]`);r&&r.removeAttribute("hidden"),t&&e.scrollIntoView({block:"nearest"}),this.dispatchEvent(new CustomEvent("change",{detail:{relatedTarget:e},bubbles:!1,cancelable:!1}))};var ro=class extends A{getItems(){return Array.from(this.querySelectorAll(`${ae},[role="option"]`))}},io=class extends A{getItems(){return Array.from(this.querySelectorAll(`${ae},[role="option"]`))}},so=class extends A{},lo=class extends A{},ao=class extends A{getItems(){return Array.from(this.querySelectorAll(`${ae},[role="option"]`))}};x("el-command-palette",oo),x("el-command-list",ro),x("el-defaults",io),x("el-no-results",so),x("el-command-group",ao),x("el-command-preview",lo);var He=null;typeof globalThis.window<"u"&&(He=HTMLDialogElement.prototype.close,Object.defineProperties(HTMLDialogElement.prototype,{close:{value(e){let t=this.closest("el-dialog");if(!(t instanceof Re))return He?.call(this,e);let n=t.beforeClose(e);if(!0===n)return He?.call(this,e);!1!==n&&n.then((t=>t?He?.call(this,e):null)).catch(console.error)}}}),document.addEventListener("command",(e=>{let t=e.target;if(!(t instanceof HTMLDialogElement&&"command"in e&&"close"===e.command))return;let n=t.closest("el-dialog");if(!(n instanceof Re))return;let o="BUTTON"===e.source?.tagName?e.source.value:void 0,r=n.beforeClose(o);!0!==r&&(e.stopImmediatePropagation(),e.preventDefault(),!1!==r&&r.then((e=>e?He?.call(t,o):null)).catch(console.error))}),!0));var Q,oe,Be,ye,Ze,uo,Re=class extends A{constructor(){super(...arguments),L(this,Ze),L(this,Q,null),L(this,oe,null),L(this,Be,!0),L(this,ye,ke(this,(()=>Array.from(this.querySelectorAll("el-dialog-panel,el-dialog-backdrop")))))}mount(e){let t=this.getNativeDialog();t.removeAttribute("open"),t.style.setProperty("right","var(--el-top-layer-scrollbar-offset, 0px)");let n=this.hasAttribute("open");for(let e of H(this,Ze,uo).call(this))e.setAttribute("aria-expanded",n.toString());Dr(t,e,(e=>{e.preventDefault();let n=new Event("cancel",{bubbles:!1,cancelable:!0});!this.dispatchEvent(n)||t.close("")})),Ge(this.querySelector("el-dialog-panel")??t,e,(()=>{this.hasAttribute("open")&&t.close("")}));let o=null;t.addEventListener("beforetoggle",(e=>{let t=e;"open"===t.newState&&"closed"===t.oldState&&this.beforeOpen();let n=this.hasAttribute("open");if("open"!==t.newState||n?"closed"===t.newState&&n&&(this.dispatchEvent(new CustomEvent("close",{bubbles:!1,cancelable:!1})),this.removeAttribute("open")):(this.dispatchEvent(new CustomEvent("open",{bubbles:!1,cancelable:!1})),this.setAttribute("open","")),"open"===t.newState&&"closed"===t.oldState)N.length>0&&!o&&(o=N[0]);else if("closed"===t.newState&&"open"===t.oldState){let e=d(this,Be);setTimeout((()=>{e?(o&&o!==document.activeElement&&o.isConnected&&Le(o),o=null):o&&o===document.activeElement&&o.isConnected&&"blur"in o&&"function"==typeof o.blur&&o.blur()}))}}),{signal:e}),t.addEventListener("focusout",(e=>{null===e.relatedTarget&&queueMicrotask((()=>{t.contains(t.ownerDocument.activeElement)||t.focus()}))}),{signal:e}),e.addEventListener("abort",(()=>{var e;d(this,ye).abort(),null==(e=d(this,oe))||e.call(this)})),this.hasAttribute("open")&&t.showModal()}onAttributeChange(e,t,n){switch(e){case"open":{let e=this.getNativeDialog();for(let e of H(this,Ze,uo).call(this))e.setAttribute("aria-expanded",null!==n?"true":"false");null===n?e.close():e.showModal();break}}}getNativeDialog(){let e=this.querySelector("dialog");if(!e)throw new Error("[ElDialog] No `` element found");return e}beforeOpen(){I(this,Be,!0),d(this,Q)&&(d(this,Q).abort(),I(this,Q,null)),d(this,oe)||I(this,oe,yt(this.ownerDocument)),d(this,ye)&&d(this,ye).start("in")}beforeClose(e){if(d(this,oe)&&(d(this,oe).call(this),I(this,oe,null)),d(this,Q))return!1;I(this,Q,new AbortController);let t=d(this,Q).signal;return new Promise((n=>{d(this,ye)?.start("out",(()=>{t.aborted||(I(this,Q,null),requestAnimationFrame((()=>{let t=this.getNativeDialog(),n=t.style.cssText;t.style.cssText=n+"transition-duration: 0 !important;",He?.call(t,e),requestAnimationFrame((()=>{t.style.cssText=n}))})),n(!0))}))}))}show(){this.getNativeDialog().showModal()}hide({restoreFocus:e=!0}={}){I(this,Be,e),this.getNativeDialog().close()}};Q=new WeakMap,oe=new WeakMap,Be=new WeakMap,ye=new WeakMap,Ze=new WeakSet,uo=function(){return document.querySelectorAll(`[commandfor="${this.getNativeDialog().id}"]`)},M(Re,"observedAttributes",["open"]);var co=class extends A{mount(e){Or(this,e,(()=>{let e=this.getDialog(),t=e.getNativeDialog();if(!t.hasAttribute("open"))return;let n=new Event("cancel",{bubbles:!1,cancelable:!0});!e.dispatchEvent(n)||t.close()}))}getDialog(){let e=this.closest("el-dialog");if(!e)throw new Error("[ElDialogPanel] No `` parent found");return e}},fo=class extends A{mount(){this.setAttribute("inert","")}};function Or(e,t,n){let o=null;document.addEventListener("pointerdown",(e=>{o=e.composedPath()[0]||e.target}),!0),document.addEventListener("click",(t=>{if(o!==t.target)return;if(t.target===e){let{clientX:o,clientY:r}=t,i=e.getBoundingClientRect();if(o>=i.left&&o<=i.right&&r>=i.top&&r<=i.bottom)return;return void n(t)}let r=e.closest("dialog");r&&r.contains(t.target)&&!e.contains(t.target)?n(t):t.target!==t.target.ownerDocument.documentElement||n(t)}),{signal:t,capture:!0})}function Dr(e,t,n){e.addEventListener("keydown",(e=>{"Escape"===e.key&&(e.defaultPrevented||n(e))}),{signal:t})}x("el-dialog",Re),x("el-dialog-panel",co),x("el-dialog-backdrop",fo);var Ne,et,po,Tt=class extends A{constructor(){super(...arguments),L(this,et),L(this,Ne,ke(this))}mount(e){this.id||(this.id=P("disclosure")),this.hasAttribute("hidden")?this.removeAttributeNoCallbacks("open"):this.setAttributeNoCallbacks("open","");let t=!this.hasAttribute("hidden");for(let e of H(this,et,po).call(this))e.setAttribute("aria-expanded",t.toString()),e.setAttribute("aria-controls",this.id);this.addEventListener("command",(e=>{if(e.target instanceof HTMLElement&&"command"in e)switch(e.command){case"--show":this.show(),e.preventDefault();break;case"--hide":this.hide(),e.preventDefault();break;case"--toggle":this.toggle(),e.preventDefault()}}),{signal:e}),e.addEventListener("abort",(()=>d(this,Ne).abort()))}onAttributeChange(e,t,n){switch(e){case"hidden":null===n?this.setAttributeNoCallbacks("open",""):this.removeAttributeNoCallbacks("open");for(let e of H(this,et,po).call(this))e.setAttribute("aria-expanded",null===n?"true":"false");null===n?d(this,Ne).start("in"):d(this,Ne).start("out");break;case"open":null===n?this.hide():this.show()}}show(){this.removeAttribute("hidden")}hide(){this.setAttribute("hidden","")}toggle(){this.hasAttribute("hidden")?this.show():this.hide()}};function St(e,t,n,o,r){let i=null;for(let n of t)n.addEventListener("pointerdown",(t=>{0===t.button&&e.classList.contains(":popover-open")&&(i=Date.now()+100)}),{signal:o,capture:!0});e.ownerDocument.addEventListener("focusin",(o=>{if(!n.hasAttribute("open"))return;let a=o.target,l=o.relatedTarget;null!==a&&(i&&Date.now()e.contains(a)))||r(l))}),{signal:o})}Ne=new WeakMap,et=new WeakSet,po=function(){return document.querySelectorAll(`[commandfor="${this.id}"]`)},M(Tt,"observedAttributes",["hidden","open"]),x("el-disclosure",Tt);var Mr=200;function Lt(e,t,n){let o=null,r="",i=null,a=null;e.id||(e.id=P(t.role));let l=t.getButton();l.id||(l.id=P(`${t.role}-button`)),Me(e,n,(()=>[t.getButton()]),(()=>t.getButton()),(()=>t.onBeforeOpen()),(()=>{t.onBeforeClose(),m(),r="",i&&(clearTimeout(i),i=null)})),Et(e,n),e.setAttribute("popover","manual"),e.setAttribute("role",t.role),l.setAttribute("popovertarget",e.id),l.setAttribute("aria-haspopup",t.role);let s=new WeakSet;function u(){let e=t.getItems(),o={passive:!0,signal:n},r="menu"===t.role?"menuitem":"option";for(let i of e)s.has(i)||(s.add(i),i.id||(i.id=P("item")),i.setAttribute("role",r),i.setAttribute("tabIndex","-1"),i.addEventListener("click",(()=>t.onItemClick(i)),o),ue(i,"mouseover",n,(()=>f(i,!1))),ue(i,"mouseout",n,(()=>m())))}u();let c=new MutationObserver(u);c.observe(e,{attributes:!1,childList:!0,subtree:!0}),St(e,[l],e,n,(t=>{null===t&&(a=Date.now()+100),e.hidePopover()}));let d=null,h=!1;l.addEventListener("pointerdown",(t=>{if(0===t.button&&!l.matches(":disabled")){if("touch"===t.pointerType)return void(h=!0);e.togglePopover(),d=Date.now()}}),{signal:n}),document.addEventListener("pointerup",(t=>{if(0===t.button&&!l.matches(":disabled")&&e.hasAttribute("open")){if(Date.now()-(d??0)>Mr){let n=t.composedPath();if(n.includes(e)){if(null!==d){let e=g();e&&e.click()}return}for(let t of n)if(t instanceof Element&&(t.getAttribute("commandfor")||t.getAttribute("popovertarget"))===e.id)return;e.hidePopover()}d=null}}),{signal:n,capture:!0}),l.addEventListener("click",(e=>{h?h=!1:(e.preventDefault(),e.stopPropagation())}),{signal:n});let p=null;function f(t,n=!0){let r=g();null!==r&&r.setAttribute("tabIndex","-1"),e.removeAttribute("tabIndex"),t.setAttribute("tabIndex","0"),t.focus({preventScroll:!0}),o=t,n&&t.scrollIntoView({block:"nearest"})}function m(){let t=g();null!==t&&t.setAttribute("tabIndex","-1"),o=null,e.hasAttribute("open")&&(e.setAttribute("tabIndex","0"),e.focus())}function g(){return o}function b(e,n=!1){if(""===e)return null;let o=t.getItems(),r=e.toLowerCase(),i=g(),a=i?o.indexOf(i):-1;if(!n&&i&&-1!==a&&B(i)&&(i.textContent?.trim().toLowerCase()||"").startsWith(r))return i;for(let e=a+1;e{let t=e;"open"===t.newState&&"closed"===t.oldState&&N.length>0&&!p&&(p=N[0])}),{signal:n}),e.addEventListener("toggle",(t=>{let n=t;"closed"===n.newState&&"open"===n.oldState&&setTimeout((()=>{!e.contains(document.activeElement)&&document.activeElement!==document.body||a&&Date.now(){i&&(clearTimeout(i),i=null),c.disconnect()})),{ignoreNextFocusRestoration:()=>a=Date.now()+100,setActiveItem:f,clearActiveItem:m,getActiveItem:g,findItemBySearchQuery:b,handleSearchKey:function(e){let t=""===r;i&&(clearTimeout(i),i=null),r+=e.toLowerCase();let n=b(r,t);n&&f(n,!0),i=setTimeout((()=>{r="",i=null}),350)},hasActiveSearchQuery:function(){return""!==r}}}var ne,Ee,C,ot,mo,tt=class extends A{constructor(){super(...arguments),L(this,ot),L(this,ne,this.attachInternals()),L(this,Ee,""),L(this,C,null)}mount(e){let t=this.getOptions();this.value=this.getAttribute("value")??this.value??"";let n=this.getButton();n.id||(n.id=P("select-button")),z(n,"--button-width",e,this),n.addEventListener("keydown",(e=>{if(!n.matches(":disabled"))switch(e.key){case"ArrowUp":case"ArrowDown":t.showPopover(),this.goToItem("selected"),e.preventDefault();break;case"Enter":e.preventDefault(),d(this,ne).form&&d(this,ne).form.requestSubmit();break;case" ":if(t.hasAttribute("open")&&d(this,C)&&d(this,C).hasActiveSearchQuery()){e.preventDefault(),e.stopPropagation(),d(this,C).handleSearchKey(e.key);break}e.preventDefault(),t.hasAttribute("open")?t.hidePopover():(t.showPopover(),this.goToItem("selected"));break;default:t.hasAttribute("open")&&1===e.key.length&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&(e.preventDefault(),e.stopPropagation(),this.handleSearchKey(e.key))}}),{signal:e});for(let e of d(this,ne).labels)e.setAttribute("for",n.id);I(this,C,Lt(t,{role:"listbox",getItems:()=>this.getItems(),onItemClick:e=>this.setSelectedOption(e),getButton:()=>this.getButton(),onBeforeOpen:()=>this.onBeforeOpen(),onBeforeClose:()=>this.onBeforeClose()},e)),t.addEventListener("keydown",(e=>{switch(e.key){case"ArrowDown":return e.preventDefault(),e.stopPropagation(),this.goToItem(3);case"ArrowUp":return e.preventDefault(),e.stopPropagation(),this.goToItem(2);case"Home":case"PageUp":return e.preventDefault(),e.stopPropagation(),this.goToItem(0);case"End":case"PageDown":return e.preventDefault(),e.stopPropagation(),this.goToItem(1);case" ":if(d(this,C)&&d(this,C).hasActiveSearchQuery())return e.preventDefault(),e.stopPropagation(),void d(this,C).handleSearchKey(e.key);case"Enter":{e.preventDefault(),e.stopPropagation();let n=this.getActiveItem();return void(n?n.click():t.hidePopover())}case"Tab":d(this,C)&&d(this,C).ignoreNextFocusRestoration();break;case"Escape":e.preventDefault(),e.stopPropagation(),t.hidePopover(),n.focus();break;default:1===e.key.length&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&(e.preventDefault(),e.stopPropagation(),d(this,C)&&d(this,C).handleSearchKey(e.key))}}),{signal:e}),t.addEventListener("toggle",(e=>{"open"===e.newState&&this.onOpen()}),{signal:e});let o=Array.from(t.querySelectorAll("el-option[disabled]"));for(let e of o)e.setAttribute("aria-disabled","true"),e.setAttribute("aria-selected","false")}onAttributeChange(e,t,n){switch(e){case"value":null!==n&&(this.value=n);break;case"required":H(this,ot,mo).call(this)}}getButton(){let e=this.querySelector("button");if(!e)throw new Error("`` must contain a button element.");return e}getOptions(){let e=this.querySelector("el-options");if(!e)throw new Error("`` must contain a `` element.");return e}setSelectedOption(e){this.value=e.getAttribute("value"),this.dispatchEvent(new Event("input",{bubbles:!0,cancelable:!0})),this.dispatchEvent(new Event("change",{bubbles:!0,cancelable:!0})),this.getOptions().hidePopover()}getOptionByName(e){return this.getOptions().getOptionByName(e)}getItems(){return this.getOptions().getItems()}getActiveItem(){return d(this,C)?.getActiveItem()}getSelectedOption(){return this.getOptionByName(d(this,Ee))}goToItem(e=4){let t=this.getItems();if(0===t.length)return;let n=this.getActiveItem();if(null===n&&(2===e||3===e))return void this.goToItem("selected");if("selected"===e){let e=this.getSelectedOption();return void(e&&B(e)?this.setActiveItem(e):this.goToItem(0))}let o=Y(t,n,e);o&&this.setActiveItem(o)}setActiveItem(e){d(this,C)&&d(this,C).setActiveItem(e)}clearActiveItem(){d(this,C)&&d(this,C).clearActiveItem()}onBeforeOpen(){let e=this.getButton(),t=e.dataset.originalTabIndex;t&&(e.dataset.originalTabIndex=t),e.setAttribute("tabIndex","-1")}onOpen(){null===this.getActiveItem()&&this.goToItem("selected")}onBeforeClose(){let e=this.getButton(),t=e.dataset.originalTabIndex;delete e.dataset.originalTabIndex,void 0!==t?e.setAttribute("tabIndex",t):e.removeAttribute("tabIndex");let n=this.getActiveItem();null!==n&&n.setAttribute("tabIndex","-1")}handleSearchKey(e){d(this,C)&&d(this,C).handleSearchKey(e)}set value(e){I(this,Ee,e),d(this,ne).setFormValue(e),H(this,ot,mo).call(this);let t=this.getSelectedOption();if(t){for(let e of this.getItems())e.setAttribute("aria-selected","false");t.setAttribute("aria-selected","true");try{this.querySelectorAll("el-selectedcontent").forEach((e=>e.update()))}catch{}}}get value(){return d(this,Ee)}};ne=new WeakMap,Ee=new WeakMap,C=new WeakMap,ot=new WeakSet,mo=function(){this.hasAttribute("required")&&!d(this,Ee)?d(this,ne).setValidity({valueMissing:!0},yn.selectRequired,this.getButton()):d(this,ne).setValidity({})},M(tt,"formAssociated",!0),M(tt,"observedAttributes",["required"]);var ho=class extends A{mount(){this.update()}update(){let e=this.getSelect().getSelectedOption();if(!e)return;let t=document.createDocumentFragment();for(let n of e.childNodes)t.append(n.cloneNode(!0));this.replaceChildren(t)}getSelect(){let e=this.closest("el-select");if(!e)throw new Error("`` must be inside of a `` element.");return e}};x("el-select",tt),x("el-selectedcontent",ho);var O,go=class extends A{getButton(){let e=this.querySelector("button");if(!e)throw new Error("[ElDropdown] No `