Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/station-home/parts/tribute-script.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php
/**
* Station Home — the `<os-tribute-heart>` 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<string,mixed> $window_args `openstation_register_window()` args.
* @param string $app_id App id.
* @return array<string,mixed>
*/
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 );
31 changes: 31 additions & 0 deletions apps/station-home/parts/view.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
'<os-tribute-heart os-preserve>' . icon( 'dashicons-heart' ) . '</os-tribute-heart>'
. '<span>' . sprintf(
/* translators: %s: Eric, the name the heart wraps itself around. */
esc( __( 'Thank you, %s', 'desktop-mode' ) ),
'<span class="os-station-home__dedication-name" data-os-tribute-name>' . esc( 'Eric' ) . '</span>'
) . '</span>'
);
}

/**
* The whole body.
*
Expand Down Expand Up @@ -186,6 +215,7 @@ class="os-station-home__brand-mark"
}
?>
</nav>
<?php echo dedication( 'rail' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built with the framework's escaping helpers. ?>
</aside>

<main class="os-station-home__main">
Expand Down Expand Up @@ -257,6 +287,7 @@ class="os-station-home__refresh"
</div>
</section>
<?php endif; ?>
<?php echo dedication( 'main' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built with the framework's escaping helpers. ?>
</main>
</div>

Expand Down
140 changes: 140 additions & 0 deletions apps/station-home/station-home.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<os-tribute-heart>`: 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/station-home/station-home.os.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
115 changes: 115 additions & 0 deletions apps/station-home/tribute/heart.test.ts
Original file line number Diff line number Diff line change
@@ -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( [] );
} );
} );
Loading
Loading