diff --git a/static/css/bonsai.css b/assets/css/bonsai.css similarity index 75% rename from static/css/bonsai.css rename to assets/css/bonsai.css index 9ce2ff1..8801a8d 100644 --- a/static/css/bonsai.css +++ b/assets/css/bonsai.css @@ -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; } diff --git a/static/css/gallery.css b/assets/css/gallery.css similarity index 100% rename from static/css/gallery.css rename to assets/css/gallery.css diff --git a/static/js/theme-toggle.js b/assets/js/theme-toggle.js similarity index 100% rename from static/js/theme-toggle.js rename to assets/js/theme-toggle.js diff --git a/i18n/en.toml b/i18n/en.toml index 95f7fb2..0361e60 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -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" diff --git a/i18n/vi.toml b/i18n/vi.toml index a0f854b..3e1490d 100644 --- a/i18n/vi.toml +++ b/i18n/vi.toml @@ -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" diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 590894d..6abb97e 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -5,7 +5,8 @@ {{- block "head_extra" . }}{{- end }} -
+ +
{{- block "main" . }}{{- end }}
{{- partial "footer.html" . -}} diff --git a/layouts/partials/avatar.html b/layouts/partials/avatar.html index 4d566ea..4dcc078 100644 --- a/layouts/partials/avatar.html +++ b/layouts/partials/avatar.html @@ -1,13 +1,43 @@ {{- /* Render bio avatar. - If params.avatar is set, render . - 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 with AVIF + WebP + JPEG fallback at 1x/2x densities. + - If params.avatar is an absolute URL, emit plain (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 -}} - {{ $name }} + {{- $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" -}} + + + + {{ $name }} + + {{- else -}} + {{ $name }} + {{- 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 -}} {{- end }} + + +{{- with site.Params.themeColor }} + +{{- end }} +{{- /* hreflang alternates for multi-language sites only. */ -}} +{{- if gt (len site.Languages) 1 }} +{{- range site.Languages }} + +{{- end }} + +{{- end }} +{{- if site.Params.manifest }} + +{{- end }} + {{- with $description }} @@ -37,6 +72,7 @@ {{ partial "schema-person.html" . }} +{{ partial "schema-website.html" . }} {{- with site.Params.faviconSvg }} @@ -45,7 +81,17 @@ {{- with site.Params.appleTouchIcon }} {{- end }} - +{{- /* Avatar preload: only for `static/`-served paths. When avatar resolves as an + `assets/` resource the 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 "/"))) }} + +{{- 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" }} + {{- if site.Params.rss }} {{- end }} @@ -54,5 +100,11 @@ Tiny (~140B) — meets the <3KB CSS / minimal-JS budget. */ -}} - +{{- $themeJs := resources.Get "js/theme-toggle.js" | resources.Minify | resources.Fingerprint "sha384" }} + {{- end }} +{{- if site.Params.share }} +{{- $shareJs := resources.Get "js/share.js" | resources.Minify | resources.Fingerprint "sha384" }} + +{{- end }} +{{ partial "analytics-loader.html" . }}