A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'loading-skeleton-header' into trunk

+293 -11
+211
src/components/LoadingScene.astro
··· 1 + --- 2 + /** 3 + * Static loading scene for the `client:only` Studio + Dashboard islands. 4 + * 5 + * Those islands are browser-only (auth + editor) and can't render server-side, 6 + * so until their JS bundle loads Astro shows this `slot="fallback"` markup. 7 + * Rather than a bare, left-aligned "Loading…", render the durable page chrome — 8 + * a logo-only header (matching AppBar's `status === 'loading'` state, so the 9 + * swap to the live bar is seamless) plus a content skeleton — so the page has 10 + * shape immediately and hydration doesn't reflow into an empty viewport. 11 + * 12 + * The fallback is plain server-rendered markup (it is NOT inside the island's 13 + * DOM), so this component's scoped styles reach it normally. The header reuses 14 + * the global `.app-bar` classes (app-bar.css is imported by both page shells); 15 + * only the static logo is shown — nav/identity depend on auth state we don't 16 + * have yet. 17 + */ 18 + interface Props { 19 + /** Which island is loading; picks the skeleton shape + content width. */ 20 + variant: 'dashboard' | 'editor'; 21 + } 22 + const { variant } = Astro.props; 23 + --- 24 + 25 + <div class="loading-scene" aria-busy="true"> 26 + <header class="app-bar"> 27 + <a class="app-bar__home" href="/" aria-label="SkyPress home"> 28 + <svg 29 + class="app-bar__mark" 30 + width="24" 31 + height="24" 32 + viewBox="0 0 32 32" 33 + fill="none" 34 + aria-hidden="true" 35 + > 36 + <rect x="2.5" y="2.5" width="27" height="27" rx="7.5" stroke="currentColor" stroke-width="2.2" /> 37 + <circle cx="16" cy="12.5" r="3.6" stroke="currentColor" stroke-width="1.9" /> 38 + <circle cx="16" cy="12.5" r="0.9" fill="currentColor" /> 39 + <path d="M7 18.5h18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" /> 40 + <path d="M9 22.8h14M9 25.6h9" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" opacity="0.55" /> 41 + </svg> 42 + <span class="app-bar__word">SkyPress</span> 43 + </a> 44 + </header> 45 + 46 + { 47 + variant === 'dashboard' ? ( 48 + <div class="loading-scene__body loading-scene__body--dash"> 49 + <div class="loading-scene__head"> 50 + <span class="sk sk-heading" /> 51 + <span class="sk sk-button" /> 52 + </div> 53 + <ul class="loading-scene__rows"> 54 + {[ 0, 1, 2 ].map( () => ( 55 + <li class="loading-scene__row"> 56 + <span class="sk sk-logo" /> 57 + <span class="loading-scene__rowtext"> 58 + <span class="sk sk-line sk-line--name" /> 59 + <span class="sk sk-line sk-line--slug" /> 60 + </span> 61 + </li> 62 + ) )} 63 + </ul> 64 + </div> 65 + ) : ( 66 + <div class="loading-scene__body loading-scene__body--editor"> 67 + <span class="sk sk-mode" /> 68 + <span class="sk sk-headline" /> 69 + <span class="sk sk-lede" /> 70 + <span class="sk sk-surface" /> 71 + </div> 72 + ) 73 + } 74 + 75 + <p class="loading-scene__sr">Loading…</p> 76 + </div> 77 + 78 + <style> 79 + /* Skeleton block: a tinted bar with a sweeping shimmer highlight. */ 80 + .sk { 81 + display: block; 82 + border-radius: var(--radius-sm); 83 + background: var(--panel); 84 + position: relative; 85 + overflow: hidden; 86 + } 87 + .sk::after { 88 + content: ''; 89 + position: absolute; 90 + inset: 0; 91 + transform: translateX(-100%); 92 + background: linear-gradient( 93 + 90deg, 94 + transparent, 95 + color-mix(in srgb, var(--paper-raised) 70%, transparent), 96 + transparent 97 + ); 98 + animation: loading-shimmer 1.5s ease-in-out infinite; 99 + } 100 + @keyframes loading-shimmer { 101 + 100% { 102 + transform: translateX(100%); 103 + } 104 + } 105 + @media (prefers-reduced-motion: reduce) { 106 + .sk::after { 107 + animation: none; 108 + } 109 + } 110 + 111 + /* Visually-hidden live label — the skeleton conveys "loading" visually, but 112 + screen readers get an explicit announcement (paired with aria-busy). */ 113 + .loading-scene__sr { 114 + position: absolute; 115 + width: 1px; 116 + height: 1px; 117 + margin: -1px; 118 + padding: 0; 119 + overflow: hidden; 120 + clip: rect(0, 0, 0, 0); 121 + white-space: nowrap; 122 + border: 0; 123 + } 124 + 125 + /* Content column — mirrors the loaded layouts so the swap doesn't shift. */ 126 + .loading-scene__body { 127 + max-width: 48rem; 128 + margin: 0 auto; 129 + padding: 0 1.25rem 5rem; 130 + } 131 + .loading-scene__body--editor { 132 + max-width: var(--studio-measure, 60rem); 133 + } 134 + 135 + /* Dashboard skeleton — mirrors `.dash__section-head` + a few `.dash__pub` rows. */ 136 + .loading-scene__head { 137 + display: flex; 138 + align-items: center; 139 + justify-content: space-between; 140 + gap: 1rem; 141 + margin: 1rem 0 1.5rem; 142 + } 143 + .sk-heading { 144 + width: clamp(10rem, 40vw, 16rem); 145 + height: clamp(1.6rem, 4vw, 2.2rem); 146 + } 147 + .sk-button { 148 + width: 9.5rem; 149 + height: 2.2rem; 150 + flex: none; 151 + } 152 + .loading-scene__rows { 153 + list-style: none; 154 + margin: 0; 155 + padding: 0; 156 + } 157 + .loading-scene__row { 158 + display: flex; 159 + align-items: center; 160 + gap: 1rem; 161 + padding: 0.9rem 0; 162 + border-top: 1px solid var(--line); 163 + } 164 + .sk-logo { 165 + width: 48px; 166 + height: 48px; 167 + border-radius: 10px; 168 + flex: none; 169 + } 170 + .loading-scene__rowtext { 171 + display: flex; 172 + flex-direction: column; 173 + gap: 0.5rem; 174 + flex: 1; 175 + min-width: 0; 176 + } 177 + .sk-line { 178 + height: 0.85rem; 179 + } 180 + .sk-line--name { 181 + width: 45%; 182 + height: 1rem; 183 + } 184 + .sk-line--slug { 185 + width: 22%; 186 + height: 0.7rem; 187 + } 188 + 189 + /* Editor skeleton — mode line, title, lede, then the framed writing surface. */ 190 + .sk-mode { 191 + width: 11rem; 192 + height: 0.9rem; 193 + margin: 0.5rem 0 1.25rem; 194 + } 195 + .sk-headline { 196 + width: 70%; 197 + height: clamp(1.9rem, 4vw, 2.6rem); 198 + margin-bottom: 0.75rem; 199 + } 200 + .sk-lede { 201 + width: 48%; 202 + height: 1.2rem; 203 + } 204 + .sk-surface { 205 + height: 60vh; 206 + min-height: 18rem; 207 + margin-top: 1.5rem; 208 + border-radius: var(--radius); 209 + border: 1px solid var(--line-strong); 210 + } 211 + </style>
+74
src/components/LoadingScene.meta.test.ts
··· 1 + /** 2 + * Source-level guard for the client:only island loading fallback. 3 + * 4 + * `dashboard.astro` and `editor.astro` mount browser-only React islands 5 + * (`client:only`). Until their JS bundle loads, Astro shows the `slot="fallback"` 6 + * markup. That fallback must carry the durable page chrome — a logo-only header 7 + * (matching AppBar's `status === 'loading'` state) plus a content skeleton — so 8 + * the page has shape before hydration instead of a bare, left-aligned "Loading…". 9 + * 10 + * Rendering the pages through astro/container isn't viable here (the runner is 11 + * pinned to jsdom for the WordPress block suites), so these assertions pin the 12 + * wiring at the source level — same approach as `_index.phase.test.ts`. 13 + */ 14 + import { readFileSync } from 'node:fs'; 15 + import { fileURLToPath } from 'node:url'; 16 + import { describe, expect, it } from 'vitest'; 17 + 18 + const read = ( rel: string ) => 19 + readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' ); 20 + 21 + describe( 'LoadingScene fallback', () => { 22 + const scene = read( './LoadingScene.astro' ); 23 + 24 + it( 'renders the durable logo header using the shared app-bar classes', () => { 25 + expect( scene ).toMatch( /class="app-bar"/ ); 26 + expect( scene ).toMatch( /class="app-bar__home"/ ); 27 + expect( scene ).toMatch( /class="app-bar__word"/ ); 28 + expect( scene ).toMatch( /SkyPress/ ); 29 + } ); 30 + 31 + it( 'omits the dynamic nav + identity (auth state is unknown pre-hydration)', () => { 32 + expect( scene ).not.toMatch( /app-bar__nav/ ); 33 + expect( scene ).not.toMatch( /app-bar__identity/ ); 34 + expect( scene ).not.toMatch( /app-bar__signout/ ); 35 + } ); 36 + 37 + it( 'renders shimmering skeleton placeholders', () => { 38 + expect( scene ).toMatch( /class="sk\b/ ); 39 + expect( scene ).toMatch( /@keyframes/ ); 40 + } ); 41 + 42 + it( 'flags the region as busy and keeps an accessible loading label', () => { 43 + expect( scene ).toMatch( /aria-busy="true"/ ); 44 + expect( scene ).toMatch( /Loading…/ ); 45 + } ); 46 + 47 + it( 'stills the shimmer for reduced-motion users', () => { 48 + expect( scene ).toMatch( /prefers-reduced-motion/ ); 49 + } ); 50 + 51 + it( 'offers distinct dashboard and editor variants', () => { 52 + expect( scene ).toMatch( /'dashboard'\s*\|\s*'editor'/ ); 53 + expect( scene ).toMatch( /variant === 'dashboard'/ ); 54 + } ); 55 + } ); 56 + 57 + describe( 'island fallbacks use LoadingScene', () => { 58 + const dashboard = read( '../pages/dashboard.astro' ); 59 + const editor = read( '../pages/editor.astro' ); 60 + 61 + it( 'dashboard fallback renders LoadingScene, not a bare "Loading…"', () => { 62 + expect( dashboard ).toMatch( /import LoadingScene from '[^']*LoadingScene.astro'/ ); 63 + expect( dashboard ).toMatch( /<LoadingScene[\s\S]*?slot="fallback"/ ); 64 + expect( dashboard ).toMatch( /variant="dashboard"/ ); 65 + expect( dashboard ).not.toMatch( /class="dash__loading">Loading…/ ); 66 + } ); 67 + 68 + it( 'editor fallback renders LoadingScene, not a bare "Loading…"', () => { 69 + expect( editor ).toMatch( /import LoadingScene from '[^']*LoadingScene.astro'/ ); 70 + expect( editor ).toMatch( /<LoadingScene[\s\S]*?slot="fallback"/ ); 71 + expect( editor ).toMatch( /variant="editor"/ ); 72 + expect( editor ).not.toMatch( /class="editor-shell__loading">Loading…/ ); 73 + } ); 74 + } );
+4 -2
src/pages/dashboard.astro
··· 1 1 --- 2 2 import Base from '../layouts/Base.astro'; 3 3 import Dashboard from '../components/Dashboard.tsx'; 4 + import LoadingScene from '../components/LoadingScene.astro'; 4 5 // Shared top-bar styles (the Dashboard island is client:only, so Astro scoped 5 6 // styles can't reach its DOM). The sign-in form styles are shared with the editor. 6 7 import '../styles/app-bar.css'; ··· 10 11 <Base title="Dashboard — SkyPress"> 11 12 <main class="dash-shell"> 12 13 <!-- client:only — auth runs only in the browser; its bundle never reaches 13 - reading pages (Decisions 0003 & 0004). No `@wordpress/*` here. --> 14 + reading pages (Decisions 0003 & 0004). No `@wordpress/*` here. The 15 + fallback shows the durable header + a skeleton until the island loads. --> 14 16 <Dashboard client:only="react"> 15 - <p slot="fallback" class="dash__loading">Loading…</p> 17 + <LoadingScene slot="fallback" variant="dashboard" /> 16 18 </Dashboard> 17 19 </main> 18 20 </Base>
+4 -9
src/pages/editor.astro
··· 1 1 --- 2 2 import Base from '../layouts/Base.astro'; 3 3 import Studio from '../components/Studio.tsx'; 4 + import LoadingScene from '../components/LoadingScene.astro'; 4 5 // The Studio is a `client:only` React island, so Astro's scoped styles never 5 6 // reach its DOM — its chrome is styled globally from these shared stylesheets. 6 7 import '../styles/app-bar.css'; ··· 11 12 <Base title="Write — SkyPress"> 12 13 <main class="editor-shell"> 13 14 <!-- client:only — auth + editor run only in the browser; their bundle never 14 - reaches reading pages (Decisions 0001 & 0004). --> 15 + reaches reading pages (Decisions 0001 & 0004). The fallback shows the 16 + durable header + a skeleton until the island loads. --> 15 17 <Studio client:only="react"> 16 - <p slot="fallback" class="editor-shell__loading">Loading…</p> 18 + <LoadingScene slot="fallback" variant="editor" /> 17 19 </Studio> 18 20 </main> 19 21 </Base> 20 - 21 - <style> 22 - .editor-shell__loading { 23 - padding: 2rem 1.25rem; 24 - color: var(--muted); 25 - } 26 - </style>