mirror of
https://github.com/tiennm99/bonsai.git
synced 2026-09-02 18:18:37 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" . -}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" . }}
|
||||
|
||||
Reference in New Issue
Block a user