From 67dd66c6cc3a5a799b6d9ef5e9e4945c80d3b303 Mon Sep 17 00:00:00 2001 From: mmtr <1233880+mmtr@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:56:34 +0200 Subject: [PATCH 1/4] Station Home: Add a tribute to Eric Binnion A quiet "Thank you, Eric" at the bottom of the sidebar links to the tribute site friends and colleagues put together. In narrow windows it moves to the end of the page. Co-Authored-By: Claude Opus 5.5 --- apps/station-home/parts/view.php | 25 ++++++++++++ apps/station-home/station-home.css | 61 ++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/apps/station-home/parts/view.php b/apps/station-home/parts/view.php index 403978d3..178e508d 100644 --- a/apps/station-home/parts/view.php +++ b/apps/station-home/parts/view.php @@ -150,6 +150,29 @@ function quick_action( array $action ) { ); } +/** + * The dedication to Eric Binnion. + * + * Rendered twice: in the rail, and at the end of the page for when + * the rail is too narrow to hold it. The stylesheet shows one. + * + * @param string $placement `rail` or `main`. + * @return string + */ +function dedication( $placement ) { + return tag( + 'a', + array( + 'class' => 'os-station-home__dedication os-station-home__dedication--' . $placement, + 'href' => 'https://friendsofericbinnion.wordpress.com/', + 'title' => __( 'The Big Legacy of Eric Binnion', 'desktop-mode' ), + 'target' => '_blank', + 'rel' => 'noopener', + ), + icon( 'dashicons-heart' ) . '' . esc( __( 'Thank you, Eric', 'desktop-mode' ) ) . '' + ); +} + /** * The whole body. * @@ -186,6 +209,7 @@ class="os-station-home__brand-mark" } ?> +
@@ -257,6 +281,7 @@ class="os-station-home__refresh" +
diff --git a/apps/station-home/station-home.css b/apps/station-home/station-home.css index 18a881c4..daa5a984 100644 --- a/apps/station-home/station-home.css +++ b/apps/station-home/station-home.css @@ -238,6 +238,57 @@ os-button.os-station-home__action:hover { font-size: 20px; } +/* + * The dedication: quiet until it is pointed at, and neutral rather + * than the accent, which marks state. + */ +.os-station-home__dedication { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + margin-block-start: 18px; + padding: 6px 12px; + border-radius: 8px; + font: 500 12px/1.3 var( --os-ui-font-mono, 'Geist Mono', monospace ); + text-decoration: none; +} + +/* + * wp-admin's own a:focus paints a link blue with a hairline ring, and + * the link keeps focus after it opens the site in a new tab. Matching + * a:focus here keeps the resting look until focus is from the keyboard. + */ +a.os-station-home__dedication, +a.os-station-home__dedication:focus { + color: var( --os-ui-fg-muted, #b3afb5 ); + box-shadow: none; +} + +.os-station-home__dedication .dashicons { + inline-size: 14px; + block-size: 14px; + color: var( --os-ui-fg-faint, #66636b ); + font-size: 14px; +} + +a.os-station-home__dedication:hover, +a.os-station-home__dedication:focus-visible, +.os-station-home__dedication:hover .dashicons, +.os-station-home__dedication:focus-visible .dashicons { + color: var( --os-ui-fg, #fffbff ); +} + +a.os-station-home__dedication:focus-visible { + outline: none; + box-shadow: var( --os-ui-focus-ring, 0 0 0 2px #0c0b0f, 0 0 0 4px #f252fc ); +} + +.os-station-home__dedication--main { + display: none; +} + .os-station-home__main { min-block-size: 0; min-inline-size: 0; @@ -665,6 +716,16 @@ a.os-station-home__card:focus-visible { border-block-end: 1px solid var( --os-ui-border, rgba( 255, 251, 255, 0.12 ) ); } + .os-station-home__dedication--rail { + display: none; + } + + .os-station-home__dedication--main { + display: flex; + inline-size: fit-content; + margin: 40px auto 0; + } + .os-station-home__location, .os-station-home__actions { margin-block-start: 0; From c01f251ee160a3741511c696f8646ddf6b0f5943 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 14 Oct 2026 12:20:04 +0200 Subject: [PATCH 2/4] Send an X-Remembering header for Eric Binnion Every response WordPress builds carries X-Remembering: Eric Binnion , the plugin-side version of the nginx line from https://ma.tt/2026/10/rip-eric/. --- desktop-mode.php | 1 + includes/remembering.php | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 includes/remembering.php diff --git a/desktop-mode.php b/desktop-mode.php index 90bc3060..fec0bb03 100644 --- a/desktop-mode.php +++ b/desktop-mode.php @@ -110,6 +110,7 @@ function openstation_request_needs_admin_modules() { // bootstraps cannot skip it while classifying the request shape. require_once OPENSTATION_DIR . 'includes/about-feed.php'; require_once OPENSTATION_DIR . 'includes/seen-intros.php'; +require_once OPENSTATION_DIR . 'includes/remembering.php'; // One-time data migrations. After os-settings.php and seen-intros.php, // whose meta-key constants and helpers the migrations call. // diff --git a/includes/remembering.php b/includes/remembering.php new file mode 100644 index 00000000..c9104e21 --- /dev/null +++ b/includes/remembering.php @@ -0,0 +1,29 @@ +" always; + * + * Sent on `init`, so it reaches every response WordPress builds: the front + * end, wp-admin, the REST API, admin-ajax and wp-login. Files the web server + * serves on its own (images, CSS, JS) never reach PHP and go without it, + * which only a server-level rule can change. + * + * @package OpenStation + */ + +defined( 'ABSPATH' ) || exit; + +/** + * Send the `X-Remembering` header. + */ +function openstation_send_remembering_header() { + if ( headers_sent() ) { + return; + } + header( 'X-Remembering: Eric Binnion ' ); +} +add_action( 'init', 'openstation_send_remembering_header' ); From 18abfab84a0b92e06a9cdd6151d6676befd490c7 Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Wed, 14 Oct 2026 14:01:04 +0200 Subject: [PATCH 3/4] Station Home: Let the tribute heart open around Eric's name on hover The heart in "Thank you, Eric" beats softly now and then to invite a hover. Pointed at, it is copied pixel for pixel onto a canvas laid exactly over the glyph; those same pixels lift off, arc across the line and open into a heart drawn around the name, keeping the heart shape all the way. The words slide left into the glyph's place and the name steps right so the heart clears "Thank you,". While the pointer stays, the pixels flow round the outline and the heart beats; on leave they fly back to the pixel they came from. The animation is ``, a small companion bundle of the Station Home window (`assets/js/apps/station-home-tribute[.min].js`), drawn with Canvas 2D. `os-preserve` keeps the App Framework's morph from touching its canvas. Reduced motion keeps the plain glyph. Co-Authored-By: Claude Opus 5.5 --- apps/station-home/parts/tribute-script.php | 60 +++ apps/station-home/parts/view.php | 8 +- apps/station-home/station-home.css | 85 ++- apps/station-home/station-home.os.php | 1 + apps/station-home/tribute/heart.test.ts | 115 ++++ apps/station-home/tribute/heart.ts | 267 ++++++++++ apps/station-home/tribute/index.ts | 583 +++++++++++++++++++++ package.json | 3 +- tests/phpunit/tests/stationHomeApp.php | 33 ++ vite.config.js | 9 + 10 files changed, 1159 insertions(+), 5 deletions(-) create mode 100644 apps/station-home/parts/tribute-script.php create mode 100644 apps/station-home/tribute/heart.test.ts create mode 100644 apps/station-home/tribute/heart.ts create mode 100644 apps/station-home/tribute/index.ts diff --git a/apps/station-home/parts/tribute-script.php b/apps/station-home/parts/tribute-script.php new file mode 100644 index 00000000..891b3ff1 --- /dev/null +++ b/apps/station-home/parts/tribute-script.php @@ -0,0 +1,60 @@ +` companion bundle. + * + * Station Home is a server view with no client half, so the one + * animated detail it has, the heart in "Thank you, Eric", ships as a + * small bundle of its own, `assets/js/apps/station-home-tribute[.min].js`, + * registered under the handle `openstation-station-home-tribute` and + * loaded as a first-open companion of the Station Home window. + * + * @package OpenStation + */ + +defined( 'ABSPATH' ) || exit; + +/** The companion script handle. */ +const OPENSTATION_STATION_HOME_TRIBUTE_HANDLE = 'openstation-station-home-tribute'; + +/** + * Register the bundle. Hooked after the framework has loaded the apps + * (the parts load DURING `init` @10, so a hook at 10 would never fire). + */ +function openstation_station_home_tribute_register_script() { + $suffix = openstation_asset_suffix(); + $path = OPENSTATION_DIR . 'assets/js/apps/station-home-tribute' . $suffix . '.js'; + wp_register_script( + OPENSTATION_STATION_HOME_TRIBUTE_HANDLE, + OPENSTATION_URL . 'assets/js/apps/station-home-tribute' . $suffix . '.js', + array(), + file_exists( $path ) ? (string) filemtime( $path ) : OPENSTATION_VERSION, + true + ); +} +add_action( 'init', 'openstation_station_home_tribute_register_script', 11 ); + +/** + * Ride the Station Home window: append the bundle to its companion + * scripts, so the element is defined before the view paints. + * + * @param array $window_args `openstation_register_window()` args. + * @param string $app_id App id. + * @return array + */ +function openstation_station_home_tribute_window_args( $window_args, $app_id ) { + if ( ! is_array( $window_args ) || 'desktop-mode-dashboard' !== (string) $app_id ) { + return $window_args; + } + // The handle registers on `init`; a caller that rebuilt `$wp_scripts` + // since would otherwise hand the window a handle nothing resolves. + if ( ! wp_script_is( OPENSTATION_STATION_HOME_TRIBUTE_HANDLE, 'registered' ) ) { + openstation_station_home_tribute_register_script(); + } + $scripts = isset( $window_args['scripts'] ) ? (array) $window_args['scripts'] : array(); + if ( ! in_array( OPENSTATION_STATION_HOME_TRIBUTE_HANDLE, $scripts, true ) ) { + $scripts[] = OPENSTATION_STATION_HOME_TRIBUTE_HANDLE; + } + $window_args['scripts'] = $scripts; + return $window_args; +} +add_filter( 'openstation_app_window_args', 'openstation_station_home_tribute_window_args', 10, 2 ); diff --git a/apps/station-home/parts/view.php b/apps/station-home/parts/view.php index 178e508d..bd4da4d0 100644 --- a/apps/station-home/parts/view.php +++ b/apps/station-home/parts/view.php @@ -169,7 +169,13 @@ function dedication( $placement ) { 'target' => '_blank', 'rel' => 'noopener', ), - icon( 'dashicons-heart' ) . '' . esc( __( 'Thank you, Eric', 'desktop-mode' ) ) . '' + // `os-preserve`: the heart's canvas belongs to its script, not the re-render. + '' . icon( 'dashicons-heart' ) . '' + . '' . sprintf( + /* translators: %s: Eric, the name the heart wraps itself around. */ + esc( __( 'Thank you, %s', 'desktop-mode' ) ), + '' . esc( 'Eric' ) . '' + ) . '' ); } diff --git a/apps/station-home/station-home.css b/apps/station-home/station-home.css index daa5a984..bcb1de52 100644 --- a/apps/station-home/station-home.css +++ b/apps/station-home/station-home.css @@ -243,12 +243,16 @@ os-button.os-station-home__action:hover { * than the accent, which marks state. */ .os-station-home__dedication { + /* Read by ``: where its pixels start and what they warm to. */ + --os-tribute-heart-rest: var( --os-ui-fg-faint, #66636b ); + --os-tribute-heart-glow: #ff5a5f; position: relative; z-index: 1; display: flex; align-items: center; gap: 8px; - margin-block-start: 18px; + /* Room for the hover heart, which is taller than the line. */ + margin-block: 30px 22px; padding: 6px 12px; border-radius: 8px; font: 500 12px/1.3 var( --os-ui-font-mono, 'Geist Mono', monospace ); @@ -269,10 +273,77 @@ a.os-station-home__dedication:focus { .os-station-home__dedication .dashicons { inline-size: 14px; block-size: 14px; - color: var( --os-ui-fg-faint, #66636b ); + color: var( --os-tribute-heart-rest ); font-size: 14px; } +/* + * The hover heart. The element stays unpositioned so its canvas is + * placed against the link; while the canvas is painting, the glyph it + * copied is hidden underneath it. + */ +os-tribute-heart { + display: flex; +} + +/* + * At rest the heart beats now and then, softly, to invite a hover. + * It stops the moment the link is pointed at, so the hover starts + * from the glyph's own, unscaled pixels. + */ +@keyframes os-tribute-heart-beat { + 0%, + 22%, + 100% { + transform: scale( 1 ); + } + + 6% { + transform: scale( 1.12 ); + color: var( --os-ui-fg-muted, #b3afb5 ); + } + + 12% { + transform: scale( 1 ); + } + + 17% { + transform: scale( 1.06 ); + } +} + +os-tribute-heart .dashicons { + animation: os-tribute-heart-beat 3.2s ease-in-out 1.5s infinite; +} + +a.os-station-home__dedication:hover os-tribute-heart .dashicons, +a.os-station-home__dedication:focus-visible os-tribute-heart .dashicons, +os-tribute-heart[active] .dashicons { + animation: none; +} + +os-tribute-heart[active] .dashicons { + visibility: hidden; +} + +.os-tribute-heart__canvas { + position: absolute; + z-index: 2; + pointer-events: none; +} + +.os-station-home__dedication-name { + /* Inline-block so the heart's script can slide it aside. */ + display: inline-block; + transition: color 0.6s ease, text-shadow 0.6s ease; +} + +a.os-station-home__dedication:hover .os-station-home__dedication-name, +a.os-station-home__dedication:focus-visible .os-station-home__dedication-name { + color: var( --os-tribute-heart-glow ); + text-shadow: 0 0 12px rgba( 255, 90, 95, 0.45 ); +} + a.os-station-home__dedication:hover, a.os-station-home__dedication:focus-visible, .os-station-home__dedication:hover .dashicons, @@ -723,7 +794,7 @@ a.os-station-home__card:focus-visible { .os-station-home__dedication--main { display: flex; inline-size: fit-content; - margin: 40px auto 0; + margin: 44px auto 28px; } .os-station-home__location, @@ -815,6 +886,14 @@ a.os-station-home__card:focus-visible { } @media ( prefers-reduced-motion: reduce ) { + .os-station-home__dedication-name { + transition: none; + } + + os-tribute-heart .dashicons { + animation: none; + } + .os-station-home__work-row, .os-station-home__attention-row, .os-station-home__action { diff --git a/apps/station-home/station-home.os.php b/apps/station-home/station-home.os.php index 6c820203..a4dd6017 100644 --- a/apps/station-home/station-home.os.php +++ b/apps/station-home/station-home.os.php @@ -35,6 +35,7 @@ require_once __DIR__ . '/parts/snapshot.php'; require_once __DIR__ . '/parts/view.php'; +require_once __DIR__ . '/parts/tribute-script.php'; /** The window id the Dashboard URL remap opens. */ const APP_ID = 'desktop-mode-dashboard'; diff --git a/apps/station-home/tribute/heart.test.ts b/apps/station-home/tribute/heart.test.ts new file mode 100644 index 00000000..621cc5e9 --- /dev/null +++ b/apps/station-home/tribute/heart.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { createHeartOutline, fitHeartAround, insidePolygon, mapToOutline } from './heart'; + +describe( 'createHeartOutline', () => { + const outline = createHeartOutline(); + + it( 'is one unit wide and centred on its bounding box', () => { + const xs = outline.polygon.map( ( p ) => p.x ); + const ys = outline.polygon.map( ( p ) => p.y ); + expect( Math.max( ...xs ) - Math.min( ...xs ) ).toBeCloseTo( 1, 2 ); + expect( Math.max( ...xs ) + Math.min( ...xs ) ).toBeCloseTo( 0, 2 ); + expect( Math.max( ...ys ) + Math.min( ...ys ) ).toBeCloseTo( 0, 2 ); + // Squatter than the classic curve's 0.9: the dip is shallow. + expect( outline.aspect ).toBeGreaterThan( 0.78 ); + expect( outline.aspect ).toBeLessThan( 0.88 ); + } ); + + it( 'is sampled at even steps along the curve', () => { + const steps = outline.polygon.map( ( p, i ) => { + const q = outline.polygon[ ( i + 1 ) % outline.polygon.length ]; + return Math.hypot( q.x - p.x, q.y - p.y ); + } ); + expect( Math.max( ...steps ) / Math.min( ...steps ) ).toBeLessThan( 1.05 ); + } ); + + it( 'starts at the dip, points down at the tip, and wraps', () => { + expect( outline.at( 0 ).x ).toBeCloseTo( 0, 3 ); + expect( outline.at( 0.5 ).y ).toBeGreaterThan( 0.4 ); + expect( outline.at( 1.25 ).x ).toBeCloseTo( outline.at( 0.25 ).x, 6 ); + } ); + + it( 'has outward normals', () => { + // Right side faces right, the tip faces down. + expect( outline.normal( 0.25 ).x ).toBeGreaterThan( 0.5 ); + expect( outline.normal( 0.5 ).y ).toBeGreaterThan( 0.5 ); + for ( const u of [ 0.1, 0.3, 0.6, 0.85 ] ) { + const p = outline.at( u ); + const n = outline.normal( u ); + expect( insidePolygon( { x: p.x + n.x * 0.02, y: p.y + n.y * 0.02 }, outline.polygon ) ).toBe( false ); + expect( insidePolygon( { x: p.x - n.x * 0.02, y: p.y - n.y * 0.02 }, outline.polygon ) ).toBe( true ); + } + } ); +} ); + +describe( 'fitHeartAround', () => { + const outline = createHeartOutline(); + + it( 'holds the whole box, and only just', () => { + const w = 40; + const h = 20; + const fit = fitHeartAround( outline, w, h ); + const corners = [ + [ -w / 2, -h / 2 ], + [ w / 2, -h / 2 ], + [ -w / 2, h / 2 ], + [ w / 2, h / 2 ], + [ 0, -h / 2 ], + ]; + for ( const [ x, y ] of corners ) { + expect( insidePolygon( { x: x / fit.width, y: ( y + fit.offsetY ) / fit.width }, outline.polygon ) ).toBe( true ); + } + const smaller = fit.width * 0.97; + const anyOut = corners.some( ( [ x, y ] ) => ! insidePolygon( { x: x / smaller, y: ( y + fit.offsetY ) / smaller }, outline.polygon ) ); + expect( anyOut ).toBe( true ); + } ); + + it( 'grows with the box', () => { + expect( fitHeartAround( outline, 60, 20 ).width ).toBeGreaterThan( fitHeartAround( outline, 30, 20 ).width ); + } ); +} ); + +describe( 'mapToOutline', () => { + const outline = createHeartOutline(); + + it( 'spreads pixels evenly round the outline', () => { + const points = []; + for ( let y = -5; y <= 5; y++ ) { + for ( let x = -5; x <= 5; x++ ) { + points.push( { x, y } ); + } + } + const slots = mapToOutline( outline, points ); + const us = slots.map( ( s ) => s.u ).sort( ( a, b ) => a - b ); + const gaps = us.map( ( u, i ) => ( i ? u - us[ i - 1 ] : u + 1 - us[ us.length - 1 ] ) ); + expect( Math.max( ...gaps ) ).toBeCloseTo( 1 / points.length, 6 ); + for ( const s of slots ) { + expect( s.band ).toBeGreaterThanOrEqual( -1 ); + expect( s.band ).toBeLessThanOrEqual( 1 ); + } + } ); + + it( 'keeps each side on its side', () => { + const slots = mapToOutline( outline, [ + { x: -4, y: -1 }, + { x: 4, y: -1 }, + { x: 0, y: 6 }, + ] ); + expect( outline.at( slots[ 0 ].u ).x ).toBeLessThan( 0 ); + expect( outline.at( slots[ 1 ].u ).x ).toBeGreaterThan( 0 ); + expect( outline.at( slots[ 2 ].u ).y ).toBeGreaterThan( 0 ); + } ); + + it( 'sends the outermost pixel in a direction to the outer edge', () => { + const slots = mapToOutline( outline, [ + { x: 1, y: 0 }, + { x: 3, y: 0 }, + ] ); + expect( slots[ 1 ].band ).toBe( 1 ); + expect( slots[ 0 ].band ).toBe( -1 ); + } ); + + it( 'handles no pixels', () => { + expect( mapToOutline( outline, [] ) ).toEqual( [] ); + } ); +} ); diff --git a/apps/station-home/tribute/heart.ts b/apps/station-home/tribute/heart.ts new file mode 100644 index 00000000..12d94f59 --- /dev/null +++ b/apps/station-home/tribute/heart.ts @@ -0,0 +1,267 @@ +/** + * Station Home tribute — heart geometry. + * + * Pure maths for ``: the classic parametric heart + * resampled by arc length, a fit that sizes it around a box of text, + * and the mapping that sends every pixel of the small filled glyph to + * a place on the large outline. Kept DOM-free so it can be tested on + * its own. + * + * Coordinates are screen-oriented (y grows downwards). The unit heart + * is one unit wide and centred on its bounding box. + */ + +export interface Vec { + x: number; + y: number; +} + +export interface HeartOutline { + /** The point at arc-length fraction `u` (wraps). */ + at( u: number ): Vec; + /** The outward unit normal at arc-length fraction `u` (wraps). */ + normal( u: number ): Vec; + /** The arc-length fraction whose point lies closest to polar angle `angle`. */ + uAtAngle( angle: number ): number; + /** The resampled outline, for hit tests. */ + polygon: Vec[]; + /** Height over width. */ + aspect: number; +} + +export interface HeartFit { + /** Heart width, in the units of the box. */ + width: number; + /** Box centre minus heart centre, along y. */ + offsetY: number; +} + +export interface OutlineSlot { + /** Arc-length fraction on the outline. */ + u: number; + /** Across the stroke: -1 inner edge, 1 outer edge. */ + band: number; +} + +const SAMPLES = 720; + +/** + * The heart curve x = 16 sin³t, y = 13 cos t − 3.5 cos 2t − 1.5 cos 3t − 0.6 cos 4t, + * flipped for screen space. t = 0 is the dip between the lobes and the + * curve runs clockwise on screen. + * + * The classic curve's harmonics are 5, 2 and 1; softened here so the + * dip between the lobes is shallow. A deep dip pushes down into the + * text the heart holds, which forces the whole heart larger. + * + * @param t Curve parameter. + */ +function rawHeart( t: number ): Vec { + const s = Math.sin( t ); + return { + x: 16 * s * s * s, + y: -( 13 * Math.cos( t ) - 3.5 * Math.cos( 2 * t ) - 1.5 * Math.cos( 3 * t ) - 0.6 * Math.cos( 4 * t ) ), + }; +} + +function wrap( u: number ): number { + return u - Math.floor( u ); +} + +/** + * Build the unit heart outline, resampled so equal steps of `u` are + * equal steps along the curve. + */ +export function createHeartOutline(): HeartOutline { + const dense = 4096; + const raw: Vec[] = []; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for ( let i = 0; i < dense; i++ ) { + const p = rawHeart( ( i / dense ) * Math.PI * 2 ); + raw.push( p ); + minX = Math.min( minX, p.x ); + maxX = Math.max( maxX, p.x ); + minY = Math.min( minY, p.y ); + maxY = Math.max( maxY, p.y ); + } + const width = maxX - minX; + const cx = ( minX + maxX ) / 2; + const cy = ( minY + maxY ) / 2; + const unit = raw.map( ( p ) => ( { x: ( p.x - cx ) / width, y: ( p.y - cy ) / width } ) ); + + const lengths = [ 0 ]; + for ( let i = 1; i <= dense; i++ ) { + const a = unit[ i - 1 ]; + const b = unit[ i % dense ]; + lengths.push( lengths[ i - 1 ] + Math.hypot( b.x - a.x, b.y - a.y ) ); + } + const total = lengths[ dense ]; + + const polygon: Vec[] = []; + let j = 0; + for ( let k = 0; k < SAMPLES; k++ ) { + const target = ( k / SAMPLES ) * total; + while ( lengths[ j + 1 ] < target ) { + j++; + } + const span = lengths[ j + 1 ] - lengths[ j ] || 1; + const f = ( target - lengths[ j ] ) / span; + const a = unit[ j ]; + const b = unit[ ( j + 1 ) % dense ]; + polygon.push( { x: a.x + ( b.x - a.x ) * f, y: a.y + ( b.y - a.y ) * f } ); + } + + const angles = polygon.map( ( p ) => Math.atan2( p.y, p.x ) ); + + const at = ( u: number ): Vec => { + const pos = wrap( u ) * SAMPLES; + const i = Math.floor( pos ) % SAMPLES; + const f = pos - Math.floor( pos ); + const a = polygon[ i ]; + const b = polygon[ ( i + 1 ) % SAMPLES ]; + return { x: a.x + ( b.x - a.x ) * f, y: a.y + ( b.y - a.y ) * f }; + }; + + return { + at, + normal( u: number ): Vec { + const e = 1 / SAMPLES; + const a = at( u - e ); + const b = at( u + e ); + const tx = b.x - a.x; + const ty = b.y - a.y; + const len = Math.hypot( tx, ty ) || 1; + // Clockwise on screen, so the outward side is the tangent turned left. + return { x: ty / len, y: -tx / len }; + }, + uAtAngle( angle: number ): number { + let best = 0; + let bestDiff = Infinity; + for ( let k = 0; k < SAMPLES; k++ ) { + const d = Math.abs( Math.atan2( Math.sin( angles[ k ] - angle ), Math.cos( angles[ k ] - angle ) ) ); + if ( d < bestDiff ) { + bestDiff = d; + best = k; + } + } + return best / SAMPLES; + }, + polygon, + aspect: ( maxY - minY ) / width, + }; +} + +/** + * Whether a point lies inside a polygon (even-odd rule). + * + * @param p The point. + * @param polygon The polygon. + */ +export function insidePolygon( p: Vec, polygon: Vec[] ): boolean { + let inside = false; + for ( let i = 0, j = polygon.length - 1; i < polygon.length; j = i++ ) { + const a = polygon[ i ]; + const b = polygon[ j ]; + if ( ( a.y > p.y ) !== ( b.y > p.y ) && p.x < ( ( b.x - a.x ) * ( p.y - a.y ) ) / ( b.y - a.y ) + a.x ) { + inside = ! inside; + } + } + return inside; +} + +function boxFits( outline: HeartOutline, w: number, h: number, width: number, offsetY: number ): boolean { + const steps = 8; + for ( let i = 0; i <= steps; i++ ) { + const f = i / steps - 0.5; + const edges: Vec[] = [ + { x: f * w, y: offsetY - h / 2 }, + { x: f * w, y: offsetY + h / 2 }, + { x: -w / 2, y: offsetY + f * h }, + { x: w / 2, y: offsetY + f * h }, + ]; + for ( const e of edges ) { + if ( ! insidePolygon( { x: e.x / width, y: e.y / width }, outline.polygon ) ) { + return false; + } + } + } + return true; +} + +/** + * The smallest heart that holds a `w` × `h` box, and where the box + * sits inside it. + * + * @param outline The unit outline. + * @param w Box width. + * @param h Box height. + */ +export function fitHeartAround( outline: HeartOutline, w: number, h: number ): HeartFit { + let best: HeartFit = { width: Infinity, offsetY: 0 }; + const size = Math.max( w, h, 1 ); + for ( let s = -12; s <= 12; s++ ) { + // Offsets as a fraction of the heart width, so they scale with it. + const rel = ( s / 12 ) * 0.2; + let lo = size; + let hi = size * 8; + if ( ! boxFits( outline, w, h, hi, rel * hi ) ) { + continue; + } + for ( let k = 0; k < 28; k++ ) { + const mid = ( lo + hi ) / 2; + if ( boxFits( outline, w, h, mid, rel * mid ) ) { + hi = mid; + } else { + lo = mid; + } + } + if ( hi < best.width ) { + best = { width: hi, offsetY: rel * hi }; + } + } + return best; +} + +/** + * Give every pixel of a filled shape a place on the outline. + * + * Pixels are walked clockwise by polar angle and spread evenly along + * the outline, starting where the first one points, so the left lobe + * lands on the left lobe and the tip on the tip. Across the stroke, + * the pixels farthest from the centre in their direction take the + * outer edge. + * + * @param outline The unit outline. + * @param points Pixel offsets from the shape's centre, any scale. + */ +export function mapToOutline( outline: HeartOutline, points: Vec[] ): OutlineSlot[] { + const n = points.length; + if ( 0 === n ) { + return []; + } + const angles = points.map( ( p ) => Math.atan2( p.y, p.x ) ); + const order = points.map( ( _, i ) => i ).sort( ( a, b ) => angles[ a ] - angles[ b ] ); + const start = outline.uAtAngle( angles[ order[ 0 ] ] ); + + const slots: OutlineSlot[] = new Array( n ); + order.forEach( ( index, rank ) => { + slots[ index ] = { u: wrap( start + rank / n ), band: 0 }; + } ); + + const buckets = 48; + const groups: number[][] = Array.from( { length: buckets }, () => [] ); + points.forEach( ( _, i ) => { + const b = Math.floor( ( ( angles[ i ] + Math.PI ) / ( Math.PI * 2 ) ) * buckets ) % buckets; + groups[ b ].push( i ); + } ); + for ( const group of groups ) { + group.sort( ( a, b ) => Math.hypot( points[ a ].x, points[ a ].y ) - Math.hypot( points[ b ].x, points[ b ].y ) ); + group.forEach( ( index, rank ) => { + slots[ index ].band = group.length > 1 ? ( 2 * rank ) / ( group.length - 1 ) - 1 : 0; + } ); + } + return slots; +} diff --git a/apps/station-home/tribute/index.ts b/apps/station-home/tribute/index.ts new file mode 100644 index 00000000..80889ad4 --- /dev/null +++ b/apps/station-home/tribute/index.ts @@ -0,0 +1,583 @@ +/** + * `` — the heart in Station Home's "Thank you, Eric". + * + * At rest the element is just the Dashicons heart it wraps. When the + * link is hovered (or focused from the keyboard) the glyph is copied + * pixel for pixel onto a canvas laid exactly over it, and the real + * glyph is hidden, so nothing visibly changes on that frame. Those + * same pixels then lift off, arc across the line and open out into a + * heart drawn around the name, staying heart shaped all the way. The + * words slide left into the place the glyph left, and the name steps + * right so the heart clears the words before it. + * While the pointer stays, the pixels keep travelling around the + * outline and the heart beats. On leave they fly back to the exact + * pixel they came from and the glyph returns. + * + * The canvas lives inside this element so the App Framework's morph, + * told by `os-preserve` to leave the element alone, never removes it + * mid-flight. It is positioned against the link, which is the nearest + * positioned ancestor. + * + * Reduced motion keeps the plain glyph. + * + * Built as its own bundle (`assets/js/apps/station-home-tribute[.min].js`), + * a companion script of the Station Home window. + */ + +import { createHeartOutline, fitHeartAround, mapToOutline, type HeartOutline, type Vec } from './heart'; + +const TAG = 'os-tribute-heart'; + +/** Canvas bleed around the link, in CSS pixels. */ +const BLEED = 72; +const ENTER_MS = 1400; +const LEAVE_MS = 950; +/** The largest head start one pixel gets over another; small, so the shape holds. */ +const STAGGER = 0.06; +/** Stroke width of the big heart, in CSS pixels. */ +const STROKE = 1.8; +/** Space between the name and the heart, in CSS pixels. */ +const NAME_PAD_X = 8; +const NAME_PAD_Y = 4; +/** Space kept between the heart and the words before the name, in CSS pixels. */ +const NAME_GAP = 6; +/** + * How far the heart sits above the fitted centre, in CSS pixels. The + * name's line box carries more space under the letters than over + * them, so a heart fitted to the box reads low. + */ +const HEART_LIFT = 3; + +type Rgba = [ number, number, number, number ]; + +interface Pixel { + /** Offset from the glyph's centre, CSS pixels. */ + fx: number; + fy: number; + /** Coverage, 0–1. */ + alpha: number; + u: number; + band: number; + delay: number; + seed: number; +} + +let sharedOutline: HeartOutline | null = null; + +function outline(): HeartOutline { + if ( ! sharedOutline ) { + sharedOutline = createHeartOutline(); + } + return sharedOutline; +} + +function clamp01( v: number ): number { + return Math.min( 1, Math.max( 0, v ) ); +} + +function easeInOutCubic( t: number ): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow( -2 * t + 2, 3 ) / 2; +} + +function easeOutBack( t: number ): number { + const c1 = 1.4; + const c3 = c1 + 1; + return 1 + c3 * Math.pow( t - 1, 3 ) + c1 * Math.pow( t - 1, 2 ); +} + +function easeInOutSine( t: number ): number { + return -( Math.cos( Math.PI * t ) - 1 ) / 2; +} + +function smoothstep( a: number, b: number, v: number ): number { + const t = clamp01( ( v - a ) / ( b - a ) ); + return t * t * ( 3 - 2 * t ); +} + +/** A deterministic 0–1 value per pixel. */ +function hash( i: number ): number { + const s = Math.sin( i * 12.9898 + 78.233 ) * 43758.5453; + return s - Math.floor( s ); +} + +/** Lub-dub, 0–1, once every 1.4 seconds. */ +function heartbeat( seconds: number ): number { + const x = seconds % 1.4; + const pulse = ( mu: number, sigma: number ) => Math.exp( -Math.pow( ( x - mu ) / sigma, 2 ) ); + return Math.min( 1, pulse( 0.08, 0.05 ) + 0.6 * pulse( 0.34, 0.06 ) ); +} + +/** Any CSS colour, as RGBA bytes plus 0–1 alpha. */ +function parseColor( value: string, fallback: Rgba ): Rgba { + const probe = document.createElement( 'canvas' ).getContext( '2d' ); + if ( ! probe || '' === value.trim() ) { + return fallback; + } + probe.fillStyle = '#010203'; + probe.fillStyle = value.trim(); + const normal = String( probe.fillStyle ); + if ( normal.startsWith( '#' ) ) { + if ( '#010203' === normal && '#010203' !== value.trim() ) { + return fallback; + } + const byte = ( at: number ) => parseInt( normal.slice( at, at + 2 ), 16 ); + return [ byte( 1 ), byte( 3 ), byte( 5 ), 1 ]; + } + const parts = normal.match( /[\d.]+/g ); + if ( ! parts || parts.length < 3 ) { + return fallback; + } + return [ +parts[ 0 ], +parts[ 1 ], +parts[ 2 ], parts.length > 3 ? +parts[ 3 ] : 1 ]; +} + +/** The glyph a pseudo-element's `content` paints. */ +function pseudoContent( value: string ): string { + const v = value.trim(); + if ( v.length >= 2 && ( v[ 0 ] === '"' || v[ 0 ] === "'" ) && v[ v.length - 1 ] === v[ 0 ] ) { + return v.slice( 1, -1 ); + } + return 'none' === v || 'normal' === v ? '' : v; +} + +export class OsTributeHeart extends HTMLElement { + private link: HTMLElement | null = null; + private canvas: HTMLCanvasElement | null = null; + private ctx: CanvasRenderingContext2D | null = null; + private pixels: Pixel[] = []; + private positions = new Float32Array( 0 ); + private dpr = 1; + /** Glyph centre and heart centre, relative to the canvas, CSS pixels. */ + private from: Vec = { x: 0, y: 0 }; + private to: Vec = { x: 0, y: 0 }; + /** Big heart width, and its ratio to the glyph. */ + private heartWidth = 0; + private glyphWidth = 1; + private ratio = 1; + /** Canvas origin in the viewport, CSS pixels. */ + private origin: Vec = { x: 0, y: 0 }; + private rest: Rgba = [ 102, 99, 107, 1 ]; + private glow: Rgba = [ 255, 90, 95, 1 ]; + private progress = 0; + private goal = 0; + private clock = 0; + private last = 0; + private frame = 0; + private resize: ResizeObserver | null = null; + /** + * The words, which slide left into the glyph's place, and the name + * inside them, which steps right to make room for the heart: how + * far in full, and how far right now. + */ + private words: HTMLElement | null = null; + private wordsShift = 0; + private wordsOffset = 0; + private name: HTMLElement | null = null; + private nameShift = 0; + private nameOffset = 0; + + connectedCallback(): void { + this.link = this.closest( 'a' ); + if ( ! this.link ) { + return; + } + this.link.addEventListener( 'pointerenter', this.onEnter ); + this.link.addEventListener( 'pointerleave', this.onLeave ); + this.link.addEventListener( 'focus', this.onFocus ); + this.link.addEventListener( 'blur', this.onBlur ); + } + + disconnectedCallback(): void { + if ( this.link ) { + this.link.removeEventListener( 'pointerenter', this.onEnter ); + this.link.removeEventListener( 'pointerleave', this.onLeave ); + this.link.removeEventListener( 'focus', this.onFocus ); + this.link.removeEventListener( 'blur', this.onBlur ); + } + this.stop(); + this.link = null; + } + + private onEnter = (): void => { + this.setGoal( 1 ); + }; + + private onLeave = (): void => { + if ( ! this.link?.matches( ':focus-visible' ) ) { + this.setGoal( 0 ); + } + }; + + private onFocus = (): void => { + if ( this.link?.matches( ':focus-visible' ) ) { + this.setGoal( 1 ); + } + }; + + private onBlur = (): void => { + if ( ! this.link?.matches( ':hover' ) ) { + this.setGoal( 0 ); + } + }; + + private setGoal( goal: number ): void { + if ( window.matchMedia?.( '( prefers-reduced-motion: reduce )' ).matches ) { + return; + } + if ( 1 === goal && 0 === this.progress && ! this.frame && ! this.prepare() ) { + return; + } + this.goal = goal; + if ( ! this.frame && ( this.progress > 0 || goal > 0 ) ) { + this.last = performance.now(); + this.frame = requestAnimationFrame( this.tick ); + } + } + + /** + * Lay the canvas over the link and copy the glyph into pixels. + * Returns false when there is nothing to animate. + */ + private prepare(): boolean { + const glyph = this.querySelector< HTMLElement >( '.dashicons' ); + if ( ! this.link || ! glyph || ! this.place() ) { + return false; + } + const style = getComputedStyle( this.link ); + this.rest = parseColor( style.getPropertyValue( '--os-tribute-heart-rest' ), this.rest ); + this.glow = parseColor( style.getPropertyValue( '--os-tribute-heart-glow' ), this.glow ); + return this.capture( glyph ); + } + + /** Size and align the canvas, and measure where the heart goes. */ + private place(): boolean { + const link = this.link; + const name = link?.querySelector< HTMLElement >( '[data-os-tribute-name]' ); + const glyph = this.querySelector< HTMLElement >( '.dashicons' ); + if ( ! link || ! name || ! glyph ) { + return false; + } + const box = link.getBoundingClientRect(); + if ( box.width <= 0 || box.height <= 0 ) { + return false; + } + this.dpr = window.devicePixelRatio || 1; + const dpr = this.dpr; + + if ( ! this.canvas ) { + this.canvas = document.createElement( 'canvas' ); + this.canvas.className = 'os-tribute-heart__canvas'; + this.canvas.setAttribute( 'aria-hidden', 'true' ); + this.ctx = this.canvas.getContext( '2d' ); + this.appendChild( this.canvas ); + } + if ( ! this.ctx ) { + return false; + } + + // Start the canvas on a whole device pixel, so a canvas pixel + // is a screen pixel and the copy lands exactly on the glyph. + const left = Math.round( ( box.left - BLEED ) * dpr ) / dpr; + const top = Math.round( ( box.top - BLEED ) * dpr ) / dpr; + const width = Math.ceil( ( box.width + BLEED * 2 ) * dpr ); + const height = Math.ceil( ( box.height + BLEED * 2 ) * dpr ); + this.canvas.style.left = `${ left - box.left }px`; + this.canvas.style.top = `${ top - box.top }px`; + this.canvas.style.width = `${ width / dpr }px`; + this.canvas.style.height = `${ height / dpr }px`; + if ( this.canvas.width !== width || this.canvas.height !== height ) { + this.canvas.width = width; + this.canvas.height = height; + } + this.origin = { x: left, y: top }; + + // Everything measured where it rests, not where it has moved to. + this.name = name; + this.words = name.parentElement; + const n = name.getBoundingClientRect(); + const nameLeft = n.left - this.nameOffset - this.wordsOffset; + + // With the glyph gone, the words take its place. + this.wordsShift = 0; + if ( this.words && this.words !== link ) { + this.wordsShift = Math.min( 0, glyph.getBoundingClientRect().left - ( this.words.getBoundingClientRect().left - this.wordsOffset ) ); + } + const fit = fitHeartAround( outline(), n.width + NAME_PAD_X * 2, n.height + NAME_PAD_Y * 2 ); + this.heartWidth = fit.width; + this.ratio = this.heartWidth / this.glyphWidth; + + // The heart is wider than the name; the name steps right until + // the heart clears the words before it ("Thank you,"). + this.nameShift = 0; + const before = name.previousSibling; + const words = before && Node.TEXT_NODE === before.nodeType ? ( before.textContent ?? '' ).trimEnd() : ''; + if ( before && '' !== words ) { + const range = document.createRange(); + range.setStart( before, 0 ); + range.setEnd( before, words.length ); + const heartLeft = nameLeft + n.width / 2 - fit.width / 2; + const wordsRight = range.getBoundingClientRect().right - this.wordsOffset; + this.nameShift = Math.max( 0, wordsRight + NAME_GAP - heartLeft ); + } + + this.to = { + x: nameLeft + n.width / 2 + this.nameShift + this.wordsShift - left, + y: n.top + n.height / 2 - fit.offsetY - HEART_LIFT - top, + }; + return true; + } + + /** Rasterise the glyph exactly as the page paints it, and keep its pixels. */ + private capture( glyph: HTMLElement ): boolean { + const dpr = this.dpr; + const { x: left, y: top } = this.origin; + const before = getComputedStyle( glyph, '::before' ); + const char = pseudoContent( before.content ); + if ( '' === char ) { + return false; + } + const rect = glyph.getBoundingClientRect(); + const font = `${ before.fontStyle } ${ before.fontWeight } ${ before.fontSize } ${ before.fontFamily }`; + const fontSize = parseFloat( before.fontSize ) || rect.height; + const lineHeight = parseFloat( before.lineHeight ) || fontSize; + + // Room for a glyph that overhangs its box. + const margin = Math.ceil( fontSize * 0.5 * dpr ); + const x0 = Math.floor( ( rect.left - left ) * dpr ) - margin; + const y0 = Math.floor( ( rect.top - top ) * dpr ) - margin; + const w = Math.ceil( rect.width * dpr ) + margin * 2; + const h = Math.ceil( rect.height * dpr ) + margin * 2; + + const scratch = document.createElement( 'canvas' ); + scratch.width = w; + scratch.height = h; + const sctx = scratch.getContext( '2d', { willReadFrequently: true } ); + if ( ! sctx ) { + return false; + } + sctx.font = font; + const metrics = sctx.measureText( char ); + const ascent = metrics.fontBoundingBoxAscent ?? fontSize * 0.8; + const descent = metrics.fontBoundingBoxDescent ?? fontSize * 0.2; + // The inline box of `::before`: centred in its line, centred in the icon box. + const baseline = rect.top + ( lineHeight - ( ascent + descent ) ) / 2 + ascent; + const start = rect.left + ( rect.width - metrics.width ) / 2; + + sctx.setTransform( dpr, 0, 0, dpr, -x0, -y0 ); + sctx.textBaseline = 'alphabetic'; + sctx.fillStyle = '#fff'; + sctx.fillText( char, start - left, baseline - top ); + + const data = sctx.getImageData( 0, 0, w, h ).data; + const raw: { x: number; y: number; a: number }[] = []; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for ( let row = 0; row < h; row++ ) { + for ( let col = 0; col < w; col++ ) { + const a = data[ ( row * w + col ) * 4 + 3 ]; + if ( a < 3 ) { + continue; + } + const x = x0 + col; + const y = y0 + row; + raw.push( { x, y, a: a / 255 } ); + minX = Math.min( minX, x ); + maxX = Math.max( maxX, x + 1 ); + minY = Math.min( minY, y ); + maxY = Math.max( maxY, y + 1 ); + } + } + if ( 0 === raw.length ) { + return false; + } + + this.from = { x: ( minX + maxX ) / 2 / dpr, y: ( minY + maxY ) / 2 / dpr }; + const glyphWidth = ( maxX - minX ) / dpr; + this.glyphWidth = glyphWidth; + this.ratio = this.heartWidth / glyphWidth; + + const offsets = raw.map( ( p ) => ( { x: ( p.x + 0.5 ) / dpr - this.from.x, y: ( p.y + 0.5 ) / dpr - this.from.y } ) ); + const slots = mapToOutline( outline(), offsets.map( ( o ) => ( { x: o.x / glyphWidth, y: o.y / glyphWidth } ) ) ); + this.pixels = offsets.map( ( o, i ) => ( { + fx: o.x, + fy: o.y, + alpha: raw[ i ].a, + u: slots[ i ].u, + band: slots[ i ].band, + // Outer pixels leave first and land last, like a skin peeling off. + delay: STAGGER * ( 0.75 * ( 1 - ( slots[ i ].band + 1 ) / 2 ) + 0.25 * hash( i ) ), + seed: hash( i + 1000 ), + } ) ); + this.positions = new Float32Array( this.pixels.length * 2 ); + return true; + } + + private tick = ( now: number ): void => { + const dt = Math.min( 64, now - this.last ); + this.last = now; + this.clock += dt / 1000; + const step = dt / ( this.goal > this.progress ? ENTER_MS : LEAVE_MS ); + this.progress = this.goal > this.progress ? Math.min( this.goal, this.progress + step ) : Math.max( this.goal, this.progress - step ); + + if ( 0 === this.progress && 0 === this.goal ) { + this.stop(); + return; + } + if ( ! this.resize && 'function' === typeof ResizeObserver && this.link ) { + this.resize = new ResizeObserver( () => this.place() ); + this.resize.observe( this.link ); + } + this.draw(); + // Swap on the same frame as the first paint of the copy. + this.setAttribute( 'active', '' ); + this.frame = requestAnimationFrame( this.tick ); + }; + + private stop(): void { + if ( this.frame ) { + cancelAnimationFrame( this.frame ); + this.frame = 0; + } + this.resize?.disconnect(); + this.resize = null; + this.progress = 0; + this.goal = 0; + this.ctx?.clearRect( 0, 0, this.canvas?.width ?? 0, this.canvas?.height ?? 0 ); + this.removeAttribute( 'active' ); + this.moveWords( 0, 0 ); + } + + /** Slide the words and, within them, the name, by CSS pixels. */ + private moveWords( words: number, name: number ): void { + const slide = ( el: HTMLElement | null, by: number ) => { + if ( el ) { + el.style.transform = Math.abs( by ) > 0.01 ? `translateX(${ by.toFixed( 2 ) }px)` : ''; + } + }; + this.wordsOffset = words; + this.nameOffset = name; + slide( this.words, words ); + slide( this.name, name ); + } + + private draw(): void { + const ctx = this.ctx; + const canvas = this.canvas; + if ( ! ctx || ! canvas ) { + return; + } + const shape = outline(); + const dpr = this.dpr; + const p = this.progress; + const t = this.clock; + const settled = smoothstep( 0.82, 1, p ); + const beat = 1 + 0.075 * heartbeat( t ) * settled; + const flow = t * 0.045; + // Dots big enough that the pixels, however few, read as one line. + const perimeter = this.heartWidth * 3.2 * dpr; + const dotEnd = Math.min( 3, Math.max( 1.5, ( perimeter / Math.max( 1, this.pixels.length ) ) * 1.05 ) ); + + // The arc the heart flies along: up and over. + const travel = Math.hypot( this.to.x - this.from.x, this.to.y - this.from.y ); + const control = { + x: ( this.from.x + this.to.x ) / 2, + y: Math.min( this.from.y, this.to.y ) - Math.max( 14, travel * 0.32 ), + }; + + ctx.setTransform( 1, 0, 0, 1, 0, 0 ); + ctx.clearRect( 0, 0, canvas.width, canvas.height ); + + const [ rr, rg, rb, ra ] = this.rest; + const [ gr, gg, gb ] = this.glow; + const colors: string[] = new Array( this.pixels.length ); + const sizes = new Float32Array( this.pixels.length ); + const glows = new Float32Array( this.pixels.length ); + + // The heart as a whole: one clock for the flight, the growth + // and the tilt, so every pixel agrees on where the heart is. + const e = easeInOutCubic( p ); + const lift = Math.sin( Math.PI * e ); + const inv = 1 - e; + const cx = inv * inv * this.from.x + 2 * inv * e * control.x + e * e * this.to.x; + const cy = inv * inv * this.from.y + 2 * inv * e * control.y + e * e * this.to.y; + const scale = 1 + ( this.ratio - 1 ) * easeOutBack( clamp01( ( p - 0.12 ) / 0.88 ) ); + const tilt = 0.24 * lift; + const cos = Math.cos( tilt ); + const sin = Math.sin( tilt ); + const jitter = 0.8 * lift; + + // As the glyph lifts off, the words close the gap it leaves; + // the name steps aside while the heart is in the air, so it is + // ready when the heart lands round it. + this.moveWords( + this.wordsShift * easeInOutCubic( clamp01( ( p - 0.04 ) / 0.5 ) ), + this.nameShift * easeInOutCubic( clamp01( ( p - 0.1 ) / 0.55 ) ), + ); + + this.pixels.forEach( ( px, i ) => { + // Each pixel's own clock, a touch ahead or behind, for the + // fill opening into an outline and for its colour. + const q = clamp01( ( p - px.delay ) / ( 1 - STAGGER ) ); + // Become a small outline first, then grow: a handful of + // pixels reads as a heart only as an outline. + const morph = easeInOutSine( clamp01( q / 0.4 ) ); + + // Where this pixel sits on the big heart, flowing round it. + const u = px.u + flow; + const o = shape.at( u ); + const n = shape.normal( u ); + const breathe = 0.35 * Math.sin( t * 3.1 + px.seed * Math.PI * 2 ) * settled; + const across = px.band * ( STROKE / 2 ) + breathe; + const ox = ( o.x * this.heartWidth + n.x * across ) * beat; + const oy = ( o.y * this.heartWidth + n.y * across ) * beat; + + // Filled glyph → outline, in glyph scale, then grown and tilted. + const fx = ( px.fx + ( ox / this.ratio - px.fx ) * morph ) * scale; + const fy = ( px.fy + ( oy / this.ratio - px.fy ) * morph ) * scale; + const lx = fx * cos - fy * sin + ( hash( i * 3 + 1 ) - 0.5 ) * jitter; + const ly = fx * sin + fy * cos + ( hash( i * 7 + 2 ) - 0.5 ) * jitter; + + // Still home: snap, so the copy is the glyph's own pixels. + const home = p < 0.02; + this.positions[ i * 2 ] = home ? Math.round( ( cx + lx ) * dpr - 0.5 ) + 0.5 : ( cx + lx ) * dpr; + this.positions[ i * 2 + 1 ] = home ? Math.round( ( cy + ly ) * dpr - 0.5 ) + 0.5 : ( cy + ly ) * dpr; + + // Colour: the resting grey warms to the heart colour, and a + // spark runs round the outline once it has settled. + const warm = smoothstep( 0, 0.55, q ); + const spark = Math.pow( 0.5 + 0.5 * Math.cos( Math.PI * 2 * ( px.u - t * 0.55 ) ), 12 ) * settled; + let r = rr + ( gr - rr ) * warm; + let g = rg + ( gg - rg ) * warm; + let b = rb + ( gb - rb ) * warm; + r += ( 255 - r ) * spark * 0.85; + g += ( 255 - g ) * spark * 0.85; + b += ( 255 - b ) * spark * 0.85; + const a = ( px.alpha + ( 0.55 + 0.45 * px.alpha - px.alpha ) * morph ) * ( ra + ( 1 - ra ) * warm ); + colors[ i ] = `rgba(${ Math.round( r ) },${ Math.round( g ) },${ Math.round( b ) },${ a.toFixed( 3 ) })`; + sizes[ i ] = 1 + ( dotEnd - 1 ) * morph; + glows[ i ] = warm * a * ( 0.11 + 0.25 * spark ); + } ); + + // A soft bloom under the pixels. + ctx.globalCompositeOperation = 'lighter'; + for ( let i = 0; i < this.pixels.length; i++ ) { + if ( glows[ i ] <= 0.002 ) { + continue; + } + const s = sizes[ i ] * 3.2; + ctx.fillStyle = `rgba(${ gr },${ gg },${ gb },${ glows[ i ].toFixed( 3 ) })`; + ctx.fillRect( this.positions[ i * 2 ] - s / 2, this.positions[ i * 2 + 1 ] - s / 2, s, s ); + } + ctx.globalCompositeOperation = 'source-over'; + for ( let i = 0; i < this.pixels.length; i++ ) { + const s = sizes[ i ]; + ctx.fillStyle = colors[ i ]; + ctx.fillRect( this.positions[ i * 2 ] - s / 2, this.positions[ i * 2 + 1 ] - s / 2, s, s ); + } + } +} + +if ( ! customElements.get( TAG ) ) { + customElements.define( TAG, OsTributeHeart ); +} diff --git a/package.json b/package.json index 9eb60904..a7886ed2 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ } }, "scripts": { - "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:chromeless-bridge && npm run build:gutenberg-drop-receiver && npm run build:my-wordpress-woocommerce && npm run build:content-graph && npm run build:app-runtime && npm run build:user-profile && npm run build:apps && npm run build:ai-assistant && npm run build:animated-logo-wallpaper && npm run build:living-tree-wallpaper && npm run build:snow-wallpaper && npm run build:shell-overlays && npm run build:file-drop && npm run build:files-overlays && npm run build:notes && npm run build:dock-constellation && npm run build:window-link-visuals && npm run build:components && npm run build:mio && npm run build:window-system && npm run build:mobile && npm run build:item-visibility-menu && npm run build:workspace-wizard && npm run build:release-card && npm run build:shell-tour && npm run build:deactivation-feedback && npm run build:usage-feedback && npm run build:games && npm run build:game-inkfall && npm run build:game-alphabet-soup && npm run build:widget-heartbeat && npm run build:widget-recent-comments && npm run build:widget-post-stats && npm run build:widget-site-views && npm run build:widget-jazz-quote && npm run build:widget-starter && npm run build:widget-notes && npm run build:widget-focus-timer && npm run build:widget-drafts && npm run build:agent-run-window && npm run build:pwa-sw", + "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:chromeless-bridge && npm run build:gutenberg-drop-receiver && npm run build:my-wordpress-woocommerce && npm run build:content-graph && npm run build:app-runtime && npm run build:user-profile && npm run build:station-home-tribute && npm run build:apps && npm run build:ai-assistant && npm run build:animated-logo-wallpaper && npm run build:living-tree-wallpaper && npm run build:snow-wallpaper && npm run build:shell-overlays && npm run build:file-drop && npm run build:files-overlays && npm run build:notes && npm run build:dock-constellation && npm run build:window-link-visuals && npm run build:components && npm run build:mio && npm run build:window-system && npm run build:mobile && npm run build:item-visibility-menu && npm run build:workspace-wizard && npm run build:release-card && npm run build:shell-tour && npm run build:deactivation-feedback && npm run build:usage-feedback && npm run build:games && npm run build:game-inkfall && npm run build:game-alphabet-soup && npm run build:widget-heartbeat && npm run build:widget-recent-comments && npm run build:widget-post-stats && npm run build:widget-site-views && npm run build:widget-jazz-quote && npm run build:widget-starter && npm run build:widget-notes && npm run build:widget-focus-timer && npm run build:widget-drafts && npm run build:agent-run-window && npm run build:pwa-sw", "build:desktop": "vite build --mode development && vite build --mode production", "build:iframe-bridge": "OPENSTATION_TARGET=iframe-bridge vite build --mode development && OPENSTATION_TARGET=iframe-bridge vite build --mode production", "build:chromeless-bridge": "OPENSTATION_TARGET=chromeless-bridge vite build --mode development && OPENSTATION_TARGET=chromeless-bridge vite build --mode production", @@ -41,6 +41,7 @@ "build:content-graph": "OPENSTATION_TARGET=content-graph vite build --mode development && OPENSTATION_TARGET=content-graph vite build --mode production", "build:app-runtime": "OPENSTATION_TARGET=app-runtime vite build --mode development && OPENSTATION_TARGET=app-runtime vite build --mode production", "build:user-profile": "OPENSTATION_TARGET=user-profile vite build --mode development && OPENSTATION_TARGET=user-profile vite build --mode production", + "build:station-home-tribute": "OPENSTATION_TARGET=station-home-tribute vite build --mode development && OPENSTATION_TARGET=station-home-tribute vite build --mode production", "build:apps": "node bin/build-apps.mjs", "build:ai-assistant": "OPENSTATION_TARGET=ai-assistant vite build --mode development && OPENSTATION_TARGET=ai-assistant vite build --mode production", "build:animated-logo-wallpaper": "OPENSTATION_TARGET=animated-logo-wallpaper vite build --mode development && OPENSTATION_TARGET=animated-logo-wallpaper vite build --mode production", diff --git a/tests/phpunit/tests/stationHomeApp.php b/tests/phpunit/tests/stationHomeApp.php index 5b0fba93..d42b3126 100644 --- a/tests/phpunit/tests/stationHomeApp.php +++ b/tests/phpunit/tests/stationHomeApp.php @@ -320,6 +320,39 @@ public function test_greeting_follows_the_hour() { } } + /** + * The dedication's heart is wrapped for the hover animation, the + * morph is told to leave it alone, and the name it circles is + * marked, in the rail and at the end of the page. + * + * @covers \OpenStation\Apps\StationHome\dedication + */ + public function test_the_dedication_heart_is_wired_for_its_animation() { + $html = $this->dispatch( 'mount' )['html']; + + $this->assertSame( 2, substr_count( $html, '' ) ); + $this->assertSame( 2, substr_count( $html, 'Thank you, Eric' ) ); + $this->assertStringContainsString( 'href="https://friendsofericbinnion.wordpress.com/"', $html ); + } + + /** + * The heart's bundle is registered once and rides the Station Home + * window only. + * + * @covers ::openstation_station_home_tribute_register_script + * @covers ::openstation_station_home_tribute_window_args + */ + public function test_the_tribute_bundle_rides_the_station_home_window() { + $this->assertTrue( wp_script_is( 'openstation-station-home-tribute', 'registered' ) ); + $this->assertStringContainsString( 'assets/js/apps/station-home-tribute', wp_scripts()->registered['openstation-station-home-tribute']->src ); + + $args = openstation_station_home_tribute_window_args( array( 'scripts' => array( 'x' ) ), self::APP_ID ); + $this->assertSame( array( 'x', 'openstation-station-home-tribute' ), $args['scripts'] ); + $this->assertSame( $args, openstation_station_home_tribute_window_args( $args, self::APP_ID ), 'appended once' ); + + $this->assertSame( array( 'scripts' => array( 'x' ) ), openstation_station_home_tribute_window_args( array( 'scripts' => array( 'x' ) ), 'desktop-mode-users' ) ); + } + /** * The legacy surface is gone, not shadowed: no snapshot route, no * preference route, no registration function, no bundle handle. diff --git a/vite.config.js b/vite.config.js index 33433e18..603b8b7b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -351,6 +351,15 @@ const TARGETS = { fileBase: 'apps/user-profile', iifeName: 'openStationUserProfile', }, + // `` — the heart in Station Home's "Thank you, + // Eric" that opens into a heart around the name on hover. A + // companion script of the Station Home window + // (`apps/station-home/parts/tribute-script.php`). + 'station-home-tribute': { + entry: 'apps/station-home/tribute/index.ts', + fileBase: 'apps/station-home-tribute', + iifeName: 'openStationStationHomeTribute', + }, // Games hub — launcher grid + scoreboard + challenges client. // Registers a render callback on // `window.openStationNativeWindows['desktop-mode-games']`; the From ba5f27728c7fd2e252b57870966ac6035010e16d Mon Sep 17 00:00:00 2001 From: mmtr <1233880+mmtr@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:21:59 +0200 Subject: [PATCH 4/4] Station Home: Fix the tribute bundle test depending on test order The test checked the script handle was registered before calling the window args filter, so it failed whenever an earlier test class had rebuilt `$wp_scripts`. Calling the filter first re-registers the handle, the same way the profile bundle test does. Co-Authored-By: Claude Opus 5.5 --- tests/phpunit/tests/stationHomeApp.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/stationHomeApp.php b/tests/phpunit/tests/stationHomeApp.php index d42b3126..12db08cc 100644 --- a/tests/phpunit/tests/stationHomeApp.php +++ b/tests/phpunit/tests/stationHomeApp.php @@ -343,10 +343,13 @@ public function test_the_dedication_heart_is_wired_for_its_animation() { * @covers ::openstation_station_home_tribute_window_args */ public function test_the_tribute_bundle_rides_the_station_home_window() { + // Registered on `init` at bootstrap; a test class that rebuilds + // `$wp_scripts` loses it, so the filter path re-registers. That + // safety net is what the registration asserts check. + $args = openstation_station_home_tribute_window_args( array( 'scripts' => array( 'x' ) ), self::APP_ID ); $this->assertTrue( wp_script_is( 'openstation-station-home-tribute', 'registered' ) ); $this->assertStringContainsString( 'assets/js/apps/station-home-tribute', wp_scripts()->registered['openstation-station-home-tribute']->src ); - $args = openstation_station_home_tribute_window_args( array( 'scripts' => array( 'x' ) ), self::APP_ID ); $this->assertSame( array( 'x', 'openstation-station-home-tribute' ), $args['scripts'] ); $this->assertSame( $args, openstation_station_home_tribute_window_args( $args, self::APP_ID ), 'appended once' );