@@ -224,6 +224,156 @@ public async Task A_Synthetic_Unmapped_Route_Surfaces_As_A_404()
224224 }
225225 }
226226
227+ /// <summary>
228+ /// Pins the docs-site house-style surfaces the maintainer signed off
229+ /// on in Discussion #184 (see <c>docs/development/house-style.md</c>):
230+ /// the brand logo wired through <c>themeConfig.logo</c>, the favicon
231+ /// link, the Open Graph + Twitter Card meta block, the
232+ /// <c>theme-color</c> meta, and the 'Download latest release' hero
233+ /// CTA pointing at the GitHub releases page.
234+ /// </summary>
235+ /// <remarks>
236+ /// Each check is a focused property assertion on the rendered HTML
237+ /// so the failure message names the missing surface directly
238+ /// ('og:image meta tag missing', 'theme-color meta is #ffffff,
239+ /// expected #0073e6'). A single bundled assertion would surface
240+ /// 'page does not match expected snapshot' and force the reader
241+ /// into a diff — the structured form is the §10a positive-+-negative
242+ /// pin per surface, all in one test.
243+ ///
244+ /// The favicon asset is also fetched over HTTP to guarantee the
245+ /// <c>href</c> resolves — a stale path that 404s would still
246+ /// satisfy the meta-tag presence check, so the fetch closes the
247+ /// gap between 'tag exists' and 'tag works'.
248+ /// </remarks>
249+ [ Test ]
250+ public async Task Landing_Page_Carries_The_House_Style_Surfaces ( )
251+ {
252+ Assert . That ( _browser , Is . Not . Null , "browser was not initialised" ) ;
253+
254+ var context = await _browser ! . NewContextAsync ( ) ;
255+ try
256+ {
257+ var page = await context . NewPageAsync ( ) ;
258+ var response = await page . GotoAsync ( _baseUrl + "/" , new PageGotoOptions
259+ {
260+ WaitUntil = WaitUntilState . Load ,
261+ Timeout = PageNavigationTimeoutMs ,
262+ } ) ;
263+ Assert . That ( response , Is . Not . Null , "homepage navigation returned no response" ) ;
264+ Assert . That ( response ! . Ok , Is . True , $ "homepage returned HTTP { response . Status } ") ;
265+
266+ var probes = await page . EvaluateAsync < HouseStyleProbes > ( @"() => ({
267+ themeColor: document.querySelector('meta[name=""theme-color""]')?.getAttribute('content') ?? null,
268+ faviconHref: document.querySelector('link[rel=""icon""]')?.getAttribute('href') ?? null,
269+ faviconType: document.querySelector('link[rel=""icon""]')?.getAttribute('type') ?? null,
270+ ogTitle: document.querySelector('meta[property=""og:title""]')?.getAttribute('content') ?? null,
271+ ogImage: document.querySelector('meta[property=""og:image""]')?.getAttribute('content') ?? null,
272+ twitterCard: document.querySelector('meta[name=""twitter:card""]')?.getAttribute('content') ?? null,
273+ twitterImage: document.querySelector('meta[name=""twitter:image""]')?.getAttribute('content') ?? null,
274+ heroLogoSrc: document.querySelector('.VPNavBarTitle img.logo')?.getAttribute('src') ?? null,
275+ downloadCtaHref: (() => {
276+ const link = Array.from(document.querySelectorAll('.VPHero a, a'))
277+ .find(a => /Download latest release/i.test(a.textContent ?? ''));
278+ return link ? link.getAttribute('href') : null;
279+ })()
280+ })" ) ;
281+
282+ // theme-color — brand accent per Discussion #184.
283+ Assert . That ( probes . ThemeColor , Is . EqualTo ( "#0073e6" ) ,
284+ "theme-color meta does not match the maintainer-confirmed brand accent" ) ;
285+
286+ // Favicon — present, PNG, points at /logo.png (base-prefixed
287+ // when DOCS_BASE is set, so endsWith() is the stable match).
288+ Assert . That ( probes . FaviconHref , Is . Not . Null . And . Not . Empty , "favicon <link rel=icon> missing" ) ;
289+ Assert . That ( probes . FaviconHref , Does . EndWith ( "/logo.png" ) ,
290+ $ "favicon href does not point at /logo.png — got '{ probes . FaviconHref } '") ;
291+ Assert . That ( probes . FaviconType , Is . EqualTo ( "image/png" ) ,
292+ "favicon type attribute is not 'image/png'" ) ;
293+
294+ // Open Graph — title + image surface so social previews render.
295+ Assert . That ( probes . OgTitle , Is . EqualTo ( "MTConnect.NET" ) ,
296+ "og:title meta does not match the expected site title" ) ;
297+ Assert . That ( probes . OgImage , Is . Not . Null . And . Not . Empty , "og:image meta missing" ) ;
298+ Assert . That ( probes . OgImage , Does . EndWith ( "/logo.png" ) ,
299+ $ "og:image does not point at /logo.png — got '{ probes . OgImage } '") ;
300+
301+ // Twitter Card — large summary card with the same image.
302+ Assert . That ( probes . TwitterCard , Is . EqualTo ( "summary_large_image" ) ,
303+ "twitter:card meta is not 'summary_large_image'" ) ;
304+ Assert . That ( probes . TwitterImage , Is . Not . Null . And . Not . Empty , "twitter:image meta missing" ) ;
305+ Assert . That ( probes . TwitterImage , Does . EndWith ( "/logo.png" ) ,
306+ $ "twitter:image does not point at /logo.png — got '{ probes . TwitterImage } '") ;
307+
308+ // themeConfig.logo — VitePress renders the logo as an <img>
309+ // inside .VPNavBarTitle when themeConfig.logo is set. The
310+ // text site title is hidden (themeConfig.siteTitle: false)
311+ // because the logo PNG already carries the wordmark.
312+ Assert . That ( probes . HeroLogoSrc , Is . Not . Null . And . Not . Empty ,
313+ "no <img> rendered inside .VPNavBarTitle — themeConfig.logo did not take effect" ) ;
314+ Assert . That ( probes . HeroLogoSrc , Does . EndWith ( "/logo.png" ) ,
315+ $ "nav logo src does not point at /logo.png — got '{ probes . HeroLogoSrc } '") ;
316+
317+ // Hero 'Download latest release' CTA — text + canonical link.
318+ Assert . That ( probes . DownloadCtaHref , Is . EqualTo (
319+ "https://github.com/TrakHound/MTConnect.NET/releases/latest" ) ,
320+ "'Download latest release' hero CTA missing or points elsewhere" ) ;
321+
322+ // Closing the gap between 'tag present' and 'tag works':
323+ // fetch the favicon over HTTP and assert it returns 200.
324+ // A stale logo path (e.g. an old `/favicon.ico` reference
325+ // after a rename) would satisfy the meta-tag check above
326+ // but break the favicon for end users.
327+ var faviconUrl = probes . FaviconHref ! . StartsWith ( "http" , StringComparison . Ordinal )
328+ ? probes . FaviconHref
329+ : _baseUrl + probes . FaviconHref ;
330+ var faviconResponse = await page . Context . APIRequest . GetAsync ( faviconUrl ) ;
331+ Assert . That ( faviconResponse . Status , Is . EqualTo ( 200 ) ,
332+ $ "favicon at { faviconUrl } returned HTTP { faviconResponse . Status } ") ;
333+
334+ await page . CloseAsync ( ) ;
335+ }
336+ finally
337+ {
338+ await context . CloseAsync ( ) ;
339+ }
340+ }
341+
342+ // The JS payload returned by the house-style probe uses camelCase
343+ // keys; pin them explicitly so a future Playwright upgrade that
344+ // tightens case-insensitive deserialisation cannot silently turn
345+ // every probe into a null pass-through (which would hide a
346+ // regression in the rendered meta surface).
347+ private sealed class HouseStyleProbes
348+ {
349+ [ JsonPropertyName ( "themeColor" ) ]
350+ public string ? ThemeColor { get ; set ; }
351+
352+ [ JsonPropertyName ( "faviconHref" ) ]
353+ public string ? FaviconHref { get ; set ; }
354+
355+ [ JsonPropertyName ( "faviconType" ) ]
356+ public string ? FaviconType { get ; set ; }
357+
358+ [ JsonPropertyName ( "ogTitle" ) ]
359+ public string ? OgTitle { get ; set ; }
360+
361+ [ JsonPropertyName ( "ogImage" ) ]
362+ public string ? OgImage { get ; set ; }
363+
364+ [ JsonPropertyName ( "twitterCard" ) ]
365+ public string ? TwitterCard { get ; set ; }
366+
367+ [ JsonPropertyName ( "twitterImage" ) ]
368+ public string ? TwitterImage { get ; set ; }
369+
370+ [ JsonPropertyName ( "heroLogoSrc" ) ]
371+ public string ? HeroLogoSrc { get ; set ; }
372+
373+ [ JsonPropertyName ( "downloadCtaHref" ) ]
374+ public string ? DownloadCtaHref { get ; set ; }
375+ }
376+
227377 /// <summary>
228378 /// Walks every markdown-backed route the docs/ tree implies and
229379 /// asserts none rendered a 404. Failures are collected across all
0 commit comments