feat(theme): Lighthouse meta hardening + skip-link

Adds canonical, og:url, robots, and theme-color meta tags for Lighthouse
validation. Hardens avatar with width/height/fetchpriority/decoding and
<picture> pipeline. Adds skip-link and tap-target CSS enhancements.

Improves Core Web Vitals and accessibility scores.
This commit is contained in:
2026-05-15 19:50:43 +07:00
parent 3a9364554d
commit 15ae06b770
8 changed files with 270 additions and 15 deletions
+133 -1
View File
@@ -364,12 +364,144 @@ body {
[data-theme="light"] .theme-toggle .theme-toggle__sun { display: none; }
[data-theme="light"] .theme-toggle .theme-toggle__moon { display: block; }
/* Per-link enhancements (Phase 3 / v0.5) */
.link__thumb {
width: 24px;
height: 24px;
border-radius: 4px;
flex-shrink: 0;
object-fit: cover;
}
.link--featured {
padding: 1.1rem 1.4rem;
border-color: var(--bonsai-accent);
border-width: 1.5px;
}
.link__note {
display: block;
font-size: .75rem;
color: var(--bonsai-muted);
margin-top: .15rem;
line-height: 1.3;
}
/* Page-level actions: share button + QR + vCard download */
.bio__share, .bio__vcard {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .5rem;
margin: 1.5rem auto 0;
padding: .65rem 1.1rem;
background: var(--bonsai-surface);
color: var(--bonsai-text);
border: 1px solid var(--bonsai-border);
border-radius: var(--bonsai-radius);
font: inherit;
font-size: .85rem;
cursor: pointer;
text-decoration: none;
transition: transform .15s ease, border-color .15s ease;
}
.bio__share { display: flex; }
.bio__vcard { display: flex; }
.bio__share:hover, .bio__vcard:hover {
transform: translateY(-1px);
border-color: var(--bonsai-accent);
}
.bio__share:focus-visible, .bio__vcard:focus-visible {
outline: 2px solid var(--bonsai-accent);
outline-offset: 2px;
}
.bio__share-icon { display: inline-flex; width: 16px; height: 16px; color: var(--bonsai-accent); }
.bio__share-icon svg { width: 16px; height: 16px; }
.bio__share-toast {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%);
padding: .5rem 1rem;
background: var(--bonsai-text);
color: var(--bonsai-bg);
border-radius: var(--bonsai-radius);
font-size: .85rem;
box-shadow: var(--bonsai-shadow);
z-index: 200;
}
.bio__qr {
margin-top: 1.5rem;
text-align: center;
}
.bio__qr > summary {
cursor: pointer;
color: var(--bonsai-muted);
font-size: .85rem;
list-style: none;
}
.bio__qr > summary::-webkit-details-marker { display: none; }
.bio__qr-img {
display: block;
margin: .75rem auto 0;
max-width: 200px;
height: auto;
border-radius: var(--bonsai-radius);
background: #fff;
padding: .5rem;
}
/* Multi-section bio: grouped link blocks with optional headings + descriptions. */
.bio__section { margin-top: 1.75rem; }
.bio__section:first-of-type { margin-top: 1.5rem; }
.bio__section-title {
font-family: var(--bonsai-font-display);
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 .35rem;
color: var(--bonsai-text);
}
.bio__section-desc {
font-size: .9rem;
color: var(--bonsai-muted);
margin: 0 0 .75rem;
}
.bio__section .bio__links { margin-top: .5rem; }
/* Inline-layout tap-target enforcement (Phase 6 a11y): WCAG 2.5.5 / Lighthouse 48×48 */
.bio__links--inline .link {
min-width: 48px;
min-height: 48px;
padding: .75rem;
justify-content: center;
}
.bio__links--inline { gap: clamp(.5rem, 1.5vw, 1rem); }
/* Skip-link: keyboard-only affordance. Off-screen until focused. */
.skip-link {
position: absolute;
top: -3rem;
left: .5rem;
padding: .5rem 1rem;
background: var(--bonsai-accent);
color: var(--bonsai-bg);
border-radius: var(--bonsai-radius);
text-decoration: none;
z-index: 100;
transition: top .15s ease;
}
.skip-link:focus {
top: .5rem;
outline: 2px solid var(--bonsai-text);
outline-offset: 2px;
}
/* Note: gallery selectors (.themes-gallery, .variants-gallery) live in gallery.css
and load only on /themes/ and /variants/ demo pages via the head_extra block. */
@media (prefers-reduced-motion: reduce) {
.link, .link:hover, .link:active,
.theme-toggle, .theme-toggle:hover, .theme-toggle:active {
.theme-toggle, .theme-toggle:hover, .theme-toggle:active,
.skip-link,
.bio__share, .bio__share:hover, .bio__vcard, .bio__vcard:hover {
transition: none;
transform: none;
}
+21
View File
@@ -12,3 +12,24 @@ other = "Toggle theme"
[footer_default]
other = "© {{ .year }} {{ .name }}"
[skip_to_content]
other = "Skip to content"
[share_label]
other = "Share this page"
[share_copied]
other = "Link copied"
[share_failed]
other = "Could not share"
[show_qr]
other = "Show QR code"
[qr_alt]
other = "QR code linking to this page"
[save_contact]
other = "Save contact"
+21
View File
@@ -11,3 +11,24 @@ other = "Đổi giao diện"
[footer_default]
other = "© {{ .year }} {{ .name }}"
[skip_to_content]
other = "Bỏ qua tới nội dung"
[share_label]
other = "Chia sẻ trang này"
[share_copied]
other = "Đã sao chép liên kết"
[share_failed]
other = "Không thể chia sẻ"
[show_qr]
other = "Hiện mã QR"
[qr_alt]
other = "Mã QR liên kết tới trang này"
[save_contact]
other = "Lưu danh thiếp"
+2 -1
View File
@@ -5,7 +5,8 @@
{{- block "head_extra" . }}{{- end }}
</head>
<body>
<main class="bonsai">
<a class="skip-link" href="#main">{{ i18n "skip_to_content" | default "Skip to content" }}</a>
<main class="bonsai" id="main">
{{- block "main" . }}{{- end }}
</main>
{{- partial "footer.html" . -}}
+35 -7
View File
@@ -1,13 +1,43 @@
{{- /* Render bio avatar.
If params.avatar is set, render <img>.
Otherwise, render an inline SVG circle with initials in accent color.
Initials override: params.avatarInitials.
Background override: params.avatarBg (defaults to --bonsai-accent). */ -}}
- If params.avatar resolves as a local asset (assets/ or static/ path),
emit <picture> with AVIF + WebP + JPEG fallback at 1x/2x densities.
- If params.avatar is an absolute URL, emit plain <img> (no processing).
- If unset, emit inline SVG circle with initials in accent color.
Initials override: params.avatarInitials. Background: params.avatarBg. */ -}}
{{- $name := site.Params.name | default site.Title -}}
{{- $avatar := site.Params.avatar -}}
{{- if $avatar -}}
<img class="bio__avatar" src="{{ $avatar | strings.TrimPrefix "/" | relURL }}" alt="{{ $name }}" loading="eager" />
{{- $isHTTP := strings.HasPrefix $avatar "http" -}}
{{- $local := false -}}
{{- $res := "" -}}
{{- if not $isHTTP -}}
{{- $clean := strings.TrimPrefix $avatar "/" -}}
{{- with resources.Get $clean -}}
{{- $res = . -}}
{{- $local = true -}}
{{- end -}}
{{- end -}}
{{- if $local -}}
{{- /* Hugo image pipeline: AVIF + WebP + JPEG, 1x + 2x for retina. */ -}}
{{- $jpg1 := $res.Process "resize 112x112 jpg q85" -}}
{{- $jpg2 := $res.Process "resize 224x224 jpg q85" -}}
{{- $webp1 := $res.Process "resize 112x112 webp q75" -}}
{{- $webp2 := $res.Process "resize 224x224 webp q75" -}}
{{- $avif1 := $res.Process "resize 112x112 avif q60" -}}
{{- $avif2 := $res.Process "resize 224x224 avif q60" -}}
<picture>
<source type="image/avif" srcset="{{ $avif1.RelPermalink }} 1x, {{ $avif2.RelPermalink }} 2x" />
<source type="image/webp" srcset="{{ $webp1.RelPermalink }} 1x, {{ $webp2.RelPermalink }} 2x" />
<img class="bio__avatar" src="{{ $jpg1.RelPermalink }}"
srcset="{{ $jpg1.RelPermalink }} 1x, {{ $jpg2.RelPermalink }} 2x"
width="112" height="112" alt="{{ $name }}"
loading="eager" fetchpriority="high" decoding="async" />
</picture>
{{- else -}}
<img class="bio__avatar" src="{{ $avatar | strings.TrimPrefix "/" | relURL }}" alt="{{ $name }}"
width="112" height="112" loading="eager" fetchpriority="high" decoding="async" />
{{- end -}}
{{- else -}}
{{- $initials := site.Params.avatarInitials -}}
{{- if not $initials -}}
@@ -20,8 +50,6 @@
{{- if not $initials -}}{{- $initials = "?" -}}{{- end -}}
{{- end -}}
{{- $bg := site.Params.avatarBg | default "var(--bonsai-accent)" -}}
{{- /* Background may be a CSS var or any color value; mark as safeCSS so Hugo
doesn't escape var(...) into ZgotmplZ when injecting into the style attribute. */ -}}
{{- $svgStyle := printf "color:var(--bonsai-bg);background:transparent" | safeCSS -}}
{{- $circleStyle := printf "fill:%s" $bg | safeCSS -}}
<svg class="bio__avatar bio__avatar--initials" viewBox="0 0 112 112" width="112" height="112"
+58 -6
View File
@@ -2,10 +2,13 @@
{{- $tagline := site.Params.tagline | default "" -}}
{{- $description := site.Params.bio | default $tagline | default site.Params.description -}}
{{- $avatar := site.Params.avatar -}}
{{- /* OG image resolution:
- params.ogImage = false → emit nothing.
- params.ogImageUrl set → use it (1200×630 expected → summary_large_image).
- else fall back to params.avatar (square → summary card). */ -}}
{{- /* OG image resolution cascade:
1. params.ogImage = false → emit nothing.
2. params.ogImageUrl set → use it (1200×630 → summary_large_image).
3. params.ogAuto = true AND ogAutoBase + ogAutoFont supplied → auto-generate
via Hugo's images.Filter + images.Text overlay at build time.
(Bonsai v0.5 ships infra-only; user supplies the base PNG + TTF font.)
4. else fall back to params.avatar (square → summary card). */ -}}
{{- $ogImageEnabled := site.Params.ogImage | default true -}}
{{- $ogImageUrl := "" -}}
{{- $twitterCard := "summary" -}}
@@ -13,6 +16,22 @@
{{- if site.Params.ogImageUrl -}}
{{- $ogImageUrl = site.Params.ogImageUrl | absURL -}}
{{- $twitterCard = "summary_large_image" -}}
{{- else if and site.Params.ogAuto site.Params.ogAutoBase site.Params.ogAutoFont -}}
{{- $base := resources.Get site.Params.ogAutoBase -}}
{{- $font := resources.Get site.Params.ogAutoFont -}}
{{- if and $base $font -}}
{{- $titleOpts := dict "size" 72 "color" "#2b2b2b" "x" 80 "y" 220 "font" $font -}}
{{- $generated := $base | images.Filter (images.Text $name $titleOpts) -}}
{{- with $tagline -}}
{{- $taglineOpts := dict "size" 36 "color" "#6b6b6b" "x" 80 "y" 340 "font" $font -}}
{{- $generated = $generated | images.Filter (images.Text . $taglineOpts) -}}
{{- end -}}
{{- $ogImageUrl = $generated.Permalink -}}
{{- $twitterCard = "summary_large_image" -}}
{{- else if $avatar -}}
{{- warnf "bonsai: params.ogAuto enabled but ogAutoBase/ogAutoFont resources not found; falling back to avatar" -}}
{{- $ogImageUrl = $avatar | absURL -}}
{{- end -}}
{{- else if $avatar -}}
{{- $ogImageUrl = $avatar | absURL -}}
{{- end -}}
@@ -24,8 +43,24 @@
{{- with $description }}
<meta name="description" content="{{ . }}" />
{{- end }}
<link rel="canonical" href="{{ .Permalink }}" />
<meta name="robots" content="{{ site.Params.robots | default `index,follow` }}" />
{{- with site.Params.themeColor }}
<meta name="theme-color" content="{{ . }}" />
{{- end }}
{{- /* hreflang alternates for multi-language sites only. */ -}}
{{- if gt (len site.Languages) 1 }}
{{- range site.Languages }}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ site.BaseURL }}{{ if not .Lang }}{{ else }}{{ .Lang }}/{{ end }}" />
{{- end }}
<link rel="alternate" hreflang="x-default" href="{{ site.BaseURL }}" />
{{- end }}
{{- if site.Params.manifest }}
<link rel="manifest" href="{{ `manifest.webmanifest` | relURL }}" />
{{- end }}
<meta property="og:type" content="profile" />
<meta property="og:url" content="{{ .Permalink }}" />
<meta property="og:title" content="{{ $name }}" />
{{- with $description }}
<meta property="og:description" content="{{ . }}" />
@@ -37,6 +72,7 @@
<meta name="twitter:card" content="{{ $twitterCard }}" />
{{ partial "schema-person.html" . }}
{{ partial "schema-website.html" . }}
{{- with site.Params.faviconSvg }}
<link rel="icon" type="image/svg+xml" href="{{ . | strings.TrimPrefix "/" | relURL }}" />
@@ -45,7 +81,17 @@
{{- with site.Params.appleTouchIcon }}
<link rel="apple-touch-icon" href="{{ . | strings.TrimPrefix "/" | relURL }}" />
{{- end }}
<link rel="stylesheet" href="{{ `css/bonsai.css` | relURL }}" />
{{- /* Avatar preload: only for `static/`-served paths. When avatar resolves as an
`assets/` resource the <picture> pipeline emits its own srcset/fetchpriority
and a fixed-path preload would be wasted (different filename + format). */ -}}
{{- if and site.Params.avatar (ne (site.Params.preloadAvatar | default true) false) (not (strings.HasPrefix site.Params.avatar "http")) (not (resources.Get (strings.TrimPrefix site.Params.avatar "/"))) }}
<link rel="preload" as="image" href="{{ site.Params.avatar | strings.TrimPrefix "/" | relURL }}" fetchpriority="high" />
{{- end }}
{{- /* Fingerprinted + minified CSS via Hugo's resource pipeline.
integrity= attribute lets browsers / CSP `style-src 'self' 'sha384-...'`
validate the file. */ -}}
{{- $css := resources.Get "css/bonsai.css" | resources.Minify | resources.Fingerprint "sha384" }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" crossorigin="anonymous" />
{{- if site.Params.rss }}
<link rel="alternate" type="application/rss+xml" title="{{ site.Params.name | default site.Title }}" href="{{ `index.xml` | absURL }}" />
{{- end }}
@@ -54,5 +100,11 @@
Tiny (~140B) — meets the <3KB CSS / minimal-JS budget. */ -}}
<script>(function(){try{var t=localStorage.getItem('bonsai-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t;}catch(e){}})();</script>
<noscript><style>.theme-toggle{display:none!important}</style></noscript>
<script defer src="{{ `js/theme-toggle.js` | relURL }}"></script>
{{- $themeJs := resources.Get "js/theme-toggle.js" | resources.Minify | resources.Fingerprint "sha384" }}
<script defer src="{{ $themeJs.RelPermalink }}" integrity="{{ $themeJs.Data.Integrity }}" crossorigin="anonymous"></script>
{{- end }}
{{- if site.Params.share }}
{{- $shareJs := resources.Get "js/share.js" | resources.Minify | resources.Fingerprint "sha384" }}
<script defer src="{{ $shareJs.RelPermalink }}" integrity="{{ $shareJs.Data.Integrity }}" crossorigin="anonymous"></script>
{{- end }}
{{ partial "analytics-loader.html" . }}