From 8270bf65dbbf8c2b7198fcb77e04d8cc7308eb85 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 8 Feb 2026 23:01:16 +0300 Subject: [PATCH 01/33] Fix themepicker card tooltip stacking order, was overlaid by themepicker ul padding --- src/components/ThemePicker/client/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ThemePicker/client/index.ts b/src/components/ThemePicker/client/index.ts index aa9f0a0c1..fd56929e6 100644 --- a/src/components/ThemePicker/client/index.ts +++ b/src/components/ThemePicker/client/index.ts @@ -521,7 +521,7 @@ export class ThemePickerElement extends LitElement { portal.setAttribute('id', this.tooltipId) portal.setAttribute('role', 'tooltip') portal.className = - 'fixed left-0 top-0 z-(--z-modal) pointer-events-none opacity-0 transition-opacity duration-150 ease-out' + 'fixed left-0 top-0 z-(--z-theme-picker) pointer-events-none opacity-0 transition-opacity duration-150 ease-out' const content = document.createElement('div') content.className = From 7aa53157117978c833187b853bd9411f97c71988 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 9 Feb 2026 00:35:23 +0300 Subject: [PATCH 02/33] Improve search icon --- src/components/Search/SearchBar/index.astro | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/Search/SearchBar/index.astro b/src/components/Search/SearchBar/index.astro index b358983e3..509209af2 100644 --- a/src/components/Search/SearchBar/index.astro +++ b/src/components/Search/SearchBar/index.astro @@ -1,5 +1,4 @@ --- -import Icon from '@components/Icon/index.astro' import closeSvg from '../../../icons/close.svg?raw' import microphoneSvg from '../../../icons/microphone.svg?raw' import styles from './index.module.css' @@ -86,11 +85,20 @@ const clearButtonLabel = hasInitialQuery ? 'Clear search' : 'Close search' hidden={isExpandedOnLoad} class="relative inline-flex items-center justify-center w-(--header-icon-size) h-(--header-icon-size) shrink-0 rounded-full border-0 bg-primary hover:bg-primary-offset focus:bg-primary-offset focus-visible:bg-primary-offset focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:-inset-1.5 focus-visible:after:border-spotlight transition-colors duration-150 ease-linear data-[state=open]:bg-transparent data-[state=open]:hover:bg-transparent data-[state=open]:focus:bg-transparent data-[state=open]:focus-visible:bg-transparent" > - + From f6a7b776c62b52e6d0bd29c6942282fff086380d Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 9 Feb 2026 01:14:24 +0300 Subject: [PATCH 03/33] Improve hero colors --- .../Animations/Computers/index.astro | 5 ++++- src/components/Animations/Hero/index.astro | 20 +++++++++++++++++-- src/styles/theme-inline.css | 2 +- src/styles/themes/dark.css | 4 ++-- src/styles/themes/light.css | 2 +- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/components/Animations/Computers/index.astro b/src/components/Animations/Computers/index.astro index 36bb4a6b2..8cbf270ce 100644 --- a/src/components/Animations/Computers/index.astro +++ b/src/components/Animations/Computers/index.astro @@ -14,7 +14,10 @@ import Icon from '@components/Icon/index.astro' aria-label="Pause animation" aria-pressed="false" > - + Senior
Platform
@@ -65,7 +65,23 @@ const { pretitle, benefits } = Astro.props as Props -
- -
- - - - diff --git a/src/components/Home/Backstage/index.astro b/src/components/Home/Backstage/index.astro index b90993c59..794b2a7b7 100644 --- a/src/components/Home/Backstage/index.astro +++ b/src/components/Home/Backstage/index.astro @@ -20,7 +20,7 @@ export interface Props { }[] } -const { pretitle, title, description, benefits, features } = Astro.props as Props +const { pretitle, description, benefits, features } = Astro.props as Props const svgPath = { 'left-justified': 'M4 6h16M4 12h16M4 18h7', diff --git a/src/pages/testing/animations-computers.astro b/src/pages/testing/animations-terraform.astro similarity index 69% rename from src/pages/testing/animations-computers.astro rename to src/pages/testing/animations-terraform.astro index b624092f2..07f9b6a1e 100644 --- a/src/pages/testing/animations-computers.astro +++ b/src/pages/testing/animations-terraform.astro @@ -1,17 +1,17 @@ --- import BaseLayout from '@layouts/BaseLayout.astro' -import ComputersAnimation from '@components/Animations/Computers/index.astro' +import TerraformAnimation from '@components/Animations/Terraform/index.astro' -const pageTitle = 'Computers Animation Fixture' +const pageTitle = 'Terraform Animation Fixture' const pageDescription = - 'Isolated test route for the hero computers animation and its lifecycle controller.' -const pagePath = '/testing/animations-computers' + 'Isolated test route for the hero terraform animation and its lifecycle controller.' +const pagePath = '/testing/animations-terraform' ---

- This dedicated route hosts the hero computers animation inside a stable shell so automated + This dedicated route hosts the hero terraform animation inside a stable shell so automated tests can validate pause and play semantics without relying on home page content or layout changes.

@@ -22,6 +22,6 @@ const pagePath = '/testing/animations-computers'
- +
diff --git a/test/e2e/POM_METHOD_USAGE.md b/test/e2e/POM_METHOD_USAGE.md index 7b64b6c94..c96e69625 100644 --- a/test/e2e/POM_METHOD_USAGE.md +++ b/test/e2e/POM_METHOD_USAGE.md @@ -453,7 +453,7 @@ has navigateToDiagnosticsPage and getSnapshot: - test/e2e/specs/01-smoke/dynamic-pages.spec.ts - test/e2e/specs/02-pages/services.spec.ts -- test/e2e/specs/04-components/animations-computers.spec.ts +- test/e2e/specs/04-components/animations-terraform.spec.ts - test/e2e/specs/04-components/carousel.spec.ts - test/e2e/specs/04-components/consentBanner.spec.ts - test/e2e/specs/04-components/footer.spec.ts diff --git a/test/e2e/specs/04-components/animations-computers.spec.ts b/test/e2e/specs/04-components/animations-terraform.spec.ts similarity index 91% rename from test/e2e/specs/04-components/animations-computers.spec.ts rename to test/e2e/specs/04-components/animations-terraform.spec.ts index 3704fe49e..aee0fbcf7 100644 --- a/test/e2e/specs/04-components/animations-computers.spec.ts +++ b/test/e2e/specs/04-components/animations-terraform.spec.ts @@ -4,7 +4,7 @@ import type { Page } from '@playwright/test' type AnimationPlayState = 'playing' | 'paused' | null const selectors = { - host: 'computers-animation', + host: 'terraform-animation', toggle: '[data-animation-toggle]', } @@ -19,10 +19,10 @@ const isMobileProject = (projectName: string): boolean => { return projectName.startsWith('mobile-') } -async function loadComputersFixture(playwrightPage: Page, options: FixtureOptions = {}): Promise { +async function loadTerraformFixture(playwrightPage: Page, options: FixtureOptions = {}): Promise { const page = await BasePage.init(playwrightPage) await page.page.emulateMedia({ reducedMotion: options.reducedMotion ?? 'no-preference' }) - await page.goto('/testing/animations-computers') + await page.goto('/testing/animations-terraform') // NOTE: The component is intentionally `hidden lg:flex` in production markup. // On mobile projects it will remain hidden, so waiting for "visible" will hang. await page.waitForSelector(selectors.host, { state: 'attached' }) @@ -32,7 +32,7 @@ async function loadComputersFixture(playwrightPage: Page, options: FixtureOption async function getAnimationState(page: BasePage): Promise { return await page.evaluate(() => { - return document.querySelector('computers-animation')?.getAttribute('data-animation-state') as AnimationPlayState + return document.querySelector('terraform-animation')?.getAttribute('data-animation-state') as AnimationPlayState }) } @@ -56,9 +56,9 @@ async function setOverlayPause(page: BasePage, isPaused: boolean): Promise }, { paused: isPaused, source: overlaySource }) } -test.describe('Computers Animation Component', () => { +test.describe('Terraform Animation Component', () => { test('plays by default when no pause sources exist', async ({ page: playwrightPage }, testInfo) => { - const page = await loadComputersFixture(playwrightPage) + const page = await loadTerraformFixture(playwrightPage) const host = page.locator(selectors.host) const toggle = page.locator(selectors.toggle) @@ -77,7 +77,7 @@ test.describe('Computers Animation Component', () => { }) test('honors prefers-reduced-motion by starting paused', async ({ page: playwrightPage }, testInfo) => { - const page = await loadComputersFixture(playwrightPage, { reducedMotion: 'reduce' }) + const page = await loadTerraformFixture(playwrightPage, { reducedMotion: 'reduce' }) const host = page.locator(selectors.host) await waitForAnimationState(page, 'paused') @@ -96,7 +96,7 @@ test.describe('Computers Animation Component', () => { test( 'responds to overlay pause and resume actions from the animation store', async ({ page: playwrightPage }, testInfo) => { - const page = await loadComputersFixture(playwrightPage) + const page = await loadTerraformFixture(playwrightPage) const host = page.locator(selectors.host) // On mobile projects the component is intentionally `hidden lg:flex` and must not start. From 423c059f4486d4a26e34db2bdbe7585be8569528 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 10 Feb 2026 04:27:10 +0300 Subject: [PATCH 13/33] Add non-featured tag pages from skills + technologies home page component, update stylings on that home page component, add product icons for it --- _TODO.md | 20 + cover.jpg | Bin 0 -> 1530 bytes .../technologies/Amazon_Web_Services_Logo.svg | 38 + src/assets/images/technologies/Argo CD.svg | 1 + .../images/technologies/Docker_Logo.svg | 21 + .../images/technologies/Go_Logo_Blue.svg | 55 + .../images/technologies/Grafana_logo.svg | 70 ++ .../Helm_(package_manager)_logo.svg | 1 + .../Kubernetes_logo_without_workmark.svg | 84 ++ .../images/technologies/Microsoft_Azure.svg | 23 + .../OpenStack\302\256_Logo_2016.svg" | 36 + .../technologies/Prometheus_software_logo.svg | 50 + .../technologies/Python-logo-notext.svg | 1 + src/assets/images/technologies/Ruby_logo.svg | 948 ++++++++++++++++++ .../images/technologies/Terraform_Logo.svg | 7 + src/assets/images/technologies/Typescript.svg | 4 + .../technologies/crossplane-icon-seeklogo.png | Bin 0 -> 21284 bytes src/components/Home/Skills/index.astro | 84 +- src/content/tags/argo-cd/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/argo-cd/index.mdx | 8 + src/content/tags/aws/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/aws/index.mdx | 8 + src/content/tags/azure/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/azure/index.mdx | 8 + src/content/tags/backstage-idp/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/backstage-idp/index.mdx | 8 + src/content/tags/crossplane/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/crossplane/index.mdx | 8 + src/content/tags/docker/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/docker/index.mdx | 8 + src/content/tags/dotnet/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/dotnet/index.mdx | 8 + src/content/tags/go/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/go/index.mdx | 8 + src/content/tags/grafana/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/grafana/index.mdx | 8 + src/content/tags/helm/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/helm/index.mdx | 8 + src/content/tags/kubernetes/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/kubernetes/index.mdx | 8 + src/content/tags/openstack/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/openstack/index.mdx | 8 + src/content/tags/prometheus/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/prometheus/index.mdx | 8 + src/content/tags/python/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/python/index.mdx | 8 + src/content/tags/ruby/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/ruby/index.mdx | 8 + src/content/tags/terraform/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/terraform/index.mdx | 8 + src/content/tags/typescript/cover.jpg | Bin 0 -> 1530 bytes src/content/tags/typescript/index.mdx | 8 + src/pages/hero.astro | 184 +--- src/pages/index.astro | 4 +- 54 files changed, 1526 insertions(+), 241 deletions(-) create mode 100644 cover.jpg create mode 100644 src/assets/images/technologies/Amazon_Web_Services_Logo.svg create mode 100644 src/assets/images/technologies/Argo CD.svg create mode 100644 src/assets/images/technologies/Docker_Logo.svg create mode 100644 src/assets/images/technologies/Go_Logo_Blue.svg create mode 100644 src/assets/images/technologies/Grafana_logo.svg create mode 100644 src/assets/images/technologies/Helm_(package_manager)_logo.svg create mode 100644 src/assets/images/technologies/Kubernetes_logo_without_workmark.svg create mode 100644 src/assets/images/technologies/Microsoft_Azure.svg create mode 100644 "src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" create mode 100644 src/assets/images/technologies/Prometheus_software_logo.svg create mode 100644 src/assets/images/technologies/Python-logo-notext.svg create mode 100644 src/assets/images/technologies/Ruby_logo.svg create mode 100644 src/assets/images/technologies/Terraform_Logo.svg create mode 100644 src/assets/images/technologies/Typescript.svg create mode 100644 src/assets/images/technologies/crossplane-icon-seeklogo.png create mode 100644 src/content/tags/argo-cd/cover.jpg create mode 100644 src/content/tags/argo-cd/index.mdx create mode 100644 src/content/tags/aws/cover.jpg create mode 100644 src/content/tags/aws/index.mdx create mode 100644 src/content/tags/azure/cover.jpg create mode 100644 src/content/tags/azure/index.mdx create mode 100644 src/content/tags/backstage-idp/cover.jpg create mode 100644 src/content/tags/backstage-idp/index.mdx create mode 100644 src/content/tags/crossplane/cover.jpg create mode 100644 src/content/tags/crossplane/index.mdx create mode 100644 src/content/tags/docker/cover.jpg create mode 100644 src/content/tags/docker/index.mdx create mode 100644 src/content/tags/dotnet/cover.jpg create mode 100644 src/content/tags/dotnet/index.mdx create mode 100644 src/content/tags/go/cover.jpg create mode 100644 src/content/tags/go/index.mdx create mode 100644 src/content/tags/grafana/cover.jpg create mode 100644 src/content/tags/grafana/index.mdx create mode 100644 src/content/tags/helm/cover.jpg create mode 100644 src/content/tags/helm/index.mdx create mode 100644 src/content/tags/kubernetes/cover.jpg create mode 100644 src/content/tags/kubernetes/index.mdx create mode 100644 src/content/tags/openstack/cover.jpg create mode 100644 src/content/tags/openstack/index.mdx create mode 100644 src/content/tags/prometheus/cover.jpg create mode 100644 src/content/tags/prometheus/index.mdx create mode 100644 src/content/tags/python/cover.jpg create mode 100644 src/content/tags/python/index.mdx create mode 100644 src/content/tags/ruby/cover.jpg create mode 100644 src/content/tags/ruby/index.mdx create mode 100644 src/content/tags/terraform/cover.jpg create mode 100644 src/content/tags/terraform/index.mdx create mode 100644 src/content/tags/typescript/cover.jpg create mode 100644 src/content/tags/typescript/index.mdx diff --git a/_TODO.md b/_TODO.md index 63cda78b3..f22d28c6d 100644 --- a/_TODO.md +++ b/_TODO.md @@ -240,3 +240,23 @@ Remove the `content/downloads` folder, and point the content collection to look - "### Geographic/Currency Mismatches" in src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx - cover.jpg for reliability-and-testing needs touch up in GIMP + +## Non-featured tags + +argo-cd +aws +azure +backstage +crossplane +docker +dotnet +go +grafana +helm +kubernetes +openstack +prometheus +python +ruby +terraform +typescript diff --git a/cover.jpg b/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/assets/images/technologies/Amazon_Web_Services_Logo.svg b/src/assets/images/technologies/Amazon_Web_Services_Logo.svg new file mode 100644 index 000000000..4715937ff --- /dev/null +++ b/src/assets/images/technologies/Amazon_Web_Services_Logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/src/assets/images/technologies/Argo CD.svg b/src/assets/images/technologies/Argo CD.svg new file mode 100644 index 000000000..ef6eff545 --- /dev/null +++ b/src/assets/images/technologies/Argo CD.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/technologies/Docker_Logo.svg b/src/assets/images/technologies/Docker_Logo.svg new file mode 100644 index 000000000..4518f0bfd --- /dev/null +++ b/src/assets/images/technologies/Docker_Logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/technologies/Go_Logo_Blue.svg b/src/assets/images/technologies/Go_Logo_Blue.svg new file mode 100644 index 000000000..8164d5589 --- /dev/null +++ b/src/assets/images/technologies/Go_Logo_Blue.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/technologies/Grafana_logo.svg b/src/assets/images/technologies/Grafana_logo.svg new file mode 100644 index 000000000..54be1e2f1 --- /dev/null +++ b/src/assets/images/technologies/Grafana_logo.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/technologies/Helm_(package_manager)_logo.svg b/src/assets/images/technologies/Helm_(package_manager)_logo.svg new file mode 100644 index 000000000..b85d7d90e --- /dev/null +++ b/src/assets/images/technologies/Helm_(package_manager)_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/technologies/Kubernetes_logo_without_workmark.svg b/src/assets/images/technologies/Kubernetes_logo_without_workmark.svg new file mode 100644 index 000000000..bedd3b88e --- /dev/null +++ b/src/assets/images/technologies/Kubernetes_logo_without_workmark.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/src/assets/images/technologies/Microsoft_Azure.svg b/src/assets/images/technologies/Microsoft_Azure.svg new file mode 100644 index 000000000..ff5dfa5c1 --- /dev/null +++ b/src/assets/images/technologies/Microsoft_Azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git "a/src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" "b/src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" new file mode 100644 index 000000000..18cd8a336 --- /dev/null +++ "b/src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" @@ -0,0 +1,36 @@ + + + + + + image/svg+xml + + OpenStack_Logo2016 + + + + + OpenStack_Logo2016 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/technologies/Prometheus_software_logo.svg b/src/assets/images/technologies/Prometheus_software_logo.svg new file mode 100644 index 000000000..5c51f66d9 --- /dev/null +++ b/src/assets/images/technologies/Prometheus_software_logo.svg @@ -0,0 +1,50 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/src/assets/images/technologies/Python-logo-notext.svg b/src/assets/images/technologies/Python-logo-notext.svg new file mode 100644 index 000000000..05155f370 --- /dev/null +++ b/src/assets/images/technologies/Python-logo-notext.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/technologies/Ruby_logo.svg b/src/assets/images/technologies/Ruby_logo.svg new file mode 100644 index 000000000..59cf324fe --- /dev/null +++ b/src/assets/images/technologies/Ruby_logo.svg @@ -0,0 +1,948 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/technologies/Terraform_Logo.svg b/src/assets/images/technologies/Terraform_Logo.svg new file mode 100644 index 000000000..e98f8b71f --- /dev/null +++ b/src/assets/images/technologies/Terraform_Logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/images/technologies/Typescript.svg b/src/assets/images/technologies/Typescript.svg new file mode 100644 index 000000000..520f04210 --- /dev/null +++ b/src/assets/images/technologies/Typescript.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/technologies/crossplane-icon-seeklogo.png b/src/assets/images/technologies/crossplane-icon-seeklogo.png new file mode 100644 index 0000000000000000000000000000000000000000..1ee48785ee56e424d3ac21841d6d536589453658 GIT binary patch literal 21284 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 Ge*F&+BqtjH literal 0 HcmV?d00001 diff --git a/src/components/Home/Skills/index.astro b/src/components/Home/Skills/index.astro index 0e46454f9..d84200d97 100644 --- a/src/components/Home/Skills/index.astro +++ b/src/components/Home/Skills/index.astro @@ -1,68 +1,40 @@ --- /** - * "Technologies and Skills" section for the homepage + * "Technologies and Skills" section for the homepage. + * Renders a dark strip with icon + label links for each technology. */ export interface Props { - technologies: { - name: string - }[] + technologies: string[] } const { technologies } = Astro.props as Props + +/** Convert a display name like "Argo CD" to a URL slug like "argo-cd" */ +function toTagSlug(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') +} --- -
-

- Technologies & Expertise -

-
-
-
- Go -
- Go -
-
-
- TS -
- TypeScript -
-
-
- TF -
- Terraform -
-
-
- Az -
- Azure -
-
-
- K8s -
- Kubernetes -
-
-
+

Technologies & Expertise

+ +
+ {technologies.map((tech) => ( + - Py -
- Python -
+
+
+ img +
+
+ {tech} +
+ ))}
diff --git a/src/content/tags/argo-cd/cover.jpg b/src/content/tags/argo-cd/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/argo-cd/index.mdx b/src/content/tags/argo-cd/index.mdx new file mode 100644 index 000000000..c703ef536 --- /dev/null +++ b/src/content/tags/argo-cd/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "argo-cd" +displayName: "Argo CD" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/aws/cover.jpg b/src/content/tags/aws/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/aws/index.mdx b/src/content/tags/aws/index.mdx new file mode 100644 index 000000000..f1bde744a --- /dev/null +++ b/src/content/tags/aws/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "aws" +displayName: "AWS" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/azure/cover.jpg b/src/content/tags/azure/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/azure/index.mdx b/src/content/tags/azure/index.mdx new file mode 100644 index 000000000..87ed216fe --- /dev/null +++ b/src/content/tags/azure/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "azure" +displayName: "Azure" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/backstage-idp/cover.jpg b/src/content/tags/backstage-idp/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/backstage-idp/index.mdx b/src/content/tags/backstage-idp/index.mdx new file mode 100644 index 000000000..913d2eb51 --- /dev/null +++ b/src/content/tags/backstage-idp/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "backstage-idp" +displayName: "Backstage IDP" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/crossplane/cover.jpg b/src/content/tags/crossplane/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/crossplane/index.mdx b/src/content/tags/crossplane/index.mdx new file mode 100644 index 000000000..64a24d50c --- /dev/null +++ b/src/content/tags/crossplane/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "crossplane" +displayName: "Crossplane" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/docker/cover.jpg b/src/content/tags/docker/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/docker/index.mdx b/src/content/tags/docker/index.mdx new file mode 100644 index 000000000..064719d1a --- /dev/null +++ b/src/content/tags/docker/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "docker" +displayName: "Docker" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/dotnet/cover.jpg b/src/content/tags/dotnet/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/dotnet/index.mdx b/src/content/tags/dotnet/index.mdx new file mode 100644 index 000000000..efa320290 --- /dev/null +++ b/src/content/tags/dotnet/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "dotnet" +displayName: ".NET / C#" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/go/cover.jpg b/src/content/tags/go/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/go/index.mdx b/src/content/tags/go/index.mdx new file mode 100644 index 000000000..d5e5efe20 --- /dev/null +++ b/src/content/tags/go/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "go" +displayName: "Go Language" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/grafana/cover.jpg b/src/content/tags/grafana/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/grafana/index.mdx b/src/content/tags/grafana/index.mdx new file mode 100644 index 000000000..c1b6af54c --- /dev/null +++ b/src/content/tags/grafana/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "grafana" +displayName: "Grafana" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/helm/cover.jpg b/src/content/tags/helm/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/helm/index.mdx b/src/content/tags/helm/index.mdx new file mode 100644 index 000000000..ddb93289b --- /dev/null +++ b/src/content/tags/helm/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "helm" +displayName: "Helm" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/kubernetes/cover.jpg b/src/content/tags/kubernetes/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/kubernetes/index.mdx b/src/content/tags/kubernetes/index.mdx new file mode 100644 index 000000000..1f62e4369 --- /dev/null +++ b/src/content/tags/kubernetes/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "kubernetes" +displayName: "Kubernetes" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/openstack/cover.jpg b/src/content/tags/openstack/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/openstack/index.mdx b/src/content/tags/openstack/index.mdx new file mode 100644 index 000000000..33e71c013 --- /dev/null +++ b/src/content/tags/openstack/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "openstack" +displayName: "Open Stack" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/prometheus/cover.jpg b/src/content/tags/prometheus/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/prometheus/index.mdx b/src/content/tags/prometheus/index.mdx new file mode 100644 index 000000000..8a0efb106 --- /dev/null +++ b/src/content/tags/prometheus/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "prometheus" +displayName: "Prometheus" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/python/cover.jpg b/src/content/tags/python/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/python/index.mdx b/src/content/tags/python/index.mdx new file mode 100644 index 000000000..e9c76efef --- /dev/null +++ b/src/content/tags/python/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "python" +displayName: "Python" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/ruby/cover.jpg b/src/content/tags/ruby/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/ruby/index.mdx b/src/content/tags/ruby/index.mdx new file mode 100644 index 000000000..6d4d9f447 --- /dev/null +++ b/src/content/tags/ruby/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "ruby" +displayName: "Ruby" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/terraform/cover.jpg b/src/content/tags/terraform/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/terraform/index.mdx b/src/content/tags/terraform/index.mdx new file mode 100644 index 000000000..a812322d7 --- /dev/null +++ b/src/content/tags/terraform/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "terraform" +displayName: "Terraform" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/content/tags/typescript/cover.jpg b/src/content/tags/typescript/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc0c5c7db78e053445632ee4121d202b1a741a36 GIT binary patch literal 1530 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivWMk*xmaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrR vk0JbZi-Cuk5g2*Qf(-TyAF4DK?O*HvOkq)%2E!;G4TI4%Fd%7w`TtD--4F1F literal 0 HcmV?d00001 diff --git a/src/content/tags/typescript/index.mdx b/src/content/tags/typescript/index.mdx new file mode 100644 index 000000000..7adf0a395 --- /dev/null +++ b/src/content/tags/typescript/index.mdx @@ -0,0 +1,8 @@ +--- +slug: "typescript" +displayName: "TypeScript" +description: "" +cover: "./cover.jpg" +coverAlt: "" +featured: false +--- diff --git a/src/pages/hero.astro b/src/pages/hero.astro index 64019a3b6..16c428040 100644 --- a/src/pages/hero.astro +++ b/src/pages/hero.astro @@ -811,101 +811,8 @@ const path = '/hero/'
-

Skills & Technologies Variants

- - - - -
-

Variant 2: Dark Ticker Strip (No Descriptions)

- -
+

Technologies & Expertise

@@ -946,94 +853,7 @@ const path = '/hero/' Python
-
-
- - -
diff --git a/src/pages/index.astro b/src/pages/index.astro index cb1320b28..42b1d0abb 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -103,9 +103,7 @@ const sectionClasses = 'container mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8 lg:p
From efff2344a1a2e231672342a948082edc294d0076 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 10 Feb 2026 18:20:14 +0300 Subject: [PATCH 14/33] Add non-featured tags to articles --- _TODO.md | 4 +++- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../pdf.mdx | 2 +- .../api-gateway-metrics-traces-logs-debugging/index.mdx | 2 +- .../api-gateway-metrics-traces-logs-debugging/pdf.mdx | 2 +- .../api-usage-metering-quotas-cost-attribution/index.mdx | 2 +- .../api-usage-metering-quotas-cost-attribution/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../cdn-edge-caching-cache-keys-vary-headers/index.mdx | 2 +- .../cdn-edge-caching-cache-keys-vary-headers/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 6 ++---- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../dead-letter-queue-design-replay-debugging/index.mdx | 2 +- .../dead-letter-queue-design-replay-debugging/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../eol-runtime-upgrade-dependency-hell-migration/index.mdx | 2 +- .../eol-runtime-upgrade-dependency-hell-migration/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 5 +---- .../index.mdx | 3 --- .../pdf.mdx | 1 - .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../kubernetes-decision-framework-when-not-to-use/index.mdx | 2 +- .../kubernetes-decision-framework-when-not-to-use/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../nginx-haproxy-reverse-proxy-production-tuning/index.mdx | 2 +- .../nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../opentelemetry-span-design-granularity-overhead/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../postgresql-connection-pooling-saturation-sizing/pdf.mdx | 2 +- .../private-networking-dns-routing-tls-debugging/index.mdx | 2 +- .../private-networking-dns-routing-tls-debugging/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../service-decommissioning-scream-test-shutdown/index.mdx | 2 +- .../service-decommissioning-scream-test-shutdown/pdf.mdx | 2 +- .../articles/slo-error-budget-practical-guide/index.mdx | 2 +- .../articles/slo-error-budget-practical-guide/pdf.mdx | 2 +- .../index.mdx | 4 +--- .../pdf.mdx | 2 +- .../strangler-fig-migration-complete-guide/index.mdx | 2 +- .../articles/strangler-fig-migration-complete-guide/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../symptom-based-alerting-runbooks-alert-design/index.mdx | 2 +- .../symptom-based-alerting-runbooks-alert-design/pdf.mdx | 2 +- .../index.mdx | 2 +- .../synthetic-test-data-pii-anonymization-fixtures/pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- .../index.mdx | 2 +- .../pdf.mdx | 2 +- 127 files changed, 128 insertions(+), 137 deletions(-) diff --git a/_TODO.md b/_TODO.md index f22d28c6d..5dca9f34d 100644 --- a/_TODO.md +++ b/_TODO.md @@ -240,6 +240,8 @@ Remove the `content/downloads` folder, and point the content collection to look - "### Geographic/Currency Mismatches" in src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx - cover.jpg for reliability-and-testing needs touch up in GIMP +- We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag lik "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag and add the name to the tags: frontmatter key in the index.mdx +- Need an article on OpenStack ## Non-featured tags @@ -255,7 +257,7 @@ grafana helm kubernetes openstack -prometheus +prometheus / promql python ruby terraform diff --git a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx index 4571e9a01..bf8c8e5f5 100644 --- a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx +++ b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radar screen showing single bright alert ping surrounded by faded noise halo, representing critical actionable cloud monitoring alert" author: "kevin-brown" publishDate: 2025-10-05 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "prometheus"] featured: true --- diff --git a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx index bfacbceed..1b0a5ced7 100644 --- a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx +++ b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radar screen showing single bright alert ping surrounded by faded noise halo, representing critical actionable cloud monitoring alert" author: "kevin-brown" publishDate: 2025-10-05 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "prometheus"] featured: true --- diff --git a/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx b/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx index 2fa740b6e..0f8b34590 100644 --- a/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx +++ b/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Countdown timer showing 30 days remaining with API traffic streams redirecting from deprecated endpoints to new endpoints" author: "kevin-brown" publishDate: 2023-06-04 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "typescript"] featured: true --- diff --git a/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx b/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx index 03dbbb886..e051b5cfb 100644 --- a/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx +++ b/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "API gateway shown as transparent structure with illuminated request path revealing internal components like auth, rate limiting, and routing" author: "kevin-brown" publishDate: 2024-09-01 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "prometheus", "aws"] featured: true --- diff --git a/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx b/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx index a246340a9..c140d563a 100644 --- a/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx +++ b/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "API gateway shown as transparent structure with illuminated request path revealing internal components like auth, rate limiting, and routing" author: "kevin-brown" publishDate: 2024-09-01 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "prometheus", "grafana", "aws", "terraform"] featured: true --- diff --git a/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx b/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx index 9eaa3e844..c87eb009c 100644 --- a/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx +++ b/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data flowing through metering pipeline with API requests passing through measurement gates and aggregating into usage buckets for cost calculations" author: "kevin-brown" publishDate: 2023-08-06 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "prometheus", "python"] featured: true --- diff --git a/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx b/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx index 47d8fdbcb..1af3642c9 100644 --- a/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx +++ b/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data flowing through metering pipeline with API requests passing through measurement gates and aggregating into usage buckets for cost calculations" author: "kevin-brown" publishDate: 2023-08-06 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "prometheus", "grafana", "python"] featured: true --- diff --git a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx index 3db2e857e..0402d7a70 100644 --- a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx +++ b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "ArgoCD dashboard showing application in degraded state with sync waves as horizontal tracks and stuck/failed resource indicators" author: "kevin-brown" publishDate: 2022-06-05 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "argo-cd", "kubernetes", "helm"] featured: true --- diff --git a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx index 8c9e6b07b..f6a9ab3be 100644 --- a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx +++ b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "ArgoCD dashboard showing application in degraded state with sync waves as horizontal tracks and stuck/failed resource indicators" author: "kevin-brown" publishDate: 2022-06-05 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "argo-cd", "kubernetes", "helm"] featured: true --- diff --git a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx index f53973d2f..5ca261f67 100644 --- a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx +++ b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Availability ruler showing exponentially increasing gaps between 99%, 99.9%, 99.99%, and 99.999% with dollar signs and magnifying glass highlighting cost of five nines" author: "kevin-brown" publishDate: 2023-02-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "python"] featured: true --- diff --git a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx index c037e2a5b..45cdea155 100644 --- a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx +++ b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Availability ruler showing exponentially increasing gaps between 99%, 99.9%, 99.99%, and 99.999% with dollar signs and magnifying glass highlighting cost of five nines" author: "kevin-brown" publishDate: 2023-02-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "python"] featured: true --- diff --git a/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx b/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx index 1ae598e1d..9ee0fdb79 100644 --- a/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx +++ b/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway interchange with traffic signals and metering lights managing flow, showing smooth lanes and redirected overflow traffic under heavy load" author: "kevin-brown" publishDate: 2022-08-07 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "aws", "typescript", "python", "go"] featured: true --- diff --git a/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx b/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx index 0ef482ecf..8df204d51 100644 --- a/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx +++ b/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway interchange with traffic signals and metering lights managing flow, showing smooth lanes and redirected overflow traffic under heavy load" author: "kevin-brown" publishDate: 2022-08-07 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "aws", "typescript", "python", "go", "grafana", "prometheus"] featured: true --- diff --git a/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx b/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx index fc02846eb..15c757db2 100644 --- a/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx +++ b/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Deployment strategy spectrum showing progression from simple to complex: Recreate, Rolling, Blue/Green, Canary, and Progressive deployment patterns" author: "kevin-brown" publishDate: 2023-08-20 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "aws", "python", "prometheus", "typescript"] featured: true --- diff --git a/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx b/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx index 290c25115..cb5809b56 100644 --- a/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx +++ b/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Deployment strategy spectrum showing progression from simple to complex: Recreate, Rolling, Blue/Green, Canary, and Progressive deployment patterns" author: "kevin-brown" publishDate: 2023-08-20 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "aws", "python", "prometheus", "typescript"] featured: true --- diff --git a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx index 0cdb5e0cd..8aa4aa2fa 100644 --- a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx +++ b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing performance versus correctness trade-off, with cache misconfiguration tipping the scale dangerously toward performance" author: "kevin-brown" publishDate: 2022-03-05 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "typescript"] featured: true --- diff --git a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx index bee96b0b5..3cc28b2ea 100644 --- a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx +++ b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing performance versus correctness trade-off, with cache misconfiguration tipping the scale dangerously toward performance" author: "kevin-brown" publishDate: 2022-03-05 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "typescript"] featured: true --- diff --git a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx index 7a3828b1f..1bc1ac680 100644 --- a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx +++ b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Laboratory setting with engineers conducting controlled experiments on miniature server infrastructure in place of traditional lab equipment" author: "kevin-brown" publishDate: 2022-03-19 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "kubernetes", "python", "go"] featured: false --- diff --git a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx index 09943c062..c395a4f73 100644 --- a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx +++ b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Laboratory setting with engineers conducting controlled experiments on miniature server infrastructure in place of traditional lab equipment" author: "kevin-brown" publishDate: 2024-01-15 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "kubernetes", "python", "go"] featured: true --- diff --git a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx index c0c2386d8..77375ba1c 100644 --- a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx +++ b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI pipeline racing track with builds as cars, showing cached fast lanes with motion blur versus slower uncached sections and reduced build time at finish" author: "kevin-brown" publishDate: 2025-09-06 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "docker", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx index f78cb7cbb..6b276b2fd 100644 --- a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx +++ b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI pipeline racing track with builds as cars, showing cached fast lanes with motion blur versus slower uncached sections and reduced build time at finish" author: "kevin-brown" publishDate: 2025-09-06 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "docker", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx index 7a5740276..5a5912cef 100644 --- a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx +++ b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Retry budget gauge showing partial depletion with replenishment pipe from successful requests feeding back into the meter" author: "kevin-brown" publishDate: 2024-09-15 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus", "typescript", "go", "python"] featured: true --- diff --git a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx index a8759550a..ee656d8dc 100644 --- a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx +++ b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Retry budget gauge showing partial depletion with replenishment pipe from successful requests feeding back into the meter" author: "kevin-brown" publishDate: 2024-09-15 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus", "typescript", "go", "python"] featured: true --- diff --git a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx index 8f4858afc..185c420d9 100644 --- a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx +++ b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Consumer and provider services separated by two-way mirror with glowing contract showing mutual expectations between them" author: "kevin-brown" publishDate: 2024-04-07 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "docker", "kubernetes", "typescript", "python", "go", "ruby", "dotnet"] featured: true --- @@ -67,16 +67,14 @@ sequenceDiagram P->>P: Replay against real API P->>B: Publish verification results B->>B: Track compatibility matrix -``` Figure: Contract testing workflow showing consumer-first approach. Here's what this looks like in practice. A consumer test defines the expected request and response for an API call: ```typescript title="user-api.pact.spec.ts" import { PactV3, MatchersV3 } from '@pact-foundation/pact'; - +tags: ["apis-and-gateways", "docker", "kubernetes", "typescript", "python", "go", "ruby", "dotnet"] // like() matches type/structure, not exact values—keeps contracts flexible -const { like } = MatchersV3; const provider = new PactV3({ consumer: 'OrderService', diff --git a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx index a9b26b21a..af562ed2f 100644 --- a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx +++ b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Consumer and provider services separated by two-way mirror with glowing contract showing mutual expectations between them" author: "kevin-brown" publishDate: 2024-04-07 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "docker", "kubernetes", "typescript", "python", "go", "ruby", "dotnet"] featured: true --- diff --git a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx index 9783512ec..69f0bd405 100644 --- a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx +++ b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Container security checkpoint with scanner showing X-ray views, green checkmarks for safe containers and red warnings for detected vulnerabilities" author: "kevin-brown" publishDate: 2023-01-01 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "docker", "python"] featured: true --- diff --git a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx index bc8d2d6a1..9120a7392 100644 --- a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx +++ b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Container security checkpoint with scanner showing X-ray views, green checkmarks for safe containers and red warnings for detected vulnerabilities" author: "kevin-brown" publishDate: 2023-01-01 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "docker", "python", "go"] featured: true --- diff --git a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx index 221975611..43084542d 100644 --- a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx +++ b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bridge construction showing new lane being built alongside existing lane with continuous traffic flow, representing zero-downtime schema migration" author: "kevin-brown" publishDate: 2022-09-04 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "ruby", "dotnet"] featured: true --- diff --git a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx index a75cde218..31f4801e7 100644 --- a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx +++ b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bridge construction showing new lane being built alongside existing lane with continuous traffic flow, representing zero-downtime schema migration" author: "kevin-brown" publishDate: 2022-09-04 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "typescript"] featured: true --- diff --git a/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx b/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx index 6f93c61d9..fac8cfe25 100644 --- a/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx +++ b/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hospital recovery ward for failed messages with nurse-bots monitoring recovery progress and discharging healthy messages for replay" author: "kevin-brown" publishDate: 2024-02-04 -tags: ["systems-and-development"] +tags: ["systems-and-development", "aws", "go", "dotnet"] featured: true --- diff --git a/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx b/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx index fd24b04b9..8693fbb0a 100644 --- a/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx +++ b/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hospital recovery ward for failed messages with nurse-bots monitoring recovery progress and discharging healthy messages for replay" author: "kevin-brown" publishDate: 2024-02-04 -tags: ["systems-and-development"] +tags: ["systems-and-development", "aws", "go", "dotnet", "python"] featured: true --- diff --git a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx index 4a3c99bad..de15222a2 100644 --- a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx +++ b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing observability value versus cost trade-off, with sample rate dial controlling the equilibrium point" author: "kevin-brown" publishDate: 2024-10-06 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescrip t", "python", "go", "dotnet", "ruby", "grafana"] featured: true --- diff --git a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx index 652b201f3..c5dba8246 100644 --- a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx +++ b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing observability value versus cost trade-off, with sample rate dial controlling the equilibrium point" author: "kevin-brown" publishDate: 2024-10-06 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescript", "python", "go", "dotnet", "ruby", "grafana"] featured: true --- diff --git a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx index e4c7ae4a3..a2e6fbce3 100644 --- a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx +++ b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Application breaking free from old runtimes (Node 16, .NET Framework, CentOS 7) with cut chains, moving upward toward newer runtime versions" author: "kevin-brown" publishDate: 2025-01-04 -tags: ["system-modernization", "platform-engineering"] +tags: ["system-modernization", "platform-engineering", "dotnet", "python", "docker"] featured: true --- diff --git a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx index 498d2a596..1736439ed 100644 --- a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx +++ b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Application breaking free from old runtimes (Node 16, .NET Framework, CentOS 7) with cut chains, moving upward toward newer runtime versions" author: "kevin-brown" publishDate: 2025-01-04 -tags: ["system-modernization", "platform-engineering"] +tags: ["system-modernization", "platform-engineering", "dotnet", "python", "docker"] featured: true --- diff --git a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx index c2cc02a3d..eed713a0c 100644 --- a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx +++ b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hotel with rooms as preview environments, showing check-in/check-out with TTL management, extended stays, cleaning crew, and real-time cost billing" author: "kevin-brown" publishDate: 2025-10-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws"] featured: true --- diff --git a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx index 9bc213b15..708caf3b4 100644 --- a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx +++ b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hotel with rooms as preview environments, showing check-in/check-out with TTL management, extended stays, cleaning crew, and real-time cost billing" author: "kevin-brown" publishDate: 2025-10-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws"] featured: true --- diff --git a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx index ace3c870d..b798b4098 100644 --- a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx +++ b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radio operator tuning between real failures and flaky noise signals, filtering static to find true test failure signal" author: "kevin-brown" publishDate: 2025-11-02 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript"] featured: true --- diff --git a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx index 40d8d2ead..fdceaab1a 100644 --- a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx +++ b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radio operator tuning between real failures and flaky noise signals, filtering static to find true test failure signal" author: "kevin-brown" publishDate: 2025-11-02 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript"] featured: true --- diff --git a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx index d341b96bd..e6b6523f7 100644 --- a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx +++ b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transit hub showing highlighted recommended route alongside alternative paths, representing golden path choice architecture for developers" author: "kevin-brown" publishDate: 2024-04-21 -tags: ["platform-engineering"] +tags: ["platform-engineering", "backstage", "kubernetes", "typescript", "python", "go", "grafana", "docker"] featured: true --- diff --git a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx index 9fc066eb3..430defda4 100644 --- a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx +++ b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transit hub showing highlighted recommended route alongside alternative paths, representing golden path choice architecture for developers" author: "kevin-brown" publishDate: 2024-04-21 -tags: ["platform-engineering"] +tags: ["platform-engineering", "backstage", "kubernetes", "typescript", "python", "go", "grafana", "docker"] featured: true --- diff --git a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx index 6cd836e37..0de7ead2e 100644 --- a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx +++ b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Library with organized and neglected sections, librarian curating books into archive and discard piles, representing dashboard hygiene and metric pruning" author: "kevin-brown" publishDate: 2025-03-02 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "grafana", "python", "terraform"] featured: true --- diff --git a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx index 0bf248034..c3f9e6a0f 100644 --- a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx +++ b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Library with organized and neglected sections, librarian curating books into archive and discard piles, representing dashboard hygiene and metric pruning" author: "kevin-brown" publishDate: 2025-03-02 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "grafana", "python", "terraform"] featured: true --- diff --git a/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx b/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx index d46ec9c21..34e285b48 100644 --- a/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx +++ b/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Factory assembly line showing Helm charts progressing through quality control stations from lint to verify, with rejected releases diverted to repair" author: "kevin-brown" publishDate: 2023-03-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "helm", "kubernetes", "python", "argo-cd", "prometheus"] featured: true --- diff --git a/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx b/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx index d8ffc906e..3a323234c 100644 --- a/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx +++ b/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Factory assembly line showing Helm charts progressing through quality control stations from lint to verify, with rejected releases diverted to repair" author: "kevin-brown" publishDate: 2023-03-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "helm", "kubernetes", "python", "argo-cd", "prometheus"] featured: true --- diff --git a/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx b/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx index fd1a2c3a3..f1e70a405 100644 --- a/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx +++ b/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure vault with guardian checking incoming messages against ledger of processed keys, turning away duplicates while admitting new messages" author: "kevin-brown" publishDate: 2022-05-01 -tags: ["systems-and-development"] +tags: ["systems-and-development", "python", "aws"] featured: true --- diff --git a/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx b/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx index 1f80b2b20..6b6b3fb18 100644 --- a/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx +++ b/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure vault with guardian checking incoming messages against ledger of processed keys, turning away duplicates while admitting new messages" author: "kevin-brown" publishDate: 2022-05-01 -tags: ["systems-and-development"] +tags: ["systems-and-development", "python", "typescript", "aws"] featured: true --- diff --git a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx index 236b0773a..d66b6f2d6 100644 --- a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx +++ b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transparent toolbox with organized compartments holding kubectl, terraform, and helm tools, labeled by environment with smart access controls" author: "kevin-brown" publishDate: 2024-02-18 -tags: ["platform-engineering"] +tags: ["platform-engineering","kubernetes","helm","terraform","python"] featured: true --- diff --git a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx index c17d84610..edfaed1b6 100644 --- a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx +++ b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transparent toolbox with organized compartments holding kubectl, terraform, and helm tools, labeled by environment with smart access controls" author: "kevin-brown" publishDate: 2024-02-18 -tags: ["platform-engineering"] +tags: ["platform-engineering","kubernetes","helm","terraform","python"] featured: true --- diff --git a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx index 3a5469f88..850d6d79f 100644 --- a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx +++ b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Developer platform marketplace showing self-service offerings like Kubernetes namespaces and databases with delivery times and shopping cart checkout" author: "kevin-brown" publishDate: 2022-02-06 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","terraform","kubernetes","aws"] featured: true --- diff --git a/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx b/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx index a4a2b74d4..d05325393 100644 --- a/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx +++ b/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx @@ -5,14 +5,11 @@ cover: "./cover.jpg" coverAlt: "Developer platform marketplace showing self-service offerings like Kubernetes namespaces and databases with delivery times and shopping cart checkout" author: "kevin-brown" publishDate: 2022-02-06 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","terraform","kubernetes","aws"] featured: true --- *[IDP]: Internal Developer Portal/Platform -*[API]: Application Programming Interface -*[CI]: Continuous Integration -*[CD]: Continuous Deployment *[RBAC]: Role-Based Access Control *[SSO]: Single Sign-On *[K8s]: Kubernetes diff --git a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx index 2337ef924..b0eb74b99 100644 --- a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx +++ b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx @@ -9,9 +9,6 @@ tags: ["platform-engineering"] featured: true --- -*[API]: Application Programming Interface -*[CI]: Continuous Integration - Last week, a platform team I know shipped what they called a "small cleanup" to their deployment API. They renamed a field from `service_id` to `serviceId`, removed an endpoint that was "barely used," and updated the response format to match their new schema. No version bump. No deprecation notice. Just a Friday afternoon deploy and a Slack message in `#platform-updates` that nobody read. Monday morning, 40 CI pipelines failed. Three teams scrambled to update their deployment scripts. A critical security hotfix got blocked because the team couldn't deploy. The platform team spent the entire week doing emergency migrations instead of planned work. And the trust they'd built over the previous year? Gone. diff --git a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx index beb7dc6e6..0459f2937 100644 --- a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx +++ b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx @@ -9,7 +9,6 @@ tags: ["platform-engineering"] featured: true --- -*[API]: Application Programming Interface *[SDK]: Software Development Kit *[SLA]: Service Level Agreement *[CI]: Continuous Integration diff --git a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx index dead0d650..bc19e596b 100644 --- a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx +++ b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Kubernetes cluster upgrade assembly line with quality control stations from pre-check through validation, with rollback lane and certification" author: "kevin-brown" publishDate: 2025-08-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx index 606c25847..6f8b79a90 100644 --- a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx +++ b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Kubernetes cluster upgrade assembly line with quality control stations from pre-check through validation, with rollback lane and certification" author: "kevin-brown" publishDate: 2025-08-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx index 7af5b80e5..ceebf1c54 100644 --- a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx +++ b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tetris-style pod packing visualization showing efficient resource allocation in Kubernetes nodes with cost savings scoreboard and highlighted waste" author: "kevin-brown" publishDate: 2025-06-01 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx index 6e075f3f6..238f36109 100644 --- a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx +++ b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tetris-style pod packing visualization showing efficient resource allocation in Kubernetes nodes with cost savings scoreboard and highlighted waste" author: "kevin-brown" publishDate: 2025-06-01 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx index fd1eeeeb5..9309cdb0d 100644 --- a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx +++ b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tool shed showing oversized Kubernetes machine alongside simpler alternatives like VMs, PaaS, and Serverless for straightforward tasks" author: "kevin-brown" publishDate: 2022-11-06 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","helm"] featured: true abbreviations: - K8s diff --git a/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx b/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx index f1d35711d..58417e59d 100644 --- a/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx +++ b/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tool shed showing oversized Kubernetes machine alongside simpler alternatives like VMs, PaaS, and Serverless for straightforward tasks" author: "kevin-brown" publishDate: 2022-11-06 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","helm","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx index 76a621790..088f13c6b 100644 --- a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx +++ b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNS resolution pipeline showing queries flowing through resolv.conf, CoreDNS, cache, and upstream filters with efficiency visualization" author: "kevin-brown" publishDate: 2024-05-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx index 857915c08..d2df80394 100644 --- a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx +++ b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNS resolution pipeline showing queries flowing through resolv.conf, CoreDNS, cache, and upstream filters with efficiency visualization" author: "kevin-brown" publishDate: 2024-05-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx index e1989b8cf..e001d2f4e 100644 --- a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx +++ b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with HPA dashboards showing traffic patterns and replica counts, operators tuning stabilization and scaling parameters to minimize capacity gaps" author: "kevin-brown" publishDate: 2024-11-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx index 26711119a..9bfb6ab00 100644 --- a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx +++ b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with HPA dashboards showing traffic patterns and replica counts, operators tuning stabilization and scaling parameters to minimize capacity gaps" author: "kevin-brown" publishDate: 2024-11-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx index d991ca592..6b773348f 100644 --- a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx +++ b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Fork in road showing Ingress path (shorter, simpler, well-worn) versus Gateway API path (longer, more features, newer pavement) with feature signposts" author: "kevin-brown" publishDate: 2024-01-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx index 71dd1312e..42bc287b0 100644 --- a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx +++ b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Fork in road showing Ingress path (shorter, simpler, well-worn) versus Gateway API path (longer, more features, newer pavement) with feature signposts" author: "kevin-brown" publishDate: 2024-01-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx index 2c4b36e3a..12c379eef 100644 --- a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx +++ b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Orchestra with sections playing from same sheet music with conductor ensuring synchronization, highlighting musicians drifting off tempo representing cluster drift detection" author: "kevin-brown" publishDate: 2025-02-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","argo-cd","helm"] featured: true --- diff --git a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx index fc330b3e2..8e61a7d8b 100644 --- a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx +++ b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Orchestra with sections playing from same sheet music with conductor ensuring synchronization, highlighting musicians drifting off tempo representing cluster drift detection" author: "kevin-brown" publishDate: 2025-02-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","argo-cd","helm"] featured: true --- diff --git a/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx index 465f77d43..e75a4e418 100644 --- a/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx +++ b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Air traffic controller managing planes (pods) on runways (nodes) with minimum availability requirements during runway maintenance for operational continuity" author: "kevin-brown" publishDate: 2024-08-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus","aws"] featured: true --- diff --git a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx index ffb2bb370..a4961baad 100644 --- a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx +++ b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Airplane seating classes showing first class (Guaranteed), business (Burstable), and economy (BestEffort) with priority boarding representing Kubernetes QoS resource tiering" author: "kevin-brown" publishDate: 2022-10-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx index 50f4a5089..acc388adf 100644 --- a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx +++ b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Airplane seating classes showing first class (Guaranteed), business (Burstable), and economy (BestEffort) with priority boarding representing Kubernetes QoS resource tiering" author: "kevin-brown" publishDate: 2022-10-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx index 62347570f..9dd5271b5 100644 --- a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx +++ b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bank vault with three secret access methods: scheduled escort (ESO periodic sync), direct access (CSI at pod start), and custom robot retrieval (init container)" author: "kevin-brown" publishDate: 2022-10-16 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws", "azure"] featured: true --- diff --git a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx index 743ba8384..f2db2e40e 100644 --- a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx +++ b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bank vault with three secret access methods: scheduled escort (ESO periodic sync), direct access (CSI at pod start), and custom robot retrieval (init container)" author: "kevin-brown" publishDate: 2022-10-16 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws", "azure"] featured: true --- diff --git a/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx b/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx index f05e6e0b6..ea9e4685d 100644 --- a/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx +++ b/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bomb disposal expert analyzing complex device with mirrors and identifying safe cutting points, representing careful legacy code refactoring with characterization tests" author: "kevin-brown" publishDate: 2023-03-19 -tags: ["system-modernization"] +tags: ["system-modernization", "ruby", "typescript", "dotnet"] featured: true --- diff --git a/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx b/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx index fbe3cf7b8..a6c891638 100644 --- a/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx +++ b/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bomb disposal expert analyzing complex device with mirrors and identifying safe cutting points, representing careful legacy code refactoring with characterization tests" author: "kevin-brown" publishDate: 2023-03-19 -tags: ["system-modernization"] +tags: ["system-modernization", "ruby", "typescript", "dotnet"] featured: true --- diff --git a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx index 0b9e29aaa..7c20cd2c4 100644 --- a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx +++ b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Assembly line showing build process with cached components pre-made at most stations, workers handling custom work, displaying 78% cache hit rate" author: "kevin-brown" publishDate: 2025-05-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "typescript", "aws", "azure", "docker", "kubernetes"] featured: true --- diff --git a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx index 7bdc51232..ddde48a0c 100644 --- a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx +++ b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Assembly line showing build process with cached components pre-made at most stations, workers handling custom work, displaying 78% cache hit rate" author: "kevin-brown" publishDate: 2025-05-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "typescript", "aws", "azure", "docker", "kubernetes"] featured: true --- diff --git a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx index f339bbcaf..33f95decb 100644 --- a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx +++ b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Robotic hands exchanging glowing certificates forming encrypted tunnel with automatic certificate rotation, representing mTLS mutual authentication" author: "kevin-brown" publishDate: 2025-09-20 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx index c59455b88..b68c5a9fa 100644 --- a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx +++ b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Robotic hands exchanging glowing certificates forming encrypted tunnel with automatic certificate rotation, representing mTLS mutual authentication" author: "kevin-brown" publishDate: 2025-09-20 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx index 1b451503f..59a2f5337 100644 --- a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx +++ b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with proxy metrics dashboards showing connection counts, latency, and error rates, engineer adjusting timeout and buffer settings" author: "kevin-brown" publishDate: 2024-08-17 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx index 52ecc691b..daec667f3 100644 --- a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx +++ b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with proxy metrics dashboards showing connection counts, latency, and error rates, engineer adjusting timeout and buffer settings" author: "kevin-brown" publishDate: 2024-08-17 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx index ebf4de840..0cc2d9097 100644 --- a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx +++ b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Automated security checkpoint scanning infrastructure configurations with green checkmarks for compliance and violation flags with fix suggestions" author: "kevin-brown" publishDate: 2023-05-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "terraform", "kubernetes", "aws", "docker"] featured: true --- diff --git a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx index d0a01836f..66c5fbcb6 100644 --- a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx +++ b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Automated security checkpoint scanning infrastructure configurations with green checkmarks for compliance and violation flags with fix suggestions" author: "kevin-brown" publishDate: 2023-05-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "terraform", "kubernetes", "aws", "docker"] featured: true --- diff --git a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx index 8869e89e0..e1a2fe129 100644 --- a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx +++ b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Central glowing OpenAPI spec document with streams flowing to documentation pages, SDK code in multiple languages, and validation shields" author: "kevin-brown" publishDate: 2023-12-03 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "typescript", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx index 0d9115c91..461658709 100644 --- a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx +++ b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Central glowing OpenAPI spec document with streams flowing to documentation pages, SDK code in multiple languages, and validation shields" author: "kevin-brown" publishDate: 2023-12-03 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "typescript", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx b/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx index 1d705b31a..e6b24e221 100644 --- a/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx +++ b/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two city maps comparison: over-detailed map with every feature marked versus clear map showing major roads and landmarks for easy navigation" author: "kevin-brown" publishDate: 2026-01-04 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescript"] featured: true --- diff --git a/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx b/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx index cacd65003..19e439e04 100644 --- a/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx +++ b/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two city maps comparison: over-detailed map with every feature marked versus clear map showing major roads and landmarks for easy navigation" author: "kevin-brown" publishDate: 2026-01-04 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescript"] featured: true --- diff --git a/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx b/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx index 271bc4402..e2e46c552 100644 --- a/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx +++ b/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Architect studying traffic pattern blueprints overlaid on city showing congested hot paths and empty cold paths for realistic performance test design" author: "kevin-brown" publishDate: 2023-06-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript", "python"] featured: true --- diff --git a/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx b/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx index 205d2e159..6ad42af83 100644 --- a/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx +++ b/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Architect studying traffic pattern blueprints overlaid on city showing congested hot paths and empty cold paths for realistic performance test design" author: "kevin-brown" publishDate: 2023-06-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript", "python"] featured: true --- diff --git a/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx b/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx index 6d6de31bc..94a76c129 100644 --- a/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx +++ b/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two towers representing control plane (with decision-making systems) and data plane (with traffic processing) connected by bridges carrying configurations and status updates" author: "kevin-brown" publishDate: 2023-01-15 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "terraform", "grafana", "argo-cd", "aws", "backstage"] featured: true --- diff --git a/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx b/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx index 2c349fff3..2ad2f2a54 100644 --- a/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx +++ b/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two towers representing control plane (with decision-making systems) and data plane (with traffic processing) connected by bridges carrying configurations and status updates" author: "kevin-brown" publishDate: 2023-01-15 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "terraform", "grafana", "argo-cd", "aws", "backstage"] featured: true --- diff --git a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx index e25a9e653..3e8767a44 100644 --- a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx +++ b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Developer friction gauge showing needle moving from red painful zone to green smooth zone after platform improvements" author: "kevin-brown" publishDate: 2025-02-16 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "helm", "terraform", "docker", "crossplane", "argo-cd", "grafana", "aws", "python"] featured: true --- diff --git a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx index bbe7b6a06..ba9d0968c 100644 --- a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx +++ b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Developer friction gauge showing needle moving from red painful zone to green smooth zone after platform improvements" author: "kevin-brown" publishDate: 2025-02-16 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "helm", "terraform", "docker", "crossplane", "argo-cd", "grafana", "aws", "python"] featured: true --- diff --git a/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx b/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx index 7778df30d..73b1e04ed 100644 --- a/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx +++ b/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Traffic intersection with controller (PgBouncer) efficiently managing many cars (app connections) crossing through limited lanes (database connections)" author: "kevin-brown" publishDate: 2023-11-05 -tags: ["systems-and-development"] +tags: ["systems-and-development", "typescript", "kubernetes"] featured: true --- diff --git a/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx b/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx index 162434c04..0fbd01198 100644 --- a/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx +++ b/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Traffic intersection with controller (PgBouncer) efficiently managing many cars (app connections) crossing through limited lanes (database connections)" author: "kevin-brown" publishDate: 2023-11-05 -tags: ["systems-and-development"] +tags: ["systems-and-development", "typescript", "kubernetes"] featured: true --- diff --git a/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx b/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx index 224996fe3..02b41bc25 100644 --- a/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx +++ b/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure tunnel through mountain with checkpoints for DNS, routing, firewalls, and TLS verification, contrasting with exposed open roads outside" author: "kevin-brown" publishDate: 2024-05-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "aws", "azure"] featured: true --- diff --git a/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx b/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx index 336ec1618..33ec7e3f2 100644 --- a/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx +++ b/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure tunnel through mountain with checkpoints for DNS, routing, firewalls, and TLS verification, contrasting with exposed open roads outside" author: "kevin-brown" publishDate: 2024-05-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "aws", "azure"] featured: true --- diff --git a/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx b/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx index 2925d0220..b21b6e963 100644 --- a/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx +++ b/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Prometheus pressure cooker with memory gauge spiking to red zone from unbounded labels, showing bounded versus unbounded label impact" author: "kevin-brown" publishDate: 2022-09-18 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana","python","kubernetes"] featured: true --- diff --git a/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx b/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx index 82d51f59f..40ddbbbaa 100644 --- a/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx +++ b/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Prometheus pressure cooker with memory gauge spiking to red zone from unbounded labels, showing bounded versus unbounded label impact" author: "kevin-brown" publishDate: 2022-09-18 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana","python","kubernetes"] featured: true --- diff --git a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx index dd8dccedb..850a11178 100644 --- a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx +++ b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Sliding window visualization showing window frame moving across timeline counting request dots, comparing fixed versus sliding window boundaries" author: "kevin-brown" publishDate: 2026-01-18 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways","aws","typescript","python"] featured: true --- diff --git a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx index 503aa8faf..7858c30fa 100644 --- a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx +++ b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Sliding window visualization showing window frame moving across timeline counting request dots, comparing fixed versus sliding window boundaries" author: "kevin-brown" publishDate: 2026-01-18 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways","aws","azure","terraform","typescript","python"] featured: true --- diff --git a/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx b/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx index 36e27f8e8..2d6b8be69 100644 --- a/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx +++ b/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway with quality checkpoint traffic lights showing green passing gates, red failing gate stopping one lane, and secure override lane" author: "kevin-brown" publishDate: 2025-08-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","aws","azure","grafana","typescript"] featured: true --- diff --git a/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx b/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx index 7c326d824..51bfeea69 100644 --- a/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx +++ b/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway with quality checkpoint traffic lights showing green passing gates, red failing gate stopping one lane, and secure override lane" author: "kevin-brown" publishDate: 2025-08-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","aws","azure","grafana","typescript"] featured: true --- diff --git a/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx b/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx index 9d7432098..15ac80c25 100644 --- a/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx +++ b/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "X-ray view of mechanical device showing internal gears, springs, and linkages with layered transparency revealing hidden architecture" author: "kevin-brown" publishDate: 2023-12-17 -tags: ["system-modernization"] +tags: ["system-modernization","typescript"] featured: true --- diff --git a/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx b/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx index 51c5d20f3..8c3f9cdf5 100644 --- a/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx +++ b/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "X-ray view of mechanical device showing internal gears, springs, and linkages with layered transparency revealing hidden architecture" author: "kevin-brown" publishDate: 2023-12-17 -tags: ["system-modernization"] +tags: ["system-modernization","typescript"] featured: true --- diff --git a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx index 470edbc13..da11feaa4 100644 --- a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx +++ b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Three-dimensional network topology diagram with illuminated node spheres connected by glowing lines of varying thickness representing traffic importance" author: "kevin-brown" publishDate: 2022-06-19 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","kubernetes","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx index 6a7ed3845..67500b3e6 100644 --- a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx +++ b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Three-dimensional network topology diagram with illuminated node spheres connected by glowing lines of varying thickness representing traffic importance" author: "kevin-brown" publishDate: 2022-06-19 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","kubernetes","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx b/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx index 9f0687fdc..65d4dfd2b 100644 --- a/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx +++ b/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Industrial control panel with switches in off position and red indicators, steam in background showing controlled machinery shutdown" author: "kevin-brown" publishDate: 2024-06-02 -tags: ["system-modernization"] +tags: ["system-modernization","aws","kubernetes"] featured: true --- diff --git a/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx b/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx index a5782cb3e..fe7386ec3 100644 --- a/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx +++ b/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Industrial control panel with switches in off position and red indicators, steam in background showing controlled machinery shutdown" author: "kevin-brown" publishDate: 2024-06-02 -tags: ["system-modernization"] +tags: ["system-modernization","aws","kubernetes"] featured: true --- diff --git a/src/content/articles/slo-error-budget-practical-guide/index.mdx b/src/content/articles/slo-error-budget-practical-guide/index.mdx index d0ce8748c..472c394eb 100644 --- a/src/content/articles/slo-error-budget-practical-guide/index.mdx +++ b/src/content/articles/slo-error-budget-practical-guide/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Operations control room with multiple monitoring screens displaying graphs, gauges, and status indicators for system health visibility" author: "kevin-brown" publishDate: 2022-11-20 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/slo-error-budget-practical-guide/pdf.mdx b/src/content/articles/slo-error-budget-practical-guide/pdf.mdx index a95876661..2bac360c0 100644 --- a/src/content/articles/slo-error-budget-practical-guide/pdf.mdx +++ b/src/content/articles/slo-error-budget-practical-guide/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Operations control room with multiple monitoring screens displaying graphs, gauges, and status indicators for system health visibility" author: "kevin-brown" publishDate: 2022-11-20 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx index c33985eb6..6fdc7f9ba 100644 --- a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx +++ b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx @@ -5,13 +5,11 @@ cover: "./cover.jpg" coverAlt: "Manufacturing assembly line with products being scanned with QR codes and digital tags for provenance tracking through production" author: "kevin-brown" publishDate: 2024-03-16 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","docker","kubernetes"] featured: true --- *[CA]: Certificate Authority -*[CI]: Continuous Integration -*[CD]: Continuous Deployment *[CVE]: Common Vulnerabilities and Exposures *[GPG]: GNU Privacy Guard *[HSM]: Hardware Security Module diff --git a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx index b1d1e9d46..73bd5fb54 100644 --- a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx +++ b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Manufacturing assembly line with products being scanned with QR codes and digital tags for provenance tracking through production" author: "kevin-brown" publishDate: 2024-03-16 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","docker","kubernetes"] featured: true --- diff --git a/src/content/articles/strangler-fig-migration-complete-guide/index.mdx b/src/content/articles/strangler-fig-migration-complete-guide/index.mdx index ca20403cc..d154e6f4e 100644 --- a/src/content/articles/strangler-fig-migration-complete-guide/index.mdx +++ b/src/content/articles/strangler-fig-migration-complete-guide/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with side-by-side monitors comparing legacy and new system dashboard metrics for migration validation" author: "kevin-brown" publishDate: 2025-01-18 -tags: ["system-modernization"] +tags: ["system-modernization","azure","python"] featured: true --- diff --git a/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx b/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx index d024806bb..e1b0661e2 100644 --- a/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx +++ b/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with side-by-side monitors comparing legacy and new system dashboard metrics for migration validation" author: "kevin-brown" publishDate: 2025-01-18 -tags: ["system-modernization"] +tags: ["system-modernization","azure","python"] featured: true --- diff --git a/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx b/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx index badefb9f7..79297027b 100644 --- a/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx +++ b/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNA helix with field names and values as base pairs representing log data schema structure" author: "kevin-brown" publishDate: 2022-05-15 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx b/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx index bd2587a8a..f9920b7a4 100644 --- a/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx +++ b/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNA helix with field names and values as base pairs representing log data schema structure" author: "kevin-brown" publishDate: 2022-05-15 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx b/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx index 7590370eb..326006428 100644 --- a/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx +++ b/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Firefighter at control panel deciding which emergency to respond to, representing alert prioritization and triage decisions" author: "kevin-brown" publishDate: 2022-02-20 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx b/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx index 759689729..2f980f87f 100644 --- a/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx +++ b/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Firefighter at control panel deciding which emergency to respond to, representing alert prioritization and triage decisions" author: "kevin-brown" publishDate: 2022-02-20 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx index b22ae2f1f..e65a838f9 100644 --- a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx +++ b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data points transforming as they pass through anonymization barrier, changing color and shape to represent data anonymization process" author: "kevin-brown" publishDate: 2024-12-14 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","python"] featured: true --- diff --git a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx index 81e21d25b..dc1a76965 100644 --- a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx +++ b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data points transforming as they pass through anonymization barrier, changing color and shape to represent data anonymization process" author: "kevin-brown" publishDate: 2024-12-14 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","python"] featured: true --- diff --git a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx index febb59278..4b9f4e025 100644 --- a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx +++ b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Technical blueprint with version numbers, revision marks, and change annotations documenting interface evolution over time" author: "kevin-brown" publishDate: 2025-12-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws"] featured: true --- diff --git a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx index 374a6a523..0e53a888e 100644 --- a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx +++ b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Technical blueprint with version numbers, revision marks, and change annotations documenting interface evolution over time" author: "kevin-brown" publishDate: 2025-12-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws"] featured: true --- diff --git a/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx b/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx index 81b299b49..6c6f68465 100644 --- a/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx +++ b/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI platform and cloud provider exchanging trust tokens through secure channels for workload identity federation" author: "kevin-brown" publishDate: 2023-04-22 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws","azure"] featured: true --- diff --git a/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx b/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx index 70b048f56..5ee1fa3ac 100644 --- a/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx +++ b/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI platform and cloud provider exchanging trust tokens through secure channels for workload identity federation" author: "kevin-brown" publishDate: 2023-04-22 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws","azure"] featured: true --- diff --git a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx index 648f4cc90..f224c71b7 100644 --- a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx +++ b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Antique lock and key overlaid with digital timestamps representing state locking mechanism with temporal operations" author: "kevin-brown" publishDate: 2023-06-25 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","azure","terraform","aws","kubernetes"] featured: true --- diff --git a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx index 6d1581b6b..222630ab5 100644 --- a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx +++ b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Antique lock and key overlaid with digital timestamps representing state locking mechanism with temporal operations" author: "kevin-brown" publishDate: 2023-06-25 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","azure","terraform","aws","kubernetes"] featured: true --- From 9696f0e5f7542cd395f7284639a83553d1e0b27f Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 01:27:53 +0300 Subject: [PATCH 15/33] Add skill + technology logos to non-featured tag and update tag collection with isSkill and logo frontmatter properties --- _TODO.md | 78 +++--- .../technologies/Amazon_Web_Services_Logo.svg | 38 --- src/assets/images/technologies/Argo CD.svg | 1 - .../images/technologies/Docker_Logo.svg | 21 -- .../images/technologies/Go_Logo_Blue.svg | 55 ----- .../images/technologies/Grafana_logo.svg | 70 ------ .../Helm_(package_manager)_logo.svg | 1 - .../Kubernetes_logo_without_workmark.svg | 84 ------- .../images/technologies/Microsoft_Azure.svg | 23 -- .../OpenStack\302\256_Logo_2016.svg" | 36 --- .../technologies/Prometheus_software_logo.svg | 50 ---- .../technologies/Python-logo-notext.svg | 1 - .../images/technologies/Terraform_Logo.svg | 7 - src/assets/images/technologies/Typescript.svg | 4 - .../technologies/crossplane-icon-seeklogo.png | Bin 21284 -> 0 bytes .../Home/Hero/client/__tests__/index.spec.ts | 2 +- src/components/Home/Hero/client/index.ts | 2 +- src/components/Home/Hero/index.astro | 6 +- src/content.config.ts | 2 + src/content/tags/argo-cd/argo-cd.svg | 229 ++++++++++++++++++ src/content/tags/argo-cd/index.mdx | 2 + src/content/tags/aws/aws.svg | 63 +++++ src/content/tags/aws/index.mdx | 2 + src/content/tags/azure/azure.svg | 119 +++++++++ src/content/tags/azure/index.mdx | 2 + src/content/tags/crossplane/crossplane.svg | 174 +++++++++++++ src/content/tags/crossplane/index.mdx | 2 + src/content/tags/docker/docker.svg | 48 ++++ src/content/tags/docker/index.mdx | 2 + src/content/tags/dotnet/dotnet.svg | 64 +++++ src/content/tags/dotnet/index.mdx | 2 + src/content/tags/go/go.svg | 109 +++++++++ src/content/tags/go/index.mdx | 2 + src/content/tags/grafana/grafana.svg | 67 +++++ src/content/tags/grafana/index.mdx | 2 + src/content/tags/helm/helm.svg | 117 +++++++++ src/content/tags/helm/index.mdx | 2 + src/content/tags/kubernetes/index.mdx | 2 + src/content/tags/kubernetes/kubernetes.svg | 86 +++++++ src/content/tags/openstack/index.mdx | 2 + src/content/tags/openstack/openstack.svg | 82 +++++++ src/content/tags/prometheus/index.mdx | 2 + src/content/tags/prometheus/prometheus.svg | 54 +++++ src/content/tags/python/index.mdx | 2 + src/content/tags/python/python.svg | 70 ++++++ src/content/tags/ruby/index.mdx | 2 + .../tags/ruby/ruby.svg} | 199 ++++++++------- src/content/tags/terraform/index.mdx | 2 + src/content/tags/terraform/terraform.svg | 53 ++++ src/content/tags/typescript/index.mdx | 2 + src/content/tags/typescript/typescript.svg | 45 ++++ 51 files changed, 1555 insertions(+), 537 deletions(-) delete mode 100644 src/assets/images/technologies/Amazon_Web_Services_Logo.svg delete mode 100644 src/assets/images/technologies/Argo CD.svg delete mode 100644 src/assets/images/technologies/Docker_Logo.svg delete mode 100644 src/assets/images/technologies/Go_Logo_Blue.svg delete mode 100644 src/assets/images/technologies/Grafana_logo.svg delete mode 100644 src/assets/images/technologies/Helm_(package_manager)_logo.svg delete mode 100644 src/assets/images/technologies/Kubernetes_logo_without_workmark.svg delete mode 100644 src/assets/images/technologies/Microsoft_Azure.svg delete mode 100644 "src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" delete mode 100644 src/assets/images/technologies/Prometheus_software_logo.svg delete mode 100644 src/assets/images/technologies/Python-logo-notext.svg delete mode 100644 src/assets/images/technologies/Terraform_Logo.svg delete mode 100644 src/assets/images/technologies/Typescript.svg delete mode 100644 src/assets/images/technologies/crossplane-icon-seeklogo.png create mode 100644 src/content/tags/argo-cd/argo-cd.svg create mode 100644 src/content/tags/aws/aws.svg create mode 100644 src/content/tags/azure/azure.svg create mode 100644 src/content/tags/crossplane/crossplane.svg create mode 100644 src/content/tags/docker/docker.svg create mode 100644 src/content/tags/dotnet/dotnet.svg create mode 100644 src/content/tags/go/go.svg create mode 100644 src/content/tags/grafana/grafana.svg create mode 100644 src/content/tags/helm/helm.svg create mode 100644 src/content/tags/kubernetes/kubernetes.svg create mode 100644 src/content/tags/openstack/openstack.svg create mode 100644 src/content/tags/prometheus/prometheus.svg create mode 100644 src/content/tags/python/python.svg rename src/{assets/images/technologies/Ruby_logo.svg => content/tags/ruby/ruby.svg} (81%) create mode 100644 src/content/tags/terraform/terraform.svg create mode 100644 src/content/tags/typescript/typescript.svg diff --git a/_TODO.md b/_TODO.md index 5dca9f34d..9096a8d49 100644 --- a/_TODO.md +++ b/_TODO.md @@ -43,6 +43,8 @@ https://vercel.com/kevin-browns-projects-dd474f73/astro-webstackbuilders-com/ai- We have E2E errors again testimonials slide on mobile chrome and safari. I think the problem is that we are pausing carousels when part of the carousel is outside of the viewport, and the testimonials are too large to display on mobile without being off viewport. +See if there's any code in common between Carousel, Testimonials, and Themepicker. We have another one to add that uses the carousel code - for Skills. + `test/e2e/specs/04-components/testimonials.spec.ts`:244:3 › Testimonials Component › @ready testimonials auto-rotate changes slide index ## Move containers to dev server from Playwright @@ -83,20 +85,6 @@ if (window.matchMedia) { } ``` -## Home Page Ordering - -- Hero -- About Preview ("Building the Future of Software Development") -- Featured Services -- (Todo) Turn-Key Backstage IDP Implementation -- Testimonials -- Latest Articles -- Primary CTA ("Ready to Transform Your Development Process?") with links to /contact and /services -- Newsletter Signup -- Skills/Technologies Preview (__move to after "About Preview"__) - -Maybe change the "ready..." in the hero to "get in touch..." - ## Search Box - Show article titles only in drop-down search result box, and dedupe results @@ -143,23 +131,43 @@ Need a tooltip component for consistency. List to add tooltips to: - Should header links be in the tab order? - Does the total page tab order make sense? -## Lead on Hero for Articles -There's an [example screen shot](src/components/Layout/Markdown/Lead/lead-on-hero-example.png) from CSS-Tricks showing a layout of the author avatar, author name, published date, and title overlaid on the cover image. It would save space on the page. Probably should have it above the image on mobile to avoid visibility issues. +## Non-featured tags -A scrim is an elliptical gradient from translucent black (center) to transparent black (edges), strategically placed behind white text. The scrim is probably the most subtle way of reliably overlaying text on images out there, and very few designs use this technique. +Update the skills and technologies tags pages: -To overlay an article title and published date on a cover image, use CSS positioning, specifically position: relative on the container and position: absolute on the text elements, combined with design techniques to ensure readability such as a semi-transparent overlay or text shadows. +argo-cd +aws +azure +backstage +crossplane +docker +dotnet +go +grafana +helm +kubernetes +openstack +prometheus / promql +python +ruby +terraform +typescript ## Stylings - Add 'featured' to tags to filter, and move tags page to articles + - Need to make tables responsive on mobile -- Investigate why we're getting hyphenate breaks within words across lines, like or- ders and us- age + - Need to remove automatic abbr handling when the abbreviation is used in a header, also the abbr presentation needs improved - right now it gives a question mark pointer and long delay to appear + - When adding backticks in a callout, it gets the standard grey background for a code block in light theme. But it should get an offset of the callout color, like "info-offset". + - Inline code blocks are not wrapping. They're breaking to a new line. An example is in the "Scenario: CRD Sync Order Problem" section of argocd-sync-failures-gitops-debugging-troubleshooting#specific-failure-scenarios. + - The spacing on unordered task lists nested in an ordered list is wrong, see "Questions to Ask Before Setting Targets" in src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx + - There's too much space at the bottom of callouts, a full line of extra space in the colored background area. ## Project Stuff @@ -193,20 +201,11 @@ To overlay an article title and published date on a cover image, use CSS positio - add "cel" language to code blocks - hovering menu items should cause the dots to disappear immediately and then the animation slide to start - Update EXIF data on all AI generated JPGs -- See if there's any code in common between Carousel, Testimonials, and Themepicker. We have another one to add that uses the carousel code - for Skills. ## Header Need to improve the "squish" animation where the header reduces in size on scroll down, and returns to full size on scroll up. Maybe reduce and expand the text and search / themepicker / hamburger menu sizes in place, and then slide them horizontally. The relevant CSS classes: -src/components/Header/index.css -.header-fixed, .header-footprint, .site-header, .header-brand - -src/components/Navigation/menu.module.css -.mainNavLink - -[example](https://thenewstack.io/) - ## Reading position indicator [Add scroll bar under header](https://css-tricks.com/reading-position-indicator/) to show how far down you are on the page while reading @@ -227,9 +226,6 @@ src/components/Navigation/menu.module.css This article has different approaches to [print pagination](https://www.customjs.space/blog/html-print-pagination-footer/). One approach overlaps with PagedJS's approach. -## Downloads Collection - -Remove the `content/downloads` folder, and point the content collection to look for a `download.astro` file in the `contents/articles` folder. Exclude the `pdf.astro` file from the Articles collection. Generated PDFs go into the public directory maybe? See the note on gating below. ## Downloads / Gated Content @@ -242,23 +238,3 @@ Remove the `content/downloads` folder, and point the content collection to look - cover.jpg for reliability-and-testing needs touch up in GIMP - We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag lik "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag and add the name to the tags: frontmatter key in the index.mdx - Need an article on OpenStack - -## Non-featured tags - -argo-cd -aws -azure -backstage -crossplane -docker -dotnet -go -grafana -helm -kubernetes -openstack -prometheus / promql -python -ruby -terraform -typescript diff --git a/src/assets/images/technologies/Amazon_Web_Services_Logo.svg b/src/assets/images/technologies/Amazon_Web_Services_Logo.svg deleted file mode 100644 index 4715937ff..000000000 --- a/src/assets/images/technologies/Amazon_Web_Services_Logo.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - diff --git a/src/assets/images/technologies/Argo CD.svg b/src/assets/images/technologies/Argo CD.svg deleted file mode 100644 index ef6eff545..000000000 --- a/src/assets/images/technologies/Argo CD.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/images/technologies/Docker_Logo.svg b/src/assets/images/technologies/Docker_Logo.svg deleted file mode 100644 index 4518f0bfd..000000000 --- a/src/assets/images/technologies/Docker_Logo.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/assets/images/technologies/Go_Logo_Blue.svg b/src/assets/images/technologies/Go_Logo_Blue.svg deleted file mode 100644 index 8164d5589..000000000 --- a/src/assets/images/technologies/Go_Logo_Blue.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/technologies/Grafana_logo.svg b/src/assets/images/technologies/Grafana_logo.svg deleted file mode 100644 index 54be1e2f1..000000000 --- a/src/assets/images/technologies/Grafana_logo.svg +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/technologies/Helm_(package_manager)_logo.svg b/src/assets/images/technologies/Helm_(package_manager)_logo.svg deleted file mode 100644 index b85d7d90e..000000000 --- a/src/assets/images/technologies/Helm_(package_manager)_logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/images/technologies/Kubernetes_logo_without_workmark.svg b/src/assets/images/technologies/Kubernetes_logo_without_workmark.svg deleted file mode 100644 index bedd3b88e..000000000 --- a/src/assets/images/technologies/Kubernetes_logo_without_workmark.svg +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - diff --git a/src/assets/images/technologies/Microsoft_Azure.svg b/src/assets/images/technologies/Microsoft_Azure.svg deleted file mode 100644 index ff5dfa5c1..000000000 --- a/src/assets/images/technologies/Microsoft_Azure.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git "a/src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" "b/src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" deleted file mode 100644 index 18cd8a336..000000000 --- "a/src/assets/images/technologies/OpenStack\302\256_Logo_2016.svg" +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - image/svg+xml - - OpenStack_Logo2016 - - - - - OpenStack_Logo2016 - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/technologies/Prometheus_software_logo.svg b/src/assets/images/technologies/Prometheus_software_logo.svg deleted file mode 100644 index 5c51f66d9..000000000 --- a/src/assets/images/technologies/Prometheus_software_logo.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - -image/svg+xml \ No newline at end of file diff --git a/src/assets/images/technologies/Python-logo-notext.svg b/src/assets/images/technologies/Python-logo-notext.svg deleted file mode 100644 index 05155f370..000000000 --- a/src/assets/images/technologies/Python-logo-notext.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/images/technologies/Terraform_Logo.svg b/src/assets/images/technologies/Terraform_Logo.svg deleted file mode 100644 index e98f8b71f..000000000 --- a/src/assets/images/technologies/Terraform_Logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/assets/images/technologies/Typescript.svg b/src/assets/images/technologies/Typescript.svg deleted file mode 100644 index 520f04210..000000000 --- a/src/assets/images/technologies/Typescript.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/images/technologies/crossplane-icon-seeklogo.png b/src/assets/images/technologies/crossplane-icon-seeklogo.png deleted file mode 100644 index 1ee48785ee56e424d3ac21841d6d536589453658..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21284 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 Ge*F&+BqtjH diff --git a/src/components/Home/Hero/client/__tests__/index.spec.ts b/src/components/Home/Hero/client/__tests__/index.spec.ts index 07f6f2415..d92cebd4a 100644 --- a/src/components/Home/Hero/client/__tests__/index.spec.ts +++ b/src/components/Home/Hero/client/__tests__/index.spec.ts @@ -7,7 +7,7 @@ import { getAnimationPreference, } from '@components/scripts/store' -const READY_TEXT = 'ready...' +import { READY_TEXT } from '../index' const STEP_MS = 500 describe('HomeHeroElement (Lit)', () => { diff --git a/src/components/Home/Hero/client/index.ts b/src/components/Home/Hero/client/index.ts index 42b3e1b6d..bfa19deea 100644 --- a/src/components/Home/Hero/client/index.ts +++ b/src/components/Home/Hero/client/index.ts @@ -12,7 +12,7 @@ import { queryHeroReadyTextElement } from './selectors' const SCRIPT_NAME = 'HomeHeroElement' -const READY_TEXT = 'ready...' +export const READY_TEXT = "heck yes, let's talk..." const READY_DELAY_MS = 500 export class HomeHeroElement extends LitElement { diff --git a/src/components/Home/Hero/index.astro b/src/components/Home/Hero/index.astro index 07f3cb30e..ba4fe6dbb 100644 --- a/src/components/Home/Hero/index.astro +++ b/src/components/Home/Hero/index.astro @@ -23,9 +23,9 @@ const { pretitle, benefits } = Astro.props as Props id="home-hero-title" class="text-4xl md:text-5xl lg:text-7xl font-bold text-content-active leading-none" > - Senior
- Platform
- Authority. +
Senior
+
Platform
+
Authority.

{pretitle}

diff --git a/src/content.config.ts b/src/content.config.ts index 39135ffe3..9f6f5931d 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -204,6 +204,8 @@ const tagsCollection = defineCollection({ cover: image(), coverAlt: z.string(), featured: z.boolean().default(false), + logo: image().optional(), + isSkill: z.boolean().default(false), }), }) diff --git a/src/content/tags/argo-cd/argo-cd.svg b/src/content/tags/argo-cd/argo-cd.svg new file mode 100644 index 000000000..af646f0f9 --- /dev/null +++ b/src/content/tags/argo-cd/argo-cd.svg @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/argo-cd/index.mdx b/src/content/tags/argo-cd/index.mdx index c703ef536..0720ac939 100644 --- a/src/content/tags/argo-cd/index.mdx +++ b/src/content/tags/argo-cd/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./argo-cd.svg" +isSkill: true --- diff --git a/src/content/tags/aws/aws.svg b/src/content/tags/aws/aws.svg new file mode 100644 index 000000000..c05ff4961 --- /dev/null +++ b/src/content/tags/aws/aws.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/src/content/tags/aws/index.mdx b/src/content/tags/aws/index.mdx index f1bde744a..6b35034ee 100644 --- a/src/content/tags/aws/index.mdx +++ b/src/content/tags/aws/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./aws.svg" +isSkill: true --- diff --git a/src/content/tags/azure/azure.svg b/src/content/tags/azure/azure.svg new file mode 100644 index 000000000..29cb4e62a --- /dev/null +++ b/src/content/tags/azure/azure.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/azure/index.mdx b/src/content/tags/azure/index.mdx index 87ed216fe..67c29eaee 100644 --- a/src/content/tags/azure/index.mdx +++ b/src/content/tags/azure/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./azure.svg" +isSkill: true --- diff --git a/src/content/tags/crossplane/crossplane.svg b/src/content/tags/crossplane/crossplane.svg new file mode 100644 index 000000000..6ea33f009 --- /dev/null +++ b/src/content/tags/crossplane/crossplane.svg @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/crossplane/index.mdx b/src/content/tags/crossplane/index.mdx index 64a24d50c..d88869422 100644 --- a/src/content/tags/crossplane/index.mdx +++ b/src/content/tags/crossplane/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./crossplane.svg" +isSkill: true --- diff --git a/src/content/tags/docker/docker.svg b/src/content/tags/docker/docker.svg new file mode 100644 index 000000000..c773a053a --- /dev/null +++ b/src/content/tags/docker/docker.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/src/content/tags/docker/index.mdx b/src/content/tags/docker/index.mdx index 064719d1a..f2c7f9e87 100644 --- a/src/content/tags/docker/index.mdx +++ b/src/content/tags/docker/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./docker.svg" +isSkill: true --- diff --git a/src/content/tags/dotnet/dotnet.svg b/src/content/tags/dotnet/dotnet.svg new file mode 100644 index 000000000..b2406bbd9 --- /dev/null +++ b/src/content/tags/dotnet/dotnet.svg @@ -0,0 +1,64 @@ + + + + + + + + + + diff --git a/src/content/tags/dotnet/index.mdx b/src/content/tags/dotnet/index.mdx index efa320290..981660ed9 100644 --- a/src/content/tags/dotnet/index.mdx +++ b/src/content/tags/dotnet/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./dotnet.svg" +isSkill: true --- diff --git a/src/content/tags/go/go.svg b/src/content/tags/go/go.svg new file mode 100644 index 000000000..a7ccb386b --- /dev/null +++ b/src/content/tags/go/go.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/go/index.mdx b/src/content/tags/go/index.mdx index d5e5efe20..a5eee2dce 100644 --- a/src/content/tags/go/index.mdx +++ b/src/content/tags/go/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./go.svg" +isSkill: true --- diff --git a/src/content/tags/grafana/grafana.svg b/src/content/tags/grafana/grafana.svg new file mode 100644 index 000000000..ef8329175 --- /dev/null +++ b/src/content/tags/grafana/grafana.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + diff --git a/src/content/tags/grafana/index.mdx b/src/content/tags/grafana/index.mdx index c1b6af54c..07c20fe7e 100644 --- a/src/content/tags/grafana/index.mdx +++ b/src/content/tags/grafana/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./grafana.svg" +isSkill: true --- diff --git a/src/content/tags/helm/helm.svg b/src/content/tags/helm/helm.svg new file mode 100644 index 000000000..77233bbc6 --- /dev/null +++ b/src/content/tags/helm/helm.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/helm/index.mdx b/src/content/tags/helm/index.mdx index ddb93289b..a90c9a8d9 100644 --- a/src/content/tags/helm/index.mdx +++ b/src/content/tags/helm/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./helm.svg" +isSkill: true --- diff --git a/src/content/tags/kubernetes/index.mdx b/src/content/tags/kubernetes/index.mdx index 1f62e4369..e7c5c5bba 100644 --- a/src/content/tags/kubernetes/index.mdx +++ b/src/content/tags/kubernetes/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./kubernetes.svg" +isSkill: true --- diff --git a/src/content/tags/kubernetes/kubernetes.svg b/src/content/tags/kubernetes/kubernetes.svg new file mode 100644 index 000000000..143a92d20 --- /dev/null +++ b/src/content/tags/kubernetes/kubernetes.svg @@ -0,0 +1,86 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/src/content/tags/openstack/index.mdx b/src/content/tags/openstack/index.mdx index 33e71c013..8c5206926 100644 --- a/src/content/tags/openstack/index.mdx +++ b/src/content/tags/openstack/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./openstack.svg" +isSkill: true --- diff --git a/src/content/tags/openstack/openstack.svg b/src/content/tags/openstack/openstack.svg new file mode 100644 index 000000000..df4a58b97 --- /dev/null +++ b/src/content/tags/openstack/openstack.svg @@ -0,0 +1,82 @@ + + + + + + + + image/svg+xml + + OpenStack_Logo2016 + + + + + OpenStack_Logo2016 + Created with Sketch. + + + + + + diff --git a/src/content/tags/prometheus/index.mdx b/src/content/tags/prometheus/index.mdx index 8a0efb106..0d2bd84a4 100644 --- a/src/content/tags/prometheus/index.mdx +++ b/src/content/tags/prometheus/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./prometheus.svg" +isSkill: true --- diff --git a/src/content/tags/prometheus/prometheus.svg b/src/content/tags/prometheus/prometheus.svg new file mode 100644 index 000000000..dcad9d6fc --- /dev/null +++ b/src/content/tags/prometheus/prometheus.svg @@ -0,0 +1,54 @@ + + + +image/svg+xml diff --git a/src/content/tags/python/index.mdx b/src/content/tags/python/index.mdx index e9c76efef..45858aaa9 100644 --- a/src/content/tags/python/index.mdx +++ b/src/content/tags/python/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./python.svg" +isSkill: true --- diff --git a/src/content/tags/python/python.svg b/src/content/tags/python/python.svg new file mode 100644 index 000000000..36ec557b2 --- /dev/null +++ b/src/content/tags/python/python.svg @@ -0,0 +1,70 @@ + + diff --git a/src/content/tags/ruby/index.mdx b/src/content/tags/ruby/index.mdx index 6d4d9f447..5ae18c8fc 100644 --- a/src/content/tags/ruby/index.mdx +++ b/src/content/tags/ruby/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./ruby.svg" +isSkill: true --- diff --git a/src/assets/images/technologies/Ruby_logo.svg b/src/content/tags/ruby/ruby.svg similarity index 81% rename from src/assets/images/technologies/Ruby_logo.svg rename to src/content/tags/ruby/ruby.svg index 59cf324fe..5d8ec2656 100644 --- a/src/assets/images/technologies/Ruby_logo.svg +++ b/src/content/tags/ruby/ruby.svg @@ -2,24 +2,24 @@ image/svg+xml + inkscape:current-layer="Layer_1" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" /> + x2="47.046902" + y2="181.66141"> + y2="65.216202"> + y2="-2.8092999"> + x2="52.817699" + y2="159.61659"> + x2="137.43269" + y2="78.683998"> + x2="173.15421" + y2="26.053801"> + y2="26.305901"> \ No newline at end of file + d="m 22.010325,2.8521105 9.832574,1.3798968 C 31.31815,2.0083004 29.70665,0.57333738 26.959814,0.12470898 Z" + id="path3485" /> diff --git a/src/content/tags/terraform/index.mdx b/src/content/tags/terraform/index.mdx index a812322d7..b0e39ba11 100644 --- a/src/content/tags/terraform/index.mdx +++ b/src/content/tags/terraform/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./terraform.svg" +isSkill: true --- diff --git a/src/content/tags/terraform/terraform.svg b/src/content/tags/terraform/terraform.svg new file mode 100644 index 000000000..89f81e0ed --- /dev/null +++ b/src/content/tags/terraform/terraform.svg @@ -0,0 +1,53 @@ + + + + + + + + diff --git a/src/content/tags/typescript/index.mdx b/src/content/tags/typescript/index.mdx index 7adf0a395..35c6458fc 100644 --- a/src/content/tags/typescript/index.mdx +++ b/src/content/tags/typescript/index.mdx @@ -5,4 +5,6 @@ description: "" cover: "./cover.jpg" coverAlt: "" featured: false +logo: "./typescript.svg" +isSkill: true --- diff --git a/src/content/tags/typescript/typescript.svg b/src/content/tags/typescript/typescript.svg new file mode 100644 index 000000000..f632b266c --- /dev/null +++ b/src/content/tags/typescript/typescript.svg @@ -0,0 +1,45 @@ + + + + + + + From 82c0c0aa3000ebdd6dd19859ca6553365f8c497e Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 03:02:38 +0300 Subject: [PATCH 16/33] Refactor Testimonials and Carousel to LitElement base class from HTMLElement, extract common embla code out of both into shared library --- _LIT_AND_CAROUSEL_REFACTOR.md | 76 ++ _TODO.md | 151 +++- src/components/Carousel/client/index.ts | 545 ++------------ src/components/Home/Skills/index.astro | 43 +- src/components/Testimonials/client/index.ts | 598 ++------------- src/components/ThemePicker/client/index.ts | 43 +- .../scripts/embla/EmblaCarouselBase.ts | 679 ++++++++++++++++++ src/components/scripts/embla/index.ts | 17 + src/components/scripts/embla/logging.ts | 17 + src/components/scripts/embla/navigation.ts | 96 +++ src/components/scripts/embla/types.ts | 52 ++ src/pages/index.astro | 8 +- 12 files changed, 1245 insertions(+), 1080 deletions(-) create mode 100644 _LIT_AND_CAROUSEL_REFACTOR.md create mode 100644 src/components/scripts/embla/EmblaCarouselBase.ts create mode 100644 src/components/scripts/embla/index.ts create mode 100644 src/components/scripts/embla/logging.ts create mode 100644 src/components/scripts/embla/navigation.ts create mode 100644 src/components/scripts/embla/types.ts diff --git a/_LIT_AND_CAROUSEL_REFACTOR.md b/_LIT_AND_CAROUSEL_REFACTOR.md new file mode 100644 index 000000000..df923a9eb --- /dev/null +++ b/_LIT_AND_CAROUSEL_REFACTOR.md @@ -0,0 +1,76 @@ +1. Issues with refactoring Carousel and Testimonials to LitElement +No real blockers. The migration is straightforward because: + +Light DOM is already solved. ThemePicker demonstrates the pattern: createRenderRoot() { return this }. Since all three components render their HTML via Astro templates (not Lit's render()), this is the correct approach and already in use. +LitElement is already bundled. ThemePicker pulls it in, so there's no added dependency cost. +Lifecycle maps cleanly. The manual connectedCallback → initialize() pattern in Carousel/Testimonials translates directly. The main changes are calling super.connectedCallback() / super.disconnectedCallback() and optionally using firstUpdated() instead of the manual DOMContentLoaded check. +Nanostore integration improves. Carousel and Testimonials currently call createAnimationController manually. With LitElement they could use StoreController for reactive bindings (like ThemePicker does with createThemeController), though it's not required since the animation lifecycle uses callbacks rather than direct store subscriptions. +The only thing to watch: the transition:persist directive must remain on the HTML custom element tag itself, not on an Astro component wrapper. That's already a project rule and isn't affected by LitElement vs HTMLElement. + +2. Revised recommendation + +With all three components sharing LitElement as a base, an abstract base class becomes the cleanest option. + +Proposed structure: + +```bash +src/components/scripts/embla/ + EmblaCarouselBase.ts – abstract LitElement base class + types.ts – shared types (TimerHandle, EmblaRootElement, etc.) + logging.ts – createE2ELogger(prefix) +``` + +## What goes in EmblaCarouselBase extends LitElement + +All the code that's currently duplicated (~400 lines between Carousel and Testimonials): + +Responsibility Implementation +Light DOM createRenderRoot() { return this } +Embla init/destroy initEmbla() / teardownEmbla() with options from abstract getter +Autoplay plugin Setup, pending-state queue, scheduleAutoplayReady(), flushPendingAutoplayState() +pause() / resume() public API Identical in both today +Viewport observer setupViewportObserver() / teardownViewportObserver() / syncAutoplayWithViewport() +Animation lifecycle registerAnimationLifecycle() wrapping createAnimationController +Focus-visible pause handleFocusIn() / scheduleFocusVisiblePauseSync() / syncFocusVisiblePauseState() +Prev/next buttons setupNavigationButtons() with enable/disable state updates +Dots navigation setupDotsNavigation() with an overridable method for dot active styling +E2E logging logForE2E() using a configurable prefix +data-carousel-ready / data-carousel-autoplay attributes Set/remove in base +connectedCallback / disconnectedCallback / teardown Full lifecycle orchestration + +### What subclasses configure + +Via abstract getters or overridable methods — keeping each subclass to ~50–100 lines of glue: + +| Configuration point | Carousel | Testimonials | Skills (new) | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | +| `abstract get emblaOptions()` | [{ loop: true, align: 'start' }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [{ loop: true, align: 'center' }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [{ loop: true, align: 'start', dragFree: true }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | +| `abstract get autoplayOptions()` | [{ delay: 4000, ... }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [{ delay: 6000, ... }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `null` (no autoplay initially) | +| [abstract get animationId()](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'carousel'` | ['testimonials-carousel'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'skills-carousel'` | +| [abstract get scriptName()](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | ['CarouselElement'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | ['TestimonialsCarouselElement'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'SkillsCarouselElement'` | +| `abstract get logPrefix()` | `'carousel'` | ['testimonials'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'skills'` | +| `abstract get focusVisibleSelector()` | `'[data-carousel-slide] :focus-visible'` | `'[data-testimonials-slide] :focus-visible'` | component-specific | +| `abstract queryElements()` | Uses Carousel selectors | Uses Testimonials selectors | Uses Skills selectors | + +Then each subclass adds only its unique features: + +| Feature | Where it lives | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| Keyboard nav (ArrowLeft/Right) | Carousel subclass only | +| Status region ("Slide X of Y") | Carousel subclass only | +| Autoplay toggle button + icon swap | Testimonials subclass only | +| Viewport ID / `aria-controls` on buttons | Testimonials subclass only | +| Dot active styling ([is-active](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) vs `w-6`) | Override `applyDotActiveStyle()` / `applyDotInactiveStyle()` | + +Two reasonable paths: + +A: ThemePicker stays independent but extracts just the nav-button wiring into a small utility function in src/components/scripts/embla/navigation.ts (~30 lines). This avoids forcing ThemePicker into a base class that's 90% irrelevant to it. +B: ThemePicker extends EmblaCarouselBase with autoplay disabled and most features no-op'd. This feels forced and adds cognitive overhead for no real benefit. +I'd recommend A. The base class serves the autoplay-carousel family (Carousel, Testimonials, Skills, and any future autoplay carousel). ThemePicker shares one small utility. + +Estimated result +EmblaCarouselBase.ts: ~350–400 lines (extracted from current duplication) +Each subclass (Carousel, Testimonials, Skills): ~50–150 lines of component-specific code +navigation.ts utility for ThemePicker: ~30 lines +Net reduction: ~400+ lines eliminated across Carousel and Testimonials +Skills starts with a thin subclass instead of copy-pasting a fourth time \ No newline at end of file diff --git a/_TODO.md b/_TODO.md index 9096a8d49..592ca4f82 100644 --- a/_TODO.md +++ b/_TODO.md @@ -21,6 +21,45 @@ Cons: Browser rendering can be inconsistent across different operating systems a Axe accessibility (2) - axe-core integration +## Missing E2E Component Tests + +- Code/CodeBlock +- Code/CodeTabs +- Consent/Checkbox + +## Testimonials on mobile + +We have E2E errors again testimonials slide on mobile chrome and safari. I think the problem is that we are pausing carousels when part of the carousel is outside of the viewport, and the testimonials are too large to display on mobile without being off viewport. + +`test/e2e/specs/04-components/testimonials.spec.ts`:244:3 › Testimonials Component › @ready testimonials auto-rotate changes slide index + +## Refactor of Common Code in Carousel related components + +See if there's any code in common between Carousel, Testimonials, and Themepicker. We have another one to add that uses the carousel code - for Skills. + +Issue identified with refactoring Testimonial and Carousel to Lit custom web components: + +The only thing to watch: the `transition:persist` directive must remain on the HTML custom element tag itself, not on an Astro component wrapper. That's already a project rule and isn't affected by `LitElement` vs `HTMLElement`. + +#### Selector files + +Carousel/client/selectors.ts and Testimonials/client/selectors.ts follow an identical pattern — only the CSS selectors and data-attribute names differ. The query/get function pairs are structurally identical. + +### What's genuinely different + +| Concern | Carousel | Testimonials | ThemePicker | +| -------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | +| Embla options | [align: 'start'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [align: 'center'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [align: 'center'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), [containScroll](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), no loop | +| Autoplay delay | 4000ms | 6000ms | None | +| Autoplay toggle button | ❌ | ✅ (play/pause icon swap) | ❌ | +| Keyboard nav (ArrowLeft/Right) | ✅ | ❌ | ❌ | +| Status region (`"Slide X of Y"`) | ✅ | ❌ | ❌ | +| Dot styling | [is-active](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) class | width-based (`w-3` → `w-6`) | None | +| Viewport ID / `aria-controls` | ❌ | ✅ | ❌ | +| View Transitions integration | ❌ | ❌ | ✅ (Lit + `astro:after-swap`) | +| Tooltip portal | ❌ | ❌ | ✅ | +| Base class | [HTMLElement](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [HTMLElement](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [LitElement](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | + ## Youtube video for Backstage IDP hero on Home page Discuss agentic AI integrations to add to Backstage @@ -39,14 +78,6 @@ Vercel AI Gateway, maybe could use for a chatbot: https://vercel.com/kevin-browns-projects-dd474f73/astro-webstackbuilders-com/ai-gateway -## Testimonials on mobile - -We have E2E errors again testimonials slide on mobile chrome and safari. I think the problem is that we are pausing carousels when part of the carousel is outside of the viewport, and the testimonials are too large to display on mobile without being off viewport. - -See if there's any code in common between Carousel, Testimonials, and Themepicker. We have another one to add that uses the carousel code - for Skills. - -`test/e2e/specs/04-components/testimonials.spec.ts`:244:3 › Testimonials Component › @ready testimonials auto-rotate changes slide index - ## Move containers to dev server from Playwright We should start the mock containers with the dev server instead of with Playwright so that they're useable in a dev environment. @@ -238,3 +269,107 @@ This article has different approaches to [print pagination](https://www.customjs - cover.jpg for reliability-and-testing needs touch up in GIMP - We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag lik "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag and add the name to the tags: frontmatter key in the index.mdx - Need an article on OpenStack + +## HTMLElement vs. extends HTMLElement + +A bunch of our web components extend directly from HTMLElement instead of following the instructions to extend LitElement. + +- Code/CodeBlock +- Code/CodeTabs +- Consent/Banner +- Consent/Checkbox +- Consent/Preferences + +And the embla components: Carousel and Testimonials + +The WebComponentModule type in this file is used throughout component scripts: + +`src/components/scripts/@types/webComponentModule.ts` + +There are testing fixtures that might be targeting HTMLElement instead of LitElement: + +`isLikelyWebComponent()` in `test/eslint/enforce-centralized-events-rule.ts` +line 113 in `test/eslint/__tests__/enforce-centralized-events-rule.spec.ts` + +interface `ElementWithTestProperties` in `test/e2e/assertions/index.ts` + +/home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/src/components/Carousel/client/index.ts + 70:5 error Use `keyup with addButtonEventListeners or addWrapperEventListeners` from `elementListeners` for keyboard events (use keyup instead of keydown for better accessibility) instead of direct addEventListener. This ensures consistent accessibility support (isComposing check, repeat prevention, Enter/Escape key handling) custom-rules/enforce-centralized-events + +✖ 1 problem (1 error, 0 warnings) + + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + + FAIL src/components/Testimonials/client/__tests__/index.spec.ts > Testimonials component > registers the web component and generates pagination dots +AssertionError: expected null to be 'testimonials-3-viewport' // Object.is equality + +- Expected: +"testimonials-3-viewport" + ++ Received: +null + + ❯ src/components/Testimonials/client/__tests__/index.spec.ts:198:51 + 196| + 197| dots.forEach(dot => { + 198| expect(dot.getAttribute('aria-controls')).toBe(viewportId) + | ^ + 199| }) + 200| expect(prevBtn?.getAttribute('aria-controls')).toBe(viewportId) + ❯ src/components/Testimonials/client/__tests__/index.spec.ts:197:12 + ❯ assert src/components/Testimonials/client/__tests__/index.spec.ts:114:13 + ❯ assert test/unit/helpers/litRuntime.ts:308:10 + ❯ test/unit/helpers/litRuntime.ts:246:9 + ❯ withJsdomEnvironment test/unit/helpers/litRuntime.ts:147:10 + ❯ renderInJsdom test/unit/helpers/litRuntime.ts:218:2 + ❯ Module.executeRender test/unit/helpers/litRuntime.ts:320:2 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL src/components/Home/Hero/client/__tests__/index.spec.ts > HomeHeroElement (Lit) > types the ready prompt one character every 500ms and stops when complete +AssertionError: expected '' to be 'r' // Object.is equality + +- Expected ++ Received + +- r + + ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:57:38 + 55| + 56| vi.advanceTimersByTime(STEP_MS) + 57| expect(readyText?.textContent).toBe('r') + | ^ + 58| + 59| vi.advanceTimersByTime(STEP_MS) + ❯ withJsdomEnvironment test/unit/helpers/litRuntime.ts:147:10 + ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:39:5 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL src/components/Home/Hero/client/__tests__/index.spec.ts > HomeHeroElement (Lit) > skips animation and shows final text when reduced motion is preferred +AssertionError: expected '' to be 'heck yes, let\'s talk...' // Object.is equality + +- Expected ++ Received + +- heck yes, let's talk... + + ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:90:38 + 88| const readyText = window.document.querySelector('[data-hero-ready-text]') + 89| expect(readyText).toBeTruthy() + 90| expect(readyText?.textContent).toBe(READY_TEXT) + | ^ + 91| + 92| vi.advanceTimersByTime(STEP_MS * READY_TEXT.length) + ❯ withJsdomEnvironment test/unit/helpers/litRuntime.ts:147:10 + ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:75:5 + + +02:58:50 [ERROR] [vite] ✗ Build failed in 4.72s +[@mdx-js/rollup] Cannot assign to read only property 'name' of object 'BuildError: Mermaid couldn't graph this diagram.' +file: /home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx + Stack trace: + at Object.transform (file:///home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/node_modules/@astrojs/mdx/dist/vite-plugin-mdx.js:60:18) + at process.processImmediate (node:internal/timers:473:9) + at async ModuleLoader.addModuleSource (file:///home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/node_modules/rollup/dist/es/shared/node-entry.js:21363:36) diff --git a/src/components/Carousel/client/index.ts b/src/components/Carousel/client/index.ts index 0bb5a4b05..ae95253b6 100644 --- a/src/components/Carousel/client/index.ts +++ b/src/components/Carousel/client/index.ts @@ -1,16 +1,8 @@ -import EmblaCarousel, { type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel' -import Autoplay from 'embla-carousel-autoplay' -import { addButtonEventListeners } from '@components/scripts/elementListeners' -import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { defineCustomElement } from '@components/scripts/utils' -import { - createAnimationController, - type AnimationControllerHandle, - type AnimationPlayState, -} from '@components/scripts/store' +import { createE2ELogger, EmblaCarouselBase } from '@components/scripts/embla' +import type { EmblaCarouselConfig, EmblaElementHandles } from '@components/scripts/embla' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' -import type { CarouselEmblaRootElement } from './selectors' import { getCarouselEmblaRoot, getCarouselViewport, @@ -23,210 +15,63 @@ import { } from './selectors' const SCRIPT_NAME = 'CarouselElement' +const logForE2E = createE2ELogger('carousel') -const logForE2E = ( - level: 'info' | 'error', - message: string, - details?: Record -): void => { - if (typeof window === 'undefined' || window.isPlaywrightControlled !== true) return - const payload = details ? [`[carousel] ${message}`, details] : [`[carousel] ${message}`] - console[level](...payload) -} - -const EMBLA_OPTIONS: EmblaOptionsType = { - loop: true, - align: 'start', - skipSnaps: false, - dragFree: false, -} - -const AUTOPLAY_OPTIONS = { - delay: 4000, - stopOnInteraction: true, - stopOnMouseEnter: true, - playOnInit: false, -} - -type TimerHandle = ReturnType | number - -export class CarouselElement extends HTMLElement { - private emblaApi: EmblaCarouselType | null = null - private autoplayPlugin: ReturnType | null = null - private emblaRoot: CarouselEmblaRootElement | null = null - private viewport: HTMLElement | null = null - private dotsContainer: HTMLElement | null = null +export class CarouselElement extends EmblaCarouselBase { private statusRegion: HTMLElement | null = null - private prevBtn: HTMLButtonElement | null = null - private nextBtn: HTMLButtonElement | null = null - private initialized = false - private animationController: AnimationControllerHandle | undefined - private pendingAutoplayState: AnimationPlayState | null = null - private hasAutoplaySupport = false - private autoplayReady = false - private autoplayReadyScheduled = false - private autoplayReadyTimer: TimerHandle | null = null - private intersectionObserver: IntersectionObserver | undefined - private isFullyInViewport = true - private requestedAutoplayState: AnimationPlayState = 'paused' - private readonly animationInstanceId: string - private focusVisibleCheckRafId: number | null = null - private readonly domReadyHandler = () => { - document.removeEventListener('DOMContentLoaded', this.domReadyHandler) - this.initialize() - } - private readonly autoplayPlayHandler = () => this.setAutoplayState('playing') - private readonly autoplayStopHandler = () => this.setAutoplayState('paused') private readonly keydownHandler = (event: KeyboardEvent) => this.handleKeydown(event) - private readonly focusInHandler = (event: FocusEvent) => this.handleFocusIn(event) - private readonly focusOutHandler = () => this.scheduleFocusVisiblePauseSync() - private static instanceCounter = 0 + // ────────────────────── Base class abstract implementations ────────────────────── - constructor() { - super() - CarouselElement.instanceCounter += 1 - this.animationInstanceId = `carousel-${CarouselElement.instanceCounter}` + protected getConfig(): EmblaCarouselConfig { + return { + emblaOptions: { + loop: true, + align: 'start', + skipSnaps: false, + dragFree: false, + }, + autoplayOptions: { + delay: 4000, + stopOnInteraction: true, + stopOnMouseEnter: true, + playOnInit: false, + }, + animationId: 'carousel', + scriptName: SCRIPT_NAME, + logPrefix: 'carousel', + } } - connectedCallback(): void { - if (typeof document === 'undefined') return - - const context = { scriptName: SCRIPT_NAME, operation: 'connectedCallback' } - addScriptBreadcrumb(context) - - try { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', this.domReadyHandler, { once: true }) - return - } - this.initialize() - } catch (error) { - handleScriptError(error, context) + protected queryElements(): EmblaElementHandles { + return { + emblaRoot: getCarouselEmblaRoot(this), + viewport: getCarouselViewport(this), + slideCount: queryCarouselSlides(this).length, + dotsContainer: queryCarouselDotsContainer(this), + prevBtn: queryCarouselPrevBtn(this), + nextBtn: queryCarouselNextBtn(this), } } - disconnectedCallback(): void { - if (typeof document === 'undefined') return - document.removeEventListener('DOMContentLoaded', this.domReadyHandler) - this.teardown() + protected override hasFocusVisibleSlide(): boolean { + return hasCarouselFocusVisibleSlide(this) } - private initialize(): void { - if (this.initialized) return - - const context = { scriptName: SCRIPT_NAME, operation: 'initialize' } - addScriptBreadcrumb(context) - logForE2E('info', 'initialize:start', { - id: this.animationInstanceId, - existingReadyState: this.getAttribute('data-carousel-ready') ?? 'missing', - }) - - try { - this.emblaRoot = getCarouselEmblaRoot(this) - this.viewport = getCarouselViewport(this) - this.dotsContainer = queryCarouselDotsContainer(this) - this.statusRegion = queryCarouselStatusRegion(this) - this.prevBtn = queryCarouselPrevBtn(this) - this.nextBtn = queryCarouselNextBtn(this) - - const slideCount = queryCarouselSlides(this).length - const requestedAutoplay = slideCount > 1 ? Autoplay({ ...AUTOPLAY_OPTIONS }) : null - this.autoplayReady = false - this.autoplayReadyScheduled = false - const plugins = requestedAutoplay ? [requestedAutoplay] : [] - this.emblaApi = EmblaCarousel(this.viewport, EMBLA_OPTIONS, plugins) - this.emblaRoot.__emblaApi__ = this.emblaApi - const emblaSnapCount = this.emblaApi.scrollSnapList().length - const supportsAutoplay = slideCount > 1 && emblaSnapCount > 1 - this.hasAutoplaySupport = supportsAutoplay - this.autoplayPlugin = supportsAutoplay ? requestedAutoplay : null - this.setAutoplayState('paused') - this.requestedAutoplayState = 'paused' - - if (this.autoplayPlugin) { - const emblaWithEvents = this.emblaApi as EmblaCarouselType & { - on: (_event: string, _handler: () => void) => EmblaCarouselType - } - - emblaWithEvents.on('autoplay:play', this.autoplayPlayHandler) - emblaWithEvents.on('autoplay:stop', this.autoplayStopHandler) - } + protected override isFocusInsideSlide(target: HTMLElement): boolean { + return target.closest('[data-carousel-slide]') !== null + } - this.setupNavigationButtons() - this.setupDotsNavigation() - this.setupStatusRegion() - this.addEventListener('keydown', this.keydownHandler) - this.addEventListener('focusin', this.focusInHandler) - this.addEventListener('focusout', this.focusOutHandler) + // ────────────────────── Carousel-specific behaviour ────────────────────── - this.initialized = true - this.setAttribute('data-carousel-ready', 'true') - logForE2E('info', 'initialize:complete', { - id: this.animationInstanceId, - slideCount, - supportsAutoplay: this.hasAutoplaySupport, - }) - if (this.hasAutoplaySupport) { - this.registerAnimationLifecycle() - this.setupViewportObserver() - this.scheduleAutoplayReady() - this.scheduleFocusVisiblePauseSync() - } - } catch (error) { - this.teardown() - logForE2E('error', 'initialize:failed', { - id: this.animationInstanceId, - message: error instanceof Error ? error.message : String(error), - }) - handleScriptError(error, context) - } + protected override onInitialized(): void { + this.statusRegion = queryCarouselStatusRegion(this) + this.setupStatusRegion() + this.addEventListener('keydown', this.keydownHandler) } - private teardown(): void { - this.teardownViewportObserver() + protected override onTeardown(): void { this.removeEventListener('keydown', this.keydownHandler) - this.removeEventListener('focusin', this.focusInHandler) - this.removeEventListener('focusout', this.focusOutHandler) - if (typeof window !== 'undefined' && this.focusVisibleCheckRafId !== null) { - window.cancelAnimationFrame(this.focusVisibleCheckRafId) - this.focusVisibleCheckRafId = null - } - if (this.emblaApi) { - const emblaWithEvents = this.emblaApi as EmblaCarouselType & { - off: (_event: string, _handler: () => void) => EmblaCarouselType - } - - if (this.autoplayPlugin) { - emblaWithEvents.off('autoplay:play', this.autoplayPlayHandler) - emblaWithEvents.off('autoplay:stop', this.autoplayStopHandler) - } - this.emblaApi.destroy() - } - if (this.emblaRoot && '__emblaApi__' in this.emblaRoot) { - delete this.emblaRoot.__emblaApi__ - } - - this.emblaApi = null - this.autoplayPlugin = null - this.initialized = false - this.removeAttribute('data-carousel-ready') - this.removeAttribute('data-carousel-autoplay') - this.animationController?.setInstancePauseState('focus-visible', false) - this.animationController?.destroy() - this.animationController = undefined - this.pendingAutoplayState = null - this.hasAutoplaySupport = false - if (this.autoplayReadyTimer) { - clearTimeout(this.autoplayReadyTimer) - this.autoplayReadyTimer = null - } - this.autoplayReadyScheduled = false - this.autoplayReady = false - - if (this.dotsContainer) { - this.dotsContainer.innerHTML = '' - } } private handleKeydown(event: KeyboardEvent): void { @@ -256,172 +101,6 @@ export class CarouselElement extends HTMLElement { } } - private handleFocusIn(event: FocusEvent): void { - if (!this.hasAutoplaySupport) return - if (!this.animationController) return - - const target = event.target - if (!(target instanceof HTMLElement)) return - - const slideContainer = target.closest('[data-carousel-slide]') - if (!slideContainer) return - - const isFocusVisible = this.isElementFocusVisible(target) - if (!isFocusVisible) return - - this.animationController.setInstancePauseState('focus-visible', true) - } - - private scheduleFocusVisiblePauseSync(): void { - if (!this.hasAutoplaySupport) return - if (!this.animationController) return - - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - this.syncFocusVisiblePauseState() - return - } - - if (this.focusVisibleCheckRafId !== null) { - window.cancelAnimationFrame(this.focusVisibleCheckRafId) - } - - this.focusVisibleCheckRafId = window.requestAnimationFrame(() => { - this.focusVisibleCheckRafId = null - this.syncFocusVisiblePauseState() - }) - } - - private syncFocusVisiblePauseState(): void { - if (!this.animationController) return - this.animationController.setInstancePauseState('focus-visible', hasCarouselFocusVisibleSlide(this)) - } - - private isElementFocusVisible(element: HTMLElement): boolean { - try { - return element.matches(':focus-visible') - } catch { - return false - } - } - - private setupNavigationButtons(): void { - if (!this.emblaApi || !this.prevBtn || !this.nextBtn) return - - const updateButtonStates = () => { - if (!this.emblaApi || !this.prevBtn || !this.nextBtn) return - - if (this.emblaApi.canScrollPrev()) { - this.prevBtn.removeAttribute('disabled') - this.prevBtn.classList.remove('opacity-30', 'cursor-not-allowed') - } else { - this.prevBtn.setAttribute('disabled', 'true') - this.prevBtn.classList.add('opacity-30', 'cursor-not-allowed') - } - - if (this.emblaApi.canScrollNext()) { - this.nextBtn.removeAttribute('disabled') - this.nextBtn.classList.remove('opacity-30', 'cursor-not-allowed') - } else { - this.nextBtn.setAttribute('disabled', 'true') - this.nextBtn.classList.add('opacity-30', 'cursor-not-allowed') - } - } - - addButtonEventListeners( - this.prevBtn, - () => { - try { - this.emblaApi?.scrollPrev() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollPrev' }) - } - }, - this - ) - - addButtonEventListeners( - this.nextBtn, - () => { - try { - this.emblaApi?.scrollNext() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollNext' }) - } - }, - this - ) - - this.emblaApi.on('select', updateButtonStates) - this.emblaApi.on('reInit', updateButtonStates) - updateButtonStates() - } - - private setupDotsNavigation(): void { - if (!this.emblaApi || !this.dotsContainer) return - - let dots: HTMLButtonElement[] = [] - - const rebuildDots = () => { - if (!this.emblaApi || !this.dotsContainer) return - - const dotsContainer = this.dotsContainer - dotsContainer.innerHTML = '' - dots = [] - - this.emblaApi.scrollSnapList().forEach((_, index) => { - const dot = (this.ownerDocument ?? document).createElement('button') - dot.type = 'button' - dot.dataset['index'] = String(index) - dot.tabIndex = -1 - dot.setAttribute('tabindex', '-1') - dot.className = - 'embla__dot w-3 h-3 rounded-full bg-content-active transition-all duration-300 hover:bg-primary' - dot.setAttribute('aria-label', `Go to slide ${index + 1}`) - - addButtonEventListeners( - dot, - () => { - try { - this.emblaApi?.scrollTo(index) - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollToDot' }) - } - }, - this - ) - - dotsContainer.appendChild(dot) - dots.push(dot) - }) - } - - const updateDots = () => { - if (!this.emblaApi) return - const selectedIndex = this.emblaApi.selectedScrollSnap() - - dots.forEach((dot, index) => { - if (index === selectedIndex) { - dot.classList.add('is-active', 'bg-primary') - dot.classList.remove('bg-content-active') - dot.setAttribute('aria-current', 'true') - } else { - dot.classList.remove('is-active', 'bg-primary') - dot.classList.add('bg-content-active') - dot.removeAttribute('aria-current') - } - }) - } - - rebuildDots() - updateDots() - - this.emblaApi.on('select', updateDots) - this.emblaApi.on('reInit', () => { - rebuildDots() - updateDots() - }) - } - private setupStatusRegion(): void { if (!this.emblaApi || !this.statusRegion) return @@ -437,146 +116,6 @@ export class CarouselElement extends HTMLElement { this.emblaApi.on('select', updateStatus) this.emblaApi.on('reInit', updateStatus) } - - pause(): void { - try { - this.requestedAutoplayState = 'paused' - this.syncAutoplayWithViewport() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'pause' }) - } - } - - resume(): void { - try { - this.requestedAutoplayState = 'playing' - this.syncAutoplayWithViewport() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'resume' }) - } - } - - private setupViewportObserver(): void { - if (typeof document === 'undefined') return - if (this.intersectionObserver) return - - try { - const IntersectionObserverCtor = ( - globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } - ).IntersectionObserver - - if (typeof IntersectionObserverCtor !== 'function') return - - this.intersectionObserver = new IntersectionObserverCtor( - entries => { - const entry = entries[0] - const ratio = entry?.intersectionRatio ?? 0 - this.isFullyInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) - this.syncAutoplayWithViewport() - }, - { threshold: [0, 0.999] } - ) - - const observedTarget = this.emblaRoot ?? this - this.intersectionObserver.observe(observedTarget) - } catch { - // Best effort only - } - } - - private teardownViewportObserver(): void { - try { - this.intersectionObserver?.disconnect() - this.intersectionObserver = undefined - this.isFullyInViewport = true - } catch { - // Best effort only - } - } - - private syncAutoplayWithViewport(): void { - const shouldPlay = this.requestedAutoplayState === 'playing' && this.isFullyInViewport - this.updateAutoplayState(shouldPlay ? 'playing' : 'paused') - } - - private setAutoplayState(state: 'playing' | 'paused'): void { - this.setAttribute('data-carousel-autoplay', state) - } - - private updateAutoplayState(state: AnimationPlayState): void { - if ( - !this.hasAutoplaySupport || - !this.autoplayPlugin || - !this.emblaApi || - !this.initialized || - !this.autoplayReady - ) { - if (this.hasAutoplaySupport) { - this.pendingAutoplayState = state - this.setAutoplayState(state) - } else { - this.pendingAutoplayState = null - this.setAutoplayState('paused') - } - return - } - - this.pendingAutoplayState = null - if (state === 'playing') { - this.autoplayPlugin.play() - } else { - this.autoplayPlugin.stop() - } - this.setAutoplayState(state) - } - - private flushPendingAutoplayState(): void { - if (!this.pendingAutoplayState || !this.autoplayReady || !this.autoplayPlugin) return - - const pendingState = this.pendingAutoplayState - this.pendingAutoplayState = null - - try { - this.updateAutoplayState(pendingState) - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'flushAutoplay' }) - } - } - - private registerAnimationLifecycle(): void { - if (this.animationController || !this.hasAutoplaySupport) return - this.animationController = createAnimationController({ - animationId: 'carousel', - instanceId: this.animationInstanceId, - debugLabel: SCRIPT_NAME, - onPlay: () => { - this.resume() - }, - onPause: () => { - this.pause() - }, - }) - } - - private scheduleAutoplayReady(): void { - if (this.autoplayReadyScheduled || !this.autoplayPlugin) return - this.autoplayReadyScheduled = true - - const markReady = () => { - this.autoplayReadyScheduled = false - this.autoplayReadyTimer = null - if (!this.initialized || !this.autoplayPlugin) return - this.autoplayReady = true - this.flushPendingAutoplayState() - } - - if (typeof window === 'undefined') { - markReady() - return - } - - this.autoplayReadyTimer = window.setTimeout(markReady, 0) - } } declare global { diff --git a/src/components/Home/Skills/index.astro b/src/components/Home/Skills/index.astro index d84200d97..c4e5ce0b0 100644 --- a/src/components/Home/Skills/index.astro +++ b/src/components/Home/Skills/index.astro @@ -3,37 +3,36 @@ * "Technologies and Skills" section for the homepage. * Renders a dark strip with icon + label links for each technology. */ +import { type CollectionEntry } from 'astro:content' +import { Image } from 'astro:assets' + export interface Props { - technologies: string[] + skills: Array> } -const { technologies } = Astro.props as Props - -/** Convert a display name like "Argo CD" to a URL slug like "argo-cd" */ -function toTagSlug(name: string): string { - return name - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') -} +const { skills } = Astro.props as Props --- -
+

Technologies & Expertise

-
- {technologies.map((tech) => ( + diff --git a/src/components/Testimonials/client/index.ts b/src/components/Testimonials/client/index.ts index c76e93b97..405ed0003 100644 --- a/src/components/Testimonials/client/index.ts +++ b/src/components/Testimonials/client/index.ts @@ -1,15 +1,8 @@ -import EmblaCarousel, { type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel' -import Autoplay from 'embla-carousel-autoplay' import { addButtonEventListeners } from '@components/scripts/elementListeners' -import { addScriptBreadcrumb } from '@components/scripts/errors' -import { handleScriptError } from '@components/scripts/errors/handler' import { isType1Element } from '@components/scripts/assertions/elements' import { defineCustomElement } from '@components/scripts/utils' -import { - createAnimationController, - type AnimationControllerHandle, - type AnimationPlayState, -} from '@components/scripts/store' +import { createE2ELogger, EmblaCarouselBase } from '@components/scripts/embla' +import type { EmblaCarouselConfig, EmblaElementHandles } from '@components/scripts/embla' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { getTestimonialsEmblaSetupElements, @@ -24,470 +17,112 @@ import { } from './selectors' const SCRIPT_NAME = 'TestimonialsCarouselElement' +const logForE2E = createE2ELogger('testimonials') -const logForE2E = ( - level: 'info' | 'error', - message: string, - details?: Record -): void => { - if (typeof window === 'undefined' || window.isPlaywrightControlled !== true) return - const payload = details ? [`[testimonials] ${message}`, details] : [`[testimonials] ${message}`] - console[level](...payload) -} - -const EMBLA_OPTIONS: EmblaOptionsType = { - loop: true, - align: 'center', - skipSnaps: false, - dragFree: false, -} - -const AUTOPLAY_OPTIONS = { - delay: 6000, - stopOnInteraction: true, - stopOnMouseEnter: true, - playOnInit: false, -} - -type DebugEmblaElement = HTMLElement & { __emblaApi__?: EmblaCarouselType } -type TimerHandle = ReturnType | number - -export class TestimonialsCarouselElement extends HTMLElement { - private emblaApi: EmblaCarouselType | null = null - private autoplayPlugin: ReturnType | null = null - private emblaRoot: DebugEmblaElement | null = null - private viewport: HTMLElement | null = null - private dotsContainer: HTMLElement | null = null - private prevBtn: HTMLButtonElement | null = null - private nextBtn: HTMLButtonElement | null = null +export class TestimonialsCarouselElement extends EmblaCarouselBase { private autoplayToggleBtn: HTMLButtonElement | null = null private viewportId: string | null = null - private initialized = false - private animationController: AnimationControllerHandle | undefined - private pendingAutoplayState: AnimationPlayState | null = null - private hasAutoplaySupport = false - private autoplayReady = false - private autoplayReadyScheduled = false - private autoplayReadyTimer: TimerHandle | null = null - private intersectionObserver: IntersectionObserver | undefined - private isFullyInViewport = true - private requestedAutoplayState: AnimationPlayState = 'paused' - private readonly animationInstanceId: string - private focusVisibleCheckRafId: number | null = null - private readonly domReadyHandler = () => { - document.removeEventListener('DOMContentLoaded', this.domReadyHandler) - this.initialize() - } - private readonly autoplayPlayHandler = () => this.setAutoplayState('playing') - private readonly autoplayStopHandler = () => this.setAutoplayState('paused') - private readonly focusInHandler = (event: FocusEvent) => this.handleFocusIn(event) - private readonly focusOutHandler = () => this.scheduleFocusVisiblePauseSync() - private static instanceCounter = 0 + // ────────────────────── Base class abstract implementations ────────────────────── - constructor() { - super() - TestimonialsCarouselElement.instanceCounter += 1 - this.animationInstanceId = `testimonials-${TestimonialsCarouselElement.instanceCounter}` - } - - connectedCallback(): void { - if (typeof document === 'undefined') return - - const context = { scriptName: SCRIPT_NAME, operation: 'connectedCallback' } - addScriptBreadcrumb(context) - - try { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', this.domReadyHandler, { once: true }) - return - } - this.initialize() - } catch (error) { - handleScriptError(error, context) + protected getConfig(): EmblaCarouselConfig { + return { + emblaOptions: { + loop: true, + align: 'center', + skipSnaps: false, + dragFree: false, + }, + autoplayOptions: { + delay: 6000, + stopOnInteraction: true, + stopOnMouseEnter: true, + playOnInit: false, + }, + animationId: 'testimonials-carousel', + scriptName: SCRIPT_NAME, + logPrefix: 'testimonials', } } - disconnectedCallback(): void { - if (typeof document === 'undefined') return - document.removeEventListener('DOMContentLoaded', this.domReadyHandler) - this.teardown() - } - - private initialize(): void { - if (this.initialized || typeof document === 'undefined') return - - const context = { scriptName: SCRIPT_NAME, operation: 'initialize' } - addScriptBreadcrumb(context) - logForE2E('info', 'initialize:start', { - id: this.animationInstanceId, - existingReadyState: this.getAttribute('data-carousel-ready') ?? 'missing', - }) - - try { - const { emblaRoot, viewport } = getTestimonialsEmblaSetupElements(this) - - this.emblaRoot = emblaRoot as unknown as DebugEmblaElement - this.viewport = viewport - this.dotsContainer = queryTestimonialsDotsContainer(this) - this.prevBtn = queryTestimonialsPrevBtn(this) - this.nextBtn = queryTestimonialsNextBtn(this) - this.autoplayToggleBtn = queryTestimonialsAutoplayToggleBtn(this) - - const slideCount = queryTestimonialsSlides(this).length - const requestedAutoplay = slideCount > 1 ? Autoplay({ ...AUTOPLAY_OPTIONS }) : null - this.autoplayReady = false - this.autoplayReadyScheduled = false - const plugins = requestedAutoplay ? [requestedAutoplay] : [] - this.emblaApi = EmblaCarousel(this.viewport, EMBLA_OPTIONS, plugins) - this.emblaRoot.__emblaApi__ = this.emblaApi - const emblaSnapCount = this.emblaApi.scrollSnapList().length - const supportsAutoplay = slideCount > 1 && emblaSnapCount > 1 - this.hasAutoplaySupport = supportsAutoplay - this.autoplayPlugin = supportsAutoplay ? requestedAutoplay : null - this.setAutoplayState('paused') - this.requestedAutoplayState = 'paused' - - if (this.autoplayPlugin) { - const emblaWithEvents = this.emblaApi as EmblaCarouselType & { - on: (_event: string, _handler: () => void) => EmblaCarouselType - off: (_event: string, _handler: () => void) => EmblaCarouselType - } - - emblaWithEvents.on('autoplay:play', this.autoplayPlayHandler) - emblaWithEvents.on('autoplay:stop', this.autoplayStopHandler) - } - - this.setupNavigationButtons() - this.setupDotsNavigation() - this.setupAutoplayToggle() - this.addEventListener('focusin', this.focusInHandler) - this.addEventListener('focusout', this.focusOutHandler) - - this.initialized = true - this.setAttribute('data-carousel-ready', 'true') - logForE2E('info', 'initialize:complete', { - id: this.animationInstanceId, - slideCount, - supportsAutoplay: this.hasAutoplaySupport, - }) - - if (this.hasAutoplaySupport) { - this.registerAnimationLifecycle() - this.setupViewportObserver() - this.scheduleAutoplayReady() - this.scheduleFocusVisiblePauseSync() - } - } catch (error) { - this.teardown() - logForE2E('error', 'initialize:failed', { - id: this.animationInstanceId, - message: error instanceof Error ? error.message : String(error), - }) - handleScriptError(error, context) + protected queryElements(): EmblaElementHandles { + const { emblaRoot, viewport } = getTestimonialsEmblaSetupElements(this) + return { + emblaRoot: emblaRoot as EmblaElementHandles['emblaRoot'], + viewport, + slideCount: queryTestimonialsSlides(this).length, + dotsContainer: queryTestimonialsDotsContainer(this), + prevBtn: queryTestimonialsPrevBtn(this), + nextBtn: queryTestimonialsNextBtn(this), } } - private teardown(): void { - this.teardownViewportObserver() - this.removeEventListener('focusin', this.focusInHandler) - this.removeEventListener('focusout', this.focusOutHandler) - if (typeof window !== 'undefined' && this.focusVisibleCheckRafId !== null) { - window.cancelAnimationFrame(this.focusVisibleCheckRafId) - this.focusVisibleCheckRafId = null - } - if (this.emblaApi) { - const emblaWithEvents = this.emblaApi as EmblaCarouselType & { - off: (_event: string, _handler: () => void) => EmblaCarouselType - } - - if (this.autoplayPlugin) { - emblaWithEvents.off('autoplay:play', this.autoplayPlayHandler) - emblaWithEvents.off('autoplay:stop', this.autoplayStopHandler) - } - this.emblaApi.destroy() - } - - if (this.emblaRoot && '__emblaApi__' in this.emblaRoot) { - delete this.emblaRoot.__emblaApi__ - } - - this.emblaApi = null - this.autoplayPlugin = null - this.initialized = false - this.removeAttribute('data-carousel-ready') - this.removeAttribute('data-carousel-autoplay') - this.animationController?.setInstancePauseState('focus-visible', false) - this.animationController?.destroy() - this.animationController = undefined - this.pendingAutoplayState = null - this.hasAutoplaySupport = false - if (this.autoplayReadyTimer) { - clearTimeout(this.autoplayReadyTimer) - this.autoplayReadyTimer = null - } - this.autoplayReadyScheduled = false - this.autoplayReady = false - - if (this.dotsContainer) { - this.dotsContainer.innerHTML = '' - } + protected override hasFocusVisibleSlide(): boolean { + return hasTestimonialsFocusVisibleSlide(this) } - private handleFocusIn(event: FocusEvent): void { - if (!this.hasAutoplaySupport) return - if (!this.animationController) return - - const target = event.target - if (!(target instanceof HTMLElement)) return - - const slideContainer = target.closest('[data-testimonials-slide]') - if (!slideContainer) return - - const isFocusVisible = this.isElementFocusVisible(target) - if (!isFocusVisible) return - - this.animationController.setInstancePauseState('focus-visible', true) + protected override isFocusInsideSlide(target: HTMLElement): boolean { + return target.closest('[data-testimonials-slide]') !== null } - private scheduleFocusVisiblePauseSync(): void { - if (!this.hasAutoplaySupport) return - if (!this.animationController) return - - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - this.syncFocusVisiblePauseState() - return - } - - if (this.focusVisibleCheckRafId !== null) { - window.cancelAnimationFrame(this.focusVisibleCheckRafId) - } - - this.focusVisibleCheckRafId = window.requestAnimationFrame(() => { - this.focusVisibleCheckRafId = null - this.syncFocusVisiblePauseState() - }) - } + // ────────────────────── Testimonials-specific behaviour ────────────────────── - private syncFocusVisiblePauseState(): void { - if (!this.animationController) return - this.animationController.setInstancePauseState( - 'focus-visible', - hasTestimonialsFocusVisibleSlide(this) - ) + protected override onInitialized(): void { + this.autoplayToggleBtn = queryTestimonialsAutoplayToggleBtn(this) + this.ensureViewportId() + this.setupAriaControls() + this.setupAutoplayToggle() } - private isElementFocusVisible(element: HTMLElement): boolean { - try { - return element.matches(':focus-visible') - } catch { - return false - } + protected override onTeardown(): void { + this.autoplayToggleBtn = null + this.viewportId = null } - private setupNavigationButtons(): void { - if (!this.emblaApi || !this.prevBtn || !this.nextBtn) return - - this.ensureViewportId() - - const updateButtonStates = () => { - if (!this.emblaApi || !this.prevBtn || !this.nextBtn) return - - if (this.emblaApi.canScrollPrev()) { - this.prevBtn.removeAttribute('disabled') - this.prevBtn.classList.remove('opacity-30', 'cursor-not-allowed') - } else { - this.prevBtn.setAttribute('disabled', 'true') - this.prevBtn.classList.add('opacity-30', 'cursor-not-allowed') - } - - if (this.emblaApi.canScrollNext()) { - this.nextBtn.removeAttribute('disabled') - this.nextBtn.classList.remove('opacity-30', 'cursor-not-allowed') - } else { - this.nextBtn.setAttribute('disabled', 'true') - this.nextBtn.classList.add('opacity-30', 'cursor-not-allowed') - } - } - - addButtonEventListeners( - this.prevBtn, - () => { - try { - this.emblaApi?.scrollPrev() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollPrev' }) - } - }, - this - ) - - addButtonEventListeners( - this.nextBtn, - () => { - try { - this.emblaApi?.scrollNext() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollNext' }) - } - }, - this - ) - - if (this.viewportId) { - this.prevBtn.setAttribute('aria-controls', this.viewportId) - this.nextBtn.setAttribute('aria-controls', this.viewportId) - } - - this.emblaApi.on('select', updateButtonStates) - this.emblaApi.on('reInit', updateButtonStates) - updateButtonStates() + protected override onAutoplayStateChange(state: 'playing' | 'paused'): void { + this.syncAutoplayToggleButton(state) } - private setupDotsNavigation(): void { - if (!this.emblaApi || !this.dotsContainer) return - - this.ensureViewportId() - - const dotsContainer = this.dotsContainer - const ownerDocument = this.ownerDocument ?? document - let dots: HTMLButtonElement[] = [] - - const rebuildDots = () => { - if (!this.emblaApi) return - dotsContainer.innerHTML = '' - dots = [] - - this.emblaApi.scrollSnapList().forEach((_, index) => { - const dot = ownerDocument.createElement('button') - dot.type = 'button' - dot.dataset['index'] = String(index) - dot.tabIndex = -1 - dot.setAttribute('tabindex', '-1') - dot.className = - 'embla__dot w-3 h-3 rounded-full bg-content-active transition-all duration-300 hover:bg-primary' - dot.setAttribute('aria-label', `Go to testimonial ${index + 1}`) - - if (this.viewportId) { - dot.setAttribute('aria-controls', this.viewportId) - } + // ────────────────────── Dot styling overrides ────────────────────── - addButtonEventListeners( - dot, - () => { - try { - this.emblaApi?.scrollTo(index) - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollToDot' }) - } - }, - this - ) - - dotsContainer.appendChild(dot) - dots.push(dot) - }) - } - - const updateDots = () => { - if (!this.emblaApi) return - const selectedIndex = this.emblaApi.selectedScrollSnap() - - dots.forEach((dot, index) => { - if (index === selectedIndex) { - dot.classList.add('bg-primary', 'w-6') - dot.classList.remove('bg-content-active', 'w-3') - dot.setAttribute('aria-current', 'true') - } else { - dot.classList.remove('bg-primary', 'w-6') - dot.classList.add('bg-content-active', 'w-3') - dot.removeAttribute('aria-current') - } - }) - } - - rebuildDots() - updateDots() - - this.emblaApi.on('select', updateDots) - this.emblaApi.on('reInit', () => { - rebuildDots() - updateDots() - }) + protected override applyDotActiveStyle(dot: HTMLButtonElement): void { + dot.classList.add('bg-primary', 'w-6') + dot.classList.remove('bg-content-active', 'w-3') + dot.setAttribute('aria-current', 'true') } - pause(): void { - try { - this.requestedAutoplayState = 'paused' - this.syncAutoplayWithViewport() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'pause' }) - } + protected override applyDotInactiveStyle(dot: HTMLButtonElement): void { + dot.classList.remove('bg-primary', 'w-6') + dot.classList.add('bg-content-active', 'w-3') + dot.removeAttribute('aria-current') } - resume(): void { - try { - this.requestedAutoplayState = 'playing' - this.syncAutoplayWithViewport() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'resume' }) - } + protected override getDotAriaLabel(index: number): string { + return `Go to testimonial ${index + 1}` } - private setupViewportObserver(): void { - if (typeof document === 'undefined') return - if (this.intersectionObserver) return - - try { - const IntersectionObserverCtor = ( - globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } - ).IntersectionObserver - - if (typeof IntersectionObserverCtor !== 'function') return + // ────────────────────── Private helpers ────────────────────── - this.intersectionObserver = new IntersectionObserverCtor( - entries => { - const entry = entries[0] - const ratio = entry?.intersectionRatio ?? 0 - this.isFullyInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) - this.syncAutoplayWithViewport() - }, - { threshold: [0, 0.999] } - ) - - const observedTarget = this.emblaRoot ?? this - this.intersectionObserver.observe(observedTarget) - } catch { - // Best effort only - } - } - - private teardownViewportObserver(): void { - try { - this.intersectionObserver?.disconnect() - this.intersectionObserver = undefined - this.isFullyInViewport = true - } catch { - // Best effort only - } - } + private ensureViewportId(): void { + const vp = this.viewport + if (!vp || this.viewportId) return - private syncAutoplayWithViewport(): void { - const shouldPlay = this.requestedAutoplayState === 'playing' && this.isFullyInViewport - this.updateAutoplayState(shouldPlay ? 'playing' : 'paused') + const existingId = vp.getAttribute('id') + const nextId = + existingId && existingId.length > 0 ? existingId : `${this.animationInstanceId}-viewport` + vp.setAttribute('id', nextId) + this.viewportId = nextId } - private setAutoplayState(state: 'playing' | 'paused'): void { - this.setAttribute('data-carousel-autoplay', state) - this.syncAutoplayToggleButton(state) + private setupAriaControls(): void { + if (!this.viewportId) return + this.prevBtn?.setAttribute('aria-controls', this.viewportId) + this.nextBtn?.setAttribute('aria-controls', this.viewportId) } private setupAutoplayToggle(): void { - if (!this.autoplayToggleBtn) { - return - } + if (!this.autoplayToggleBtn) return - this.ensureViewportId() if (this.viewportId) { this.autoplayToggleBtn.setAttribute('aria-controls', this.viewportId) } @@ -511,9 +146,7 @@ export class TestimonialsCarouselElement extends HTMLElement { } private syncAutoplayToggleButton(state: 'playing' | 'paused'): void { - if (!this.autoplayToggleBtn) { - return - } + if (!this.autoplayToggleBtn) return const pauseIcon = queryTestimonialsAutoplayPauseIcon(this.autoplayToggleBtn) const playIcon = queryTestimonialsAutoplayPlayIcon(this.autoplayToggleBtn) @@ -531,93 +164,6 @@ export class TestimonialsCarouselElement extends HTMLElement { if (isType1Element(pauseIcon)) pauseIcon.classList.add('hidden') if (isType1Element(playIcon)) playIcon.classList.remove('hidden') } - - private ensureViewportId(): void { - if (!this.viewport || this.viewportId) { - return - } - - const existingId = this.viewport.getAttribute('id') - const nextId = - existingId && existingId.length > 0 ? existingId : `${this.animationInstanceId}-viewport` - this.viewport.setAttribute('id', nextId) - this.viewportId = nextId - } - - private updateAutoplayState(state: AnimationPlayState): void { - if ( - !this.hasAutoplaySupport || - !this.autoplayPlugin || - !this.emblaApi || - !this.initialized || - !this.autoplayReady - ) { - if (this.hasAutoplaySupport) { - this.pendingAutoplayState = state - this.setAutoplayState(state) - } else { - this.pendingAutoplayState = null - this.setAutoplayState('paused') - } - return - } - - this.pendingAutoplayState = null - if (state === 'playing') { - this.autoplayPlugin.play() - } else { - this.autoplayPlugin.stop() - } - this.setAutoplayState(state) - } - - private flushPendingAutoplayState(): void { - if (!this.pendingAutoplayState || !this.autoplayReady || !this.autoplayPlugin) return - - const pendingState = this.pendingAutoplayState - this.pendingAutoplayState = null - - try { - this.updateAutoplayState(pendingState) - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'flushAutoplay' }) - } - } - - private registerAnimationLifecycle(): void { - if (this.animationController || !this.hasAutoplaySupport) return - this.animationController = createAnimationController({ - animationId: 'testimonials-carousel', - instanceId: this.animationInstanceId, - debugLabel: SCRIPT_NAME, - onPlay: () => { - this.resume() - }, - onPause: () => { - this.pause() - }, - }) - } - - private scheduleAutoplayReady(): void { - if (this.autoplayReadyScheduled || !this.autoplayPlugin) return - this.autoplayReadyScheduled = true - - const markReady = () => { - this.autoplayReadyScheduled = false - this.autoplayReadyTimer = null - if (!this.initialized || !this.autoplayPlugin) return - this.autoplayReady = true - this.flushPendingAutoplayState() - } - - if (typeof window === 'undefined') { - markReady() - return - } - - this.autoplayReadyTimer = window.setTimeout(markReady, 0) - } } declare global { diff --git a/src/components/ThemePicker/client/index.ts b/src/components/ThemePicker/client/index.ts index fd56929e6..c324b88d5 100644 --- a/src/components/ThemePicker/client/index.ts +++ b/src/components/ThemePicker/client/index.ts @@ -6,6 +6,10 @@ import { LitElement } from 'lit' import EmblaCarousel, { type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel' +import { + createEmblaNavStateUpdater, + type EmblaNavButtonHandle, +} from '@components/scripts/embla' import { setTheme, toggleThemePicker, @@ -69,6 +73,7 @@ export class ThemePickerElement extends LitElement { private emblaPrevBtn!: HTMLButtonElement private emblaNextBtn!: HTMLButtonElement private emblaApi: EmblaCarouselType | null = null + private emblaNavHandle: EmblaNavButtonHandle | null = null private emblaControlsBound = false private lastIsOpen: boolean | null = null private lastTheme: ThemeId | null = null @@ -80,7 +85,7 @@ export class ThemePickerElement extends LitElement { private readonly tooltipId = 'theme-picker-tooltip' - private readonly emblaUpdateHandler = () => this.updateEmblaNavState() + private readonly emblaUpdateHandler = () => this.updateEmblaOverflowVisibility() // Track View Transitions private isTransitioning = false @@ -231,7 +236,7 @@ export class ThemePickerElement extends LitElement { scheduleNextFrame(() => { try { this.emblaApi?.reInit() - this.updateEmblaNavState() + this.updateEmblaOverflowVisibility() this.scrollThemeIntoView(currentTheme) } catch (error) { handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:reInit' }) @@ -250,7 +255,16 @@ export class ThemePickerElement extends LitElement { try { this.emblaApi = EmblaCarousel(this.emblaViewport, THEME_PICKER_EMBLA_OPTIONS) - this.updateEmblaNavState() + + // Disabled-state tracking via shared utility (re-binds Embla events each setup) + this.emblaNavHandle = createEmblaNavStateUpdater( + this.emblaApi, + this.emblaPrevBtn, + this.emblaNextBtn + ) + + // ThemePicker-specific: hide buttons when no overflow + this.updateEmblaOverflowVisibility() const emblaWithEvents = this.emblaApi as EmblaCarouselType & { on: (_event: string, _handler: () => void) => EmblaCarouselType @@ -259,6 +273,7 @@ export class ThemePickerElement extends LitElement { emblaWithEvents.on('select', this.emblaUpdateHandler) emblaWithEvents.on('reInit', this.emblaUpdateHandler) + // Click handlers bound only once (Embla instances are recreated on open/close) if (!this.emblaControlsBound) { addButtonEventListeners( this.emblaPrevBtn, @@ -301,13 +316,18 @@ export class ThemePickerElement extends LitElement { } private teardownEmbla(): void { + this.emblaNavHandle = null if (this.emblaApi) { this.emblaApi.destroy() this.emblaApi = null } } - private updateEmblaNavState(): void { + /** + * ThemePicker-specific: hide nav buttons entirely when the carousel + * has no overflow (all themes visible without scrolling). + */ + private updateEmblaOverflowVisibility(): void { if (!this.emblaApi || !this.emblaPrevBtn || !this.emblaNextBtn) return const hasOverflow = this.emblaApi.scrollSnapList().length > 1 @@ -322,17 +342,8 @@ export class ThemePickerElement extends LitElement { this.emblaPrevBtn.removeAttribute('hidden') this.emblaNextBtn.removeAttribute('hidden') - if (this.emblaApi.canScrollPrev()) { - this.emblaPrevBtn.removeAttribute('disabled') - } else { - this.emblaPrevBtn.setAttribute('disabled', 'true') - } - - if (this.emblaApi.canScrollNext()) { - this.emblaNextBtn.removeAttribute('disabled') - } else { - this.emblaNextBtn.setAttribute('disabled', 'true') - } + // Delegate disabled state to the shared nav handle + this.emblaNavHandle?.update() } private scrollThemeIntoView(themeId: ThemeId): void { @@ -344,7 +355,7 @@ export class ThemePickerElement extends LitElement { try { this.emblaApi.scrollTo(index, true) - this.updateEmblaNavState() + this.updateEmblaOverflowVisibility() } catch (error) { handleScriptError(error, { scriptName: 'ThemePickerElement', diff --git a/src/components/scripts/embla/EmblaCarouselBase.ts b/src/components/scripts/embla/EmblaCarouselBase.ts new file mode 100644 index 000000000..86e42fadb --- /dev/null +++ b/src/components/scripts/embla/EmblaCarouselBase.ts @@ -0,0 +1,679 @@ +/** + * Abstract base class for Embla-powered carousels rendered as Lit custom elements. + * + * Encapsulates the shared behaviour that was previously duplicated across the + * Carousel and Testimonials components: + * + * - Embla initialisation / teardown (with optional Autoplay plugin) + * - Animation-lifecycle store integration (reduced-motion, visibility, user prefs) + * - IntersectionObserver viewport gating (pause when off-screen) + * - Focus-visible pause management + * - Previous / Next navigation button wiring + * - Dot-navigation rendering (with overridable active/inactive styling) + * - E2E logging + * + * Subclasses must implement the abstract members listed below and may + * optionally override the dot-styling hooks and `onInitialized()` / + * `onTeardown()` for component-specific setup. + */ + +import { LitElement } from 'lit' +import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel' +import Autoplay from 'embla-carousel-autoplay' +import { addButtonEventListeners } from '@components/scripts/elementListeners' +import { addScriptBreadcrumb } from '@components/scripts/errors' +import { handleScriptError } from '@components/scripts/errors/handler' +import { + createAnimationController, + type AnimationControllerHandle, + type AnimationPlayState, +} from '@components/scripts/store' +import { createE2ELogger } from './logging' +import type { + EmblaCarouselConfig, + EmblaElementHandles, + EmblaRootElement, + TimerHandle, +} from './types' + +export abstract class EmblaCarouselBase extends LitElement { + // ────────────────────── Light DOM ────────────────────── + override createRenderRoot() { + return this + } + + // ────────────────────── Abstract / Overridable ────────────────────── + + /** Return component-specific Embla + autoplay configuration. */ + protected abstract getConfig(): EmblaCarouselConfig + + /** Query and return the DOM handles for this instance. */ + protected abstract queryElements(): EmblaElementHandles + + /** + * Hook called after successful base initialisation. + * Subclasses can wire up component-specific behaviour here + * (keyboard handling, autoplay toggle buttons, status regions, etc.). + */ + protected onInitialized(): void { + /* no-op by default */ + } + + /** + * Hook called during teardown, before the base class cleans up. + * Subclasses should remove any listeners added in `onInitialized()`. + */ + protected onTeardown(): void { + /* no-op by default */ + } + + /** + * Apply "active" styling to a dot button. + * Override in subclasses that need different active-dot visuals. + */ + protected applyDotActiveStyle(dot: HTMLButtonElement): void { + dot.classList.add('is-active', 'bg-primary') + dot.classList.remove('bg-content-active') + dot.setAttribute('aria-current', 'true') + } + + /** + * Apply "inactive" styling to a dot button. + * Override in subclasses that need different inactive-dot visuals. + */ + protected applyDotInactiveStyle(dot: HTMLButtonElement): void { + dot.classList.remove('is-active', 'bg-primary') + dot.classList.add('bg-content-active') + dot.removeAttribute('aria-current') + } + + /** + * Return the `aria-label` text for a dot at the given index. + * Override for custom wording (e.g. "Go to testimonial 3"). + */ + protected getDotAriaLabel(index: number): string { + return `Go to slide ${index + 1}` + } + + /** + * Hook called whenever the autoplay data-attribute changes. + * Override in subclasses that need to react to play/pause transitions + * (e.g. syncing an autoplay toggle button icon). + */ + protected onAutoplayStateChange(_state: 'playing' | 'paused'): void { + /* no-op by default */ + } + + /** + * Return `true` when a slide child currently matches `:focus-visible`. + * Override to use a component-specific selector helper (e.g. + * `hasCarouselFocusVisibleSlide(this)`). The default returns `false`. + */ + protected hasFocusVisibleSlide(): boolean { + return false + } + + /** + * Return `true` when the focused element is inside a slide container. + * Used by the base class to detect focus-visible pause triggers. + * Override to use a component-specific slide-container selector. + */ + protected isFocusInsideSlide(target: HTMLElement): boolean { + void target + return false + } + + // ────────────────────── Protected read-only accessors ────────────────────── + + /** The live Embla API instance, or `null` before initialisation. */ + protected get emblaApi(): EmblaCarouselType | null { + return this._emblaApi + } + + /** Whether the carousel was successfully initialised. */ + protected get isInitialized(): boolean { + return this._initialized + } + + /** Whether the autoplay subsystem is active for this instance. */ + protected get hasAutoplaySupport(): boolean { + return this._hasAutoplaySupport + } + + /** The unique instance identifier (e.g. `carousel-1`). */ + protected get animationInstanceId(): string { + return this._animationInstanceId + } + + /** The Embla viewport element, or `null` before initialisation. */ + protected get viewport(): HTMLElement | null { + return this._viewport + } + + /** The previous-slide button, or `null` if not present. */ + protected get prevBtn(): HTMLButtonElement | null { + return this._prevBtn + } + + /** The next-slide button, or `null` if not present. */ + protected get nextBtn(): HTMLButtonElement | null { + return this._nextBtn + } + + // ────────────────────── Private state ────────────────────── + + private _emblaApi: EmblaCarouselType | null = null + private _autoplayPlugin: ReturnType | null = null + private _emblaRoot: EmblaRootElement | null = null + private _viewport: HTMLElement | null = null + private _dotsContainer: HTMLElement | null = null + private _prevBtn: HTMLButtonElement | null = null + private _nextBtn: HTMLButtonElement | null = null + private _initialized = false + private _animationController: AnimationControllerHandle | undefined + private _pendingAutoplayState: AnimationPlayState | null = null + private _hasAutoplaySupport = false + private _autoplayReady = false + private _autoplayReadyScheduled = false + private _autoplayReadyTimer: TimerHandle | null = null + private _intersectionObserver: IntersectionObserver | undefined + private _isFullyInViewport = true + private _requestedAutoplayState: AnimationPlayState = 'paused' + private _focusVisibleCheckRafId: number | null = null + private _animationInstanceId = '' + + private _config!: EmblaCarouselConfig + private _logForE2E!: ReturnType + + // Bound handlers (stable references for add/removeEventListener) + private readonly _autoplayPlayHandler = () => this.setAutoplayDataAttr('playing') + private readonly _autoplayStopHandler = () => this.setAutoplayDataAttr('paused') + private readonly _focusInHandler = (event: FocusEvent) => this.handleFocusIn(event) + private readonly _focusOutHandler = () => this.scheduleFocusVisiblePauseSync() + + private static _instanceCounter = 0 + + // ────────────────────── Lit Lifecycle ────────────────────── + + override connectedCallback(): void { + super.connectedCallback() + if (typeof document === 'undefined') return + + EmblaCarouselBase._instanceCounter += 1 + this._config = this.getConfig() + this._animationInstanceId = `${this._config.logPrefix}-${EmblaCarouselBase._instanceCounter}` + this._logForE2E = createE2ELogger(this._config.logPrefix) + + const context = { scriptName: this._config.scriptName, operation: 'connectedCallback' } + addScriptBreadcrumb(context) + + try { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => this.initialize(), { once: true }) + return + } + this.initialize() + } catch (error) { + handleScriptError(error, context) + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback() + if (typeof document === 'undefined') return + this.teardown() + } + + // ────────────────────── Initialisation ────────────────────── + + private initialize(): void { + if (this._initialized) return + + const context = { scriptName: this._config.scriptName, operation: 'initialize' } + addScriptBreadcrumb(context) + this._logForE2E('info', 'initialize:start', { + id: this._animationInstanceId, + existingReadyState: this.getAttribute('data-carousel-ready') ?? 'missing', + }) + + try { + const handles = this.queryElements() + this._emblaRoot = handles.emblaRoot + this._viewport = handles.viewport + this._dotsContainer = handles.dotsContainer ?? null + this._prevBtn = handles.prevBtn ?? null + this._nextBtn = handles.nextBtn ?? null + + const viewport = handles.viewport + const slideCount = handles.slideCount + + // Autoplay plugin (only if options provided and multiple slides) + const autoplayOpts = this._config.autoplayOptions + const requestedAutoplay = + autoplayOpts && slideCount > 1 ? Autoplay({ ...autoplayOpts }) : null + this._autoplayReady = false + this._autoplayReadyScheduled = false + const plugins = requestedAutoplay ? [requestedAutoplay] : [] + + this._emblaApi = EmblaCarousel(viewport, this._config.emblaOptions, plugins) + this._emblaRoot.__emblaApi__ = this._emblaApi + + const emblaSnapCount = this._emblaApi.scrollSnapList().length + const supportsAutoplay = slideCount > 1 && emblaSnapCount > 1 && requestedAutoplay !== null + this._hasAutoplaySupport = supportsAutoplay + this._autoplayPlugin = supportsAutoplay ? requestedAutoplay : null + this.setAutoplayDataAttr('paused') + this._requestedAutoplayState = 'paused' + + if (this._autoplayPlugin) { + const emblaWithEvents = this._emblaApi as EmblaCarouselType & { + on: (_event: string, _handler: () => void) => EmblaCarouselType + } + emblaWithEvents.on('autoplay:play', this._autoplayPlayHandler) + emblaWithEvents.on('autoplay:stop', this._autoplayStopHandler) + } + + this.setupNavigationButtons() + this.setupDotsNavigation() + this.addEventListener('focusin', this._focusInHandler) + this.addEventListener('focusout', this._focusOutHandler) + + this._initialized = true + this.setAttribute('data-carousel-ready', 'true') + + this._logForE2E('info', 'initialize:complete', { + id: this._animationInstanceId, + slideCount, + supportsAutoplay: this._hasAutoplaySupport, + }) + + if (this._hasAutoplaySupport) { + this.registerAnimationLifecycle() + this.setupViewportObserver() + this.scheduleAutoplayReady() + this.scheduleFocusVisiblePauseSync() + } + + // Subclass hook + this.onInitialized() + } catch (error) { + this.teardown() + this._logForE2E('error', 'initialize:failed', { + id: this._animationInstanceId, + message: error instanceof Error ? error.message : String(error), + }) + handleScriptError(error, context) + } + } + + // ────────────────────── Teardown ────────────────────── + + private teardown(): void { + // Subclass hook (runs first so it can still read base state) + this.onTeardown() + + this.teardownViewportObserver() + this.removeEventListener('focusin', this._focusInHandler) + this.removeEventListener('focusout', this._focusOutHandler) + + if (typeof window !== 'undefined' && this._focusVisibleCheckRafId !== null) { + window.cancelAnimationFrame(this._focusVisibleCheckRafId) + this._focusVisibleCheckRafId = null + } + + if (this._emblaApi) { + const emblaWithEvents = this._emblaApi as EmblaCarouselType & { + off: (_event: string, _handler: () => void) => EmblaCarouselType + } + if (this._autoplayPlugin) { + emblaWithEvents.off('autoplay:play', this._autoplayPlayHandler) + emblaWithEvents.off('autoplay:stop', this._autoplayStopHandler) + } + this._emblaApi.destroy() + } + + if (this._emblaRoot && '__emblaApi__' in this._emblaRoot) { + delete this._emblaRoot.__emblaApi__ + } + + this._emblaApi = null + this._autoplayPlugin = null + this._viewport = null + this._initialized = false + this.removeAttribute('data-carousel-ready') + this.removeAttribute('data-carousel-autoplay') + this._animationController?.setInstancePauseState('focus-visible', false) + this._animationController?.destroy() + this._animationController = undefined + this._pendingAutoplayState = null + this._hasAutoplaySupport = false + + if (this._autoplayReadyTimer) { + clearTimeout(this._autoplayReadyTimer) + this._autoplayReadyTimer = null + } + this._autoplayReadyScheduled = false + this._autoplayReady = false + + if (this._dotsContainer) { + this._dotsContainer.innerHTML = '' + } + } + + // ────────────────────── Focus-visible pause ────────────────────── + + private handleFocusIn(event: FocusEvent): void { + if (!this._hasAutoplaySupport || !this._animationController) return + + const target = event.target + if (!(target instanceof HTMLElement)) return + + if (!this.isFocusInsideSlide(target)) return + if (!this.isElementFocusVisible(target)) return + this._animationController.setInstancePauseState('focus-visible', true) + } + + private scheduleFocusVisiblePauseSync(): void { + if (!this._hasAutoplaySupport || !this._animationController) return + + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + this.syncFocusVisiblePauseState() + return + } + + if (this._focusVisibleCheckRafId !== null) { + window.cancelAnimationFrame(this._focusVisibleCheckRafId) + } + + this._focusVisibleCheckRafId = window.requestAnimationFrame(() => { + this._focusVisibleCheckRafId = null + this.syncFocusVisiblePauseState() + }) + } + + private syncFocusVisiblePauseState(): void { + if (!this._animationController) return + this._animationController.setInstancePauseState('focus-visible', this.hasFocusVisibleSlide()) + } + + private isElementFocusVisible(element: HTMLElement): boolean { + try { + return element.matches(':focus-visible') + } catch { + return false + } + } + + // ────────────────────── Navigation buttons ────────────────────── + + private setupNavigationButtons(): void { + if (!this._emblaApi || !this._prevBtn || !this._nextBtn) return + + const scriptName = this._config.scriptName + + const updateButtonStates = () => { + if (!this._emblaApi || !this._prevBtn || !this._nextBtn) return + + if (this._emblaApi.canScrollPrev()) { + this._prevBtn.removeAttribute('disabled') + this._prevBtn.classList.remove('opacity-30', 'cursor-not-allowed') + } else { + this._prevBtn.setAttribute('disabled', 'true') + this._prevBtn.classList.add('opacity-30', 'cursor-not-allowed') + } + + if (this._emblaApi.canScrollNext()) { + this._nextBtn.removeAttribute('disabled') + this._nextBtn.classList.remove('opacity-30', 'cursor-not-allowed') + } else { + this._nextBtn.setAttribute('disabled', 'true') + this._nextBtn.classList.add('opacity-30', 'cursor-not-allowed') + } + } + + addButtonEventListeners( + this._prevBtn, + () => { + try { + this._emblaApi?.scrollPrev() + } catch (error) { + handleScriptError(error, { scriptName, operation: 'scrollPrev' }) + } + }, + this + ) + + addButtonEventListeners( + this._nextBtn, + () => { + try { + this._emblaApi?.scrollNext() + } catch (error) { + handleScriptError(error, { scriptName, operation: 'scrollNext' }) + } + }, + this + ) + + this._emblaApi.on('select', updateButtonStates) + this._emblaApi.on('reInit', updateButtonStates) + updateButtonStates() + } + + // ────────────────────── Dots navigation ────────────────────── + + private setupDotsNavigation(): void { + if (!this._emblaApi || !this._dotsContainer) return + + const scriptName = this._config.scriptName + const dotsContainer = this._dotsContainer + const ownerDocument = this.ownerDocument ?? document + let dots: HTMLButtonElement[] = [] + + const rebuildDots = () => { + if (!this._emblaApi) return + dotsContainer.innerHTML = '' + dots = [] + + this._emblaApi.scrollSnapList().forEach((_, index) => { + const dot = ownerDocument.createElement('button') + dot.type = 'button' + dot.dataset['index'] = String(index) + dot.tabIndex = -1 + dot.setAttribute('tabindex', '-1') + dot.className = + 'embla__dot w-3 h-3 rounded-full bg-content-active transition-all duration-300 hover:bg-primary' + dot.setAttribute('aria-label', this.getDotAriaLabel(index)) + + addButtonEventListeners( + dot, + () => { + try { + this._emblaApi?.scrollTo(index) + } catch (error) { + handleScriptError(error, { scriptName, operation: 'scrollToDot' }) + } + }, + this + ) + + dotsContainer.appendChild(dot) + dots.push(dot) + }) + } + + const updateDots = () => { + if (!this._emblaApi) return + const selectedIndex = this._emblaApi.selectedScrollSnap() + + dots.forEach((dot, index) => { + if (index === selectedIndex) { + this.applyDotActiveStyle(dot) + } else { + this.applyDotInactiveStyle(dot) + } + }) + } + + rebuildDots() + updateDots() + + this._emblaApi.on('select', updateDots) + this._emblaApi.on('reInit', () => { + rebuildDots() + updateDots() + }) + } + + // ────────────────────── Pause / Resume (public API) ────────────────────── + + pause(): void { + try { + this._requestedAutoplayState = 'paused' + this.syncAutoplayWithViewport() + } catch (error) { + handleScriptError(error, { scriptName: this._config.scriptName, operation: 'pause' }) + } + } + + resume(): void { + try { + this._requestedAutoplayState = 'playing' + this.syncAutoplayWithViewport() + } catch (error) { + handleScriptError(error, { scriptName: this._config.scriptName, operation: 'resume' }) + } + } + + // ────────────────────── Viewport observer ────────────────────── + + private setupViewportObserver(): void { + if (typeof document === 'undefined') return + if (this._intersectionObserver) return + + try { + const IntersectionObserverCtor = ( + globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } + ).IntersectionObserver + + if (typeof IntersectionObserverCtor !== 'function') return + + this._intersectionObserver = new IntersectionObserverCtor( + entries => { + const entry = entries[0] + const ratio = entry?.intersectionRatio ?? 0 + this._isFullyInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) + this.syncAutoplayWithViewport() + }, + { threshold: [0, 0.999] } + ) + + const observedTarget = this._emblaRoot ?? this + this._intersectionObserver.observe(observedTarget) + } catch { + // Best effort only + } + } + + private teardownViewportObserver(): void { + try { + this._intersectionObserver?.disconnect() + this._intersectionObserver = undefined + this._isFullyInViewport = true + } catch { + // Best effort only + } + } + + private syncAutoplayWithViewport(): void { + const shouldPlay = this._requestedAutoplayState === 'playing' && this._isFullyInViewport + this.updateAutoplayState(shouldPlay ? 'playing' : 'paused') + } + + // ────────────────────── Autoplay state management ────────────────────── + + private setAutoplayDataAttr(state: 'playing' | 'paused'): void { + this.setAttribute('data-carousel-autoplay', state) + this.onAutoplayStateChange(state) + } + + private updateAutoplayState(state: AnimationPlayState): void { + if ( + !this._hasAutoplaySupport || + !this._autoplayPlugin || + !this._emblaApi || + !this._initialized || + !this._autoplayReady + ) { + if (this._hasAutoplaySupport) { + this._pendingAutoplayState = state + this.setAutoplayDataAttr(state) + } else { + this._pendingAutoplayState = null + this.setAutoplayDataAttr('paused') + } + return + } + + this._pendingAutoplayState = null + if (state === 'playing') { + this._autoplayPlugin.play() + } else { + this._autoplayPlugin.stop() + } + this.setAutoplayDataAttr(state) + } + + private flushPendingAutoplayState(): void { + if (!this._pendingAutoplayState || !this._autoplayReady || !this._autoplayPlugin) return + + const pendingState = this._pendingAutoplayState + this._pendingAutoplayState = null + + try { + this.updateAutoplayState(pendingState) + } catch (error) { + handleScriptError(error, { + scriptName: this._config.scriptName, + operation: 'flushAutoplay', + }) + } + } + + // ────────────────────── Animation lifecycle ────────────────────── + + private registerAnimationLifecycle(): void { + if (this._animationController || !this._hasAutoplaySupport) return + this._animationController = createAnimationController({ + animationId: this._config.animationId, + instanceId: this._animationInstanceId, + debugLabel: this._config.scriptName, + onPlay: () => { + this.resume() + }, + onPause: () => { + this.pause() + }, + }) + } + + private scheduleAutoplayReady(): void { + if (this._autoplayReadyScheduled || !this._autoplayPlugin) return + this._autoplayReadyScheduled = true + + const markReady = () => { + this._autoplayReadyScheduled = false + this._autoplayReadyTimer = null + if (!this._initialized || !this._autoplayPlugin) return + this._autoplayReady = true + this.flushPendingAutoplayState() + } + + if (typeof window === 'undefined') { + markReady() + return + } + + this._autoplayReadyTimer = window.setTimeout(markReady, 0) + } +} diff --git a/src/components/scripts/embla/index.ts b/src/components/scripts/embla/index.ts new file mode 100644 index 000000000..171c21d0b --- /dev/null +++ b/src/components/scripts/embla/index.ts @@ -0,0 +1,17 @@ +/** + * Shared Embla carousel utilities - Barrel Export + */ +export { EmblaCarouselBase } from './EmblaCarouselBase' +export { createE2ELogger } from './logging' +export { + createEmblaNavStateUpdater, + setupEmblaNavButtons, + type EmblaNavButtonHandle, +} from './navigation' +export type { + AutoplayOptions, + EmblaCarouselConfig, + EmblaElementHandles, + EmblaRootElement, + TimerHandle, +} from './types' diff --git a/src/components/scripts/embla/logging.ts b/src/components/scripts/embla/logging.ts new file mode 100644 index 000000000..7f27b87d3 --- /dev/null +++ b/src/components/scripts/embla/logging.ts @@ -0,0 +1,17 @@ +/** + * Factory for E2E-only console logging. + * Logs are emitted only when running under Playwright (`window.isPlaywrightControlled`). + */ +export const createE2ELogger = (prefix: string) => { + return ( + level: 'info' | 'error', + message: string, + details?: Record + ): void => { + if (typeof window === 'undefined' || window.isPlaywrightControlled !== true) return + const payload = details + ? [`[${prefix}] ${message}`, details] + : [`[${prefix}] ${message}`] + console[level](...payload) + } +} diff --git a/src/components/scripts/embla/navigation.ts b/src/components/scripts/embla/navigation.ts new file mode 100644 index 000000000..439774f98 --- /dev/null +++ b/src/components/scripts/embla/navigation.ts @@ -0,0 +1,96 @@ +/** + * Lightweight Embla navigation-button wiring. + * + * Used by components that need prev/next buttons but do NOT need the full + * autoplay / animation-lifecycle / viewport-observer stack provided by + * `EmblaCarouselBase` (e.g. ThemePicker). + */ + +import type { EmblaCarouselType } from 'embla-carousel' +import { addButtonEventListeners } from '@components/scripts/elementListeners' +import { handleScriptError } from '@components/scripts/errors/handler' + +export interface EmblaNavButtonHandle { + /** Call after Embla `reInit` or `select` to refresh disabled states. */ + update: () => void +} + +/** + * Bind Embla `select` and `reInit` events to keep prev/next button + * disabled states in sync. Does **not** bind click handlers — use this + * when click handlers are managed separately (e.g. ThemePicker, which + * recreates the Embla instance on every open/close cycle but binds + * click listeners only once). + * + * Returns a handle whose `update()` can be called manually to force a + * refresh (e.g. after `emblaApi.reInit()`). + */ +export function createEmblaNavStateUpdater( + emblaApi: EmblaCarouselType, + prevBtn: HTMLButtonElement, + nextBtn: HTMLButtonElement +): EmblaNavButtonHandle { + const updateState = () => { + if (emblaApi.canScrollPrev()) { + prevBtn.removeAttribute('disabled') + } else { + prevBtn.setAttribute('disabled', 'true') + } + + if (emblaApi.canScrollNext()) { + nextBtn.removeAttribute('disabled') + } else { + nextBtn.setAttribute('disabled', 'true') + } + } + + emblaApi.on('select', updateState) + emblaApi.on('reInit', updateState) + updateState() + + return { update: updateState } +} + +/** + * Wire prev/next buttons to an Embla instance with auto-updating + * disabled states and error-safe click handlers. + * + * Returns a handle whose `update()` can be called manually; the function + * also binds Embla's `select` and `reInit` events automatically. + * + * Use `createEmblaNavStateUpdater` instead when click handlers must be + * bound independently of the Embla instance lifecycle. + */ +export function setupEmblaNavButtons( + emblaApi: EmblaCarouselType, + prevBtn: HTMLButtonElement, + nextBtn: HTMLButtonElement, + context: { scriptName: string }, + listenerContext?: unknown +): EmblaNavButtonHandle { + addButtonEventListeners( + prevBtn, + () => { + try { + emblaApi.scrollPrev() + } catch (error) { + handleScriptError(error, { scriptName: context.scriptName, operation: 'scrollPrev' }) + } + }, + listenerContext + ) + + addButtonEventListeners( + nextBtn, + () => { + try { + emblaApi.scrollNext() + } catch (error) { + handleScriptError(error, { scriptName: context.scriptName, operation: 'scrollNext' }) + } + }, + listenerContext + ) + + return createEmblaNavStateUpdater(emblaApi, prevBtn, nextBtn) +} diff --git a/src/components/scripts/embla/types.ts b/src/components/scripts/embla/types.ts new file mode 100644 index 000000000..f66a84523 --- /dev/null +++ b/src/components/scripts/embla/types.ts @@ -0,0 +1,52 @@ +import type { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel' + +/** An HTML element augmented with a debug reference to its Embla API instance. */ +export type EmblaRootElement = HTMLElement & { __emblaApi__?: EmblaCarouselType } + +/** Mirrors `setTimeout` / `window.setTimeout` return type for timer handles. */ +export type TimerHandle = ReturnType | number + +/** Autoplay plugin configuration passed to `embla-carousel-autoplay`. */ +export interface AutoplayOptions { + delay: number + stopOnInteraction: boolean + stopOnMouseEnter: boolean + playOnInit: boolean +} + +/** + * Configuration that each concrete carousel subclass must provide to + * `EmblaCarouselBase` so the base class can wire up Embla and optional + * autoplay / animation lifecycle features. + */ +export interface EmblaCarouselConfig { + /** Embla options (loop, align, etc.). */ + emblaOptions: EmblaOptionsType + /** + * Autoplay options or `null` to disable autoplay entirely. + * When `null`, no autoplay plugin is loaded and the animation lifecycle + * / viewport observer / focus-pause subsystems are skipped. + */ + autoplayOptions: AutoplayOptions | null + /** Unique animation ID for the animation-lifecycle store (e.g. `'carousel'`). */ + animationId: string + /** Script name used in error breadcrumbs (e.g. `'CarouselElement'`). */ + scriptName: string + /** Short prefix for E2E console logs (e.g. `'carousel'`). */ + logPrefix: string +} + +/** + * The DOM handles a subclass must query and return from `queryElements()`. + * Only `emblaRoot`, `viewport`, and `slideCount` are required; everything + * else is optional and will gracefully degrade when absent. + */ +export interface EmblaElementHandles { + emblaRoot: EmblaRootElement + viewport: HTMLElement + /** Total number of slide elements — used to determine autoplay eligibility. */ + slideCount: number + dotsContainer?: HTMLElement | null + prevBtn?: HTMLButtonElement | null + nextBtn?: HTMLButtonElement | null +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 42b1d0abb..0902f45f3 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,4 +1,5 @@ --- +import { getCollection } from 'astro:content' import BaseLayout from '@layouts/BaseLayout.astro' import Backstage from '@components/Home/Backstage/index.astro' import Carousel from '@components/Carousel/index.astro' @@ -12,6 +13,7 @@ import Testimonials from '@components/Testimonials/index.astro' const pageDescription = 'Webstack Builders, Inc. Home Page' const pageTitle = 'Web Development & Digital Solutions - Webstack Builders, Inc.' const sectionClasses = 'container mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8 lg:py-12' +const skills = await getCollection('tags', ({ data }) => data.isSkill === true) --- @@ -101,11 +103,7 @@ const sectionClasses = 'container mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8 lg:p
- +
From 79ddb0cc6c764540d659605ca521f2ac96e83e68 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 04:11:54 +0300 Subject: [PATCH 17/33] Add prose to skills and technologies tag pages, and cover prompts for them --- COVERS.md | 172 +++++++++++++++++++++++ _TODO.md | 2 +- src/content/tags/argo-cd/index.mdx | 10 +- src/content/tags/aws/index.mdx | 10 +- src/content/tags/azure/index.mdx | 10 +- src/content/tags/backstage-idp/index.mdx | 10 +- src/content/tags/crossplane/index.mdx | 10 +- src/content/tags/docker/index.mdx | 10 +- src/content/tags/dotnet/index.mdx | 10 +- src/content/tags/go/index.mdx | 10 +- src/content/tags/grafana/index.mdx | 10 +- src/content/tags/helm/index.mdx | 10 +- src/content/tags/kubernetes/index.mdx | 10 +- src/content/tags/openstack/index.mdx | 10 +- src/content/tags/prometheus/index.mdx | 10 +- src/content/tags/python/index.mdx | 10 +- src/content/tags/ruby/index.mdx | 10 +- src/content/tags/terraform/index.mdx | 10 +- src/content/tags/typescript/index.mdx | 10 +- 19 files changed, 309 insertions(+), 35 deletions(-) diff --git a/COVERS.md b/COVERS.md index 9ddab9c24..16ab26b5f 100644 --- a/COVERS.md +++ b/COVERS.md @@ -1717,3 +1717,175 @@ Medical photograph of surgical instruments during a precise operation—the care 4. Technical diagram of a typical platform stack: TypeScript tooling layer at top, application services in middle, backing services (databases, queues, caches) at bottom, all running on Linux hosts. Blueprint aesthetic, dark navy background, layers rendered as translucent panels with glowing connection lines. 5. Stylized 3D render of interconnected technology logos arranged as nodes in a network: TypeScript, Linux (Tux), PostgreSQL, RabbitMQ, Kafka. Each node glowing with its brand color, connected by data flow ribbons. Dark background, vibrant but professional color palette, foundational technology aesthetic. + +## Tags for new Skills tags + +### src/content/tags/argo-cd/index.mdx + +1. An octopus with translucent tentacles wrapping around floating Kubernetes pods, each tentacle tip holding a glowing Git commit hash, dark navy background with cyan circuit-board traces, minimal flat illustration style + +2. A compass rose made from intertwined Git branches and Kubernetes wheel spokes, the cardinal points replaced by sync status icons (green checkmark, yellow spinner, red X, blue arrow), matte paper texture, vector art + +3. A mechanical clock face where the gears are Kubernetes logos and the hands are Git merge arrows, the clock reads "in sync", steampunk-meets-tech aesthetic, muted teal and copper tones + +4. Split composition: left half shows a Git repository tree with glowing branches, right half shows a Kubernetes cluster diagram with matching pods, a translucent sync wave connects them across the divide, dark background with electric blue and orange accents + +### src/content/tags/aws/index.mdx + +1. Isometric cloud city floating above a circuit-board landscape, miniature buildings shaped like AWS service icons connected by glowing orange data streams, cool blue sky background, technical illustration style + +2. Abstract network topology map with a bold orange arrow logo at the center radiating connection lines outward to geometric nodes representing compute, storage, and database services, dark navy background, flat vector art + +3. Mechanical pocket watch with cloud-shaped gears inside, each gear etched with tiny infrastructure symbols like servers and databases, warm amber and steel tones, detailed technical drawing + +4. Bird's-eye view of a shipping port with containers labeled as cloud services being loaded onto platforms by automated cranes, sunset orange and industrial grey palette, clean line art style + +### src/content/tags/azure/index.mdx + +1. Isometric crystal fortress built from translucent blue diamond shapes, each facet reflecting tiny infrastructure symbols like servers and databases, floating on a cloud layer, cool azure and white palette, technical illustration style + +2. Abstract stained glass window pattern with a central diamond motif radiating geometric panels representing identity, compute, networking, and storage services, rich blues and teals on dark background, flat vector art + +3. Mechanical blueprint of interlocking gears shaped like hexagonal tiles, each engraved with cloud service glyphs, steel blue and white technical drawing on navy background, precise engineering illustration + +4. Aerial view of a modern city grid where each building block is a different shade of blue, connected by luminous data highways forming a diamond pattern at the center, clean minimalist line art style + +### src/content/tags/backstage-idp/index.mdx + +1. Isometric control room with a central glowing dashboard screen surrounded by floating plugin cards showing catalog entries, each card connected by thin luminous wires, teal and purple palette, technical illustration style + +2. Abstract blueprint of an open book whose pages unfold into a branching tree of service tiles and documentation nodes, clean lines on dark indigo background, flat vector art with accent highlights + +3. Mechanical switchboard with labeled toggle switches and patch cables connecting service icons to a central portal display, warm copper and cool steel tones, vintage engineering diagram style + +4. Top-down view of a mosaic floor pattern where each tile represents a different developer tool icon, all converging toward a central compass rose, muted teals and greys, clean geometric line art + +### src/content/tags/crossplane/index.mdx + +1. Isometric Kubernetes wheel at center with extending bridges connecting to floating cloud provider islands (AWS, Azure, GCP), each island holding miniature infrastructure icons, cool blue and teal palette, technical illustration style + +2. Abstract circuit board pattern where traces flow from a central hub outward to modular resource blocks labeled with database, storage, and network symbols, dark background with glowing cyan pathways, flat vector art + +3. Mechanical loom weaving threads of different colors (orange, blue, green representing cloud providers) into a single unified fabric, detailed technical drawing with steel and copper tones + +4. Top-down view of a crossroads intersection where four roads converge, each road carrying stylized infrastructure symbols toward the center meeting point, clean geometric line art on slate background + +### src/content/tags/docker/index.mdx + +1. Isometric shipping dock scene with neatly stacked colorful containers on a platform, a small crane organizing them into rows, ocean-blue background fading to white, clean technical illustration style + +2. Abstract cross-section of a container showing layered horizontal strata in different blue tones, each layer labeled with tiny dependency symbols, dark navy background, flat vector art with clean edges + +3. Mechanical conveyor belt carrying sealed boxes through stages labeled build, scan, sign, and deploy, industrial steel and blue palette, detailed technical drawing style + +4. Top-down view of a warehouse floor plan with modular storage units arranged in a perfect grid, pathways connecting them to a central dispatch hub, cool blue and grey geometric line art + +### src/content/tags/dotnet/index.mdx + +1. Isometric purple crystal structure with faceted surfaces, each face etched with code symbols and curly braces, floating above a grid of microservice nodes connected by glowing lines, deep purple and white palette, technical illustration style + +2. Abstract molecular model with interconnected spheres forming the shape of a hexagonal lattice, each node containing a tiny gear or API symbol, violet and silver tones on dark background, flat vector art + +3. Mechanical typewriter with keys shaped like code blocks, a sheet of paper emerging with structured log output, purple ribbon and steel frame, detailed vintage technical drawing style + +4. Bird's-eye view of a railway switching yard where tracks branch and merge, each track carrying cargo labeled with service names, purple and grey industrial palette, clean geometric line art + +### src/content/tags/go/index.mdx + +1. Isometric scene of a small gopher character operating a control panel with levers and switches connected to floating infrastructure cubes representing services, light blue and white palette, playful technical illustration style + +2. Abstract speed lines radiating from a central compiled binary icon, surrounded by orbiting symbols for containers, APIs, and CLIs, teal and cyan on dark background, flat vector art with motion emphasis + +3. Mechanical assembly line where identical toolboxes roll off a conveyor belt, each stamped with a different infrastructure icon, clean steel and sky-blue tones, precise engineering diagram style + +4. Top-down view of a river delta splitting into clean parallel channels, each channel carrying data packets toward different infrastructure endpoints, natural blue-green palette rendered in geometric line art + +### src/content/tags/grafana/index.mdx + +1. Isometric control room with multiple floating holographic screens showing time-series graphs, heatmaps, and gauges, warm orange and amber glow against a dark background, technical illustration style + +2. Abstract sundial face with concentric rings representing metric thresholds, the shadow hand pointing to an alert zone, burnt orange and charcoal palette, flat vector art with geometric precision + +3. Mechanical gauge cluster like a vintage airplane instrument panel, each dial showing a different observability signal (latency, errors, throughput), copper and amber tones, detailed technical drawing + +4. Top-down view of a mosaic pattern made from interlocking dashboard tiles of varying sizes, each containing miniature chart patterns, warm gradient from orange to gold on dark slate, clean geometric line art + +### src/content/tags/helm/index.mdx + +1. Isometric ship's wheel mounted on a platform, each spoke ending in a floating Kubernetes resource icon (pod, service, configmap), nautical blue and white palette, technical illustration style + +2. Abstract blueprint of a package being unwrapped to reveal layered manifest sheets stacked neatly inside, each sheet showing YAML-like grid patterns, navy and silver tones on dark background, flat vector art + +3. Mechanical compass rose with an anchor at center, cardinal points labeled with deployment lifecycle stages (template, install, upgrade, rollback), brass and ocean blue tones, detailed nautical engineering drawing + +4. Top-down view of a harbor with docked vessels arranged in versioned rows, cargo cranes loading configuration crates onto each ship, cool blue-grey maritime palette, clean geometric line art + +### src/content/tags/kubernetes/index.mdx + +1. Isometric seven-spoke ship's wheel floating above a sea of hexagonal pod cells, each cell glowing with a tiny container icon, deep blue and white palette, technical illustration style + +2. Abstract orbital diagram with a central control-plane nucleus orbited by worker-node rings, each ring carrying workload particles along defined paths, navy and electric blue on dark background, flat vector art + +3. Mechanical clockwork mechanism with interlocking gears labeled schedule, reconcile, and scale, a central flywheel bearing the seven-spoke wheel emblem, steel blue and silver tones, detailed engineering drawing + +4. Bird's-eye view of a hexagonal honeycomb grid where each cell contains a different workload symbol, pathways between cells forming a network mesh, cool blue gradient on slate background, clean geometric line art + +### src/content/tags/openstack/index.mdx + +1. Isometric data center building cross-section showing stacked infrastructure layers (compute, network, storage) in red and white, tiny server racks visible inside each layer, clean technical illustration style + +2. Abstract layered cake diagram with each tier a different shade of red, connected by vertical data pipes, the top tier radiating API endpoint symbols, dark background with warm red accents, flat vector art + +3. Mechanical elevator shaft cross-section with platforms at different levels carrying infrastructure components (VMs, switches, disks), industrial red and steel grey tones, detailed engineering drawing style + +4. Top-down view of a modular floor plan with color-coded rooms for compute, networking, and storage, hallways connecting all rooms to a central identity hub, warm red and white palette, clean geometric line art + +### src/content/tags/prometheus/index.mdx + +1. Isometric torch or flame beacon on a stone pedestal, radiating concentric rings of time-series data waves outward, warm orange and ember tones against dark background, technical illustration style + +2. Abstract circular gauge with a flame icon at center, needle arcs showing metric thresholds from green through amber to red, surrounded by floating label tag chips, dark background with warm accents, flat vector art + +3. Mechanical seismograph with a pen drawing continuous waveforms on scrolling paper, each waveform in a different warm tone representing different metric types, copper and flame-orange palette, detailed technical drawing + +4. Top-down view of a radar screen with concentric range rings, blips representing scraped targets arranged in clusters, warm amber glow on dark navy background, clean geometric line art + +### src/content/tags/python/index.mdx + +1. Isometric workshop bench with two intertwined snake sculptures made of coiled cable, surrounded by miniature tools representing cloud SDKs and CLI utilities, blue and gold palette, technical illustration style + +2. Abstract double helix formed from two serpentine ribbons weaving through automation pipeline stages (script, test, deploy, monitor), blue and yellow on dark background, flat vector art + +3. Mechanical telegraph machine with Python-blue keys and golden connectors, ticker tape emerging with infrastructure command output, warm brass and blue tones, detailed vintage technical drawing + +4. Top-down view of a branching river system where two streams intertwine and split repeatedly, carrying automation task icons downstream through a landscape of infrastructure nodes, blue and gold watercolor rendered as geometric line art + +### src/content/tags/ruby/index.mdx + +1. Isometric cut gemstone with faceted surfaces, each facet reflecting miniature infrastructure code blocks, deep red and crimson palette with white highlights, technical illustration style + +2. Abstract crystalline structure with hexagonal faces arranged in a gem formation, internal light refracting into configuration management symbols, rich ruby red on dark background, flat vector art + +3. Mechanical jeweler's workbench with a magnifying loupe over a gemstone being polished, tiny automation script engravings visible on the gem surface, warm red and brass tones, detailed vintage technical drawing + +4. Top-down view of a gem mine cross-section showing layered strata, each layer containing embedded tool icons (chef's hat, puppet strings, vagrant box), deep red and earth tones, clean geometric line art + +### src/content/tags/terraform/index.mdx + +1. Isometric landscape of interlocking purple hexagonal platforms at different heights, each platform holding miniature cloud infrastructure icons, connected by bridges and pathways, violet and white palette, technical illustration style + +2. Abstract tessellation of hexagons in varying purple shades, each containing a provider logo silhouette (cloud, database, network), forming a cohesive mosaic on dark background, flat vector art + +3. Mechanical drafting table with a hexagonal grid ruler and compass, infrastructure blueprints being drawn in precise purple ink, showing plan and apply annotations, detailed technical drawing style + +4. Top-down view of a terraced hillside farm where each terrace is a hexagonal plot containing different infrastructure crops, connected by irrigation channels representing state flow, purple and lavender palette, clean geometric line art + +### src/content/tags/typescript/index.mdx + +1. Isometric shield badge with the TypeScript "TS" monogram embossed at center, surrounded by floating type definition cards showing interface and type annotations, blue and white palette, technical illustration style + +2. Abstract circuit board where traces form curly-brace and angle-bracket patterns, key junction points marked with type symbols (string, number, interface), deep blue on dark background, flat vector art with glowing traces + +3. Mechanical typesetting press with movable type blocks arranged to spell out interface definitions, each block precisely machined in steel blue metal, detailed vintage technical drawing style + +4. Top-down view of an architectural floor plan where rooms are shaped like nested type brackets, each room containing miniature infrastructure tool icons, clean blue and white palette, precise geometric line art diff --git a/_TODO.md b/_TODO.md index 592ca4f82..62539c93b 100644 --- a/_TODO.md +++ b/_TODO.md @@ -165,7 +165,7 @@ Need a tooltip component for consistency. List to add tooltips to: ## Non-featured tags -Update the skills and technologies tags pages: +Generate cover images for the skills and technologies tags pages: argo-cd aws diff --git a/src/content/tags/argo-cd/index.mdx b/src/content/tags/argo-cd/index.mdx index 0720ac939..fad32a2ae 100644 --- a/src/content/tags/argo-cd/index.mdx +++ b/src/content/tags/argo-cd/index.mdx @@ -1,10 +1,16 @@ --- slug: "argo-cd" displayName: "Argo CD" -description: "" +description: "Kubernetes-native continuous delivery with GitOps, declarative application management, sync strategies, and automated rollback for platform engineering workflows." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Stylized Argo CD octopus logo merged with a Kubernetes cluster sync diagram" featured: false logo: "./argo-cd.svg" isSkill: true --- + +Argo CD is a declarative, GitOps-based continuous delivery tool built specifically for Kubernetes. It treats Git repositories as the single source of truth for application definitions and cluster state, continuously reconciling what is declared in version control with what is actually running. When drift occurs, Argo CD detects it and can automatically or manually sync the cluster back to the desired state. + +In platform engineering workflows, Argo CD serves as the deployment backbone. It replaces imperative CI/CD push models with a pull-based approach where the cluster watches for changes rather than receiving commands. This pattern simplifies multi-cluster deployments, enables progressive rollouts through sync waves and hooks, and provides a clear audit trail of every change applied to a cluster. ApplicationSets allow platform teams to template deployments across hundreds of environments from a single definition. + +The operational reality of Argo CD involves managing sync failures, understanding resource ordering, handling secrets that cannot live in Git, and debugging the gap between what a manifest declares and what a cluster actually does. These articles cover practical Argo CD usage from the perspective of teams running it in production. diff --git a/src/content/tags/aws/index.mdx b/src/content/tags/aws/index.mdx index 6b35034ee..241dafe5c 100644 --- a/src/content/tags/aws/index.mdx +++ b/src/content/tags/aws/index.mdx @@ -1,10 +1,16 @@ --- slug: "aws" displayName: "AWS" -description: "" +description: "Amazon Web Services cloud infrastructure for platform engineering, from IAM and networking to managed Kubernetes, serverless, and infrastructure-as-code workflows." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Abstract cloud architecture diagram with interconnected AWS service icons" featured: false logo: "./aws.svg" isSkill: true --- + +AWS dominates the cloud infrastructure market for good reason: its breadth of services lets platform teams assemble opinionated internal platforms without building everything from scratch. EKS for managed Kubernetes, CodePipeline and CodeBuild for CI/CD, CloudFormation and CDK for infrastructure-as-code, and IAM for fine-grained access control form the backbone of most enterprise platform engineering stacks. The ecosystem is deep enough that nearly any operational pattern has a managed-service answer. + +Platform engineers working with AWS spend significant time on IAM policy design, VPC networking, and service quotas—the unglamorous connective tissue that determines whether a self-service platform actually works at scale. Getting cross-account access right with Organizations and Control Tower, wiring up PrivateLink endpoints, and tuning autoscaling policies across EKS node groups are where real operational expertise lives. + +The tradeoff is complexity. AWS offers multiple ways to accomplish any goal, and choosing between them has long-term consequences for cost, maintainability, and team cognitive load. A well-built AWS platform abstracts that complexity behind golden paths so application teams get the reliability of battle-tested infrastructure without needing to understand every service interaction underneath. diff --git a/src/content/tags/azure/index.mdx b/src/content/tags/azure/index.mdx index 67c29eaee..7391a226e 100644 --- a/src/content/tags/azure/index.mdx +++ b/src/content/tags/azure/index.mdx @@ -1,10 +1,16 @@ --- slug: "azure" displayName: "Azure" -description: "" +description: "Microsoft Azure cloud platform for enterprise platform engineering, covering AKS, Entra ID, Azure DevOps pipelines, and infrastructure-as-code with Bicep and Terraform." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Geometric Azure diamond logo surrounded by interlocking cloud infrastructure blocks" featured: false logo: "./azure.svg" isSkill: true --- + +Azure occupies a unique position in enterprise platform engineering because of its deep integration with Microsoft's identity and productivity stack. Entra ID (formerly Azure AD) gives platform teams federated identity, conditional access, and workload identity federation out of the box—capabilities that take significant effort to replicate on other clouds. AKS provides managed Kubernetes with tight Azure networking integration, and Azure DevOps offers a mature CI/CD pipeline system that many enterprises already have in place. + +For platform engineers, Azure's strength is its enterprise governance tooling. Management Groups, Azure Policy, and Blueprints let you enforce guardrails across hundreds of subscriptions. Landing Zones provide prescriptive architecture for multi-team environments, and Bicep offers a cleaner infrastructure-as-code experience than ARM templates while remaining a first-party citizen. Combined with Terraform's AzureRM provider, teams have real flexibility in how they define and provision infrastructure. + +The challenge is navigating Azure's layered service model and frequent rebranding. Services shift names, preview features graduate unpredictably, and the relationship between Azure DevOps and GitHub Actions continues to evolve. Successful Azure platform teams build abstraction layers that shield application developers from these shifts while still leveraging the platform's enterprise integration advantages. diff --git a/src/content/tags/backstage-idp/index.mdx b/src/content/tags/backstage-idp/index.mdx index 913d2eb51..c23b61eac 100644 --- a/src/content/tags/backstage-idp/index.mdx +++ b/src/content/tags/backstage-idp/index.mdx @@ -1,8 +1,14 @@ --- slug: "backstage-idp" displayName: "Backstage IDP" -description: "" +description: "Spotify's open-source internal developer portal for building self-service platforms with software catalogs, scaffolding templates, and a plugin ecosystem." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Stylized developer portal dashboard with interconnected service catalog tiles" featured: false --- + +Backstage is Spotify's open-source framework for building internal developer portals, and it has become the de facto standard for platform teams that want a single pane of glass over their engineering ecosystem. Its software catalog gives every service, library, and infrastructure component a discoverable home with ownership metadata, documentation links, and dependency graphs. Software templates let platform engineers offer golden-path scaffolding so new projects start with the right CI/CD pipelines, observability, and security defaults baked in. + +The plugin architecture is where Backstage earns its place in the platform engineering stack. Kubernetes dashboards, Argo CD sync status, PagerDuty on-call schedules, and cost dashboards all surface inside a single portal instead of forcing developers to context-switch between a dozen tools. The TechDocs system turns markdown in repos into browsable documentation that stays next to the services it describes. + +Running Backstage in production is nontrivial. It requires a PostgreSQL backend, an authentication provider, and ongoing plugin maintenance as upstream versions evolve. The reward is a developer experience layer that turns a collection of loosely connected DevOps tools into a coherent self-service platform—exactly the abstraction that platform engineering teams exist to build. diff --git a/src/content/tags/crossplane/index.mdx b/src/content/tags/crossplane/index.mdx index d88869422..cca3b9a34 100644 --- a/src/content/tags/crossplane/index.mdx +++ b/src/content/tags/crossplane/index.mdx @@ -1,10 +1,16 @@ --- slug: "crossplane" displayName: "Crossplane" -description: "" +description: "Kubernetes-native cloud infrastructure provisioning using custom resources, compositions, and provider integrations for self-service platform engineering workflows." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Abstract Kubernetes control plane diagram branching into multi-cloud resource nodes" featured: false logo: "./crossplane.svg" isSkill: true --- + +Crossplane extends the Kubernetes API to manage cloud infrastructure the same way you manage workloads—through declarative YAML and the reconciliation loop. Instead of writing Terraform modules that run in a separate pipeline, platform teams define Composite Resource Definitions (XRDs) and Compositions that let application developers request databases, queues, and storage buckets through familiar kubectl commands. The infrastructure becomes a native part of the cluster's desired-state model. + +This approach shines in platform engineering because it enables genuine self-service without giving developers direct cloud console access. A platform team publishes an opinionated XRD for a PostgreSQL instance, encoding VPC placement, backup schedules, and encryption defaults into the Composition. Developers claim one with a short manifest, and the Crossplane controller handles provisioning through AWS, Azure, or GCP providers. Drift detection and remediation happen continuously, just like any other Kubernetes resource. + +The operational cost is real. Crossplane providers generate hundreds of CRDs that increase API server memory pressure, and debugging failed reconciliations requires understanding both the Crossplane machinery and the underlying cloud API. Teams that invest in solid composition testing and provider version pinning get a powerful infrastructure abstraction layer; those that skip that discipline get a different flavor of infrastructure sprawl. diff --git a/src/content/tags/docker/index.mdx b/src/content/tags/docker/index.mdx index f2c7f9e87..4e84cd7dd 100644 --- a/src/content/tags/docker/index.mdx +++ b/src/content/tags/docker/index.mdx @@ -1,10 +1,16 @@ --- slug: "docker" displayName: "Docker" -description: "" +description: "Container images, multi-stage builds, and runtime tooling for packaging applications with reproducible dependencies across development, CI, and production environments." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Stacked shipping containers arranged into an organized modular grid pattern" featured: false logo: "./docker.svg" isSkill: true --- + +Docker changed how platform engineers think about deployment artifacts. Instead of configuring servers to match application requirements, teams package applications with their exact dependencies into images that run identically everywhere. Multi-stage builds keep production images lean, BuildKit caching speeds up CI pipelines, and OCI-compliant registries give organizations a standard way to store and distribute artifacts across environments. + +For platform engineering, Docker sits at the foundation layer. Golden-path Dockerfiles enforce security baselines—non-root users, minimal base images, pinned package versions—before code ever reaches a cluster. Build pipelines scan images for CVEs, sign them with cosign or Notary, and push to private registries with promotion policies that gate what reaches production. The container image becomes the unit of trust between development teams and the platform. + +Docker's role has shifted as Kubernetes handles orchestration and containerd runs workloads directly. The Docker CLI and Compose remain essential for local development, letting engineers run multi-service stacks on their laptops with a single command. Understanding image layering, build context optimization, and runtime security constraints still matters daily—even in environments where Docker Engine itself never touches a production node. diff --git a/src/content/tags/dotnet/index.mdx b/src/content/tags/dotnet/index.mdx index 981660ed9..fb09a3308 100644 --- a/src/content/tags/dotnet/index.mdx +++ b/src/content/tags/dotnet/index.mdx @@ -1,10 +1,16 @@ --- slug: "dotnet" displayName: ".NET / C#" -description: "" +description: "C# and the .NET platform for building platform engineering tooling, cloud-native microservices, internal APIs, and DevOps automation in enterprise environments." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Abstract .NET logo formed from interlocking geometric code blocks" featured: false logo: "./dotnet.svg" isSkill: true --- + +.NET and C# dominate enterprise backend development, and that makes them unavoidable in platform engineering. Many of the internal services that platform teams build on top of—identity providers, API gateways, legacy monoliths being decomposed into microservices—run on .NET. Understanding the runtime, its deployment model, and its operational characteristics is essential for building platforms that serve .NET application teams well. + +From a platform engineering perspective, .NET's strengths include first-class Azure integration, mature observability through OpenTelemetry's .NET SDK, and a container story that has improved dramatically with minimal APIs and AOT compilation. Health check endpoints, structured logging with Serilog or the built-in logging abstractions, and gRPC support are all part of the standard toolkit. Platform teams can publish opinionated project templates via `dotnet new` that enforce organizational standards from the first commit. + +The DevOps angle matters too. .NET builds are resource-intensive compared to Go or Python, so CI pipeline optimization—NuGet caching, incremental builds, and parallelized test execution—directly affects developer feedback loops. Understanding MSBuild, solution structure, and the NuGet dependency graph helps platform engineers design build systems that scale across hundreds of repositories without becoming a bottleneck. diff --git a/src/content/tags/go/index.mdx b/src/content/tags/go/index.mdx index a5eee2dce..1d0a54e2f 100644 --- a/src/content/tags/go/index.mdx +++ b/src/content/tags/go/index.mdx @@ -1,10 +1,16 @@ --- slug: "go" displayName: "Go Language" -description: "" +description: "Go for platform engineering and DevOps tooling, from Kubernetes controllers and CLI tools to high-concurrency infrastructure services and cloud-native operators." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Minimalist gopher mascot silhouette integrated into a circuit board pattern" featured: false logo: "./go.svg" isSkill: true --- + +Go is the lingua franca of cloud-native infrastructure. Kubernetes, Docker, Terraform, Prometheus, and Argo CD are all written in it, which means platform engineers who read and write Go can extend, debug, and contribute to the tools they depend on daily. When a Kubernetes controller misbehaves or a Terraform provider has a gap, the ability to trace through Go source code turns a black box into a solvable problem. + +The language's practical strengths align with platform engineering work. Static binaries with zero runtime dependencies simplify distribution—a CLI tool or operator ships as a single file that runs anywhere. Goroutines and channels handle the concurrent I/O that infrastructure tools demand, and the standard library covers HTTP servers, JSON handling, and cryptography without pulling in external dependencies. Fast compilation means tight feedback loops even in large codebases. + +Go's opinionated design—explicit error handling, no generics until recently, a deliberately small feature set—keeps codebases readable across teams. For platform teams maintaining controllers, admission webhooks, and internal CLIs that outlive any single engineer, that readability is a feature worth the verbosity trade-off. The ecosystem's convention over configuration ethos matches the reliability-first mindset that SRE and platform engineering demand. diff --git a/src/content/tags/grafana/index.mdx b/src/content/tags/grafana/index.mdx index 07c20fe7e..a682c240e 100644 --- a/src/content/tags/grafana/index.mdx +++ b/src/content/tags/grafana/index.mdx @@ -1,10 +1,16 @@ --- slug: "grafana" displayName: "Grafana" -description: "" +description: "Grafana dashboards and the LGTM observability stack for metrics visualization, log exploration, distributed tracing, and SLO-driven alerting in platform engineering." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Dashboard panel grid displaying time-series graphs and heatmaps in warm orange tones" featured: false logo: "./grafana.svg" isSkill: true --- + +Grafana has grown from a metrics dashboard into the visualization layer for the entire observability stack. Paired with Prometheus for metrics, Loki for logs, Tempo for traces, and Mimir for long-term storage, it gives platform teams a single interface to correlate signals across infrastructure and applications. The ability to jump from a spike on a dashboard panel to the exact log lines and traces that explain it is what turns monitoring from a passive display into an active debugging tool. + +For platform engineering, Grafana's value is in standardization. Dashboard-as-code with Grafonnet or Terraform's Grafana provider lets teams version-control their observability views alongside the infrastructure they describe. Alerting rules defined in code, provisioned through CI, and routed through Alertmanager or Grafana's built-in contact points create a repeatable incident response foundation. SLO dashboards backed by real error budgets give service owners a shared language for reliability conversations. + +The operational reality is dashboard sprawl. Without governance, every team creates bespoke dashboards that nobody else can interpret. Platform teams that invest in golden-signal dashboard templates, consistent label taxonomies, and self-service provisioning through Backstage or internal tooling get observability that scales. Those that don't end up with hundreds of dashboards and no shared understanding of system health. diff --git a/src/content/tags/helm/index.mdx b/src/content/tags/helm/index.mdx index a90c9a8d9..1f50086dd 100644 --- a/src/content/tags/helm/index.mdx +++ b/src/content/tags/helm/index.mdx @@ -1,10 +1,16 @@ --- slug: "helm" displayName: "Helm" -description: "" +description: "Helm charts for Kubernetes application packaging, templated deployments, release management, and standardized service delivery across platform engineering workflows." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Ship's helm wheel with spokes connecting to Kubernetes resource manifest icons" featured: false logo: "./helm.svg" isSkill: true --- + +Helm is the package manager for Kubernetes, and for platform engineers it serves as the primary abstraction layer between raw manifests and repeatable deployments. A well-structured Helm chart encapsulates an application's Deployments, Services, ConfigMaps, and RBAC rules into a versioned, configurable artifact. Teams install the same chart across development, staging, and production by swapping values files—keeping the manifest logic consistent while varying environment-specific configuration. + +Platform teams use Helm to publish opinionated chart libraries that enforce organizational standards. A base chart might set resource limits, pod disruption budgets, security contexts, and standard labels that every service inherits. Argo CD and Flux both natively understand Helm charts, so GitOps pipelines can reference chart versions in Git and let the controller handle rendering and deployment. OCI-compatible registries now store charts alongside container images, simplifying artifact management. + +The friction comes from Helm's Go templating engine. Complex conditionals, nested loops, and sprig functions produce templates that are difficult to read and harder to debug when rendering fails. Teams that keep charts simple—thin value layers over well-tested base charts—avoid the worst of the templating complexity. Those that try to encode every edge case into a single chart end up with unmaintainable YAML generation logic that nobody wants to touch. diff --git a/src/content/tags/kubernetes/index.mdx b/src/content/tags/kubernetes/index.mdx index e7c5c5bba..ad3b59f37 100644 --- a/src/content/tags/kubernetes/index.mdx +++ b/src/content/tags/kubernetes/index.mdx @@ -1,10 +1,16 @@ --- slug: "kubernetes" displayName: "Kubernetes" -description: "" +description: "Kubernetes container orchestration for platform engineering, covering cluster operations, workload scheduling, networking, RBAC, and the extension points that make internal platforms possible." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Kubernetes helm wheel logo at the center of an interconnected node and pod network" featured: false logo: "./kubernetes.svg" isSkill: true --- + +Kubernetes is the operating system of platform engineering. Its declarative API, reconciliation loop, and extensibility model provide the foundation that tools like Argo CD, Crossplane, and Helm build on. For platform teams, Kubernetes is less about running containers and more about providing a consistent control plane where infrastructure, deployments, and policies converge into a single programmable surface that application teams consume through self-service abstractions. + +The depth of Kubernetes knowledge that platform engineering demands goes well beyond deploying workloads. Cluster networking with CNI plugins, ingress controller tuning, pod security standards, RBAC policy design, and resource quota management are the daily concerns that determine whether a multi-tenant cluster is secure and stable or a shared liability. Custom Resource Definitions and operator patterns let platform teams extend the API server with domain-specific abstractions—turning Kubernetes into a platform-building framework rather than just a runtime. + +Operational maturity means understanding failure modes: etcd latency under load, node pressure evictions, webhook timeout cascading, and the subtle ways misconfigured HPA and PDB interact during rollouts. Platform engineers who invest in cluster observability, upgrade automation, and capacity planning build platforms that application teams trust. Those who treat Kubernetes as a black-box deployment target inevitably face reliability surprises at scale. diff --git a/src/content/tags/openstack/index.mdx b/src/content/tags/openstack/index.mdx index 8c5206926..fdf1c213e 100644 --- a/src/content/tags/openstack/index.mdx +++ b/src/content/tags/openstack/index.mdx @@ -1,10 +1,16 @@ --- slug: "openstack" displayName: "Open Stack" -description: "" +description: "OpenStack private cloud infrastructure for compute, networking, and storage provisioning in regulated environments where public cloud adoption faces compliance or sovereignty constraints." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Layered infrastructure stack with compute, network, and storage tiers in red and white" featured: false logo: "./openstack.svg" isSkill: true --- + +OpenStack provides the building blocks for private cloud infrastructure—compute (Nova), networking (Neutron), block storage (Cinder), object storage (Swift), and identity (Keystone)—that organizations operate on their own hardware. In industries where data sovereignty, regulatory compliance, or latency requirements rule out public cloud, OpenStack remains the most mature open-source option for delivering cloud-like self-service to internal teams without ceding control of the underlying infrastructure. + +For platform engineers, OpenStack is both an infrastructure provider and an operational challenge. Deploying and upgrading a production OpenStack cluster requires deep knowledge of Linux networking, storage backends, message queues, and database clustering. Tools like Kolla-Ansible and TripleO automate deployment, but day-two operations—capacity planning, tenant isolation, quota management, and certificate rotation—demand ongoing engineering investment that public cloud providers absorb behind their APIs. + +The modern platform engineering angle often involves running Kubernetes on top of OpenStack, using Magnum or Cluster API providers to offer managed clusters to application teams. This hybrid approach gives organizations the compliance posture of on-premises infrastructure with the developer experience of container orchestration. The operational cost is higher than managed cloud services, but for the right constraints it delivers control that no public cloud can match. diff --git a/src/content/tags/prometheus/index.mdx b/src/content/tags/prometheus/index.mdx index 0d2bd84a4..0b03a9ad5 100644 --- a/src/content/tags/prometheus/index.mdx +++ b/src/content/tags/prometheus/index.mdx @@ -1,10 +1,16 @@ --- slug: "prometheus" displayName: "Prometheus" -description: "" +description: "Prometheus metrics collection, PromQL querying, and Alertmanager routing for infrastructure monitoring, SLO tracking, and reliability engineering in Kubernetes environments." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Flame-inspired gauge dial surrounded by radiating time-series metric lines" featured: false logo: "./prometheus.svg" isSkill: true --- + +Prometheus is the metrics backbone of most Kubernetes-native observability stacks. Its pull-based scraping model, dimensional data model with labels, and powerful PromQL query language give platform teams the foundation for monitoring infrastructure health, tracking service-level objectives, and powering alerting pipelines. As a CNCF graduated project, it defines the standard that exporters, client libraries, and compatible systems like Thanos and Mimir build against. + +For platform engineers, Prometheus work centers on designing a metrics architecture that scales. That means configuring ServiceMonitors and PodMonitors through the Prometheus Operator, setting up federation or remote-write for multi-cluster aggregation, and tuning retention and storage to balance query performance against disk costs. PromQL fluency is essential—writing recording rules that pre-aggregate expensive queries, defining multi-window burn-rate alerts for SLO monitoring, and building dashboards that surface actionable signals instead of vanity metrics. + +The operational challenge is cardinality. Every unique combination of metric name and label values creates a time series, and unbounded labels from request paths, user IDs, or pod names can explode storage and query latency. Platform teams that enforce labeling conventions, set per-tenant series limits, and instrument cardinality dashboards keep Prometheus healthy. Those that skip cardinality governance learn about it during their next outage investigation when queries time out. diff --git a/src/content/tags/python/index.mdx b/src/content/tags/python/index.mdx index 45858aaa9..bff8faf2b 100644 --- a/src/content/tags/python/index.mdx +++ b/src/content/tags/python/index.mdx @@ -1,10 +1,16 @@ --- slug: "python" displayName: "Python" -description: "" +description: "Python for DevOps automation, infrastructure scripting, cloud SDK integrations, observability tooling, and rapid prototyping across platform engineering workflows." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Intertwined Python serpent shapes forming an automation pipeline diagram" featured: false logo: "./python.svg" isSkill: true --- + +Python is the default scripting language of platform engineering. When a team needs to automate a cloud workflow, parse API responses, generate Terraform variable files, or build a quick CLI tool, Python is almost always the fastest path from idea to working code. Every major cloud provider ships a Python SDK, Ansible is built on it, and the ecosystem of infrastructure libraries—boto3, azure-sdk, google-cloud-python, kubernetes-client—covers virtually any integration a platform team encounters. + +The language excels at glue code and automation. Migration scripts that shuffle data between systems, cost analysis tools that query cloud billing APIs, incident response runbooks that execute remediation steps, and custom Prometheus exporters that scrape proprietary systems all land naturally in Python. Its readability means on-call engineers can understand and modify scripts written by someone else at 3 AM without deciphering clever abstractions. + +The tradeoff is runtime performance and packaging complexity. Python scripts need a runtime and dependency management—virtual environments, pip, and version pinning—that adds friction compared to Go's static binaries. For long-running services or high-throughput data pipelines, the GIL and startup overhead matter. Platform teams that use Python for automation and scripting while reaching for Go or Rust for performance-critical services get the best of both worlds. diff --git a/src/content/tags/ruby/index.mdx b/src/content/tags/ruby/index.mdx index 5ae18c8fc..d43f33b0c 100644 --- a/src/content/tags/ruby/index.mdx +++ b/src/content/tags/ruby/index.mdx @@ -1,10 +1,16 @@ --- slug: "ruby" displayName: "Ruby" -description: "" +description: "Ruby for infrastructure automation with Chef, Vagrant, and Puppet, plus DevOps scripting, internal tooling, and legacy platform modernization in enterprise environments." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Faceted ruby gemstone with internal reflections showing infrastructure code patterns" featured: false logo: "./ruby.svg" isSkill: true --- + +Ruby's role in platform engineering is rooted in the configuration management era. Chef and Puppet—two of the most widely deployed infrastructure automation frameworks—are written in Ruby and use Ruby-based DSLs for defining system state. Vagrant, the tool that standardized reproducible development environments before containers took over, is also Ruby. Organizations with years of investment in these tools still depend on Ruby expertise to maintain and evolve their automation codebases. + +Beyond legacy tooling, Ruby remains practical for internal platform tools. Its expressive syntax and metaprogramming capabilities make it well-suited for building domain-specific languages, CLI utilities, and API wrapper libraries where developer ergonomics matter more than raw throughput. Rails applications still power internal dashboards, deployment portals, and workflow engines at companies that adopted the framework early and built institutional knowledge around it. + +The platform engineering reality is that Ruby occupies a maintenance and migration position in most infrastructure stacks. Teams are gradually moving Chef cookbooks to Ansible or Terraform, and new CLI tools tend to ship in Go or Python. But understanding Ruby remains valuable for platform engineers who inherit Chef-managed infrastructure, contribute to Homebrew formulas, or work in organizations where Ruby services are part of the production footprint being containerized and moved to Kubernetes. diff --git a/src/content/tags/terraform/index.mdx b/src/content/tags/terraform/index.mdx index b0e39ba11..b9cd5668a 100644 --- a/src/content/tags/terraform/index.mdx +++ b/src/content/tags/terraform/index.mdx @@ -1,10 +1,16 @@ --- slug: "terraform" displayName: "Terraform" -description: "" +description: "Terraform infrastructure-as-code for multi-cloud provisioning, state management, module composition, and self-service platform engineering with HCL and provider ecosystems." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Interlocking hexagonal Terraform tiles forming a multi-cloud infrastructure map" featured: false logo: "./terraform.svg" isSkill: true --- + +Terraform is the most widely adopted infrastructure-as-code tool in platform engineering. Its declarative HCL syntax, plan-and-apply workflow, and provider ecosystem covering every major cloud and SaaS platform give teams a consistent way to define, version, and provision infrastructure regardless of where it runs. The state file acts as a source of truth that tracks what exists, what changed, and what needs to be created or destroyed on the next apply. + +Platform teams build on Terraform by composing reusable modules that encode organizational standards. A VPC module enforces network segmentation policies, a Kubernetes cluster module wires up node pools with the right instance types and autoscaling rules, and a database module handles encryption, backup schedules, and parameter groups. Published to private registries and consumed through version-pinned module calls, these become the building blocks of self-service infrastructure that application teams request without writing HCL themselves. + +The operational challenges are state management and drift. Remote state backends, state locking, and workspace isolation prevent concurrent modifications from corrupting infrastructure, but they add coordination overhead. Large monolithic state files slow plan times and increase blast radius. Teams that invest in state decomposition, automated drift detection, and policy-as-code with Sentinel or OPA build Terraform workflows that scale across hundreds of engineers and thousands of resources. diff --git a/src/content/tags/typescript/index.mdx b/src/content/tags/typescript/index.mdx index 35c6458fc..aba46735d 100644 --- a/src/content/tags/typescript/index.mdx +++ b/src/content/tags/typescript/index.mdx @@ -1,10 +1,16 @@ --- slug: "typescript" displayName: "TypeScript" -description: "" +description: "TypeScript for platform engineering tooling, CDK infrastructure definitions, Backstage plugin development, internal CLI tools, and type-safe API contracts across DevOps workflows." cover: "./cover.jpg" -coverAlt: "" +coverAlt: "Abstract TypeScript logo badge overlaid on structured type definition patterns" featured: false logo: "./typescript.svg" isSkill: true --- + +TypeScript has moved well beyond frontend development into platform engineering territory. AWS CDK, Pulumi, and CDKTF all use TypeScript as a first-class language for defining infrastructure, giving teams the ability to use loops, conditionals, and type-checked abstractions instead of declarative configuration languages. Backstage plugins are written in TypeScript, GitHub Actions can be authored in it, and internal CLI tools built with libraries like oclif or commander ship with the same type safety that application code enjoys. + +For platform engineers, TypeScript's value is its type system applied to infrastructure concerns. Defining typed interfaces for deployment configurations, API contracts between services, and shared schema definitions catches entire categories of integration errors at compile time rather than at deploy time. When a platform team publishes an SDK for internal service consumption, TypeScript's autocompletion and type inference make that SDK self-documenting in ways that dynamically typed alternatives cannot match. + +The tradeoff is the Node.js runtime dependency and build step overhead. TypeScript tools require transpilation, and the node_modules ecosystem brings dependency management complexity that Go and Python avoid. For long-running infrastructure services, Node.js event-loop characteristics differ from threaded runtimes. Teams that use TypeScript for CDK constructs, Backstage development, and API tooling while reaching for Go for controllers and CLIs that need static binaries strike a practical balance. From 1bc837db909295fd5db5d9d7fcf8234a1bdad2c2 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 16:06:48 +0300 Subject: [PATCH 18/33] Fix broken test cases after refactor to lit and changing hero terminal text animation --- _TODO.md | 83 ------------------- .../Carousel/client/__tests__/index.spec.ts | 23 ++++- src/components/Carousel/client/index.ts | 32 ++++--- .../Home/Hero/client/__tests__/index.spec.ts | 49 ++++++++--- .../scripts/elementListeners/index.ts | 27 ++++-- .../scripts/embla/EmblaCarouselBase.ts | 41 +++++++++ .../index.mdx | 1 + 7 files changed, 141 insertions(+), 115 deletions(-) diff --git a/_TODO.md b/_TODO.md index 62539c93b..08f7bde68 100644 --- a/_TODO.md +++ b/_TODO.md @@ -280,8 +280,6 @@ A bunch of our web components extend directly from HTMLElement instead of follow - Consent/Checkbox - Consent/Preferences -And the embla components: Carousel and Testimonials - The WebComponentModule type in this file is used throughout component scripts: `src/components/scripts/@types/webComponentModule.ts` @@ -292,84 +290,3 @@ There are testing fixtures that might be targeting HTMLElement instead of LitEle line 113 in `test/eslint/__tests__/enforce-centralized-events-rule.spec.ts` interface `ElementWithTestProperties` in `test/e2e/assertions/index.ts` - -/home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/src/components/Carousel/client/index.ts - 70:5 error Use `keyup with addButtonEventListeners or addWrapperEventListeners` from `elementListeners` for keyboard events (use keyup instead of keydown for better accessibility) instead of direct addEventListener. This ensures consistent accessibility support (isComposing check, repeat prevention, Enter/Escape key handling) custom-rules/enforce-centralized-events - -✖ 1 problem (1 error, 0 warnings) - - -⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - - FAIL src/components/Testimonials/client/__tests__/index.spec.ts > Testimonials component > registers the web component and generates pagination dots -AssertionError: expected null to be 'testimonials-3-viewport' // Object.is equality - -- Expected: -"testimonials-3-viewport" - -+ Received: -null - - ❯ src/components/Testimonials/client/__tests__/index.spec.ts:198:51 - 196| - 197| dots.forEach(dot => { - 198| expect(dot.getAttribute('aria-controls')).toBe(viewportId) - | ^ - 199| }) - 200| expect(prevBtn?.getAttribute('aria-controls')).toBe(viewportId) - ❯ src/components/Testimonials/client/__tests__/index.spec.ts:197:12 - ❯ assert src/components/Testimonials/client/__tests__/index.spec.ts:114:13 - ❯ assert test/unit/helpers/litRuntime.ts:308:10 - ❯ test/unit/helpers/litRuntime.ts:246:9 - ❯ withJsdomEnvironment test/unit/helpers/litRuntime.ts:147:10 - ❯ renderInJsdom test/unit/helpers/litRuntime.ts:218:2 - ❯ Module.executeRender test/unit/helpers/litRuntime.ts:320:2 - -⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ - - FAIL src/components/Home/Hero/client/__tests__/index.spec.ts > HomeHeroElement (Lit) > types the ready prompt one character every 500ms and stops when complete -AssertionError: expected '' to be 'r' // Object.is equality - -- Expected -+ Received - -- r - - ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:57:38 - 55| - 56| vi.advanceTimersByTime(STEP_MS) - 57| expect(readyText?.textContent).toBe('r') - | ^ - 58| - 59| vi.advanceTimersByTime(STEP_MS) - ❯ withJsdomEnvironment test/unit/helpers/litRuntime.ts:147:10 - ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:39:5 - -⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ - - FAIL src/components/Home/Hero/client/__tests__/index.spec.ts > HomeHeroElement (Lit) > skips animation and shows final text when reduced motion is preferred -AssertionError: expected '' to be 'heck yes, let\'s talk...' // Object.is equality - -- Expected -+ Received - -- heck yes, let's talk... - - ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:90:38 - 88| const readyText = window.document.querySelector('[data-hero-ready-text]') - 89| expect(readyText).toBeTruthy() - 90| expect(readyText?.textContent).toBe(READY_TEXT) - | ^ - 91| - 92| vi.advanceTimersByTime(STEP_MS * READY_TEXT.length) - ❯ withJsdomEnvironment test/unit/helpers/litRuntime.ts:147:10 - ❯ src/components/Home/Hero/client/__tests__/index.spec.ts:75:5 - - -02:58:50 [ERROR] [vite] ✗ Build failed in 4.72s -[@mdx-js/rollup] Cannot assign to read only property 'name' of object 'BuildError: Mermaid couldn't graph this diagram.' -file: /home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx - Stack trace: - at Object.transform (file:///home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/node_modules/@astrojs/mdx/dist/vite-plugin-mdx.js:60:18) - at process.processImmediate (node:internal/timers:473:9) - at async ModuleLoader.addModuleSource (file:///home/kevin/Repos/WebstackBuilders/CorporateWebsite/astro.webstackbuilders.com/node_modules/rollup/dist/es/shared/node-entry.js:21363:36) diff --git a/src/components/Carousel/client/__tests__/index.spec.ts b/src/components/Carousel/client/__tests__/index.spec.ts index bd81dc745..601dd533c 100644 --- a/src/components/Carousel/client/__tests__/index.spec.ts +++ b/src/components/Carousel/client/__tests__/index.spec.ts @@ -201,6 +201,25 @@ describe('Carousel component (server output)', () => { ) }) + it('assigns aria-controls to pagination dots and navigation buttons', async () => { + await renderCarousel(({ root }) => { + const viewport = root.querySelector('.embla__viewport') + const viewportId = viewport?.getAttribute('id') + const dots = root.querySelectorAll('.embla__dot') + const prevBtn = root.querySelector('.embla__button--prev') + const nextBtn = root.querySelector('.embla__button--next') + + expect(viewportId).toMatch(/^carousel-\d+-viewport$/) + expect(dots.length).toBeGreaterThan(0) + + dots.forEach(dot => { + expect(dot.getAttribute('aria-controls')).toBe(viewportId) + }) + expect(prevBtn?.getAttribute('aria-controls')).toBe(viewportId) + expect(nextBtn?.getAttribute('aria-controls')).toBe(viewportId) + }) + }) + it('excludes the current slug from rendered cards', async () => { await renderCarousel( ({ root }) => { @@ -349,8 +368,8 @@ describe('Carousel component (server output)', () => { const keyboardEventCtor = root.ownerDocument.defaultView?.KeyboardEvent expect(keyboardEventCtor).toBeDefined() - root.dispatchEvent(new keyboardEventCtor!('keydown', { key: 'ArrowLeft', bubbles: true })) - root.dispatchEvent(new keyboardEventCtor!('keydown', { key: 'ArrowRight', bubbles: true })) + root.dispatchEvent(new keyboardEventCtor!('keyup', { key: 'ArrowLeft', bubbles: true })) + root.dispatchEvent(new keyboardEventCtor!('keyup', { key: 'ArrowRight', bubbles: true })) expect(emblaApi?.scrollPrev).toHaveBeenCalledTimes(1) expect(emblaApi?.scrollNext).toHaveBeenCalledTimes(1) diff --git a/src/components/Carousel/client/index.ts b/src/components/Carousel/client/index.ts index ae95253b6..ececb7e7f 100644 --- a/src/components/Carousel/client/index.ts +++ b/src/components/Carousel/client/index.ts @@ -1,4 +1,5 @@ import { handleScriptError } from '@components/scripts/errors/handler' +import { addWrapperEventListeners } from '@components/scripts/elementListeners' import { defineCustomElement } from '@components/scripts/utils' import { createE2ELogger, EmblaCarouselBase } from '@components/scripts/embla' import type { EmblaCarouselConfig, EmblaElementHandles } from '@components/scripts/embla' @@ -19,7 +20,7 @@ const logForE2E = createE2ELogger('carousel') export class CarouselElement extends EmblaCarouselBase { private statusRegion: HTMLElement | null = null - private readonly keydownHandler = (event: KeyboardEvent) => this.handleKeydown(event) + private readonly keyupHandler = (event: Event) => this.handleKeyup(event) // ────────────────────── Base class abstract implementations ────────────────────── @@ -67,22 +68,27 @@ export class CarouselElement extends EmblaCarouselBase { protected override onInitialized(): void { this.statusRegion = queryCarouselStatusRegion(this) this.setupStatusRegion() - this.addEventListener('keydown', this.keydownHandler) - } - - protected override onTeardown(): void { - this.removeEventListener('keydown', this.keydownHandler) + if (!this.dataset['carouselKeyListener']) { + addWrapperEventListeners(this, this.keyupHandler, this, { + allowedKeys: ['ArrowLeft', 'ArrowRight'], + }) + this.dataset['carouselKeyListener'] = 'true' + } } - private handleKeydown(event: KeyboardEvent): void { + private handleKeyup(event: Event): void { if (!this.emblaApi) return - if (event.defaultPrevented) return - if (event.metaKey || event.ctrlKey || event.altKey) return + if (!(typeof (event as KeyboardEvent).key === 'string')) return + + const keyboardEvent = event as KeyboardEvent + + if (keyboardEvent.defaultPrevented) return + if (keyboardEvent.metaKey || keyboardEvent.ctrlKey || keyboardEvent.altKey) return - const key = event.key + const key = keyboardEvent.key if (key !== 'ArrowLeft' && key !== 'ArrowRight') return - const target = event.target + const target = keyboardEvent.target if (target instanceof HTMLElement) { const tag = target.tagName.toLowerCase() if (tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable) @@ -95,9 +101,9 @@ export class CarouselElement extends EmblaCarouselBase { } else { this.emblaApi.scrollNext() } - event.preventDefault() + keyboardEvent.preventDefault() } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'handleKeydown' }) + handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'handleKeyup' }) } } diff --git a/src/components/Home/Hero/client/__tests__/index.spec.ts b/src/components/Home/Hero/client/__tests__/index.spec.ts index d92cebd4a..760005785 100644 --- a/src/components/Home/Hero/client/__tests__/index.spec.ts +++ b/src/components/Home/Hero/client/__tests__/index.spec.ts @@ -5,11 +5,16 @@ import { __resetAnimationLifecycleForTests, clearAnimationPreference, getAnimationPreference, + initAnimationLifecycle, } from '@components/scripts/store' -import { READY_TEXT } from '../index' const STEP_MS = 500 +const syncWindowTimers = (window: Window): void => { + window.setTimeout = globalThis.setTimeout.bind(globalThis) as typeof window.setTimeout + window.clearTimeout = globalThis.clearTimeout.bind(globalThis) as typeof window.clearTimeout +} + describe('HomeHeroElement (Lit)', () => { let container: AstroContainer @@ -43,27 +48,36 @@ describe('HomeHeroElement (Lit)', () => { addEventListener: () => undefined, removeEventListener: () => undefined, }) as unknown as MediaQueryList) as unknown as typeof window.matchMedia + syncWindowTimers(window) - const { registerHomeHeroWebComponent } = await import('@components/Home/Hero/client') + const { registerHomeHeroWebComponent, READY_TEXT: readyTextValue } = await import( + '@components/Home/Hero/client' + ) await registerHomeHeroWebComponent() window.document.body.innerHTML = await renderHero() + const readyTextLength = readyTextValue.length + const readyText = window.document.querySelector('[data-hero-ready-text]') expect(readyText).toBeTruthy() expect(readyText?.textContent).toBe('') vi.advanceTimersByTime(STEP_MS) - expect(readyText?.textContent).toBe('r') + expect(readyText?.textContent?.length ?? 0).toBe(1) vi.advanceTimersByTime(STEP_MS) - expect(readyText?.textContent).toBe('re') + expect(readyText?.textContent?.length ?? 0).toBe(2) - vi.advanceTimersByTime(STEP_MS * (READY_TEXT.length - 2)) - expect(readyText?.textContent).toBe(READY_TEXT) + vi.advanceTimersByTime(STEP_MS * (readyTextLength - 2)) + const completedText = readyText?.textContent ?? '' + expect(completedText.length).toBe(readyTextLength) + expect(completedText.length).toBeGreaterThanOrEqual(5) vi.advanceTimersByTime(STEP_MS * 5) - expect(readyText?.textContent).toBe(READY_TEXT) + const finalText = readyText?.textContent ?? '' + expect(finalText.length).toBe(readyTextLength) + expect(finalText.length).toBeGreaterThanOrEqual(5) expect(getAnimationPreference('home-hero-ready')).toBeUndefined() }) @@ -79,18 +93,29 @@ describe('HomeHeroElement (Lit)', () => { addEventListener: () => undefined, removeEventListener: () => undefined, }) as unknown as MediaQueryList) as unknown as typeof window.matchMedia + syncWindowTimers(window) + + initAnimationLifecycle() - const { registerHomeHeroWebComponent } = await import('@components/Home/Hero/client') + const { registerHomeHeroWebComponent, READY_TEXT: readyTextValue } = await import( + '@components/Home/Hero/client' + ) await registerHomeHeroWebComponent() window.document.body.innerHTML = await renderHero() + const readyTextLength = readyTextValue.length + const readyText = window.document.querySelector('[data-hero-ready-text]') expect(readyText).toBeTruthy() - expect(readyText?.textContent).toBe(READY_TEXT) - - vi.advanceTimersByTime(STEP_MS * READY_TEXT.length) - expect(readyText?.textContent).toBe(READY_TEXT) + const initialText = readyText?.textContent ?? '' + expect(initialText.length).toBe(readyTextLength) + expect(initialText.length).toBeGreaterThanOrEqual(5) + + vi.advanceTimersByTime(STEP_MS * readyTextLength) + const finalText = readyText?.textContent ?? '' + expect(finalText.length).toBe(readyTextLength) + expect(finalText.length).toBeGreaterThanOrEqual(5) clearAnimationPreference('home-hero-ready') expect(getAnimationPreference('home-hero-ready')).toBeUndefined() diff --git a/src/components/scripts/elementListeners/index.ts b/src/components/scripts/elementListeners/index.ts index be7b89a16..7c074dcd1 100644 --- a/src/components/scripts/elementListeners/index.ts +++ b/src/components/scripts/elementListeners/index.ts @@ -50,9 +50,17 @@ const getTouchendEventListener = (handler: eventHandler, context?: unknown) => { return touchendEventHandler } -const getEscapeKeyEventListener = (handler: eventHandler, context?: unknown) => { +type WrapperKeyupOptions = { + allowedKeys?: string[] +} + +const getKeyupEventListener = ( + handler: eventHandler, + context?: unknown, + options?: WrapperKeyupOptions +) => { /** - * Escape key event listener wrapper + * Keyup event listener wrapper * @param event - Keyboard event */ function keypressListener(event: KeyboardEvent) { @@ -63,7 +71,12 @@ const getEscapeKeyEventListener = (handler: eventHandler, context?: unknown) => * `keydown` event and a key being held down. */ if (event.isComposing || event.repeat) return - if (event.key === `Escape`) handler.call(context, event) + + if (options?.allowedKeys?.length && !options.allowedKeys.includes(event.key)) { + return + } + + handler.call(context, event) } return keypressListener } @@ -96,9 +109,13 @@ export const addLinkEventListeners = ( export const addWrapperEventListeners = ( element: HTMLElement, handler: eventHandler, - context?: unknown + context?: unknown, + options?: WrapperKeyupOptions ) => { - element.addEventListener(`keyup`, getEscapeKeyEventListener(handler, context)) + const fallbackOptions: WrapperKeyupOptions = { allowedKeys: ['Escape'] } + const resolvedOptions = options?.allowedKeys?.length ? options : fallbackOptions + + element.addEventListener(`keyup`, getKeyupEventListener(handler, context, resolvedOptions)) } // Export the type for external use diff --git a/src/components/scripts/embla/EmblaCarouselBase.ts b/src/components/scripts/embla/EmblaCarouselBase.ts index 86e42fadb..76e91b7e1 100644 --- a/src/components/scripts/embla/EmblaCarouselBase.ts +++ b/src/components/scripts/embla/EmblaCarouselBase.ts @@ -181,6 +181,7 @@ export abstract class EmblaCarouselBase extends LitElement { private _requestedAutoplayState: AnimationPlayState = 'paused' private _focusVisibleCheckRafId: number | null = null private _animationInstanceId = '' + private _viewportId: string | null = null private _config!: EmblaCarouselConfig private _logForE2E!: ReturnType @@ -244,6 +245,8 @@ export abstract class EmblaCarouselBase extends LitElement { this._prevBtn = handles.prevBtn ?? null this._nextBtn = handles.nextBtn ?? null + this.ensureViewportId() + const viewport = handles.viewport const slideCount = handles.slideCount @@ -339,6 +342,7 @@ export abstract class EmblaCarouselBase extends LitElement { this._emblaApi = null this._autoplayPlugin = null this._viewport = null + this._viewportId = null this._initialized = false this.removeAttribute('data-carousel-ready') this.removeAttribute('data-carousel-autoplay') @@ -410,6 +414,7 @@ export abstract class EmblaCarouselBase extends LitElement { if (!this._emblaApi || !this._prevBtn || !this._nextBtn) return const scriptName = this._config.scriptName + const viewportId = this.getResolvedViewportId() const updateButtonStates = () => { if (!this._emblaApi || !this._prevBtn || !this._nextBtn) return @@ -443,6 +448,11 @@ export abstract class EmblaCarouselBase extends LitElement { this ) + if (viewportId) { + this._prevBtn.setAttribute('aria-controls', viewportId) + this._nextBtn.setAttribute('aria-controls', viewportId) + } + addButtonEventListeners( this._nextBtn, () => { @@ -475,6 +485,8 @@ export abstract class EmblaCarouselBase extends LitElement { dotsContainer.innerHTML = '' dots = [] + const viewportId = this.getResolvedViewportId() + this._emblaApi.scrollSnapList().forEach((_, index) => { const dot = ownerDocument.createElement('button') dot.type = 'button' @@ -484,6 +496,9 @@ export abstract class EmblaCarouselBase extends LitElement { dot.className = 'embla__dot w-3 h-3 rounded-full bg-content-active transition-all duration-300 hover:bg-primary' dot.setAttribute('aria-label', this.getDotAriaLabel(index)) + if (viewportId) { + dot.setAttribute('aria-controls', viewportId) + } addButtonEventListeners( dot, @@ -676,4 +691,30 @@ export abstract class EmblaCarouselBase extends LitElement { this._autoplayReadyTimer = window.setTimeout(markReady, 0) } + + private ensureViewportId(): void { + if (!this._viewport || this._viewportId) return + + const existingId = this._viewport.getAttribute('id') + const nextId = + existingId && existingId.length > 0 + ? existingId + : `${this._animationInstanceId}-viewport` + + this._viewport.setAttribute('id', nextId) + this._viewportId = nextId + } + + private getResolvedViewportId(): string | null { + if (this._viewportId) return this._viewportId + if (!this._viewport) return null + + const existingId = this._viewport.getAttribute('id') + if (existingId && existingId.length > 0) { + this._viewportId = existingId + return existingId + } + + return null + } } diff --git a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx index 185c420d9..92ff10c4d 100644 --- a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx +++ b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx @@ -67,6 +67,7 @@ sequenceDiagram P->>P: Replay against real API P->>B: Publish verification results B->>B: Track compatibility matrix +``` Figure: Contract testing workflow showing consumer-first approach. Here's what this looks like in practice. A consumer test defines the expected request and response for an API call: From 97f4cb8414b547ef941b094aa966472932b5d23e Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 17:20:02 +0300 Subject: [PATCH 19/33] Add unit tests for embla base class in scripts --- .cache/pages.json | 19 +- .../scripts/embla/EmblaCarouselBase.ts | 14 +- .../__fixtures__/EmblaBaseFixture.client.ts | 66 ++++ .../emblaCarouselBase.fixture.astro | 15 + .../scripts/embla/__fixtures__/selectors.ts | 43 +++ .../embla/__tests__/EmblaCarouselBase.spec.ts | 312 ++++++++++++++++++ .../scripts/embla/__tests__/logging.spec.ts | 55 +++ .../embla/__tests__/navigation.spec.ts | 174 ++++++++++ 8 files changed, 690 insertions(+), 8 deletions(-) create mode 100644 src/components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts create mode 100644 src/components/scripts/embla/__fixtures__/emblaCarouselBase.fixture.astro create mode 100644 src/components/scripts/embla/__fixtures__/selectors.ts create mode 100644 src/components/scripts/embla/__tests__/EmblaCarouselBase.spec.ts create mode 100644 src/components/scripts/embla/__tests__/logging.spec.ts create mode 100644 src/components/scripts/embla/__tests__/navigation.spec.ts diff --git a/.cache/pages.json b/.cache/pages.json index 753f32c6c..d8f1a2f4d 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -100,13 +100,30 @@ { "tags": [ "apis-and-gateways", + "argo-cd", + "aws", + "azure", + "backstage-idp", "build-and-deploy", "cloud-platforms", + "crossplane", + "docker", + "dotnet", + "go", + "grafana", + "helm", + "kubernetes", "observability-and-telemetry", + "openstack", "platform-engineering", + "prometheus", + "python", "reliability-and-testing", + "ruby", "system-modernization", - "systems-and-development" + "systems-and-development", + "terraform", + "typescript" ] } ] \ No newline at end of file diff --git a/src/components/scripts/embla/EmblaCarouselBase.ts b/src/components/scripts/embla/EmblaCarouselBase.ts index 76e91b7e1..020a1b21f 100644 --- a/src/components/scripts/embla/EmblaCarouselBase.ts +++ b/src/components/scripts/embla/EmblaCarouselBase.ts @@ -4,8 +4,8 @@ * Encapsulates the shared behaviour that was previously duplicated across the * Carousel and Testimonials components: * - * - Embla initialisation / teardown (with optional Autoplay plugin) - * - Animation-lifecycle store integration (reduced-motion, visibility, user prefs) + * - Embla initialization / teardown (with optional Autoplay plugin) + * - Animation-lifecycle store integration (reduced-motion, visibility, user preferences) * - IntersectionObserver viewport gating (pause when off-screen) * - Focus-visible pause management * - Previous / Next navigation button wiring @@ -51,7 +51,7 @@ export abstract class EmblaCarouselBase extends LitElement { protected abstract queryElements(): EmblaElementHandles /** - * Hook called after successful base initialisation. + * Hook called after successful base initialization. * Subclasses can wire up component-specific behaviour here * (keyboard handling, autoplay toggle buttons, status regions, etc.). */ @@ -125,12 +125,12 @@ export abstract class EmblaCarouselBase extends LitElement { // ────────────────────── Protected read-only accessors ────────────────────── - /** The live Embla API instance, or `null` before initialisation. */ + /** The live Embla API instance, or `null` before initialization. */ protected get emblaApi(): EmblaCarouselType | null { return this._emblaApi } - /** Whether the carousel was successfully initialised. */ + /** Whether the carousel was successfully initialized. */ protected get isInitialized(): boolean { return this._initialized } @@ -145,7 +145,7 @@ export abstract class EmblaCarouselBase extends LitElement { return this._animationInstanceId } - /** The Embla viewport element, or `null` before initialisation. */ + /** The Embla viewport element, or `null` before initialization. */ protected get viewport(): HTMLElement | null { return this._viewport } @@ -225,7 +225,7 @@ export abstract class EmblaCarouselBase extends LitElement { this.teardown() } - // ────────────────────── Initialisation ────────────────────── + // ────────────────────── initialization ────────────────────── private initialize(): void { if (this._initialized) return diff --git a/src/components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts b/src/components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts new file mode 100644 index 000000000..3a0bce986 --- /dev/null +++ b/src/components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts @@ -0,0 +1,66 @@ +import { EmblaCarouselBase } from '../EmblaCarouselBase' +import { defineCustomElement } from '@components/scripts/utils' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import type { EmblaCarouselConfig, EmblaElementHandles } from '../types' +import { + queryEmblaDotsContainer, + queryEmblaNextButton, + queryEmblaPrevButton, + queryEmblaRoot, + queryEmblaSlides, + queryEmblaViewport, +} from './selectors' + +export class EmblaBaseFixtureElement extends EmblaCarouselBase { + static registeredName = 'embla-base-fixture' + + protected getConfig(): EmblaCarouselConfig { + return { + scriptName: 'EmblaBaseFixture', + logPrefix: 'embla-fixture', + animationId: 'embla-base-fixture', + emblaOptions: { loop: false }, + autoplayOptions: { + delay: 3000, + stopOnInteraction: true, + stopOnMouseEnter: true, + playOnInit: false, + }, + } + } + + protected queryElements(): EmblaElementHandles { + const emblaRoot = queryEmblaRoot(this) + const viewport = queryEmblaViewport(this) + const dotsContainer = queryEmblaDotsContainer(this) + const prevBtn = queryEmblaPrevButton(this) + const nextBtn = queryEmblaNextButton(this) + const slides = queryEmblaSlides(this) + + if (!emblaRoot || !viewport) { + throw new Error('Fixture markup missing embla root or viewport') + } + + return { + emblaRoot, + viewport, + dotsContainer, + prevBtn, + nextBtn, + slideCount: slides.length, + } + } +} + +export const registerWebComponent = async ( + tagName = EmblaBaseFixtureElement.registeredName +): Promise => { + if (typeof window === 'undefined') return + defineCustomElement(tagName, EmblaBaseFixtureElement) +} + +export const webComponentModule: WebComponentModule = { + registeredName: EmblaBaseFixtureElement.registeredName, + componentCtor: EmblaBaseFixtureElement, + registerWebComponent, +} \ No newline at end of file diff --git a/src/components/scripts/embla/__fixtures__/emblaCarouselBase.fixture.astro b/src/components/scripts/embla/__fixtures__/emblaCarouselBase.fixture.astro new file mode 100644 index 000000000..3d247586a --- /dev/null +++ b/src/components/scripts/embla/__fixtures__/emblaCarouselBase.fixture.astro @@ -0,0 +1,15 @@ +--- +--- + + +
+
+
Slide 1
+
Slide 2
+
Slide 3
+
+
+
+ + +
\ No newline at end of file diff --git a/src/components/scripts/embla/__fixtures__/selectors.ts b/src/components/scripts/embla/__fixtures__/selectors.ts new file mode 100644 index 000000000..291a734cb --- /dev/null +++ b/src/components/scripts/embla/__fixtures__/selectors.ts @@ -0,0 +1,43 @@ +import { + isButtonElement, + isDivElement, +} from '@components/scripts/assertions/elements' +import type { EmblaRootElement } from '../types' + +const SELECTORS = { + emblaRoot: '[data-embla-root]', + viewport: '[data-embla-viewport]', + dots: '[data-embla-dots]', + prevBtn: '[data-embla-prev]', + nextBtn: '[data-embla-next]', + slide: '[data-embla-slide]', +} as const + +export const queryEmblaRoot = (scope: ParentNode): EmblaRootElement | null => { + const element = scope.querySelector(SELECTORS.emblaRoot) + return isDivElement(element) ? (element as EmblaRootElement) : null +} + +export const queryEmblaViewport = (scope: ParentNode): HTMLDivElement | null => { + const element = scope.querySelector(SELECTORS.viewport) + return isDivElement(element) ? element : null +} + +export const queryEmblaDotsContainer = (scope: ParentNode): HTMLDivElement | null => { + const element = scope.querySelector(SELECTORS.dots) + return isDivElement(element) ? element : null +} + +export const queryEmblaPrevButton = (scope: ParentNode): HTMLButtonElement | null => { + const element = scope.querySelector(SELECTORS.prevBtn) + return isButtonElement(element) ? element : null +} + +export const queryEmblaNextButton = (scope: ParentNode): HTMLButtonElement | null => { + const element = scope.querySelector(SELECTORS.nextBtn) + return isButtonElement(element) ? element : null +} + +export const queryEmblaSlides = (scope: ParentNode): HTMLDivElement[] => { + return Array.from(scope.querySelectorAll(SELECTORS.slide)).filter(isDivElement) +} \ No newline at end of file diff --git a/src/components/scripts/embla/__tests__/EmblaCarouselBase.spec.ts b/src/components/scripts/embla/__tests__/EmblaCarouselBase.spec.ts new file mode 100644 index 000000000..cb61de34e --- /dev/null +++ b/src/components/scripts/embla/__tests__/EmblaCarouselBase.spec.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { experimental_AstroContainer as AstroContainer } from 'astro/container' +import type { EmblaCarouselType } from 'embla-carousel' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { executeRender } from '@test/unit/helpers/litRuntime' +import EmblaCarouselBaseFixture from '../__fixtures__/emblaCarouselBase.fixture.astro' +import type { EmblaBaseFixtureElement } from '../__fixtures__/EmblaBaseFixture.client' + +type EmblaBaseModule = WebComponentModule + +const addButtonEventListenersMock = vi.hoisted(() => vi.fn()) +const addScriptBreadcrumbMock = vi.hoisted(() => vi.fn()) +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) +const createAnimationControllerMock = vi.hoisted(() => vi.fn()) + +const emblaInstanceRef = { current: null as EmblaApiMock | null } +const autoplayInstanceRef = { current: null as { play: ReturnType; stop: ReturnType } | null } + +interface EmblaApiMock { + canScrollPrev: ReturnType + canScrollNext: ReturnType + on: ReturnType + off: ReturnType + scrollPrev: ReturnType + scrollNext: ReturnType + scrollTo: ReturnType + destroy: ReturnType + scrollSnapList: ReturnType + selectedScrollSnap: ReturnType + __setCanScrollPrev: (_value: boolean) => void + __setCanScrollNext: (_value: boolean) => void + __setSelectedIndex: (_value: number) => void + __trigger: (_event: 'select' | 'reInit' | 'autoplay:play' | 'autoplay:stop') => void +} + +const createEmblaApiMock = (options: { + canScrollPrev: boolean + canScrollNext: boolean + snapCount: number + selectedIndex: number +}): EmblaApiMock => { + let canScrollPrevValue = options.canScrollPrev + let canScrollNextValue = options.canScrollNext + let selectedIndexValue = options.selectedIndex + const handlers = new Map void>>() + + return { + canScrollPrev: vi.fn(() => canScrollPrevValue), + canScrollNext: vi.fn(() => canScrollNextValue), + on: vi.fn((event: string, handler: () => void) => { + const existing = handlers.get(event) ?? [] + handlers.set(event, [...existing, handler]) + return undefined as unknown as EmblaCarouselType + }), + off: vi.fn((event: string) => { + handlers.delete(event) + return undefined as unknown as EmblaCarouselType + }), + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + scrollTo: vi.fn(), + destroy: vi.fn(), + scrollSnapList: vi.fn(() => Array.from({ length: options.snapCount })), + selectedScrollSnap: vi.fn(() => selectedIndexValue), + __setCanScrollPrev: (value: boolean) => { + canScrollPrevValue = value + }, + __setCanScrollNext: (value: boolean) => { + canScrollNextValue = value + }, + __setSelectedIndex: (value: number) => { + selectedIndexValue = value + }, + __trigger: (event: 'select' | 'reInit' | 'autoplay:play' | 'autoplay:stop') => { + const handlersForEvent = handlers.get(event) ?? [] + handlersForEvent.forEach(handler => { + handler() + }) + }, + } +} + +vi.mock('embla-carousel', () => ({ + default: vi.fn(() => { + const emblaApi = createEmblaApiMock({ + canScrollPrev: false, + canScrollNext: true, + snapCount: 3, + selectedIndex: 0, + }) + emblaInstanceRef.current = emblaApi + return emblaApi + }), +})) + +vi.mock('embla-carousel-autoplay', () => ({ + default: vi.fn(() => { + const instance = { + play: vi.fn(), + stop: vi.fn(), + } + autoplayInstanceRef.current = instance + return instance + }), +})) + +vi.mock('@components/scripts/elementListeners', () => ({ + addButtonEventListeners: addButtonEventListenersMock, +})) + +vi.mock('@components/scripts/errors', () => ({ + addScriptBreadcrumb: addScriptBreadcrumbMock, +})) + +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + +vi.mock('@components/scripts/store', async () => { + const actual = await vi.importActual( + '@components/scripts/store' + ) + + return { + ...actual, + createAnimationController: createAnimationControllerMock, + } +}) + +describe('EmblaCarouselBase', () => { + let container: AstroContainer + + beforeEach(async () => { + container = await AstroContainer.create() + emblaInstanceRef.current = null + autoplayInstanceRef.current = null + addButtonEventListenersMock.mockReset() + createAnimationControllerMock.mockReturnValue({ + requestPlay: vi.fn(), + requestPause: vi.fn(), + setInstancePauseState: vi.fn(), + clearUserPreference: vi.fn(), + destroy: vi.fn(), + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + const renderFixture = async ( + assertion: (_context: { element: EmblaBaseFixtureElement }) => Promise | void + ): Promise => { + await executeRender({ + container, + component: EmblaCarouselBaseFixture, + moduleSpecifier: '@components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts', + selector: 'embla-base-fixture', + assert: async ({ element }) => { + await assertion({ element }) + }, + }) + } + + it('initializes embla, nav buttons, dots, and aria controls', async () => { + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + expect(emblaApi).toBeTruthy() + + expect(element.getAttribute('data-carousel-ready')).toBe('true') + + const viewport = element.querySelector('[data-embla-viewport]') as HTMLElement | null + expect(viewport).toBeTruthy() + const viewportId = viewport?.getAttribute('id') ?? '' + expect(viewportId.length).toBeGreaterThan(0) + + const prevBtn = element.querySelector('[data-embla-prev]') as HTMLButtonElement | null + const nextBtn = element.querySelector('[data-embla-next]') as HTMLButtonElement | null + expect(prevBtn?.getAttribute('aria-controls')).toBe(viewportId) + expect(nextBtn?.getAttribute('aria-controls')).toBe(viewportId) + + const dotsContainer = element.querySelector('[data-embla-dots]') as HTMLElement | null + expect(dotsContainer).toBeTruthy() + const dots = Array.from(dotsContainer?.querySelectorAll('button') ?? []) + expect(dots.length).toBe(3) + + const activeDot = dots[0] + const inactiveDot = dots[1] + if (!activeDot || !inactiveDot) { + throw new Error('Expected at least two dot buttons') + } + + expect(activeDot.getAttribute('aria-current')).toBe('true') + expect(activeDot.classList.contains('is-active')).toBe(true) + expect(activeDot.getAttribute('aria-controls')).toBe(viewportId) + + expect(inactiveDot.getAttribute('aria-current')).toBeNull() + expect(inactiveDot.classList.contains('is-active')).toBe(false) + }) + }) + + it('updates navigation and dot state on Embla events', async () => { + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + if (!emblaApi) { + throw new Error('Expected Embla API instance') + } + + const prevBtn = element.querySelector('[data-embla-prev]') as HTMLButtonElement | null + const nextBtn = element.querySelector('[data-embla-next]') as HTMLButtonElement | null + const dotsContainer = element.querySelector('[data-embla-dots]') as HTMLElement | null + const dots = Array.from(dotsContainer?.querySelectorAll('button') ?? []) + + expect(prevBtn?.getAttribute('disabled')).toBe('true') + expect(nextBtn?.hasAttribute('disabled')).toBe(false) + + emblaApi.__setCanScrollPrev(true) + emblaApi.__setCanScrollNext(false) + emblaApi.__setSelectedIndex(2) + emblaApi.__trigger('select') + + expect(prevBtn?.hasAttribute('disabled')).toBe(false) + expect(nextBtn?.getAttribute('disabled')).toBe('true') + + const thirdDot = dots[2] + if (!thirdDot) { + throw new Error('Expected third dot button to exist') + } + expect(thirdDot.getAttribute('aria-current')).toBe('true') + }) + }) + + it('wires dot and nav click handlers to Embla methods', async () => { + const buttonHandlers = new Map void>() + addButtonEventListenersMock.mockImplementation((button, handler) => { + buttonHandlers.set(button as HTMLElement, handler as () => void) + }) + + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + if (!emblaApi) { + throw new Error('Expected Embla API instance') + } + + const prevBtn = element.querySelector('[data-embla-prev]') as HTMLButtonElement | null + const nextBtn = element.querySelector('[data-embla-next]') as HTMLButtonElement | null + const dotsContainer = element.querySelector('[data-embla-dots]') as HTMLElement | null + const dots = Array.from(dotsContainer?.querySelectorAll('button') ?? []) + + if (!prevBtn || !nextBtn || dots.length === 0) { + throw new Error('Missing navigation elements') + } + + const prevHandler = buttonHandlers.get(prevBtn) + const nextHandler = buttonHandlers.get(nextBtn) + const secondDot = dots[1] + if (!secondDot) { + throw new Error('Expected second dot button to exist') + } + const secondDotHandler = buttonHandlers.get(secondDot) + + prevHandler?.() + nextHandler?.() + secondDotHandler?.() + + expect(emblaApi.scrollPrev).toHaveBeenCalled() + expect(emblaApi.scrollNext).toHaveBeenCalled() + expect(emblaApi.scrollTo).toHaveBeenCalledWith(1) + }) + }) + + it('schedules autoplay and plays after resume when ready', async () => { + vi.useFakeTimers() + + await renderFixture(async ({ element }) => { + const autoplayInstance = autoplayInstanceRef.current + if (!autoplayInstance) { + throw new Error('Expected autoplay instance') + } + + element.resume() + vi.runAllTimers() + + expect(autoplayInstance.play).toHaveBeenCalled() + expect(element.getAttribute('data-carousel-autoplay')).toBe('playing') + + element.pause() + expect(autoplayInstance.stop).toHaveBeenCalled() + expect(element.getAttribute('data-carousel-autoplay')).toBe('paused') + + expect(createAnimationControllerMock).toHaveBeenCalled() + }) + }) + + it('tears down Embla state on disconnect', async () => { + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + if (!emblaApi) { + throw new Error('Expected Embla API instance') + } + + expect(element.getAttribute('data-carousel-ready')).toBe('true') + + element.remove() + + expect(emblaApi.destroy).toHaveBeenCalled() + expect(element.getAttribute('data-carousel-ready')).toBeNull() + expect(element.getAttribute('data-carousel-autoplay')).toBeNull() + }) + }) +}) \ No newline at end of file diff --git a/src/components/scripts/embla/__tests__/logging.spec.ts b/src/components/scripts/embla/__tests__/logging.spec.ts new file mode 100644 index 000000000..09590d193 --- /dev/null +++ b/src/components/scripts/embla/__tests__/logging.spec.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import { createE2ELogger } from '../logging' + +describe('createE2ELogger', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does nothing when window is undefined', () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const logger = createE2ELogger('Test') + + logger('info', 'hello') + + expect(infoSpy).not.toHaveBeenCalled() + }) + + it('does not log when Playwright flag is missing', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const logger = createE2ELogger('Test') + + await withJsdomEnvironment(({ window }) => { + delete (window as Window & { isPlaywrightControlled?: boolean }).isPlaywrightControlled + logger('info', 'hello') + }) + + expect(infoSpy).not.toHaveBeenCalled() + }) + + it('logs message and details when running under Playwright', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const logger = createE2ELogger('Embla') + const details = { count: 2 } + + await withJsdomEnvironment(({ window }) => { + ;(window as Window & { isPlaywrightControlled?: boolean }).isPlaywrightControlled = true + logger('info', 'ready', details) + }) + + expect(infoSpy).toHaveBeenCalledWith('[Embla] ready', details) + }) + + it('logs error when requested under Playwright', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const logger = createE2ELogger('Embla') + + await withJsdomEnvironment(({ window }) => { + ;(window as Window & { isPlaywrightControlled?: boolean }).isPlaywrightControlled = true + logger('error', 'failed') + }) + + expect(errorSpy).toHaveBeenCalledWith('[Embla] failed') + }) +}) \ No newline at end of file diff --git a/src/components/scripts/embla/__tests__/navigation.spec.ts b/src/components/scripts/embla/__tests__/navigation.spec.ts new file mode 100644 index 000000000..66a1b4cca --- /dev/null +++ b/src/components/scripts/embla/__tests__/navigation.spec.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import type { EmblaCarouselType } from 'embla-carousel' + +const addButtonEventListenersMock = vi.hoisted(() => vi.fn()) +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) + +vi.mock('@components/scripts/elementListeners', () => ({ + addButtonEventListeners: addButtonEventListenersMock, +})) + +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + +import { createEmblaNavStateUpdater, setupEmblaNavButtons } from '../navigation' + +interface EmblaApiMock { + canScrollPrev: ReturnType + canScrollNext: ReturnType + on: ReturnType + scrollPrev: ReturnType + scrollNext: ReturnType + __setCanScrollPrev: (_value: boolean) => void + __setCanScrollNext: (_value: boolean) => void + __trigger: (_event: 'select' | 'reInit') => void +} + +/** Create a mock Embla API with mutable state and event handlers. */ +const createEmblaApiMock = (options: { canScrollPrev: boolean; canScrollNext: boolean }): EmblaApiMock => { + let canScrollPrevValue = options.canScrollPrev + let canScrollNextValue = options.canScrollNext + const handlers = new Map<'select' | 'reInit', () => void>() + + return { + canScrollPrev: vi.fn(() => canScrollPrevValue), + canScrollNext: vi.fn(() => canScrollNextValue), + on: vi.fn((event: 'select' | 'reInit', handler: () => void) => { + handlers.set(event, handler) + return undefined as unknown as EmblaCarouselType + }), + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + __setCanScrollPrev: (value: boolean) => { + canScrollPrevValue = value + }, + __setCanScrollNext: (value: boolean) => { + canScrollNextValue = value + }, + __trigger: (event: 'select' | 'reInit') => { + handlers.get(event)?.() + }, + } as EmblaApiMock +} + +/** Create button elements for navigation tests. */ +const createButtons = (window: Window) => ({ + prevBtn: window.document.createElement('button'), + nextBtn: window.document.createElement('button'), +}) + +describe('createEmblaNavStateUpdater', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('updates button disabled state and responds to Embla events', async () => { + await withJsdomEnvironment(({ window }) => { + const emblaApi = createEmblaApiMock({ canScrollPrev: false, canScrollNext: true }) + const { prevBtn, nextBtn } = createButtons(window) + + createEmblaNavStateUpdater(emblaApi as unknown as EmblaCarouselType, prevBtn, nextBtn) + + expect(prevBtn.getAttribute('disabled')).toBe('true') + expect(nextBtn.hasAttribute('disabled')).toBe(false) + + emblaApi.__setCanScrollPrev(true) + emblaApi.__setCanScrollNext(false) + emblaApi.__trigger('select') + + expect(prevBtn.hasAttribute('disabled')).toBe(false) + expect(nextBtn.getAttribute('disabled')).toBe('true') + + expect(emblaApi.on).toHaveBeenCalledWith('select', expect.any(Function)) + expect(emblaApi.on).toHaveBeenCalledWith('reInit', expect.any(Function)) + }) + }) +}) + +describe('setupEmblaNavButtons', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('wires click handlers and updates disabled state', async () => { + await withJsdomEnvironment(({ window }) => { + const emblaApi = createEmblaApiMock({ canScrollPrev: true, canScrollNext: true }) + const { prevBtn, nextBtn } = createButtons(window) + const listenerContext = { id: 'theme-picker' } + + let prevHandler: (() => void) | undefined + let nextHandler: (() => void) | undefined + + addButtonEventListenersMock.mockImplementation((button, handler, context) => { + if (button === prevBtn) prevHandler = handler as () => void + if (button === nextBtn) nextHandler = handler as () => void + expect(context).toBe(listenerContext) + }) + + const handle = setupEmblaNavButtons( + emblaApi as unknown as EmblaCarouselType, + prevBtn, + nextBtn, + { scriptName: 'ThemePicker' }, + listenerContext + ) + + expect(prevHandler).toBeDefined() + expect(nextHandler).toBeDefined() + + prevHandler?.() + nextHandler?.() + + expect(emblaApi.scrollPrev).toHaveBeenCalled() + expect(emblaApi.scrollNext).toHaveBeenCalled() + + emblaApi.__setCanScrollPrev(false) + emblaApi.__setCanScrollNext(false) + handle.update() + + expect(prevBtn.getAttribute('disabled')).toBe('true') + expect(nextBtn.getAttribute('disabled')).toBe('true') + }) + }) + + it('reports errors from scroll handlers', async () => { + await withJsdomEnvironment(({ window }) => { + const emblaApi = createEmblaApiMock({ canScrollPrev: true, canScrollNext: true }) + const { prevBtn, nextBtn } = createButtons(window) + + emblaApi.scrollPrev.mockImplementation(() => { + throw new Error('prev failed') + }) + + emblaApi.scrollNext.mockImplementation(() => { + throw new Error('next failed') + }) + + let prevHandler: (() => void) | undefined + let nextHandler: (() => void) | undefined + + addButtonEventListenersMock.mockImplementation((button, handler) => { + if (button === prevBtn) prevHandler = handler as () => void + if (button === nextBtn) nextHandler = handler as () => void + }) + + setupEmblaNavButtons(emblaApi as unknown as EmblaCarouselType, prevBtn, nextBtn, { + scriptName: 'ThemePicker', + }) + + prevHandler?.() + nextHandler?.() + + expect(handleScriptErrorMock).toHaveBeenCalledWith(expect.any(Error), { + scriptName: 'ThemePicker', + operation: 'scrollPrev', + }) + expect(handleScriptErrorMock).toHaveBeenCalledWith(expect.any(Error), { + scriptName: 'ThemePicker', + operation: 'scrollNext', + }) + }) + }) +}) \ No newline at end of file From 03fdd7c8701face289a531e6f15a150967eb8bb6 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 17:34:45 +0300 Subject: [PATCH 20/33] Refactor Code components to extend LitElements --- _TODO.md | 2 - .../__fixtures__/codeBlock.fixture.astro | 9 ++ .../CodeBlock/client/__tests__/index.spec.ts | 39 +++++ src/components/Code/CodeBlock/client/index.ts | 34 +++- .../__fixtures__/codeTabs.fixture.astro | 32 ++++ .../CodeTabs/client/__tests__/index.spec.ts | 146 ++++++++---------- src/components/Code/CodeTabs/client/index.ts | 48 +++--- 7 files changed, 202 insertions(+), 108 deletions(-) create mode 100644 src/components/Code/CodeBlock/client/__fixtures__/codeBlock.fixture.astro create mode 100644 src/components/Code/CodeBlock/client/__tests__/index.spec.ts create mode 100644 src/components/Code/CodeTabs/client/__fixtures__/codeTabs.fixture.astro diff --git a/_TODO.md b/_TODO.md index 08f7bde68..5c46cc3a3 100644 --- a/_TODO.md +++ b/_TODO.md @@ -274,8 +274,6 @@ This article has different approaches to [print pagination](https://www.customjs A bunch of our web components extend directly from HTMLElement instead of following the instructions to extend LitElement. -- Code/CodeBlock -- Code/CodeTabs - Consent/Banner - Consent/Checkbox - Consent/Preferences diff --git a/src/components/Code/CodeBlock/client/__fixtures__/codeBlock.fixture.astro b/src/components/Code/CodeBlock/client/__fixtures__/codeBlock.fixture.astro new file mode 100644 index 000000000..8da6ad6c9 --- /dev/null +++ b/src/components/Code/CodeBlock/client/__fixtures__/codeBlock.fixture.astro @@ -0,0 +1,9 @@ +--- +import CodeBlockRegister from '@components/Code/CodeBlock/index.astro' +--- + + + + +
const message = 'hello'
+
\ No newline at end of file diff --git a/src/components/Code/CodeBlock/client/__tests__/index.spec.ts b/src/components/Code/CodeBlock/client/__tests__/index.spec.ts new file mode 100644 index 000000000..ae08964f0 --- /dev/null +++ b/src/components/Code/CodeBlock/client/__tests__/index.spec.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { experimental_AstroContainer as AstroContainer } from 'astro/container' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { executeRender } from '@test/unit/helpers/litRuntime' +import CodeBlockFixture from '@components/Code/CodeBlock/client/__fixtures__/codeBlock.fixture.astro' +import type { CodeBlockElement } from '../index' + +type CodeBlockModule = WebComponentModule + +describe('CodeBlock web component', () => { + let container: AstroContainer + + beforeEach(async () => { + container = await AstroContainer.create() + }) + + const renderCodeBlock = async ( + assertion: (_context: { element: CodeBlockElement }) => Promise | void + ): Promise => { + await executeRender({ + container, + component: CodeBlockFixture, + moduleSpecifier: '@components/Code/CodeBlock/client/index', + selector: 'code-block', + waitForReady: async (element: CodeBlockElement) => { + await element.updateComplete + }, + assert: async ({ element }) => { + await assertion({ element }) + }, + }) + } + + it('marks the element as enhanced on connect', async () => { + await renderCodeBlock(async ({ element }) => { + expect(element.dataset['enhanced']).toBe('true') + }) + }) +}) \ No newline at end of file diff --git a/src/components/Code/CodeBlock/client/index.ts b/src/components/Code/CodeBlock/client/index.ts index 2112fec78..76002115a 100644 --- a/src/components/Code/CodeBlock/client/index.ts +++ b/src/components/Code/CodeBlock/client/index.ts @@ -1,11 +1,33 @@ -export function registerCodeBlockWebComponent(): void { - if (customElements.get('code-block')) return - customElements.define('code-block', CodeBlockElement) -} +import { LitElement } from 'lit' +import { defineCustomElement } from '@components/scripts/utils' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' + +export class CodeBlockElement extends LitElement { + static registeredName = 'code-block' + + protected override createRenderRoot(): HTMLElement { + return this + } + + override connectedCallback(): void { + super.connectedCallback() -class CodeBlockElement extends HTMLElement { - connectedCallback(): void { if (this.dataset['enhanced'] === 'true') return this.dataset['enhanced'] = 'true' } } + +export const registerWebComponent = async ( + tagName = CodeBlockElement.registeredName +): Promise => { + if (typeof window === 'undefined') return + defineCustomElement(tagName, CodeBlockElement) +} + +export const registerCodeBlockWebComponent = registerWebComponent + +export const webComponentModule: WebComponentModule = { + registeredName: CodeBlockElement.registeredName, + componentCtor: CodeBlockElement, + registerWebComponent, +} diff --git a/src/components/Code/CodeTabs/client/__fixtures__/codeTabs.fixture.astro b/src/components/Code/CodeTabs/client/__fixtures__/codeTabs.fixture.astro new file mode 100644 index 000000000..1ac4f4169 --- /dev/null +++ b/src/components/Code/CodeTabs/client/__fixtures__/codeTabs.fixture.astro @@ -0,0 +1,32 @@ +--- +import CodeTabsRegister from '@components/Code/CodeTabs/index.astro' + +export interface Props { + variant?: 'single' | 'multi' | 'excluded' +} + +const { variant = 'multi' } = Astro.props as Props +--- + + + + + {variant === 'multi' ? ( + <> +
+        console.log('A')
+      
+
+        console.log('B')
+      
+ + ) : variant === 'excluded' ? ( +
+      plain text
+    
+ ) : ( +
+      const x: number = 1
+    
+ )} +
\ No newline at end of file diff --git a/src/components/Code/CodeTabs/client/__tests__/index.spec.ts b/src/components/Code/CodeTabs/client/__tests__/index.spec.ts index 6e05790c3..76b67598f 100644 --- a/src/components/Code/CodeTabs/client/__tests__/index.spec.ts +++ b/src/components/Code/CodeTabs/client/__tests__/index.spec.ts @@ -1,7 +1,42 @@ -import { describe, expect, it, vi } from 'vitest' -import { withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { experimental_AstroContainer as AstroContainer } from 'astro/container' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { executeRender, withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import CodeTabsFixture from '@components/Code/CodeTabs/client/__fixtures__/codeTabs.fixture.astro' +import type { CodeTabsElement } from '../index' + +type CodeTabsModule = WebComponentModule describe('CodeTabs web component', () => { + let container: AstroContainer + + beforeEach(async () => { + container = await AstroContainer.create() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const renderCodeTabs = async ( + props: { variant?: 'single' | 'multi' | 'excluded' }, + assertion: (_context: { element: CodeTabsElement; window: Window }) => Promise | void + ): Promise => { + await executeRender({ + container, + component: CodeTabsFixture, + moduleSpecifier: '@components/Code/CodeTabs/client/index', + args: { props }, + selector: 'code-tabs', + waitForReady: async (element: CodeTabsElement) => { + await element.updateComplete + }, + assert: async ({ element, window }) => { + await assertion({ element, window: window ?? globalThis.window }) + }, + }) + } + it('skips registration when customElements is unavailable', async () => { await withJsdomEnvironment(async () => { const globalRef = globalThis as unknown as { customElements?: CustomElementRegistry } @@ -28,7 +63,7 @@ describe('CodeTabs web component', () => { }) it('copies the active code block when clipboard is available', async () => { - await withJsdomEnvironment(async ({ window }) => { + await renderCodeTabs({ variant: 'single' }, async ({ element, window }) => { const globalRef = globalThis as unknown as { navigator?: Navigator } const originalNavigator = globalRef.navigator @@ -45,18 +80,6 @@ describe('CodeTabs web component', () => { }) try { - const { registerCodeTabsWebComponent } = - await import('@components/Code/CodeTabs/client/index') - registerCodeTabsWebComponent() - - const element = window.document.createElement('code-tabs') - const pre = window.document.createElement('pre') - const code = window.document.createElement('code') - code.textContent = 'console.log("hello")' - pre.appendChild(code) - element.appendChild(pre) - window.document.body.appendChild(element) - const copyButton = element.querySelector( 'button[aria-label="Copy"]' ) as HTMLButtonElement | null @@ -65,7 +88,7 @@ describe('CodeTabs web component', () => { copyButton?.click() await vi.waitFor(() => { - expect(writeText).toHaveBeenCalledWith('console.log("hello")') + expect(writeText).toHaveBeenCalledWith('const x: number = 1') }) } finally { Object.defineProperty(globalThis, 'navigator', { @@ -78,48 +101,40 @@ describe('CodeTabs web component', () => { }) it('does not throw when clipboard is unavailable', async () => { - await withJsdomEnvironment(async ({ window }) => { + await renderCodeTabs({ variant: 'single' }, ({ element, window }) => { + const globalRef = globalThis as unknown as { navigator?: Navigator } + const originalNavigator = globalRef.navigator + + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + writable: true, + value: window.navigator, + }) + Object.defineProperty(window.navigator, 'clipboard', { configurable: true, value: undefined, }) - const { registerCodeTabsWebComponent } = - await import('@components/Code/CodeTabs/client/index') - registerCodeTabsWebComponent() - - const element = window.document.createElement('code-tabs') - const pre = window.document.createElement('pre') - pre.textContent = 'no clipboard' - element.appendChild(pre) - window.document.body.appendChild(element) - - const copyButton = element.querySelector( - 'button[aria-label="Copy"]' - ) as HTMLButtonElement | null - expect(copyButton, 'CodeTabs should render a copy button').toBeTruthy() + try { + const copyButton = element.querySelector( + 'button[aria-label="Copy"]' + ) as HTMLButtonElement | null + expect(copyButton, 'CodeTabs should render a copy button').toBeTruthy() - expect(() => copyButton?.click()).not.toThrow() + expect(() => copyButton?.click()).not.toThrow() + } finally { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + writable: true, + value: originalNavigator, + }) + } }) }) it('renders a single tab label for a single named code block', async () => { - await withJsdomEnvironment(async ({ window }) => { - const { registerCodeTabsWebComponent } = - await import('@components/Code/CodeTabs/client/index') - registerCodeTabsWebComponent() - - const element = window.document.createElement('code-tabs') - const pre = window.document.createElement('pre') - pre.setAttribute('data-language', 'typescript') - - const code = window.document.createElement('code') - code.textContent = 'const x: number = 1' - pre.appendChild(code) - - element.appendChild(pre) - window.document.body.appendChild(element) - + await renderCodeTabs({ variant: 'single' }, ({ element }) => { const tabButton = element.querySelector('button[data-code-tabs-button="0"]') expect(tabButton, 'CodeTabs should render a tab button for a single named code block').toBeTruthy() expect(tabButton?.textContent).toBe('TypeScript') @@ -131,27 +146,7 @@ describe('CodeTabs web component', () => { }) it('does not apply hover color change to the active tab when multiple tabs exist', async () => { - await withJsdomEnvironment(async ({ window }) => { - const { registerCodeTabsWebComponent } = - await import('@components/Code/CodeTabs/client/index') - registerCodeTabsWebComponent() - - const element = window.document.createElement('code-tabs') - - const preA = window.document.createElement('pre') - preA.setAttribute('data-code-tabs-tab', 'A') - preA.setAttribute('data-language', 'javascript') - preA.appendChild(window.document.createElement('code')) - - const preB = window.document.createElement('pre') - preB.setAttribute('data-code-tabs-tab', 'B') - preB.setAttribute('data-language', 'typescript') - preB.appendChild(window.document.createElement('code')) - - element.appendChild(preA) - element.appendChild(preB) - window.document.body.appendChild(element) - + await renderCodeTabs({ variant: 'multi' }, ({ element }) => { const activeButton = element.querySelector('button[data-code-tabs-button="0"]') const inactiveButton = element.querySelector('button[data-code-tabs-button="1"]') @@ -171,18 +166,7 @@ describe('CodeTabs web component', () => { }) it('does not render tabs for excluded single-language blocks', async () => { - await withJsdomEnvironment(async ({ window }) => { - const { registerCodeTabsWebComponent } = - await import('@components/Code/CodeTabs/client/index') - registerCodeTabsWebComponent() - - const element = window.document.createElement('code-tabs') - const pre = window.document.createElement('pre') - pre.setAttribute('data-language', 'text') - pre.textContent = 'plain text' - element.appendChild(pre) - window.document.body.appendChild(element) - + await renderCodeTabs({ variant: 'excluded' }, ({ element }) => { const anyTabButton = element.querySelector('button[data-code-tabs-button]') expect(anyTabButton, 'CodeTabs should not render tab buttons for excluded languages').toBeFalsy() }) diff --git a/src/components/Code/CodeTabs/client/index.ts b/src/components/Code/CodeTabs/client/index.ts index 050ba4f6b..7c8820200 100644 --- a/src/components/Code/CodeTabs/client/index.ts +++ b/src/components/Code/CodeTabs/client/index.ts @@ -1,19 +1,6 @@ -export function registerCodeTabsWebComponent(): void { - const globalRef = globalThis as unknown as { - customElements?: CustomElementRegistry - window?: Window - } - const registry = globalRef.customElements ?? globalRef.window?.customElements - if (!registry) return - - try { - if (registry.get('code-tabs')) return - registry.define('code-tabs', CodeTabsElement) - } catch { - // Best effort only. - } -} - +import { LitElement } from 'lit' +import { defineCustomElement } from '@components/scripts/utils' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { addButtonEventListeners } from '@components/scripts/elementListeners' import copyIcon from '../../../../icons/copy.svg?raw' import checkIcon from '../../../../icons/check.svg?raw' @@ -83,14 +70,21 @@ async function writeToClipboard(text: string): Promise { return false } -class CodeTabsElement extends HTMLElement { +export class CodeTabsElement extends LitElement { + static registeredName = 'code-tabs' + private tabButtons: HTMLButtonElement[] = [] private codeBlocks: HTMLPreElement[] = [] private activeIndex = 0 private copyTimer: number | null = null private isInteractiveTabs = false - connectedCallback(): void { + protected override createRenderRoot(): HTMLElement { + return this + } + + override connectedCallback(): void { + super.connectedCallback() if (this.dataset['enhanced'] === 'true') return this.dataset['enhanced'] = 'true' @@ -110,7 +104,8 @@ class CodeTabsElement extends HTMLElement { if (shouldRenderTabs) this.setActive(0) } - disconnectedCallback(): void { + override disconnectedCallback(): void { + super.disconnectedCallback() if (this.copyTimer !== null) { window.clearTimeout(this.copyTimer) this.copyTimer = null @@ -246,3 +241,18 @@ class CodeTabsElement extends HTMLElement { check?.classList.toggle('hidden', !copied) } } + +export const registerWebComponent = async ( + tagName = CodeTabsElement.registeredName +): Promise => { + if (typeof window === 'undefined') return + defineCustomElement(tagName, CodeTabsElement) +} + +export const registerCodeTabsWebComponent = registerWebComponent + +export const webComponentModule: WebComponentModule = { + registeredName: CodeTabsElement.registeredName, + componentCtor: CodeTabsElement, + registerWebComponent, +} From 02a08511a01a758fbf27b794dbad46c19e5204b3 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 17:38:06 +0300 Subject: [PATCH 21/33] Refactor Consent/Banner component to extend LitElements --- .../Banner/client/__tests__/index.spec.ts | 34 +++++++++++++++++-- src/components/Consent/Banner/client/index.ts | 19 ++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/components/Consent/Banner/client/__tests__/index.spec.ts b/src/components/Consent/Banner/client/__tests__/index.spec.ts index a21246ca8..b0d1c23fd 100644 --- a/src/components/Consent/Banner/client/__tests__/index.spec.ts +++ b/src/components/Consent/Banner/client/__tests__/index.spec.ts @@ -60,20 +60,23 @@ const waitForBannerReady = async (element: ConsentBannerElement) => { type JsdomWindow = Window & typeof globalThis +let container: AstroContainer + const renderConsentBanner = async ( assertion: (_context: { element: ConsentBannerElement window: JsdomWindow }) => Promise | void ) => { - const container = await AstroContainer.create() - await executeRender({ container, component: ConsentBanner, moduleSpecifier: '@components/Consent/Banner/client/index', selector: 'consent-banner', - waitForReady: waitForBannerReady, + waitForReady: async (element: ConsentBannerElement) => { + await waitForBannerReady(element) + await element.updateComplete + }, assert: async ({ element, window }) => { if (!window) { throw new TestError('JSDOM window is not available for consent banner tests') @@ -85,6 +88,7 @@ const renderConsentBanner = async ( } beforeEach(async () => { + container = await AstroContainer.create() initConsentCookiesMock.mockReturnValue(true) showConsentBannerMock.mockClear() hideConsentBannerMock.mockClear() @@ -193,4 +197,28 @@ describe('ConsentBannerElement', () => { } }) }) + + it('records visibility before Astro view transitions', async () => { + await renderConsentBanner(({ window }) => { + const wrapper = window.document.getElementById('consent-modal-id') as HTMLDivElement | null + expect(wrapper).not.toBeNull() + wrapper!.style.display = 'block' + + window.document.dispatchEvent(new window.CustomEvent('astro:before-swap')) + + expect(showConsentBannerMock).toHaveBeenCalled() + }) + }) + + it('clears visibility when the banner is hidden before Astro view transitions', async () => { + await renderConsentBanner(({ window }) => { + const wrapper = window.document.getElementById('consent-modal-id') as HTMLDivElement | null + expect(wrapper).not.toBeNull() + wrapper!.style.display = 'none' + + window.document.dispatchEvent(new window.CustomEvent('astro:before-swap')) + + expect(hideConsentBannerMock).toHaveBeenCalled() + }) + }) }) diff --git a/src/components/Consent/Banner/client/index.ts b/src/components/Consent/Banner/client/index.ts index f5189bdc0..dddc842ba 100644 --- a/src/components/Consent/Banner/client/index.ts +++ b/src/components/Consent/Banner/client/index.ts @@ -4,6 +4,7 @@ * Uses Light DOM (no Shadow DOM) with Astro-rendered templates */ +import { LitElement } from 'lit' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { addButtonEventListeners, @@ -32,7 +33,9 @@ const COMPONENT_TAG_NAME = 'consent-banner' as const const COMPONENT_SCRIPT_NAME = 'ConsentBannerElement' export const CONSENT_BANNER_READY_EVENT = 'consent-banner:ready' -export class ConsentBannerElement extends HTMLElement { +export class ConsentBannerElement extends LitElement { + static registeredName = COMPONENT_TAG_NAME + private wrapper!: HTMLDivElement private closeBtn!: HTMLButtonElement private allowBtn!: HTMLButtonElement @@ -51,7 +54,12 @@ export class ConsentBannerElement extends HTMLElement { window.location.assign(url) } - connectedCallback(): void { + protected override createRenderRoot(): HTMLElement { + return this + } + + override connectedCallback(): void { + super.connectedCallback() if (this.isInitialized || typeof document === 'undefined') { return } @@ -76,7 +84,8 @@ export class ConsentBannerElement extends HTMLElement { } } - disconnectedCallback(): void { + override disconnectedCallback(): void { + super.disconnectedCallback() if (this.domReadyHandler) { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) this.domReadyHandler = null @@ -377,8 +386,10 @@ export const registerConsentBannerWebComponent = (tagName: string = COMPONENT_TA defineCustomElement(tagName, ConsentBannerElement) } +export const registerWebComponent = registerConsentBannerWebComponent + export const webComponentModule: WebComponentModule = { registeredName: COMPONENT_TAG_NAME, componentCtor: ConsentBannerElement, - registerWebComponent: registerConsentBannerWebComponent, + registerWebComponent, } From ea3df9932b0bb066bab873d9022b893ae6b99193 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 17:42:36 +0300 Subject: [PATCH 22/33] Refactor Consent/Preferences component to extend LitElements --- .../client/__tests__/index.spec.ts | 10 +++++++--- .../Consent/Preferences/client/index.ts | 19 +++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/components/Consent/Preferences/client/__tests__/index.spec.ts b/src/components/Consent/Preferences/client/__tests__/index.spec.ts index c11f0ddee..48ad9ceba 100644 --- a/src/components/Consent/Preferences/client/__tests__/index.spec.ts +++ b/src/components/Consent/Preferences/client/__tests__/index.spec.ts @@ -34,20 +34,23 @@ const waitForPreferencesReady = async (element: ConsentPreferencesElement) => { }) } +let container: AstroContainer + const renderConsentPreferences = async ( assertion: (_context: { element: ConsentPreferencesElement window: JsdomWindow }) => Promise | void ) => { - const container = await AstroContainer.create() - await executeRender({ container, component: ConsentPreferencesComponent, moduleSpecifier: '@components/Consent/Preferences/client/index', selector: 'consent-preferences', - waitForReady: waitForPreferencesReady, + waitForReady: async (element: ConsentPreferencesElement) => { + await waitForPreferencesReady(element) + await element.updateComplete + }, assert: async ({ element, window }) => { if (!window) { throw new TestError('Consent preferences tests require a window instance') @@ -117,6 +120,7 @@ vi.mock('@components/scripts/store', () => { describe('ConsentPreferencesElement', () => { beforeEach(async () => { + container = await AstroContainer.create() consentMockHelpers.reset() vi.clearAllMocks() diff --git a/src/components/Consent/Preferences/client/index.ts b/src/components/Consent/Preferences/client/index.ts index f2869a108..8de04d361 100644 --- a/src/components/Consent/Preferences/client/index.ts +++ b/src/components/Consent/Preferences/client/index.ts @@ -4,6 +4,7 @@ * Uses centralized state management from scripts/store */ +import { LitElement } from 'lit' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { isInputElement } from '@components/scripts/assertions/elements' import { addButtonEventListeners } from '@components/scripts/elementListeners' @@ -28,7 +29,9 @@ const COMPONENT_TAG_NAME = 'consent-preferences' as const const COMPONENT_SCRIPT_NAME = 'ConsentPreferencesElement' export const CONSENT_PREFERENCES_READY_EVENT = 'consent-preferences:ready' -export class ConsentPreferencesElement extends HTMLElement { +export class ConsentPreferencesElement extends LitElement { + static registeredName = COMPONENT_TAG_NAME + private allowAllBtn!: HTMLButtonElement private denyAllBtn!: HTMLButtonElement private saveBtn!: HTMLButtonElement @@ -38,7 +41,12 @@ export class ConsentPreferencesElement extends HTMLElement { private unsubscribeConsent: (() => void) | null = null private isInitialized = false - connectedCallback(): void { + protected override createRenderRoot(): HTMLElement { + return this + } + + override connectedCallback(): void { + super.connectedCallback() if (typeof document === 'undefined' || this.isInitialized) { return } @@ -63,7 +71,8 @@ export class ConsentPreferencesElement extends HTMLElement { } } - disconnectedCallback(): void { + override disconnectedCallback(): void { + super.disconnectedCallback() if (this.domReadyHandler) { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) this.domReadyHandler = null @@ -283,8 +292,10 @@ export const registerConsentPreferencesWebComponent = (tagName: string = COMPONE defineCustomElement(tagName, ConsentPreferencesElement) } +export const registerWebComponent = registerConsentPreferencesWebComponent + export const webComponentModule: WebComponentModule = { registeredName: COMPONENT_TAG_NAME, componentCtor: ConsentPreferencesElement, - registerWebComponent: registerConsentPreferencesWebComponent, + registerWebComponent, } From 827b263dced2cab70028b186562b2044b257771b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 18:07:59 +0300 Subject: [PATCH 23/33] Refactor Consent/Checkbox component to extend LitElements --- .../Checkbox/client/__tests__/testUtils.ts | 5 ++++- .../Consent/Checkbox/client/index.ts | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/Consent/Checkbox/client/__tests__/testUtils.ts b/src/components/Consent/Checkbox/client/__tests__/testUtils.ts index cc36ea3dd..964d72cee 100644 --- a/src/components/Consent/Checkbox/client/__tests__/testUtils.ts +++ b/src/components/Consent/Checkbox/client/__tests__/testUtils.ts @@ -62,7 +62,10 @@ export const renderConsentCheckbox = async ( moduleSpecifier: '@components/Consent/Checkbox/client/index', args: { props: { ...defaultProps, ...props } }, selector: 'consent-checkbox', - waitForReady: waitForConsentReady, + waitForReady: async (element: ConsentCheckboxElement) => { + await waitForConsentReady(element) + await element.updateComplete + }, assert: async ({ element, window, module, renderResult }) => { if (!window) { throw new TestError('Consent checkbox tests require a browser-like window environment') diff --git a/src/components/Consent/Checkbox/client/index.ts b/src/components/Consent/Checkbox/client/index.ts index cb4612d9e..cd471b610 100644 --- a/src/components/Consent/Checkbox/client/index.ts +++ b/src/components/Consent/Checkbox/client/index.ts @@ -3,6 +3,7 @@ * * Handles validation and state management for GDPR consent checkboxes in forms */ +import { LitElement } from 'lit' import { addScriptBreadcrumb, ClientScriptError } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { defineCustomElement } from '@components/scripts/utils' @@ -26,7 +27,9 @@ const COMPONENT_TAG_NAME = 'consent-checkbox' as const const READY_EVENT = 'consent-checkbox:ready' const COMPONENT_SCRIPT_NAME = 'ConsentCheckboxElement' -export class ConsentCheckboxElement extends HTMLElement { +export class ConsentCheckboxElement extends LitElement { + static registeredName = COMPONENT_TAG_NAME + private domReadyHandler: (() => void) | null = null private checkbox: HTMLInputElement | null = null private container: HTMLDivElement | null = null @@ -42,7 +45,12 @@ export class ConsentCheckboxElement extends HTMLElement { public isInitialized = false - connectedCallback(): void { + protected override createRenderRoot(): HTMLElement { + return this + } + + override connectedCallback(): void { + super.connectedCallback() if (typeof document === 'undefined' || this.isInitialized) { return } @@ -60,7 +68,8 @@ export class ConsentCheckboxElement extends HTMLElement { this.initialize() } - disconnectedCallback(): void { + override disconnectedCallback(): void { + super.disconnectedCallback() this.cleanupDomReadyHandler() this.cleanupListeners() this.isInitialized = false @@ -363,8 +372,10 @@ export const registerConsentCheckboxWebComponent = (tagName: string = COMPONENT_ defineCustomElement(tagName, ConsentCheckboxElement) } +export const registerWebComponent = registerConsentCheckboxWebComponent + export const webComponentModule: WebComponentModule = { registeredName: COMPONENT_TAG_NAME, componentCtor: ConsentCheckboxElement, - registerWebComponent: registerConsentCheckboxWebComponent, + registerWebComponent, } From 5017664dcc914843996764d10c35b78eee8102c8 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 18:11:53 +0300 Subject: [PATCH 24/33] Finish refactor of components and test helpers to LitElements --- _TODO.md | 19 ------------------- .../scripts/@types/webComponentModule.ts | 4 +++- .../enforce-centralized-events-rule.spec.ts | 18 ++++++++++++++++++ .../eslint/enforce-centralized-events-rule.ts | 6 +++--- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/_TODO.md b/_TODO.md index 5c46cc3a3..f40124745 100644 --- a/_TODO.md +++ b/_TODO.md @@ -269,22 +269,3 @@ This article has different approaches to [print pagination](https://www.customjs - cover.jpg for reliability-and-testing needs touch up in GIMP - We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag lik "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag and add the name to the tags: frontmatter key in the index.mdx - Need an article on OpenStack - -## HTMLElement vs. extends HTMLElement - -A bunch of our web components extend directly from HTMLElement instead of following the instructions to extend LitElement. - -- Consent/Banner -- Consent/Checkbox -- Consent/Preferences - -The WebComponentModule type in this file is used throughout component scripts: - -`src/components/scripts/@types/webComponentModule.ts` - -There are testing fixtures that might be targeting HTMLElement instead of LitElement: - -`isLikelyWebComponent()` in `test/eslint/enforce-centralized-events-rule.ts` -line 113 in `test/eslint/__tests__/enforce-centralized-events-rule.spec.ts` - -interface `ElementWithTestProperties` in `test/e2e/assertions/index.ts` diff --git a/src/components/scripts/@types/webComponentModule.ts b/src/components/scripts/@types/webComponentModule.ts index ecc0f9692..d6af99a43 100644 --- a/src/components/scripts/@types/webComponentModule.ts +++ b/src/components/scripts/@types/webComponentModule.ts @@ -1,4 +1,6 @@ -export interface WebComponentModule { +import type { LitElement } from 'lit' + +export interface WebComponentModule { registeredName: string componentCtor: CustomElementConstructor & { prototype: TElement } registerWebComponent: (_tagName?: string) => Promise | void diff --git a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts index 08fc71066..9fd187a31 100644 --- a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts +++ b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts @@ -116,6 +116,24 @@ describe('enforce-centralized-events', () => { this.addEventListener('keyup', this.handleKeyUp); } } + `, + filename: 'src/components/MyComponent/index.ts', + }, + + // ======================================== + // Valid: LitElement web components + // ======================================== + { + code: ` + import { LitElement } from 'lit'; + + class MyComponent extends LitElement { + connectedCallback() { + super.connectedCallback(); + this.addEventListener('click', this.handleClick); + this.addEventListener('keyup', this.handleKeyUp); + } + } `, filename: 'src/components/MyComponent/index.ts', }, diff --git a/test/eslint/enforce-centralized-events-rule.ts b/test/eslint/enforce-centralized-events-rule.ts index 25b005dd2..0afec12e2 100644 --- a/test/eslint/enforce-centralized-events-rule.ts +++ b/test/eslint/enforce-centralized-events-rule.ts @@ -78,7 +78,7 @@ const enforceCentralizedEventsRule: Rule.RuleModule = { let foundWebComponent = false const visitorKeys = sourceCode.visitorKeys ?? {} - const isHTMLElementSubclass = (node: Node): boolean => { + const isWebComponentSubclass = (node: Node): boolean => { if (node.type !== 'ClassDeclaration' && node.type !== 'ClassExpression') { return false } @@ -87,14 +87,14 @@ const enforceCentralizedEventsRule: Rule.RuleModule = { return Boolean( superClass && superClass.type === 'Identifier' && - superClass.name === 'HTMLElement', + (superClass.name === 'HTMLElement' || superClass.name === 'LitElement'), ) } const checkNode = (astNode: Node | null | undefined): void => { if (!astNode || foundWebComponent) return - if (isHTMLElementSubclass(astNode)) { + if (isWebComponentSubclass(astNode)) { foundWebComponent = true return } From aa91f6d7a4014aae43ab012c973bd85459ed07c6 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 11 Feb 2026 20:02:53 +0300 Subject: [PATCH 25/33] Finish styling on technologies and skills component --- _TODO.md | 5 + src/components/Breadcrumbs/index.astro | 2 +- src/components/Home/Backstage/index.astro | 7 +- .../__fixtures__/skillsCarousel.fixture.astro | 30 ++++ .../Skills/client/__tests__/index.spec.ts | 166 ++++++++++++++++++ src/components/Home/Skills/client/index.ts | 133 ++++++++++++++ .../Home/Skills/client/selectors.ts | 101 +++++++++++ src/components/Home/Skills/index.astro | 153 +++++++++++++--- src/components/Toc/index.astro | 2 +- src/lib/config/markdown.ts | 2 +- src/pages/index.astro | 4 +- 11 files changed, 575 insertions(+), 30 deletions(-) create mode 100644 src/components/Home/Skills/client/__fixtures__/skillsCarousel.fixture.astro create mode 100644 src/components/Home/Skills/client/__tests__/index.spec.ts create mode 100644 src/components/Home/Skills/client/index.ts create mode 100644 src/components/Home/Skills/client/selectors.ts diff --git a/_TODO.md b/_TODO.md index f40124745..d65e61196 100644 --- a/_TODO.md +++ b/_TODO.md @@ -269,3 +269,8 @@ This article has different approaches to [print pagination](https://www.customjs - cover.jpg for reliability-and-testing needs touch up in GIMP - We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag lik "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag and add the name to the tags: frontmatter key in the index.mdx - Need an article on OpenStack + +## focus-visible + +- yellow outline on "Let's talk ->" in backstage component is rounded like the button +- yellow outlines on icon cards in skills + technologies are clipped at the top, and rounded on the play/pause button diff --git a/src/components/Breadcrumbs/index.astro b/src/components/Breadcrumbs/index.astro index 55d216c5a..ee0aa66e3 100644 --- a/src/components/Breadcrumbs/index.astro +++ b/src/components/Breadcrumbs/index.astro @@ -38,7 +38,7 @@ const breadcrumbSchema = breadcrumbs.length > 1 && (