From 83dbfc27db053267a33ce548ee04e99218398fbc Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 25 Feb 2026 15:08:43 +0300 Subject: [PATCH 01/24] Update branch protection workflow to allow Dependabot branch naming scheme --- .github/workflows/branch-protection.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml index 637da8bee..b68e0fb8b 100644 --- a/.github/workflows/branch-protection.yml +++ b/.github/workflows/branch-protection.yml @@ -18,8 +18,12 @@ jobs: run: | : "${BRANCH_NAME:?github.head_ref is required}" - # Valid patterns: bugfix/, hotfix/, feature/, infrastructure/, maintenance/, content/ - if [[ ! $BRANCH_NAME =~ ^(bugfix|hotfix|feature|infrastructure|maintenance|content|dependabot)/[a-z0-9-]+$ ]]; then + HUMAN_BRANCH_REGEX='^(bugfix|hotfix|feature|infrastructure|maintenance|content)/[a-z0-9-]+$' + DEPENDABOT_BRANCH_REGEX='^dependabot/[a-z0-9._/-]+$' + + # Human branches use a strict single-segment slug; dependabot branches are machine-generated and may include + # nested path segments, underscores, and dots (for package manager + dependency + version details). + if [[ ! $BRANCH_NAME =~ $HUMAN_BRANCH_REGEX && ! $BRANCH_NAME =~ $DEPENDABOT_BRANCH_REGEX ]]; then echo "❌ Invalid branch name: $BRANCH_NAME" echo "" echo "Branch names must follow one of these patterns:" @@ -29,9 +33,9 @@ jobs: echo " - infrastructure/ (e.g., infrastructure/setup-ci)" echo " - maintenance/ (e.g., maintenance/update-dependencies)" echo " - content/ (e.g., content/update-about-page)" - echo " - dependabot/ (machine generated)" + echo " - dependabot/<...> (machine generated; may include '/' '_' '.')" echo "" - echo "Use lowercase letters, numbers, and hyphens only." + echo "For non-dependabot branches, use lowercase letters, numbers, and hyphens only." exit 1 fi From 97caadd900c79eb2109bd6c66683bfd7311e37fc Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 25 Feb 2026 15:20:25 +0300 Subject: [PATCH 02/24] Update Newsletter CTA component description text --- src/components/CallToAction/Newsletter/layouts/article.astro | 2 +- src/components/CallToAction/Newsletter/layouts/home.astro | 2 +- src/components/CallToAction/Newsletter/layouts/page.astro | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/CallToAction/Newsletter/layouts/article.astro b/src/components/CallToAction/Newsletter/layouts/article.astro index 38c0ac214..52285cf61 100644 --- a/src/components/CallToAction/Newsletter/layouts/article.astro +++ b/src/components/CallToAction/Newsletter/layouts/article.astro @@ -8,7 +8,7 @@ export type Props = Omit const { title = 'Stay Updated', - description = 'Subscribe to our newsletter for the latest insights on web development and digital solutions.', + description = 'One deep dive per month on infrastructure topics, plus quick wins you can ship the same day.', placeholder = 'Enter your email address', buttonText = 'Subscribe', } = Astro.props diff --git a/src/components/CallToAction/Newsletter/layouts/home.astro b/src/components/CallToAction/Newsletter/layouts/home.astro index d930e1357..01f83dbd4 100644 --- a/src/components/CallToAction/Newsletter/layouts/home.astro +++ b/src/components/CallToAction/Newsletter/layouts/home.astro @@ -10,7 +10,7 @@ export type Props = Omit const { title = 'Stay Updated', - description = 'Subscribe to our newsletter for the latest insights on web development and digital solutions.', + description = 'A monthly dispatch on platform engineering, DevOps, SRE, and cloud infrastructure. Real problems, real solutions.', placeholder = 'Enter your email address', buttonText = 'Subscribe', } = Astro.props diff --git a/src/components/CallToAction/Newsletter/layouts/page.astro b/src/components/CallToAction/Newsletter/layouts/page.astro index 49b53c4c1..bd025ba6f 100644 --- a/src/components/CallToAction/Newsletter/layouts/page.astro +++ b/src/components/CallToAction/Newsletter/layouts/page.astro @@ -15,7 +15,7 @@ export type Props = Omit const { title = 'Stay Updated', - description = 'Subscribe to our newsletter for the latest insights on web development and digital solutions.', + description = 'Platform engineering insights delivered to your inbox once a month. No spam, no sales pitches.', placeholder = 'Enter your email address', buttonText = 'Subscribe', } = Astro.props From ae688fbaa8191724491a22b4a8e1bef010b563cc Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 25 Feb 2026 20:47:51 +0300 Subject: [PATCH 03/24] Fix theme picker, add progress bar to articles, fix vertical scroll bar, add sticky behavior to ToC, improve squishy effect on Header --- TROUBLESHOOTING_HEADER_SPACING.md | 195 +++++++++ .../client/__tests__/index.spec.ts | 182 ++++++++ .../Content/ProgressBar/client/index.ts | 132 ++++++ .../Content/ProgressBar/index.astro | 28 ++ .../Switcher/client/__tests__/index.spec.ts | 0 .../Content/Switcher/client/index.ts | 0 src/components/Content/Switcher/index.astro | 38 ++ src/components/Content/Switcher/index.css | 0 .../client/__tests__/headerAnimation.spec.ts | 226 ++++++++++ .../Header/client/headerAnimation.ts | 394 ++++++++++++++++++ src/components/Header/client/index.ts | 17 +- src/components/Header/index.astro | 6 +- src/components/Header/index.css | 9 +- src/components/Navigation/menu.module.css | 2 - src/components/Pages/Contact/index.astro | 5 +- src/components/ThemePicker/client/index.ts | 99 ++--- .../Toc/client/__tests__/index.spec.ts | 2 +- src/components/Toc/client/index.ts | 14 +- src/components/Toc/client/selectors.ts | 11 + src/components/Toc/index.astro | 37 +- .../scripts/bootstrap/__tests__/index.spec.ts | 3 + src/components/scripts/bootstrap/index.ts | 5 + .../store/__tests__/layoutPosition.spec.ts | 156 +++++++ src/components/scripts/store/index.ts | 12 + .../scripts/store/layoutPosition.ts | 224 ++++++++++ src/layouts/BaseLayout.astro | 5 +- src/layouts/MarkdownLayout.astro | 4 + src/pages/articles/[...slug].astro | 1 + src/pages/deep-dive/[...slug].astro | 1 + src/pages/tags/[tag].astro | 25 +- src/styles/reset.css | 1 + 31 files changed, 1731 insertions(+), 103 deletions(-) create mode 100644 TROUBLESHOOTING_HEADER_SPACING.md create mode 100644 src/components/Content/ProgressBar/client/__tests__/index.spec.ts create mode 100644 src/components/Content/ProgressBar/client/index.ts create mode 100644 src/components/Content/ProgressBar/index.astro create mode 100644 src/components/Content/Switcher/client/__tests__/index.spec.ts create mode 100644 src/components/Content/Switcher/client/index.ts create mode 100644 src/components/Content/Switcher/index.astro create mode 100644 src/components/Content/Switcher/index.css create mode 100644 src/components/Header/client/__tests__/headerAnimation.spec.ts create mode 100644 src/components/Header/client/headerAnimation.ts create mode 100644 src/components/scripts/store/__tests__/layoutPosition.spec.ts create mode 100644 src/components/scripts/store/layoutPosition.ts diff --git a/TROUBLESHOOTING_HEADER_SPACING.md b/TROUBLESHOOTING_HEADER_SPACING.md new file mode 100644 index 000000000..061f4fd25 --- /dev/null +++ b/TROUBLESHOOTING_HEADER_SPACING.md @@ -0,0 +1,195 @@ +# Header Positioning Issues + +We have a ThemePicker component at src/components/ThemePicker that slides up and down to allow users to set the theme for the site. The theme picker should push the header down with it when it opens, so that the top edge of the Header component at src/components/Header stays aligned with the bottom edge of our theme picker box. One of the goals was for the correct layout to persist across page navigation - when the theme picker is open, it stays open on the page that is navigated to. + +This functionality worked until a few weeks ago. At that time, we implemented a "squishy" functionality in the Header where when the user scrolls down the page, the header would reduce in height. When the user then scrolls up on the page, the header would return to its default full height. + +The alignment of the header with the bottom of the theme picker has not worked correctly since we made that change. We have tried at least seven or eight times to fix it, all with no success, including several attempts with Opus 4.6 and several with GPT 5.3 Codex. Right now, the header is pushed down below the bottom edge of the theme picker when it is open so that there are several lines of content from the page that should through - there is a large gap between the theme picker and the header. The header is correctly positions at the top of the page when the theme picker is closed, and the theme picker itself works correctly. + +The problems we've had with getting this system to work correctly is holding up implementation of other features that will also depend on this positioning. I have zero confidence of successfully solving this with just explaining the bug in a prompt; we've spent a great deal of time over multiple days trying to fix this with no success. It feels like we're going in circles, and the problem is something deeper than what Opus 4.6 or GPT 5.3 Codex is able to detect. + +To start, look at the git history of the relevant files, particularly src/components/ThemePicker/client/index.ts, and give me a summary of the fixes that we've tried and what they were likely intended to do to fix the problem. Please give me a summary of how the positioning system works overall - are we maintaining state in a src/components/scripts/store file? Locally in the theme picker component? Look also at the Header component, particularly src/components/Header/client/index.ts, for the history of changes we've made there. + +## How the Positioning System Works + +There are three cooperating layers: + +1. Shared State (Nanostore) + +`$themePickerOpen` in `themes.ts` is a `persistentAtom` backed by `localStorage`. This is the single source of truth for whether the picker is open. It persists across View Transitions and page reloads. The ThemePicker Lit component binds to it via `StoreController`. + +2. ThemePicker (`` custom element) + +The `` element lives in `BaseLayout.astro:103`, above the `
` in the DOM, and uses `transition:persist` to survive View Transitions. + +The modal (`.themePicker`) starts with `max-height: 0 + overflow: hidden` and animates to` max-height: 14em` when `.is-open` is added. + +When open, the modal becomes `position: fixed; top: 0` — it sits at the very top of the viewport. + +The `` element itself acts as a spacer: its CSS sets `height: var(--theme-picker-offset, 0`) — it reserves document flow space equal to the picker's visual height. + +3. Header (`header-shell` / `header-fixed`) + +The header has a footprint div (`.header-footprin`t) that reserves space in document flow. + +The actual visible header (`.header-fixed`) is `position: fixed; top: 0`. + +To push the header below the theme picker, `.header-fixed` uses `transform: translateY(var(--theme-picker-offset, 0)`). + +The squishy scroll behavior was added in commit `e5127343` ("Move header code into component"). On scroll down, `.header-shell` gets the `is-collapsed` class which shrinks padding, brand scale, icon sizes, and footprint height via CSS custom properties with a 320ms cubic-bezier transition. + +### The Communication Bridge + +When the ThemePicker opens/closes, it writes to `--theme-picker-offset` on `document.documentElement`. Both the `` spacer height and `.header-fixed` transform read that variable. There is no JS in the Header that monitors or reacts to the theme picker — it's purely CSS-variable-driven. + +## History of Fix Attempts + +### Commit `bbbbae0d` — "Fix stacking context of themepicker to be on top during scroll" + +What it did: + +- Added` transform: translateY(var(--theme-picker-offset, 0px))` and `transition: transform 0.4s` to `.header-fixed` in CSS — this is the original mechanism for pushing the header down. + +- Added the `` spacer CSS (`height: var(--theme-picker-offset, 0px); position: relative; z-index: var(--z-theme-picker)`) to reserve flow space. + +- Made the` .themePicker:global(.is-open)` become `position: fixed; top: 0` — pulled the modal out of flow and pinned it to the viewport top. + +- Added `updateHeaderOffset()` and `getThemePickerOffset()` methods to the ThemePicker class, using `Math.max(scrollHeight, boundingHeight)`. + +- Inserted `updateHeaderOffset(true/false`) calls in the open/close paths. + +Likely intent: Establish the `--theme-picker-offset` variable-driven approach to coordinate the header position with the theme picker. + +### Commit `8b08773d` — "Update header scroll size change transition timing" + +What it did: + +- Changed transition timing from `220ms ease` to `320ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` across all header properties. + +- Changed `--theme-picker-offset` default from `0px` to `0` (minor). + +- Added `requestAnimationFrame` mocks to the header collapse test. + +Likely intent: Smooth out the squishy animation. This is the commit that likely broke the alignment — the header transition timing changed but the ThemePicker offset calculation and transition timing may have become desynchronized. + +### Commit `4aa6ffb1` — "Fix to Themepicker and Subheader component on page navigation" + +What it did (most significant fix attempt): + +- Added `getLivePickerModal()` — a method to re-query the modal element from the DOM if the cached reference is stale (disconnected). + +- Added `syncOpenStateAfterNavigation()` — a method that snaps the modal to its open state without animation (sets `transition: none`, adds `.is-open`, `force-reflows`, then calls `updateHeaderOffset(true)`, and restores transitions in the next rAF). + +- Rewrote `getThemePickerOffset()` with a cascade of fallbacks: `getBoundingClientRect().height` → `offsetHeight` → computed `maxHeight` → hardcoded `14 * fontSize` (the `14em` target). + +- Moved `updateHeaderOffset(true`) from before `is-open` class application to after it (inside the rAF callback), so the measurement happens after the target height is applied. + +- Added `astro:page-load` listener that calls `syncOpenStateAfterNavigation()`. + +Likely intent: Fix the gap that appeared after View Transitions navigation, where the modal would be re-opened but the offset measurement was reading 0 because the measurement happened before `.is-open` was applied or the transition was mid-flight. + +### Commit `7877c599` — "Newsletter Confirm page style updates" + +This touched the ThemePicker file but appears to be mostly unrelated content changes. + +## The Core Problem + +The fundamental architectural tension I see: + +- The modal is `position: fixed; top: 0` when open. It's pulled out of document flow entirely. + +- The `` element is a spacer in flow whose height is set to` --theme-picker-offset`. This reserves page flow space. + +- The `.header-fixed` is also `position: fixed; top: 0` and uses `transform: translateY(--theme-picker-offset)` to push itself below the picker. + +- The `.header-footprint` is in flow and does NOT account for `--theme-picker-offset`. + +The problem is in the measurement and timing of `--theme-picker-offset`. When `getThemePickerOffset()` runs: + +- If the modal has `.is-open` but the `max-height` transition hasn't finished, `getBoundingClientRect().height` returns an intermediate value (potentially the full target since `max-height` clips but doesn't change layout height of a fixed element the same way). + +- The `14em` fallback computes to ~`224px` at `16px` font size, but the actual rendered height of the picker content may differ, especially at different viewport widths where the embla carousel and padding produce different heights. + +- More critically: when the header collapses on scroll, nothing recalculates `--theme-picker-offset`. If the picker is open and you scroll, the header shrinks but the offset stays the same → growing gap. If the page is scrolled when the picker opens, the offset may be measured against a collapsed header footprint. + +- Also the header-footprint doesn't include `--theme-picker-offset`, so the flow space reserved for the header doesn't change when the picker opens. The picker spacer (`` height) + the footprint height together should equal the total visual space above the content — but the header's` translateY` push isn't reflected in the footprint. + +## Questions to Discuss Before Attempting a Fix + +1. Should `--theme-picker-offset` also be added to the `.header-footprint` height? Currently the footprint is `var(--header-footprint-height-current)` alone. If the picker opens, the fixed header moves down but the footprint doesn't grow, so content behind the fixed elements may be misaligned. + +2. Is `position: fixed` on the open modal actually necessary? If the modal were position: relative (in-flow), the `` spacer height approach would be unnecessary — the element itself would push content down naturally. The fixed positioning was presumably added so the picker always appears at the viewport top even when scrolled, but it creates all this coordination complexity. + +3. Would it be simpler to abandon the CSS variable bridge and instead use a single JS controller that listens to both the picker state and scroll position, updating both the header transform and the footprint height in one place? + +4. The timing mismatch: The picker animates `max-height` over 400ms, the header translates over 400ms, and the header collapse transitions over 320ms. These competing transitions with offset measurements that are taken at specific points in time seem inherently fragile. Would snapping instead of animating the header offset (while keeping the picker animation) be acceptable? + +## Answers to Questions and Additional Functionality + +I would prefer to abandon the CSS variable bridge and instead use a single JS controller that listens to both the picker state and scroll position, updating both the header transform and the footprint height in one place. Visitors to the site who are sighted will always have Javascript enabled, and the themepicker and visual styling (the "squish" styling) of the header don't apply to site visitors who are using screen readers. + +I would like to implement the other features that have been blocked by problems with the theme picker as part of this scope of work. Those features and bug fixes are: + +### Vertical Scroll Bar + +Currently the righ hand-side vertical scroll bar extends to the top of the viewport. When only the header is visible (theme picker closed), the scroll bar control is partially overlapped and hard to select to pull down. When the theme picker is open and the visitor is at the top of the page, the scroll bar control is completely hidden by the combined height of the theme picker and the header. + +### Header "Squish" Animation + +The "squish" animation of the header when the user scrolls down is still very awkard in appearance. We should move this to script if it's using CSS currently for more reliability. The animation should have two distinct steps, and a forward (moving from taller vertically to shorter vertically) and backward workflow (moving from shorter vertically to taller vertically). + +In the forward workflow, the following should happen at the same time visually in the first step: + +- The logo and branding should reduce to their smaller size, moving to the left. This is correct currently. +- The five individual anchors of the nav menu should reduce to their smaller size, centered around the center vertical line running through the anchor text. Right now the anchor text moves at the same time that the text is reducing is in size since the entire nav container is being reduced in size. +- The themepicker icon, search icon, and (on mobile) the hamburger menu should reduce to their smaller size, centered around the center vertical line running through the icon. + +In the second step of the forward workflow, the following should happen at the same time visually: + +- The nav items should move to the position with the new spacing between nav items based on the reduced size of the nav container. +- The themepicker icon, search icon, and (on mobile) the hamburger menu should move to their new position. The movement will be to the right of the viewport. + +The change in the above two items is that we are separating the change in size of the nav and icons and their movement. Right now they do both at the same time. The animation is jumpy and looks awkward. + +- The vertical height of the header should change to its new reduced height at the same time as the above two items are moving to their final positions. + +In the reverse workflow, those steps should happen in opposite order: second step first, with the nav items and icons moving to the position they'll be in when the size of the header is returned to its full size. Then first step from the above, where the logo/branding, nav, and icons returning to their full size as the header is returned to its full size. + +### Article and Deep Dive Item View Progress Bar + +I'd like to add a horizontal progress bar that shows the visitor their progress through the article by total length. It should use the "success" color, and have its top fixed to the bottom of the header. It should be fairly small vertically. Let's start with the closest tailwind class to 8px for sm and above, and 4px for mobile. Use the HTML `` element unless that poses a problem. Implement the updating of the element in script, and the element itself in the `*.astro` page file. It should on these two pages: + +src/pages/articles/[...slug].astro +src/pages/deep-dive/[...slug].astro + +### Sticky ToC + +On the two articles pages, we have a Table of Contents (ToC) that's added by the Markdown rendering system. It's rendered in a div with an `id` set to `toc-drawer`. The ToC should have a "sticky" behavior where as the user scrolls down, the ToC scrolls down until the bottom of the ToC + a small amount of padding (perhaps a tailwind amount of mb-4) enters the viewport. At that point, it should stop scrolling off the screen and remain fixed in place. + +When the user scrolls up, the ToC should move down the page with content until the top of the ToC + the same amount of padding is fixed to the bottom edge of the theme picker (if open) + header + progress bar. If the ToC is shorter than the height of the viewport minus the top elements (themepicker (if open) + header + progress bar), it should stay fixed to the bottom edge of the progress bar as the visitor scrolls down once it reaches that bottom edge (e.g. once the hero image is scrolled out of view). + +### Contact Info Card + +We have a similar effect in the Contact component on the "Contact Information" right-hand `aside` element, that is currently implemented using a CSS `sticky` class: + +src/components/Pages/Contact/index.astro + +We need to change that to a script handling approach, with behavior similar to the "Sticky ToC" mentioned above. It needs to leave some or all of the `bg-page-offset` colored backbackground as padding between the `aside` element's top edge and the bottom edge of the themepicker (if open) + header. Right now the top edge of the `aside` stops scrolling at the bottom edge of the header, bug ignores the position of the bottom edge of the header if the theme picker is open. It doesn't seem to handle the "squishy" effect of the header correctly but it's hard to tell because of the broken theme picker positioning currently. Also right now, once it reaches its "sticky" position, it stays there until the user scrolls far enough down for the bottom of the main content area to push it up and then continues scrolling the aside up. It should behave like the ToC outlined above. + +### Mobile ToC Drawer + +Right now, we're hiding the ToC on mobile. We need to refactor it to work as a drawer that slides in from the right-hand side of the viewport. When slide in, it should cover the full vertical height of the mobile viewport and slide in to about 80% of the horizontal space of the mobile viewport (this number may be adjusted after seeing it in action), just enough left so the visitor can see that there's still content underneath it. + +When closed, the drawer should have a partial circle visible with an arrow-left Icon (do not hard code SVGs) that can be pressed to slide it in. When open, the drawer should have an arrow-right icon indicating a button to close it. + +### General Instructions + +Let's first develop a plan to implement this. Indicate which files will be touched, and any new files that will be created. I want to make sure that we are correctly using our store for values vs. maintaining state in components, and to make sure that we are logically organizing our code. I would like to implement this code in a way that we can create useful unit tests for the work we generate. Right now our unit tests are completely useless for this functionality - they are not catching regressions. The feature is broken yet the tests all pass. + +Prefer HTML markup in `*.astro` files over HTML literal strings injected at runtime. If it will produce more reliable code, it's okay to use HTML literal strings - just inform me of your plan to do so and let me evaluate. HTML literal strings in script are hard to maintain and lint in an Astro project. + +Regarding this comment: + +> The timing mismatch: The picker animates `max-height` over 400ms, the header translates over 400ms, and the header collapse transitions over 320ms. These competing transitions with offset measurements that are taken at specific points in time seem inherently fragile. Would snapping instead of animating the header offset (while keeping the picker animation) be acceptable? + +The header translate and collapse transitions should have the same value, and be set by a single `const` value. It's unlikely (though possible) that a visitor would trigger the theme picker animation and the header collapse / expand animations at the same time. Normally if a visitor pushes the theme picker icon, they will select a different theme before scrolling up or down on the page. The animation transition over a longer period of time significantly improves the visual appearance of the transition - we tried it in the past with a snap and it looked very awkard. We should find a way to implement the transition delay in a way that's robus, and discount the problematic case of a visitor triggering both the theme picker animation and header animation at the same time since it's unlikely to happen in practice. Please advise me if you disagree with this analysis - it does significantly improve the appearance of the header animation. diff --git a/src/components/Content/ProgressBar/client/__tests__/index.spec.ts b/src/components/Content/ProgressBar/client/__tests__/index.spec.ts new file mode 100644 index 000000000..26239270a --- /dev/null +++ b/src/components/Content/ProgressBar/client/__tests__/index.spec.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +/** + * Unit tests for ReadingProgressBar web component + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ReadingProgressBar, registerProgressBarComponent } from '../index' + +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: vi.fn(), +})) + +vi.mock('@components/scripts/utils', () => ({ + defineCustomElement: vi.fn((tagName: string, ctor: CustomElementConstructor) => { + if (!customElements.get(tagName)) { + customElements.define(tagName, ctor) + } + }), +})) + +// ============================================================================ +// HELPERS +// ============================================================================ + +function createDOM(contentHeight = 2000) { + const component = document.createElement('reading-progress-bar') as ReadingProgressBar + component.setAttribute('data-progress-bar', '') + + const progress = document.createElement('progress') + progress.max = 100 + progress.value = 0 + component.appendChild(progress) + document.body.appendChild(component) + + const content = document.createElement('div') + content.id = 'content' + // Mock getBoundingClientRect for content + content.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 0, + height: contentHeight, + bottom: contentHeight, + left: 0, + right: 800, + width: 800, + x: 0, + y: 0, + toJSON: vi.fn(), + }) + document.body.appendChild(content) + + return { component, progress, content } +} + +// ============================================================================ +// TESTS +// ============================================================================ + +describe('ReadingProgressBar', () => { + let rafCallback: FrameRequestCallback | null = null + + beforeEach(async () => { + rafCallback = null + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + rafCallback = cb + return 1 + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + + // Ensure the custom element is registered + await registerProgressBarComponent() + }) + + afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + describe('registration', () => { + it('should call defineCustomElement with the correct tag name', async () => { + const { defineCustomElement } = await import('@components/scripts/utils') + await registerProgressBarComponent() + expect(defineCustomElement).toHaveBeenCalledWith('reading-progress-bar', ReadingProgressBar) + }) + }) + + describe('component', () => { + it('should set registeredName to reading-progress-bar', () => { + expect(ReadingProgressBar.registeredName).toBe('reading-progress-bar') + }) + + it('should use light DOM', () => { + const el = new ReadingProgressBar() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((el as any).createRenderRoot()).toBe(el) + }) + + it('should start progress at 0', () => { + const { progress } = createDOM() + expect(progress.value).toBe(0) + }) + }) + + describe('progress calculation', () => { + it('should compute progress based on content position', () => { + const { progress, content } = createDOM(2000) + + const instance = document.createElement('reading-progress-bar') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const priv = instance as any + priv.progressEl = progress + priv.contentEl = content + + vi.spyOn(content, 'getBoundingClientRect').mockReturnValue({ + top: -500, + height: 2000, + bottom: 1500, + left: 0, + right: 800, + width: 800, + x: 0, + y: -500, + toJSON: vi.fn(), + }) + + priv.updateProgress() + + expect(progress.value).toBeGreaterThan(0) + expect(progress.value).toBeLessThanOrEqual(100) + }) + + it('should clamp progress to 0-100 range', () => { + const { progress, content } = createDOM(2000) + + const instance = document.createElement('reading-progress-bar') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const priv = instance as any + priv.progressEl = progress + priv.contentEl = content + + vi.spyOn(content, 'getBoundingClientRect').mockReturnValue({ + top: -5000, + height: 2000, + bottom: -3000, + left: 0, + right: 800, + width: 800, + x: 0, + y: -5000, + toJSON: vi.fn(), + }) + + priv.updateProgress() + + expect(progress.value).toBe(100) + }) + + it('should set 100% when content fits within one viewport', () => { + const { progress, content } = createDOM(500) + + const instance = document.createElement('reading-progress-bar') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const priv = instance as any + priv.progressEl = progress + priv.contentEl = content + + vi.spyOn(content, 'getBoundingClientRect').mockReturnValue({ + top: 100, + height: 500, + bottom: 600, + left: 0, + right: 800, + width: 800, + x: 0, + y: 100, + toJSON: vi.fn(), + }) + + priv.updateProgress() + + expect(progress.value).toBe(100) + }) + }) +}) diff --git a/src/components/Content/ProgressBar/client/index.ts b/src/components/Content/ProgressBar/client/index.ts new file mode 100644 index 000000000..0f93c5478 --- /dev/null +++ b/src/components/Content/ProgressBar/client/index.ts @@ -0,0 +1,132 @@ +/** + * Article reading progress bar – Lit web component + * + * Renders a fixed `` element below the header that fills as + * the visitor scrolls through the article `#content` region. Uses the + * layout-position store's `--layout-top-offset` to position itself + * directly beneath the header. + */ +import { LitElement } from 'lit' +import { defineCustomElement } from '@components/scripts/utils' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { handleScriptError } from '@components/scripts/errors/handler' + +export class ReadingProgressBar extends LitElement { + static registeredName = 'reading-progress-bar' + + /** Keep the element in light DOM so Tailwind / CSS variables work. */ + protected override createRenderRoot() { + return this + } + + // ── Internal state ────────────────────────────────────────────────── + private progressEl: HTMLProgressElement | null = null + private contentEl: HTMLElement | null = null + private rafId: number | null = null + private scrollHandler: (() => void) | null = null + private resizeHandler: (() => void) | null = null + + // ── Lifecycle ─────────────────────────────────────────────────────── + + override connectedCallback(): void { + super.connectedCallback() + this.cacheElements() + this.attachListeners() + this.updateProgress() + } + + override disconnectedCallback(): void { + this.detachListeners() + super.disconnectedCallback() + } + + // ── DOM ───────────────────────────────────────────────────────────── + + private cacheElements(): void { + this.progressEl = this.querySelector('progress') + this.contentEl = document.querySelector('#content') + } + + // ── Listeners ─────────────────────────────────────────────────────── + + private attachListeners(): void { + this.scrollHandler = () => this.scheduleUpdate() + this.resizeHandler = () => this.scheduleUpdate() + + window.addEventListener('scroll', this.scrollHandler, { passive: true }) + document.addEventListener('scroll', this.scrollHandler, { passive: true, capture: true }) + window.addEventListener('resize', this.resizeHandler, { passive: true }) + } + + private detachListeners(): void { + if (this.scrollHandler) { + window.removeEventListener('scroll', this.scrollHandler) + document.removeEventListener('scroll', this.scrollHandler, { capture: true }) + } + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler) + } + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + } + + // ── Progress calculation ──────────────────────────────────────────── + + private scheduleUpdate(): void { + if (this.rafId !== null) return + this.rafId = requestAnimationFrame(() => { + this.rafId = null + this.updateProgress() + }) + } + + /** Compute scroll progress through the `#content` region as 0–100. */ + private updateProgress(): void { + try { + if (!this.progressEl || !this.contentEl) return + + const rect = this.contentEl.getBoundingClientRect() + const viewportHeight = window.innerHeight + + // Total scrollable distance for the content region + const totalHeight = rect.height + if (totalHeight <= 0) { + this.progressEl.value = 0 + return + } + + // How far past the top of the viewport has the content scrolled? + // rect.top starts positive (below viewport top) and becomes negative. + const scrolled = -rect.top + const scrollableDistance = totalHeight - viewportHeight + + if (scrollableDistance <= 0) { + // Content fits within one screen + this.progressEl.value = 100 + return + } + + const progress = Math.min(100, Math.max(0, (scrolled / scrollableDistance) * 100)) + this.progressEl.value = progress + } catch (error) { + handleScriptError(error, { + scriptName: 'ReadingProgressBar', + operation: 'updateProgress', + }) + } + } +} + +export const registerProgressBarComponent = async ( + tagName = ReadingProgressBar.registeredName, +): Promise => { + defineCustomElement(tagName, ReadingProgressBar) +} + +export const webComponentModule: WebComponentModule = { + registeredName: ReadingProgressBar.registeredName, + componentCtor: ReadingProgressBar, + registerWebComponent: registerProgressBarComponent, +} diff --git a/src/components/Content/ProgressBar/index.astro b/src/components/Content/ProgressBar/index.astro new file mode 100644 index 000000000..cdebc63b7 --- /dev/null +++ b/src/components/Content/ProgressBar/index.astro @@ -0,0 +1,28 @@ +--- +/** + * Article reading progress bar. + * + * Fixed below the header, shows scroll progress through the article. + * Rendered as a Lit web component wrapping a native element. + * Uses --layout-top-offset from the layout-position store for vertical + * positioning. The data-progress-bar attribute lets the store measure it. + */ +--- + + + + diff --git a/src/components/Content/Switcher/client/__tests__/index.spec.ts b/src/components/Content/Switcher/client/__tests__/index.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/Content/Switcher/client/index.ts b/src/components/Content/Switcher/client/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/Content/Switcher/index.astro b/src/components/Content/Switcher/index.astro new file mode 100644 index 000000000..2101a6c13 --- /dev/null +++ b/src/components/Content/Switcher/index.astro @@ -0,0 +1,38 @@ +--- +import './index.css' + +export type Props = { + /** The currently active variant, used to determine the position of the switcher thumb */ + currentVariant: 'overview' | 'deep-dive' + /** The slug of the current article, used to construct links to the overview and deep dive pages */ + slug: string +} + +const { currentVariant, slug } = Astro.props +--- + +
+ + Overview + + + + + + Deep Dive + +
diff --git a/src/components/Content/Switcher/index.css b/src/components/Content/Switcher/index.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/Header/client/__tests__/headerAnimation.spec.ts b/src/components/Header/client/__tests__/headerAnimation.spec.ts new file mode 100644 index 000000000..cb5c8eead --- /dev/null +++ b/src/components/Header/client/__tests__/headerAnimation.spec.ts @@ -0,0 +1,226 @@ +// @vitest-environment jsdom +/** + * Unit tests for Header Animation (WAAPI two-step collapse/expand) + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + animateCollapse, + animateExpand, + HEADER_TRANSITION_DURATION, + isHeaderAnimating, +} from '../headerAnimation' + +// Mock error handler +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: vi.fn(), +})) + +// ============================================================================ +// HELPERS +// ============================================================================ + +/** Create a minimal header DOM structure for testing. */ +function createHeaderDOM() { + const shell = document.createElement('div') + shell.className = 'header-shell' + + const footprint = document.createElement('div') + footprint.className = 'header-footprint' + footprint.style.height = '60px' + + const fixed = document.createElement('div') + fixed.className = 'header-fixed' + + const header = document.createElement('header') + header.className = 'site-header' + header.style.paddingTop = '12px' + header.style.paddingBottom = '12px' + + const brand = document.createElement('span') + brand.className = 'header-brand' + brand.style.transform = 'scale(1)' + + const icon1 = document.createElement('span') + icon1.className = 'header-icon' + icon1.style.width = '42px' + icon1.style.height = '42px' + + const icon2 = document.createElement('span') + icon2.className = 'header-icon' + icon2.style.width = '42px' + icon2.style.height = '42px' + + const nav = document.createElement('span') + nav.className = 'header-nav' + const link1 = document.createElement('a') + link1.style.fontSize = '16px' + link1.textContent = 'Home' + const link2 = document.createElement('a') + link2.style.fontSize = '16px' + link2.textContent = 'About' + nav.appendChild(link1) + nav.appendChild(link2) + + header.appendChild(brand) + header.appendChild(nav) + header.appendChild(icon1) + header.appendChild(icon2) + fixed.appendChild(header) + shell.appendChild(footprint) + shell.appendChild(fixed) + document.body.appendChild(shell) + + return { shell, footprint, header, brand, icon1, icon2, nav, link1, link2 } +} + +// jsdom doesn't implement Element.animate — provide a stub +function stubAnimate() { + const mockAnimation = { + finished: Promise.resolve(), + cancel: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Element.prototype.animate = vi.fn().mockReturnValue(mockAnimation) as any + return mockAnimation +} + +// ============================================================================ +// TESTS +// ============================================================================ + +describe('headerAnimation', () => { + let dom: ReturnType + + beforeEach(() => { + dom = createHeaderDOM() + stubAnimate() + }) + + afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + describe('HEADER_TRANSITION_DURATION', () => { + it('should be 320ms', () => { + expect(HEADER_TRANSITION_DURATION).toBe(320) + }) + }) + + describe('animateCollapse', () => { + it('should add is-collapsed class after animation completes', async () => { + expect(dom.shell.classList.contains('is-collapsed')).toBe(false) + + await animateCollapse(dom.shell) + + expect(dom.shell.classList.contains('is-collapsed')).toBe(true) + }) + + it('should call Element.animate on animated elements', async () => { + await animateCollapse(dom.shell) + + // brand + siteHeader + footprint + 2 icons + 2 nav links = 7 calls + expect(Element.prototype.animate).toHaveBeenCalled() + const callCount = vi.mocked(Element.prototype.animate).mock.calls.length + expect(callCount).toBeGreaterThanOrEqual(3) // at minimum brand, header, footprint + }) + + it('should pass the correct duration to animate', async () => { + await animateCollapse(dom.shell) + + const calls = vi.mocked(Element.prototype.animate).mock.calls + calls.forEach(([, options]) => { + const opts = options as KeyframeAnimationOptions + expect(opts.duration).toBe(HEADER_TRANSITION_DURATION) + }) + }) + + it('should be a no-op if already collapsed', async () => { + dom.shell.classList.add('is-collapsed') + + await animateCollapse(dom.shell) + + expect(Element.prototype.animate).not.toHaveBeenCalled() + }) + + it('should snap to target on error', async () => { + // Make animate throw + Element.prototype.animate = vi.fn().mockImplementation(() => { + throw new Error('WAAPI not supported') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + + await animateCollapse(dom.shell) + + // Should still have the class applied (snap to target) + expect(dom.shell.classList.contains('is-collapsed')).toBe(true) + }) + }) + + describe('animateExpand', () => { + it('should remove is-collapsed class after animation completes', async () => { + dom.shell.classList.add('is-collapsed') + + await animateExpand(dom.shell) + + expect(dom.shell.classList.contains('is-collapsed')).toBe(false) + }) + + it('should call Element.animate on animated elements', async () => { + dom.shell.classList.add('is-collapsed') + + await animateExpand(dom.shell) + + expect(Element.prototype.animate).toHaveBeenCalled() + }) + + it('should be a no-op if already expanded', async () => { + await animateExpand(dom.shell) + + expect(Element.prototype.animate).not.toHaveBeenCalled() + }) + }) + + describe('isHeaderAnimating', () => { + it('should return false when no animation is running', () => { + expect(isHeaderAnimating()).toBe(false) + }) + + it('should return false after animation completes', async () => { + await animateCollapse(dom.shell) + expect(isHeaderAnimating()).toBe(false) + }) + }) + + describe('keyframe structure', () => { + it('should create 3-keyframe sequences for each element', async () => { + await animateCollapse(dom.shell) + + const calls = vi.mocked(Element.prototype.animate).mock.calls + calls.forEach(([keyframes]) => { + const kf = keyframes as Keyframe[] + expect(kf).toHaveLength(3) + expect(kf[0]?.offset).toBe(0) + expect(kf[1]?.offset).toBe(0.5) + expect(kf[2]?.offset).toBe(1) + }) + }) + }) + + describe('missing elements', () => { + it('should snap to target when header elements are missing', async () => { + // Remove the site-header to make queryElements return null + const siteHeader = dom.shell.querySelector('.site-header') + siteHeader?.remove() + + await animateCollapse(dom.shell) + + // Should still apply the class directly when elements are missing + expect(dom.shell.classList.contains('is-collapsed')).toBe(true) + expect(Element.prototype.animate).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/components/Header/client/headerAnimation.ts b/src/components/Header/client/headerAnimation.ts new file mode 100644 index 000000000..b1897a901 --- /dev/null +++ b/src/components/Header/client/headerAnimation.ts @@ -0,0 +1,394 @@ +/** + * Header Squish Animation + * + * Two-step WAAPI animation for the header collapse/expand effect triggered + * on scroll. Uses the FLIP pattern (First → Last → Invert → Play) to + * measure before/after states, then animates between them. + * + * Collapse (forward): + * Step 1 – scale/size reduction (brand, icons, nav text shrink in place) + * Step 2 – position shift (items move + header height change) + * + * Expand (reverse): + * Step 1 – position shift (header height grows + items move) + * Step 2 – scale/size growth (brand, icons, nav text grow) + */ +import { handleScriptError } from '@components/scripts/errors/handler' + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/** + * Single source of truth for header animation timing (ms). + * Both the collapse WAAPI sequence and the `.header-fixed` CSS translateY + * transition use this same value (set via --header-transition-duration). + */ +export const HEADER_TRANSITION_DURATION = 320 + +const EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' +const COLLAPSED_CLASS = 'is-collapsed' + +// ============================================================================ +// TYPES +// ============================================================================ + +interface AnimationElements { + headerShell: HTMLElement + siteHeader: HTMLElement + brand: HTMLElement + footprint: HTMLElement + icons: HTMLElement[] + navLinks: HTMLElement[] +} + +interface MeasuredSnapshot { + brandTransform: string + headerPaddingTop: string + headerPaddingBottom: string + footprintHeight: string + iconDimensions: { width: string; height: string }[] + navFontSizes: string[] +} + +// ============================================================================ +// STATE +// ============================================================================ + +let isAnimating = false +let currentAnimations: Animation[] = [] + +// ============================================================================ +// PUBLIC API +// ============================================================================ + +/** + * Whether an animation is currently in progress. + * The scroll handler should skip collapse/expand decisions while true. + */ +export const isHeaderAnimating = (): boolean => isAnimating + +/** + * Animate the header from expanded → collapsed. + * Returns a Promise that resolves when complete and the `is-collapsed` + * class is applied. + */ +export const animateCollapse = async (headerShell: HTMLElement): Promise => { + if (isAnimating) return + if (headerShell.classList.contains(COLLAPSED_CLASS)) return + + try { + const elements = queryElements(headerShell) + if (!elements) { + headerShell.classList.add(COLLAPSED_CLASS) + return + } + + isAnimating = true + cancelCurrentAnimations() + + // FLIP: measure expanded state + const from = measureState(elements) + + // FLIP: peek at collapsed state + headerShell.classList.add(COLLAPSED_CLASS) + const to = measureState(elements) + headerShell.classList.remove(COLLAPSED_CLASS) + + // Build 2-step keyframes and play + const animations = buildAnimations(elements, from, to, 'collapse') + currentAnimations = animations + + await Promise.all(animations.map(a => a.finished)) + + // Apply final CSS class + headerShell.classList.add(COLLAPSED_CLASS) + } catch (error) { + // On error, snap to target state + headerShell.classList.add(COLLAPSED_CLASS) + handleScriptError(error, { + scriptName: 'headerAnimation', + operation: 'animateCollapse', + }) + } finally { + cleanUp() + } +} + +/** + * Animate the header from collapsed → expanded. + * Returns a Promise that resolves when complete and the `is-collapsed` + * class is removed. + */ +export const animateExpand = async (headerShell: HTMLElement): Promise => { + if (isAnimating) return + if (!headerShell.classList.contains(COLLAPSED_CLASS)) return + + try { + const elements = queryElements(headerShell) + if (!elements) { + headerShell.classList.remove(COLLAPSED_CLASS) + return + } + + isAnimating = true + cancelCurrentAnimations() + + // FLIP: measure collapsed state + const from = measureState(elements) + + // FLIP: peek at expanded state + headerShell.classList.remove(COLLAPSED_CLASS) + const to = measureState(elements) + headerShell.classList.add(COLLAPSED_CLASS) + + // Build 2-step keyframes and play + const animations = buildAnimations(elements, from, to, 'expand') + currentAnimations = animations + + await Promise.all(animations.map(a => a.finished)) + + // Apply final CSS class + headerShell.classList.remove(COLLAPSED_CLASS) + } catch (error) { + // On error, snap to target state + headerShell.classList.remove(COLLAPSED_CLASS) + handleScriptError(error, { + scriptName: 'headerAnimation', + operation: 'animateExpand', + }) + } finally { + cleanUp() + } +} + +// ============================================================================ +// DOM QUERIES +// ============================================================================ + +/** + * Query all animated elements from the header shell. + * Returns null if required elements are missing (e.g. during SSR or testing). + */ +function queryElements(headerShell: HTMLElement): AnimationElements | null { + const siteHeader = headerShell.querySelector('.site-header') + const brand = headerShell.querySelector('.header-brand') + const footprint = headerShell.querySelector('.header-footprint') + + if (!siteHeader || !brand || !footprint) return null + + const icons = Array.from(headerShell.querySelectorAll('.header-icon')) + const navLinks = Array.from( + headerShell.querySelectorAll('.header-nav a'), + ) + + return { headerShell, siteHeader, brand, footprint, icons, navLinks } +} + +// ============================================================================ +// MEASUREMENT +// ============================================================================ + +/** Snapshot the current computed values of all animated properties. */ +function measureState(elements: AnimationElements): MeasuredSnapshot { + const brandStyle = getComputedStyle(elements.brand) + const headerStyle = getComputedStyle(elements.siteHeader) + const footprintStyle = getComputedStyle(elements.footprint) + + return { + brandTransform: brandStyle.transform || 'none', + headerPaddingTop: headerStyle.paddingTop || '0px', + headerPaddingBottom: headerStyle.paddingBottom || '0px', + footprintHeight: footprintStyle.height || '0px', + iconDimensions: elements.icons.map(icon => { + const s = getComputedStyle(icon) + return { width: s.width || '0px', height: s.height || '0px' } + }), + navFontSizes: elements.navLinks.map(link => { + const s = getComputedStyle(link) + return s.fontSize || '16px' + }), + } +} + +// ============================================================================ +// ANIMATION BUILDER +// ============================================================================ + +/** + * Build WAAPI animations for each element with 3-keyframe sequences. + * + * Collapse (forward) — sizes first, then positions: + * 0% → expanded values for everything + * 50% → collapsed sizes, expanded positions + * 100% → collapsed sizes, collapsed positions + * + * Expand (reverse) — positions first, then sizes: + * 0% → collapsed values for everything + * 50% → collapsed sizes, expanded positions + * 100% → expanded sizes, expanded positions + */ +function buildAnimations( + elements: AnimationElements, + from: MeasuredSnapshot, + to: MeasuredSnapshot, + direction: 'collapse' | 'expand', +): Animation[] { + const opts: KeyframeAnimationOptions = { + duration: HEADER_TRANSITION_DURATION, + easing: EASING, + fill: 'none' as FillMode, + } + + const animations: Animation[] = [] + const isCollapse = direction === 'collapse' + + // -- Brand transform (scale) — size property + animations.push( + elements.brand.animate( + sizeFirstKeyframes( + { transform: from.brandTransform }, + { transform: to.brandTransform }, + isCollapse, + ), + opts, + ), + ) + + // -- Site header padding — position property + animations.push( + elements.siteHeader.animate( + positionFirstKeyframes( + { paddingTop: from.headerPaddingTop, paddingBottom: from.headerPaddingBottom }, + { paddingTop: to.headerPaddingTop, paddingBottom: to.headerPaddingBottom }, + isCollapse, + ), + opts, + ), + ) + + // -- Footprint height — position property + animations.push( + elements.footprint.animate( + positionFirstKeyframes( + { height: from.footprintHeight }, + { height: to.footprintHeight }, + isCollapse, + ), + opts, + ), + ) + + // -- Icons (width + height) — size property + elements.icons.forEach((icon, i) => { + const fromDim = from.iconDimensions[i] + const toDim = to.iconDimensions[i] + if (!fromDim || !toDim) return + + animations.push( + icon.animate( + sizeFirstKeyframes( + { width: fromDim.width, height: fromDim.height }, + { width: toDim.width, height: toDim.height }, + isCollapse, + ), + opts, + ), + ) + }) + + // -- Nav links (font-size) — size property + elements.navLinks.forEach((link, i) => { + const fromFs = from.navFontSizes[i] + const toFs = to.navFontSizes[i] + if (!fromFs || !toFs) return + + animations.push( + link.animate( + sizeFirstKeyframes( + { fontSize: fromFs }, + { fontSize: toFs }, + isCollapse, + ), + opts, + ), + ) + }) + + return animations +} + +// ============================================================================ +// KEYFRAME HELPERS +// ============================================================================ + +/** + * Keyframes for a "size" property: + * Collapse → changes in first half, holds in second + * Expand → holds in first half, changes in second + */ +function sizeFirstKeyframes( + from: Record, + to: Record, + isCollapse: boolean, +): Keyframe[] { + if (isCollapse) { + return [ + { ...from, offset: 0 }, + { ...to, offset: 0.5 }, + { ...to, offset: 1 }, + ] + } + // Expand: sizes change in second half + return [ + { ...from, offset: 0 }, + { ...from, offset: 0.5 }, + { ...to, offset: 1 }, + ] +} + +/** + * Keyframes for a "position" property: + * Collapse → holds in first half, changes in second + * Expand → changes in first half, holds in second + */ +function positionFirstKeyframes( + from: Record, + to: Record, + isCollapse: boolean, +): Keyframe[] { + if (isCollapse) { + // Position changes in second half + return [ + { ...from, offset: 0 }, + { ...from, offset: 0.5 }, + { ...to, offset: 1 }, + ] + } + // Expand: position changes in first half + return [ + { ...from, offset: 0 }, + { ...to, offset: 0.5 }, + { ...to, offset: 1 }, + ] +} + +// ============================================================================ +// CLEANUP +// ============================================================================ + +function cancelCurrentAnimations(): void { + currentAnimations.forEach(a => { + try { + a.cancel() + } catch { + // Animation may already be finished + } + }) + currentAnimations = [] +} + +function cleanUp(): void { + currentAnimations = [] + isAnimating = false +} diff --git a/src/components/Header/client/index.ts b/src/components/Header/client/index.ts index 7dfe83d82..08fe53d29 100644 --- a/src/components/Header/client/index.ts +++ b/src/components/Header/client/index.ts @@ -5,6 +5,8 @@ */ import { handleScriptError } from '@components/scripts/errors/handler' import { isType1Element } from '@components/scripts/assertions/elements' +import { updateLayoutOffsets } from '@components/scripts/store' +import { animateCollapse, animateExpand, isHeaderAnimating } from './headerAnimation' import { getHeaderElement, getHeaderShellElement } from './selectors' const COLLAPSED_CLASS = 'is-collapsed' @@ -79,9 +81,22 @@ const setCollapsedState = (state: HeaderCollapseState, nextCollapsed: boolean): if (state.isCollapsed === nextCollapsed) { return } + if (isHeaderAnimating()) { + return + } state.isCollapsed = nextCollapsed - state.headerShell.classList.toggle(COLLAPSED_CLASS, nextCollapsed) + + const afterAnimate = () => { + // Notify the layout position store that the header height has changed + updateLayoutOffsets() + } + + if (nextCollapsed) { + animateCollapse(state.headerShell).then(afterAnimate) + } else { + animateExpand(state.headerShell).then(afterAnimate) + } } // Determines the collapsed state based on scroll position and direction. diff --git a/src/components/Header/index.astro b/src/components/Header/index.astro index 0b39c4351..63e101ae2 100644 --- a/src/components/Header/index.astro +++ b/src/components/Header/index.astro @@ -1,15 +1,18 @@ --- import Brand from '@components/Brand/index.astro' import Navigation from '@components/Navigation/index.astro' +import ProgressBar from '@components/Content/ProgressBar/index.astro' import SearchBar from '@components/Search/SearchBar/index.astro' import ThemeButton from '@components/ThemePicker/ThemeButton.astro' export interface Props { /** The URL path component e.g. 'articles/my-article' */ path: string + /** Show reading progress bar below the header */ + showProgressBar?: boolean } -const { path } = Astro.props +const { path, showProgressBar = false } = Astro.props import './index.css' --- @@ -65,6 +68,7 @@ import './index.css'
+ {showProgressBar && } diff --git a/src/components/Content/Switcher/index.css b/src/components/Content/Switcher/index.css index 218108d5b..128db4d7d 100644 --- a/src/components/Content/Switcher/index.css +++ b/src/components/Content/Switcher/index.css @@ -9,17 +9,3 @@ .content-switcher-track[aria-checked='true'] .content-switcher-thumb { transform: translateX(1.25rem); } - -.content-switcher-track[aria-checked='false'] ~ .content-switcher-label--overview { - color: var(--color-content-active); - font-weight: 600; -} - -.content-switcher-track[aria-checked='false'] ~ .content-switcher-label--deep-dive { - color: var(--color-content-offset); -} - -.content-switcher-track[aria-checked='true'] ~ .content-switcher-label--deep-dive { - color: var(--color-content-active); - font-weight: 600; -} diff --git a/src/layouts/MarkdownLayout.astro b/src/layouts/MarkdownLayout.astro index d847d8505..41760b87e 100644 --- a/src/layouts/MarkdownLayout.astro +++ b/src/layouts/MarkdownLayout.astro @@ -165,9 +165,6 @@ const shouldRenderToc = showToc !== false && tocHeadings.length > 0 {/** SLOT: hero-image, conditional on image specified */} - {/** SLOT: content-prefix, renders prior to markdown body (e.g., metadata panels) */} - - {/** SLOT: default (unnamed) */}
@@ -28,93 +24,12 @@ const author = await getEntry(article.data.author) const articleBody = article.body ?? '' const readingTime = article.data.readingTime ?? getReadingTimeLabel(articleBody) - -const path = `/articles/${article.id}` -const section = `Articles` --- - (typeof t === 'string' ? t : t.id)) ?? []} + article={article} + path={`/articles/${article.id}`} + section="Articles" {...readingTime && { readingTime }} -> - {/** SLOT: hero-image */} - { - article.data.cover && ( -
- -
- ) - } - - {/** SLOT: after-content */} -
-
-
-

- Share this article -

-

- Found this helpful? Share it with others who might benefit. -

-
- -
- -
- - {/** SLOT: related-content */} -
-
-

- Other things I've written -

-
- -
-
+/> diff --git a/src/pages/deep-dive/[...slug].astro b/src/pages/deep-dive/[...slug].astro index e7e2c1282..e8739da6a 100644 --- a/src/pages/deep-dive/[...slug].astro +++ b/src/pages/deep-dive/[...slug].astro @@ -1,18 +1,14 @@ --- import { type CollectionEntry, getCollection, getEntry } from 'astro:content' -import { Picture } from 'astro:assets' import { isDev } from '@lib/config/environmentServer' import { getReadingTimeLabel } from '@lib/markdown/utils/readingTime' -import MarkdownLayout from '@layouts/MarkdownLayout.astro' -import Shares from '@components/Social/Shares/index.astro' -import Carousel from '@components/Carousel/index.astro' -import WebMentions from '@components/WebMentions/index.astro' +import ContentLayout from '@components/Content/Layout/index.astro' export interface Props { article: CollectionEntry<'deepDives'> } -/** Generate static paths for all articles */ +/** Generate static paths for all deep-dive articles */ export async function getStaticPaths() { const articleEntries = await getCollection('deepDives', ({ data }) => { return isDev() || data.isDraft !== true @@ -28,93 +24,12 @@ const author = await getEntry(article.data.author) const articleBody = article.body ?? '' const readingTime = article.data.readingTime ?? getReadingTimeLabel(articleBody) - -const path = `/articles/deep-dives/${article.id}` -const section = `Deep Dive Articles` --- - (typeof t === 'string' ? t : t.id)) ?? []} + article={article} + path={`/deep-dive/${article.id}`} + section="Deep Dive Articles" {...readingTime && { readingTime }} -> - {/** SLOT: hero-image */} - { - article.data.cover && ( -
- -
- ) - } - - {/** SLOT: after-content */} -
-
-
-

- Share this article -

-

- Found this helpful? Share it with others who might benefit. -

-
- -
- -
- - {/** SLOT: related-content */} -
-
-

- Other things I've written -

-
- -
-
+/> From 5732aebf651c69569bac1771ebf841c799caee03 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 26 Feb 2026 14:48:47 +0300 Subject: [PATCH 12/24] Stylings on case studies list view --- src/pages/case-studies/[slug].astro | 10 ++--- src/pages/case-studies/index.astro | 63 ++++++++++++++++++----------- src/pages/testing/icons.astro | 2 +- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/pages/case-studies/[slug].astro b/src/pages/case-studies/[slug].astro index 037f85b13..e6382f34b 100644 --- a/src/pages/case-studies/[slug].astro +++ b/src/pages/case-studies/[slug].astro @@ -34,20 +34,20 @@ const section = 'Case Studies' { caseStudy.data.cover && (
) diff --git a/src/pages/case-studies/index.astro b/src/pages/case-studies/index.astro index 6433758c9..216d4a61b 100644 --- a/src/pages/case-studies/index.astro +++ b/src/pages/case-studies/index.astro @@ -1,6 +1,7 @@ --- import { getCollection } from 'astro:content' import { Picture } from 'astro:assets' +import Icon from '@components/Icon/index.astro' import PageLayout from '@layouts/PageLayout.astro' const allCaseStudies = await getCollection('caseStudies') @@ -32,10 +33,13 @@ const path = '/case-studies' { sortedCaseStudies.map(caseStudy => { return ( -
- +
+ {caseStudy.data.cover && ( -
+
)} -
-

- {caseStudy.data.title} -

+
+
+

+ {caseStudy.data.title} +

- {caseStudy.data.description && ( -

- {caseStudy.data.description} -

- )} + {caseStudy.data.description && ( +

+ {caseStudy.data.description} +

+ )} +
-
+
{caseStudy.data.client && ( - + Client: {caseStudy.data.client} )} - +
+ + +
+ Learn more + +
+
diff --git a/src/pages/testing/icons.astro b/src/pages/testing/icons.astro index d064b2219..a86693903 100644 --- a/src/pages/testing/icons.astro +++ b/src/pages/testing/icons.astro @@ -34,7 +34,7 @@ const description = 'Preview every marker icon to verify styling and availabilit
{iconNames.map(iconName => (
- + {iconName} From 4908b7f555a7f00eeb9d4bf6982aec81cd9aa97c Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 26 Feb 2026 19:06:39 +0300 Subject: [PATCH 13/24] Add Content Switcher to articles and deep-dive item views --- .cache/pages.json | 130 +++++++++--------- src/components/Breadcrumbs/index.astro | 2 +- src/components/Content/Layout/index.astro | 8 +- .../Content/Switcher/client/index.ts | 4 +- src/components/Content/Switcher/index.astro | 36 ++++- src/components/Content/Switcher/index.css | 2 +- .../Switcher/server/__tests__/index.spec.ts | 81 +++++++++++ .../Content/Switcher/server/index.ts | 32 +++++ .../Layout/Markdown/Lead/index.astro | 63 +++++---- .../Layout/Markdown/Tags/index.astro | 7 +- .../Layout/Markdown/Title/index.astro | 2 +- src/content.config.ts | 19 ++- src/layouts/BaseLayout.astro | 12 +- src/layouts/MarkdownLayout.astro | 10 +- 14 files changed, 296 insertions(+), 112 deletions(-) create mode 100644 src/components/Content/Switcher/server/__tests__/index.spec.ts create mode 100644 src/components/Content/Switcher/server/index.ts diff --git a/.cache/pages.json b/.cache/pages.json index 4602c5ebd..699f2c694 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -90,71 +90,71 @@ "contact", { "deep-dive": [ - "alert-fatigue-reduction-triage-actionable-alerts/pdf", - "api-deprecation-sunset-headers-consumer-migration/pdf", - "api-gateway-metrics-traces-logs-debugging/pdf", - "api-usage-metering-quotas-cost-attribution/pdf", - "argocd-sync-failures-gitops-debugging-troubleshooting/pdf", - "availability-targets-five-nines-cost-benefit-analysis/pdf", - "backpressure-load-shedding-admission-control-overload/pdf", - "blameless-postmortem-incident-analysis-systemic-causes/pdf", - "blue-green-canary-deployment-strategy-comparison/pdf", - "cdn-edge-caching-cache-keys-vary-headers/pdf", - "chaos-engineering-failure-injection-low-cost-experiments/pdf", - "ci-pipeline-caching-docker-layers-dependency-cache/pdf", - "circuit-breaker-retry-budget-cascade-failure-prevention/pdf", - "consumer-driven-contract-testing-pact-internal-apis/pdf", - "container-vulnerability-scanning-ci-shift-left-security/pdf", - "database-schema-migrations-continuous-deployment-zero-downtime/pdf", - "dead-letter-queue-design-replay-debugging/pdf", - "distributed-tracing-sampling-strategies-head-tail/pdf", - "eol-runtime-upgrade-dependency-hell-migration/pdf", - "ephemeral-preview-environments-cost-control-cleanup/pdf", - "flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf", - "golden-paths-developer-experience-standardization-autonomy/pdf", - "grafana-dashboard-hygiene-pruning-actionable-metrics/pdf", - "helm-release-management-drift-detection-debugging/pdf", - "idempotent-message-handlers-deduplication-retries/pdf", - "internal-cli-kubectl-terraform-wrapper-abstraction/pdf", - "internal-developer-portal-platform-self-service-actions/pdf", - "internal-platform-api-versioning-deprecation-breaking-changes/pdf", - "kubernetes-cluster-upgrade-playbook-risk-reduction/pdf", - "kubernetes-cost-optimization-resource-sizing-spot-instances/pdf", - "kubernetes-decision-framework-when-not-to-use/pdf", - "kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf", - "kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf", - "kubernetes-ingress-gateway-api-comparison-migration/pdf", - "kubernetes-multi-cluster-fleet-management-configuration/pdf", - "kubernetes-pod-resource-requests-limits-qos-classes/pdf", - "kubernetes-secrets-external-secrets-operator-csi-vault/pdf", - "legacy-code-testing-characterization-tests-seams/pdf", - "monorepo-affected-builds-remote-caching-ci-optimization/pdf", - "mtls-certificate-rotation-service-mesh-authentication/pdf", - "nginx-haproxy-reverse-proxy-production-tuning/pdf", - "on-call-rotation-small-teams-sustainable-coverage/pdf", - "opa-conftest-policy-as-code-infrastructure-guardrails/pdf", - "openapi-spec-documentation-sdk-generation-validation/pdf", - "opentelemetry-span-design-granularity-overhead/pdf", - "performance-testing-load-models-benchmark-accuracy/pdf", - "platform-architecture-control-plane-data-plane-separation/pdf", - "platform-engineering-metrics-lead-time-developer-friction/pdf", - "postgresql-connection-pooling-saturation-sizing/pdf", - "private-networking-dns-routing-tls-debugging/pdf", - "prometheus-high-cardinality-metrics-label-design/pdf", - "rate-limiting-token-bucket-leaky-bucket-implementation/pdf", - "release-quality-gates-automated-deployment-validation/pdf", - "reverse-engineering-documentation-legacy-systems/pdf", - "service-catalog-metadata-schema-ownership-tracking/pdf", - "service-decommissioning-scream-test-shutdown/pdf", - "slo-error-budget-practical-guide/pdf", - "slsa-build-provenance-artifact-signing-supply-chain/pdf", - "strangler-fig-migration-complete-guide/pdf", - "structured-logging-correlation-ids-log-schema-design/pdf", - "symptom-based-alerting-runbooks-alert-design/pdf", - "synthetic-test-data-pii-anonymization-fixtures/pdf", - "terraform-module-design-defaults-versioning-interfaces/pdf", - "terraform-state-locking-corruption-recovery-backend/pdf", - "workload-identity-federation-keyless-cloud-authentication/pdf" + "alert-fatigue-reduction-triage-actionable-alerts", + "api-deprecation-sunset-headers-consumer-migration", + "api-gateway-metrics-traces-logs-debugging", + "api-usage-metering-quotas-cost-attribution", + "argocd-sync-failures-gitops-debugging-troubleshooting", + "availability-targets-five-nines-cost-benefit-analysis", + "backpressure-load-shedding-admission-control-overload", + "blameless-postmortem-incident-analysis-systemic-causes", + "blue-green-canary-deployment-strategy-comparison", + "cdn-edge-caching-cache-keys-vary-headers", + "chaos-engineering-failure-injection-low-cost-experiments", + "ci-pipeline-caching-docker-layers-dependency-cache", + "circuit-breaker-retry-budget-cascade-failure-prevention", + "consumer-driven-contract-testing-pact-internal-apis", + "container-vulnerability-scanning-ci-shift-left-security", + "database-schema-migrations-continuous-deployment-zero-downtime", + "dead-letter-queue-design-replay-debugging", + "distributed-tracing-sampling-strategies-head-tail", + "eol-runtime-upgrade-dependency-hell-migration", + "ephemeral-preview-environments-cost-control-cleanup", + "flaky-test-diagnosis-race-conditions-e2e-stabilization", + "golden-paths-developer-experience-standardization-autonomy", + "grafana-dashboard-hygiene-pruning-actionable-metrics", + "helm-release-management-drift-detection-debugging", + "idempotent-message-handlers-deduplication-retries", + "internal-cli-kubectl-terraform-wrapper-abstraction", + "internal-developer-portal-platform-self-service-actions", + "internal-platform-api-versioning-deprecation-breaking-changes", + "kubernetes-cluster-upgrade-playbook-risk-reduction", + "kubernetes-cost-optimization-resource-sizing-spot-instances", + "kubernetes-decision-framework-when-not-to-use", + "kubernetes-dns-debugging-ndots-coredns-troubleshooting", + "kubernetes-hpa-autoscaling-metrics-tuning-latency", + "kubernetes-ingress-gateway-api-comparison-migration", + "kubernetes-multi-cluster-fleet-management-configuration", + "kubernetes-pod-resource-requests-limits-qos-classes", + "kubernetes-secrets-external-secrets-operator-csi-vault", + "legacy-code-testing-characterization-tests-seams", + "monorepo-affected-builds-remote-caching-ci-optimization", + "mtls-certificate-rotation-service-mesh-authentication", + "nginx-haproxy-reverse-proxy-production-tuning", + "on-call-rotation-small-teams-sustainable-coverage", + "opa-conftest-policy-as-code-infrastructure-guardrails", + "openapi-spec-documentation-sdk-generation-validation", + "opentelemetry-span-design-granularity-overhead", + "performance-testing-load-models-benchmark-accuracy", + "platform-architecture-control-plane-data-plane-separation", + "platform-engineering-metrics-lead-time-developer-friction", + "postgresql-connection-pooling-saturation-sizing", + "private-networking-dns-routing-tls-debugging", + "prometheus-high-cardinality-metrics-label-design", + "rate-limiting-token-bucket-leaky-bucket-implementation", + "release-quality-gates-automated-deployment-validation", + "reverse-engineering-documentation-legacy-systems", + "service-catalog-metadata-schema-ownership-tracking", + "service-decommissioning-scream-test-shutdown", + "slo-error-budget-practical-guide", + "slsa-build-provenance-artifact-signing-supply-chain", + "strangler-fig-migration-complete-guide", + "structured-logging-correlation-ids-log-schema-design", + "symptom-based-alerting-runbooks-alert-design", + "synthetic-test-data-pii-anonymization-fixtures", + "terraform-module-design-defaults-versioning-interfaces", + "terraform-state-locking-corruption-recovery-backend", + "workload-identity-federation-keyless-cloud-authentication" ] }, "hero", diff --git a/src/components/Breadcrumbs/index.astro b/src/components/Breadcrumbs/index.astro index d6eb66016..eb3198a88 100644 --- a/src/components/Breadcrumbs/index.astro +++ b/src/components/Breadcrumbs/index.astro @@ -37,7 +37,7 @@ const breadcrumbSchema = { breadcrumbs.length > 1 && ( -