diff --git a/apps/station-home/parts/tribute-script.php b/apps/station-home/parts/tribute-script.php new file mode 100644 index 000000000..891b3ff1b --- /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 403978d36..bd4da4d0a 100644 --- a/apps/station-home/parts/view.php +++ b/apps/station-home/parts/view.php @@ -150,6 +150,35 @@ 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', + ), + // `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' ) . '' + ) . '' + ); +} + /** * The whole body. * @@ -186,6 +215,7 @@ class="os-station-home__brand-mark" } ?> +
@@ -257,6 +287,7 @@ class="os-station-home__refresh" +
diff --git a/apps/station-home/station-home.css b/apps/station-home/station-home.css index 18a881c42..bcb1de524 100644 --- a/apps/station-home/station-home.css +++ b/apps/station-home/station-home.css @@ -238,6 +238,128 @@ 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 { + /* 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; + /* 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 ); + 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-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, +.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 +787,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: 44px auto 28px; + } + .os-station-home__location, .os-station-home__actions { margin-block-start: 0; @@ -754,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 6c8202034..a4dd6017a 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 000000000..621cc5e9a --- /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 000000000..12d94f596 --- /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 000000000..80889ad46 --- /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/desktop-mode.php b/desktop-mode.php index 6ab2dcdc6..d98299336 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 000000000..c9104e212 --- /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' ); diff --git a/package.json b/package.json index c8cd95b14..8529248fc 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:pulse-monitor && 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:pulse-monitor && 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 5b0fba93a..12db08ccf 100644 --- a/tests/phpunit/tests/stationHomeApp.php +++ b/tests/phpunit/tests/stationHomeApp.php @@ -320,6 +320,42 @@ 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() { + // 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 ); + + $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 b75503f6c..eea161814 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