jekyll-theme-zer0 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -0
  3. data/README.md +11 -28
  4. data/_data/authors.yml +4 -3
  5. data/_data/backlog.yml +54 -1
  6. data/_data/features.yml +101 -61
  7. data/_data/feedback_types.yml +148 -0
  8. data/_data/i18n/languages.yml +36 -0
  9. data/_data/navigation/README.md +1 -1
  10. data/_data/theme-manifest.yml +0 -2
  11. data/_data/ui-text.yml +36 -246
  12. data/_includes/README.md +4 -0
  13. data/_includes/components/README.md +1 -0
  14. data/_includes/components/author-avatar-url.html +4 -2
  15. data/_includes/components/env-switcher.html +3 -1
  16. data/_includes/components/language-toggle.html +81 -0
  17. data/_includes/components/page-feedback.html +252 -0
  18. data/_includes/components/search-modal.html +2 -2
  19. data/_includes/components/shortcuts-modal.html +1 -1
  20. data/_includes/components/translation-notice.html +27 -0
  21. data/_includes/content/intro.html +35 -76
  22. data/_includes/core/console-capture.html +89 -0
  23. data/_includes/core/footer.html +9 -4
  24. data/_includes/core/head.html +13 -0
  25. data/_includes/core/header.html +9 -4
  26. data/_includes/core/hreflang.html +33 -0
  27. data/_includes/core/i18n.html +36 -0
  28. data/_includes/navigation/breadcrumbs.html +1 -1
  29. data/_includes/navigation/navbar.html +5 -4
  30. data/_includes/navigation/sidebar-right.html +3 -2
  31. data/_includes/navigation/unified-drawer.html +1 -1
  32. data/_layouts/article.html +7 -1
  33. data/_layouts/default.html +5 -3
  34. data/_layouts/news.html +4 -2
  35. data/_layouts/root.html +17 -8
  36. data/_layouts/section.html +4 -2
  37. data/_sass/components/_page-feedback.scss +276 -0
  38. data/_sass/core/_docs-layout.scss +2 -78
  39. data/_sass/core/_obsidian.scss +9 -1
  40. data/_sass/layouts/_navbar-extras.scss +6 -1
  41. data/_sass/tokens/_layers.scss +4 -0
  42. data/assets/css/main.scss +1 -0
  43. data/assets/js/obsidian-graph.js +5 -1
  44. data/assets/js/page-feedback.js +660 -0
  45. data/scripts/README.md +20 -26
  46. data/scripts/bin/audit-consumer +1 -1
  47. data/scripts/bin/manifest +0 -1
  48. data/scripts/bin/sync-plugins +0 -1
  49. data/scripts/dev/rasterize-svg.js +65 -0
  50. data/scripts/features/generate-preview-images +49 -1390
  51. data/scripts/features/install-preview-generator +55 -33
  52. data/scripts/install/README.md +9 -20
  53. data/scripts/install/ai/prompts/wizard.system.md +8 -17
  54. data/scripts/lib/README.md +1 -5
  55. data/scripts/lib/install/deploy/README.md +3 -9
  56. data/scripts/lib/preview_generator.py +2261 -1341
  57. data/scripts/translate.rb +1114 -0
  58. metadata +14 -4
  59. data/_data/prompts.yml +0 -302
  60. data/_plugins/preview_image_generator.rb +0 -351
@@ -19,6 +19,7 @@
19
19
  - seo.html: SEO meta tags and Open Graph data
20
20
  - google-analytics.html: Google Analytics tracking
21
21
  - google-tag-manager-head.html: GTM head section
22
+ - posthog.html: PostHog product analytics (opt-in via site.posthog.enabled)
22
23
 
23
24
  Performance Notes:
24
25
  - Scripts loaded in head for immediate availability
@@ -34,6 +35,10 @@
34
35
  <!-- Only load in production builds (JEKYLL_ENV=production); the include also guards against dev hostnames -->
35
36
  {% if jekyll.environment == "production" %}{% include analytics/google-tag-manager-head.html %}{% endif %}
36
37
 
38
+ <!-- Page-feedback: early console/error capture — must run before the theme
39
+ scripts below so a report can include the logs that led up to it. -->
40
+ {% include core/console-capture.html %}
41
+
37
42
  <!-- ================================ -->
38
43
  <!-- JAVASCRIPT LIBRARIES -->
39
44
  <!-- ================================ -->
@@ -89,6 +94,10 @@ window.MathJax = {
89
94
  Outputs custom og:image with assets_prefix normalisation and non-Google site verification. -->
90
95
  {% include content/seo.html %}
91
96
 
97
+ <!-- hreflang alternates for machine-generated translations (no-op when the
98
+ page has none) — see _data/i18n/manifest.yml + scripts/translate.rb -->
99
+ {% include core/hreflang.html %}
100
+
92
101
  <!-- ========================== -->
93
102
  <!-- STRUCTURED DATA MARKUP -->
94
103
  <!-- ========================== -->
@@ -123,6 +132,10 @@ window.MathJax = {
123
132
  <!-- Only load in production AND when an ID is configured; the include also guards against dev hostnames -->
124
133
  {% if jekyll.environment == "production" and site.google_analytics %}{% include analytics/google-analytics.html %}{% endif %}
125
134
 
135
+ <!-- PostHog analytics - Configured via the `posthog:` block in _config.yml -->
136
+ <!-- Only load in production AND when explicitly enabled; the include itself also respects Do Not Track -->
137
+ {% if jekyll.environment == "production" and site.posthog.enabled %}{% include analytics/posthog.html %}{% endif %}
138
+
126
139
 
127
140
  <!-- ================================ -->
128
141
  <!-- CSS FRAMEWORKS AND LIBRARIES -->
@@ -41,9 +41,11 @@
41
41
  <!-- ================================ -->
42
42
  <!-- SKIP TO CONTENT LINK -->
43
43
  <!-- ================================ -->
44
+ <!-- UI strings resolve per page language (core/i18n.html sets `ui`) -->
45
+ {%- include core/i18n.html -%}
44
46
  <!-- Accessibility: allows keyboard users to skip navigation -->
45
47
  <a href="#main-content" class="visually-hidden-focusable position-absolute top-0 start-0 z-3 m-3 btn btn-primary">
46
- Skip to main content
48
+ {{ ui.skip_main_content | default: "Skip to main content" }}
47
49
  </a>
48
50
 
49
51
  <header id="navbar" class="container-fluid text-center fixed-top" role="banner">
@@ -149,17 +151,20 @@
149
151
  <!-- UTILITY CONTROLS -->
150
152
  <!-- ========================== -->
151
153
  <div class="navbar-utility-controls d-flex align-items-center flex-shrink-0" role="group" aria-label="Utility controls">
154
+ <!-- Language Toggle (renders only when translation.languages is set) -->
155
+ {% include components/language-toggle.html %}
156
+
152
157
  <!-- Search Modal Toggle -->
153
158
  <button
154
159
  class="btn nav-search-button"
155
160
  type="button"
156
161
  data-search-toggle
157
- aria-label="Open search"
162
+ aria-label="{{ ui.shortcuts_search | default: 'Open search' }}"
158
163
  aria-haspopup="dialog"
159
- title="Search site content"
164
+ title="{{ ui.search_button_label | default: 'Search' }}"
160
165
  >
161
166
  <i class="{{site.default_icon}} bi-search" aria-hidden="true"></i>
162
- <span class="nav-link-text d-none d-xl-inline ms-1">Search</span>
167
+ <span class="nav-link-text d-none d-xl-inline ms-1">{{ ui.search_button_label | default: "Search" }}</span>
163
168
  </button>
164
169
 
165
170
  <!-- Settings Offcanvas Toggle -->
@@ -0,0 +1,33 @@
1
+ {% comment %} Feature: ZER0-078 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: hreflang
4
+ Path: _includes/core/hreflang.html
5
+ Purpose: Emit <link rel="alternate" hreflang> tags for pages that have
6
+ machine-generated translations, so search engines associate
7
+ the English original with its /fr/ (etc.) alternates.
8
+ Params: none — reads page.lang / page.translation_source_url and the
9
+ generated _data/i18n/manifest.yml (scripts/translate.rb).
10
+ Depends on: site.translation.* config, site.data.i18n.manifest
11
+ Notes: Renders nothing when translation is disabled, the manifest is
12
+ absent, or the current page has no translations. x-default
13
+ always points at the English original.
14
+ {%- endcomment -%}
15
+ {%- if site.translation.enabled -%}
16
+ {%- assign _hl_source = site.translation.source_lang | default: 'en' -%}
17
+ {%- if page.lang and page.lang != _hl_source -%}
18
+ {%- assign _hl_src_url = page.translation_source_url -%}
19
+ {%- else -%}
20
+ {%- assign _hl_src_url = page.url -%}
21
+ {%- endif -%}
22
+ {%- assign _hl_entry = site.data.i18n.manifest.pages[_hl_src_url] -%}
23
+ {%- if _hl_src_url and _hl_entry %}
24
+ <link rel="alternate" hreflang="{{ _hl_source }}" href="{{ _hl_src_url | absolute_url }}">
25
+ {%- for _hl_lang in site.translation.languages -%}
26
+ {%- assign _hl_t = _hl_entry[_hl_lang] -%}
27
+ {%- if _hl_t %}
28
+ <link rel="alternate" hreflang="{{ _hl_lang }}" href="{{ _hl_t.url | absolute_url }}">
29
+ {%- endif -%}
30
+ {%- endfor %}
31
+ <link rel="alternate" hreflang="x-default" href="{{ _hl_src_url | absolute_url }}">
32
+ {%- endif -%}
33
+ {%- endif -%}
@@ -0,0 +1,36 @@
1
+ {% comment %} Feature: ZER0-078 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: i18n
4
+ Path: _includes/core/i18n.html
5
+ Purpose: Resolve the active UI-string set into the `ui` variable,
6
+ per-page-language aware. English strings come from
7
+ _data/ui-text.yml (the human-maintained source of truth);
8
+ alternate languages come from _data/i18n/<lang>.yml, which is
9
+ GENERATED by scripts/translate.rb — never hand-edited.
10
+ Params: lang (string, optional) — overrides page.lang. REQUIRED when
11
+ the caller is rendered via {% include_cached %} (pass
12
+ lang=page.lang there) so each language gets its own cache
13
+ entry instead of sharing one page-invariant render.
14
+ Depends on: _data/ui-text.yml, _data/i18n/<lang>.yml (generated),
15
+ site.translation.* config
16
+ Notes: Liquid assigns made inside an include are visible to the
17
+ caller, so after {% include core/i18n.html %} simply use
18
+ {{ ui.some_key | default: "English fallback" }}. Missing
19
+ languages or keys degrade to English/literal defaults.
20
+ {%- endcomment -%}
21
+ {%- comment -%}
22
+ Resolve using the FULL configured language code (e.g. "fr", "pt-BR"), not a
23
+ 2-char slice: scripts/translate.rb writes _data/i18n/<code>.yml and sets
24
+ page.lang to that exact code, so a regional code like "pt-BR" must look up
25
+ site.data.i18n["pt-BR"] — slicing to "pt" would silently miss the file and
26
+ fall back to English.
27
+ {%- endcomment -%}
28
+ {%- assign _i18n_lang = include.lang | default: page.lang | default: site.translation.source_lang | default: site.locale | default: 'en' -%}
29
+ {%- assign _i18n_source = site.translation.source_lang | default: 'en' -%}
30
+ {%- assign ui = site.data.ui-text[site.locale] | default: site.data.ui-text.en -%}
31
+ {%- unless _i18n_lang == _i18n_source -%}
32
+ {%- assign _i18n_generated = site.data.i18n[_i18n_lang] -%}
33
+ {%- if _i18n_generated -%}
34
+ {%- assign ui = _i18n_generated -%}
35
+ {%- endif -%}
36
+ {%- endunless -%}
@@ -14,7 +14,7 @@
14
14
  ===================================================================
15
15
  {%- endcomment -%}
16
16
  {% if page.url != "/" and site.breadcrumbs %}
17
- {%- assign ui = site.data.ui-text[site.locale] | default: site.data.ui-text.en -%}
17
+ {%- include core/i18n.html -%}
18
18
  {%- assign label_root = ui.breadcrumbs_root | default: "Home" -%}
19
19
  {%- assign label_aria = ui.breadcrumbs_aria | default: "Breadcrumb" -%}
20
20
  {%- assign crumbs = page.url | remove:'/index.html' | split: '/' -%}
@@ -8,11 +8,12 @@
8
8
  -->
9
9
 
10
10
  <!-- Navigation Links - Responsive Offcanvas (inline at lg+, offcanvas below) -->
11
+ {%- include core/i18n.html -%}
11
12
  <div class="offcanvas-lg offcanvas-end" tabindex="-1" id="bdNavbar" aria-labelledby="mainNavOffcanvasLabel">
12
13
 
13
14
  <!-- Main Navigation Header - Offcanvas -->
14
15
  <div class="offcanvas-header border-bottom">
15
- <h2 class="offcanvas-title h5 mb-0" id="mainNavOffcanvasLabel">Main Navigation</h2>
16
+ <h2 class="offcanvas-title h5 mb-0" id="mainNavOffcanvasLabel">{{ ui.main_nav_label | default: "Main Navigation" }}</h2>
16
17
  <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close navigation menu" data-bs-target="#bdNavbar"></button>
17
18
  </div>
18
19
 
@@ -24,7 +25,7 @@
24
25
  <li class="nav-item d-lg-none">
25
26
  <a class="nav-link" href="{{ '/' | relative_url }}" {% if page.url == '/' %}aria-current="page"{% endif %}>
26
27
  <i class="{{site.default_icon}} bi-house" aria-hidden="true"></i>
27
- Home
28
+ {{ ui.home_label | default: "Home" }}
28
29
  </a>
29
30
  </li>
30
31
  {%- comment -%}
@@ -143,7 +144,7 @@
143
144
  aria-haspopup="dialog"
144
145
  >
145
146
  <i class="{{site.default_icon}} bi-search" aria-hidden="true"></i>
146
- Search
147
+ {{ ui.search_button_label | default: "Search" }}
147
148
  </button>
148
149
  </li>
149
150
  <li class="nav-item d-lg-none">
@@ -156,7 +157,7 @@
156
157
  aria-haspopup="dialog"
157
158
  >
158
159
  <i class="{{site.default_icon}} bi-gear" aria-hidden="true"></i>
159
- Settings
160
+ {{ ui.settings_button_label | default: "Settings" }}
160
161
  </button>
161
162
  </li>
162
163
  </ul>
@@ -5,6 +5,7 @@
5
5
  includes: nav_list.html, content/toc.html
6
6
  -->
7
7
 
8
+ {%- include core/i18n.html -%}
8
9
  <!-- FOUC guard: apply hidden TOC preference before paint -->
9
10
  <script>
10
11
  try {
@@ -17,7 +18,7 @@
17
18
  <!-- Right sidebar container -->
18
19
  <div class="offcanvas-lg offcanvas-end" tabindex="-1" id="tocContents" aria-labelledby="tocLabel">
19
20
  <div class="offcanvas-header">
20
- <h2 class="offcanvas-title h5 mb-0" id="tocLabel">Table of Contents</h2>
21
+ <h2 class="offcanvas-title h5 mb-0" id="tocLabel">{{ ui.toc_title | default: "Table of Contents" }}</h2>
21
22
  <div class="d-flex align-items-center gap-1">
22
23
  <button type="button"
23
24
  class="btn btn-sm btn-link text-body-secondary bd-toc-visibility-toggle d-lg-none px-2"
@@ -37,7 +38,7 @@
37
38
  <div class="mb-3 d-flex align-items-center justify-content-between gap-2">
38
39
  <strong class="d-flex align-items-center mb-0">
39
40
  <i class="bi bi-file-text me-2" aria-hidden="true"></i>
40
- On this page
41
+ {{ ui.toc_label | default: "On this page" }}
41
42
  </strong>
42
43
  <button type="button"
43
44
  class="btn btn-sm btn-link text-body-secondary bd-toc-visibility-toggle d-none d-lg-inline-flex flex-shrink-0 px-1"
@@ -23,7 +23,7 @@
23
23
  ===================================================================
24
24
  {%- endcomment -%}
25
25
  {% if site.navigation.unified_mobile_drawer %}
26
- {%- assign ui = site.data.ui-text[site.locale] | default: site.data.ui-text.en -%}
26
+ {%- include core/i18n.html -%}
27
27
  <div class="offcanvas offcanvas-start zer0-unified-drawer d-lg-none"
28
28
  tabindex="-1"
29
29
  id="zer0UnifiedDrawer"
@@ -15,6 +15,9 @@ layout: default
15
15
  Front Matter Variables:
16
16
  - post_type: Article type (standard, featured, breaking, opinion, review, tutorial, listicle, interview)
17
17
  - sidebar: Override sidebar visibility (true/false)
18
+ - show_hero: Opt into the top-of-article hero image (page.preview) for any
19
+ post_type without changing layout/sidebar/badges (true; featured and
20
+ breaking posts show the hero automatically)
18
21
 
19
22
  Post Type Variations:
20
23
  - standard (default): Normal article with sidebar
@@ -221,12 +224,15 @@ layout: default
221
224
  preview image is set. Breaking stories were previously skipped because the
222
225
  condition checked only post_type == "featured"; since breaking is mapped
223
226
  before featured (see top of file), breaking+featured posts showed no hero.
227
+ Any other post type can opt in with `show_hero: true` in front matter —
228
+ this flips only the hero, leaving sidebar, typography, and badges at their
229
+ post_type defaults (issue #303).
224
230
  Nested ifs keep the "only when page.preview exists" guard intact (Liquid has
225
231
  no parentheses, so a flat `a or b and c` would misgroup).
226
232
  {% endcomment %}
227
233
  {% assign show_hero = false %}
228
234
  {% if page.preview %}
229
- {% if post_type == "featured" or post_type == "breaking" %}
235
+ {% if post_type == "featured" or post_type == "breaking" or page.show_hero %}
230
236
  {% assign show_hero = true %}
231
237
  {% endif %}
232
238
  {% endif %}
@@ -86,8 +86,10 @@ layout: root
86
86
  <!-- ================================ -->
87
87
  <!-- MAIN CONTENT AREA -->
88
88
  <!-- ================================ -->
89
- <!-- Primary content section with scroll spy for table of contents navigation -->
90
- <main class="bd-main order-1" data-bs-spy="scroll" data-bs-target="#TableOfContents" data-bs-offset="100" data-bs-smooth-scroll="true">
89
+ <!-- Primary content section with scroll spy for table of contents navigation.
90
+ Not a <main>: root.html already provides the single <main id="main-content">
91
+ landmark; a nested <main> here would duplicate it (issue #299). -->
92
+ <div class="bd-main order-1" data-bs-spy="scroll" data-bs-target="#TableOfContents" data-bs-offset="100" data-bs-smooth-scroll="true">
91
93
 
92
94
  {%- comment -%}
93
95
  Page introduction: title, breadcrumbs, metadata.
@@ -129,5 +131,5 @@ layout: root
129
131
  {% include content/backlinks.html %}
130
132
  {% endif %}
131
133
  </div>
132
- </main>
134
+ </div>
133
135
  </div>
data/_layouts/news.html CHANGED
@@ -78,7 +78,9 @@ source: "https://getbootstrap.com/docs/5.3/examples/blog/#"
78
78
  </div>
79
79
  </div>
80
80
 
81
- <main class="container">
81
+ <!-- Not a <main>: root.html already provides the single <main id="main-content">
82
+ landmark; a nested <main> here would duplicate it (issue #299). -->
83
+ <div class="container">
82
84
 
83
85
  {% if section_style == "magazine" %}
84
86
  <!-- ================================ -->
@@ -578,7 +580,7 @@ source: "https://getbootstrap.com/docs/5.3/examples/blog/#"
578
580
  </div>
579
581
  </section>
580
582
 
581
- </main>
583
+ </div>
582
584
 
583
585
  <style>
584
586
  .hover-lift {
data/_layouts/root.html CHANGED
@@ -46,7 +46,7 @@
46
46
  {%- else -%}
47
47
  {%- assign _bs_theme = 'dark' -%}
48
48
  {%- endif -%}
49
- <html lang="{{ site.locale | slice: 0,2 | default: 'en' }}" class="no-js" data-bs-theme="{{ _bs_theme }}" data-color-mode-default="{{ _cm }}" data-theme-skin="{{ site.theme_skin | default: 'dark' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
49
+ <html lang="{{ page.lang | default: site.locale | slice: 0, 2 | default: 'en' }}" class="no-js" data-bs-theme="{{ _bs_theme }}" data-color-mode-default="{{ _cm }}" data-theme-skin="{{ site.theme_skin | default: 'dark' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
50
50
  <head>
51
51
  <!-- =============================================== -->
52
52
  <!-- HEAD SECTION: Meta tags, styles, and SEO setup -->
@@ -82,11 +82,13 @@
82
82
  Enable with `navigation.unified_mobile_drawer: true` in _config.yml. -->
83
83
  {% include navigation/unified-drawer.html %}
84
84
 
85
- <!-- Search modal (site-wide) — page-invariant, cached once per build. -->
86
- {% include_cached components/search-modal.html %}
85
+ <!-- Search modal (site-wide) — cached once per build PER LANGUAGE
86
+ (the lang param keys the include-cache entry; page.lang is nil
87
+ on English pages, so they all still share a single render). -->
88
+ {% include_cached components/search-modal.html lang=page.lang %}
87
89
 
88
- <!-- Keyboard shortcuts help modal (triggered by `?`) — page-invariant. -->
89
- {% include_cached components/shortcuts-modal.html %}
90
+ <!-- Keyboard shortcuts help modal (triggered by `?`) — cached per language. -->
91
+ {% include_cached components/shortcuts-modal.html lang=page.lang %}
90
92
 
91
93
  <!-- Settings offcanvas (Appearance / Site / Developer tabs) -->
92
94
  {% include components/info-section.html %}
@@ -98,10 +100,14 @@
98
100
  <!-- MAIN CONTENT AREA -->
99
101
  <!-- ======================== -->
100
102
  <!-- Skip-link target: keep a single, consistent #main-content anchor site-wide.
101
- Semantic <main> landmark (consistent with the default/section/news layouts)
102
- so assistive tech, search engines, and AI content extractors can isolate the
103
+ This is the ONE <main> landmark per page — child layouts (default/section/
104
+ news) must use non-landmark wrappers (<div>) inside it (issue #299) — so
105
+ assistive tech, search engines, and AI content extractors can isolate the
103
106
  primary content from the surrounding header/nav/footer chrome. -->
104
107
  <main id="main-content">
108
+ <!-- Machine-translation disclosure — renders only on generated
109
+ translations (page.machine_translated); no-op elsewhere. -->
110
+ {%- include components/translation-notice.html -%}
105
111
  {{ content }}
106
112
  </main>
107
113
 
@@ -116,7 +122,7 @@
116
122
  fresh: jekyll-include-cache stores in memory only (Jekyll 3.x has no
117
123
  Jekyll::Cache) and clears on every build's :pre_render hook — it never
118
124
  persists to .jekyll-cache. -->
119
- {%- include_cached core/footer.html -%}
125
+ {%- include_cached core/footer.html lang=page.lang -%}
120
126
  {%- include core/footer-fabs.html -%}
121
127
 
122
128
  <!-- Privacy-compliant cookie consent banner — page-invariant. -->
@@ -125,6 +131,9 @@
125
131
  <!-- AI Chat Assistant -->
126
132
  {%- include components/ai-chat.html -%}
127
133
 
134
+ <!-- Page-feedback capture widget ("Improve this page" → GitHub issue) -->
135
+ {%- include components/page-feedback.html -%}
136
+
128
137
  <!-- JavaScript dependencies and custom scripts -->
129
138
  <div>
130
139
  <!-- Search functionality -->
@@ -156,7 +156,9 @@ layout: root
156
156
  <!-- ================================ -->
157
157
  <!-- MAIN CONTENT AREA -->
158
158
  <!-- ================================ -->
159
- <main class="col-lg-9 col-xl-10 section-layout-main">
159
+ <!-- Not a <main>: root.html already provides the single <main id="main-content">
160
+ landmark; a nested <main> here would duplicate it (issue #299). -->
161
+ <div class="col-lg-9 col-xl-10 section-layout-main">
160
162
 
161
163
  <!-- Section Header -->
162
164
  <header class="section-header mb-4">
@@ -526,7 +528,7 @@ layout: root
526
528
  </div>
527
529
  </aside>
528
530
 
529
- </main>
531
+ </div>
530
532
  </div>
531
533
  </div>
532
534
 
@@ -0,0 +1,276 @@
1
+ // ============================================================================
2
+ // Page-feedback widget — global FAB + capture modal
3
+ // ----------------------------------------------------------------------------
4
+ // Markup: _includes/components/page-feedback.html
5
+ // Behaviour: assets/js/page-feedback.js
6
+ // Tokens: shared FAB + layer scale (_sass/tokens/_spacing.scss, _layers.scss)
7
+ // Theming: leans on Bootstrap 5.3 CSS variables (--bs-body-bg / -color /
8
+ // -border-color) so it follows [data-bs-theme] light/dark for free.
9
+ // ============================================================================
10
+
11
+ // --- Global FAB (bottom-left, clear of the right-hand FAB stack) ------------
12
+ .pf-fab {
13
+ position: fixed;
14
+ bottom: var(--zer0-space-fab-offset, 1rem);
15
+ left: var(--zer0-space-fab-offset, 1rem);
16
+ z-index: var(--zer0-layer-fab-feedback, 1051);
17
+
18
+ display: inline-flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ width: var(--zer0-space-fab-size, 3.5rem);
22
+ height: var(--zer0-space-fab-size, 3.5rem);
23
+ padding: 0;
24
+ border: 0;
25
+ border-radius: 50%;
26
+ background-color: var(--zer0-color-primary, var(--bs-primary));
27
+ color: #fff;
28
+ font-size: 1.25rem;
29
+ opacity: .9;
30
+ box-shadow: var(--zer0-shadow-fab, 0 .5rem 1rem rgba(0, 0, 0, .25));
31
+ transition:
32
+ opacity var(--zer0-motion-duration-fast, 120ms) var(--zer0-motion-ease-standard, ease),
33
+ transform var(--zer0-motion-duration-fast, 120ms) var(--zer0-motion-ease-standard, ease);
34
+
35
+ &:hover,
36
+ &:focus-visible {
37
+ opacity: 1;
38
+ color: #fff;
39
+ transform: translateY(-2px);
40
+ }
41
+ }
42
+
43
+ // Lock background scroll while the dialog is open (set by page-feedback.js).
44
+ .pf-no-scroll { overflow: hidden; }
45
+
46
+ // --- Modal overlay ----------------------------------------------------------
47
+ .pf-overlay {
48
+ position: fixed;
49
+ inset: 0;
50
+ z-index: var(--zer0-layer-feedback-modal, 1096); // above the cookie banner (1095)
51
+ display: flex;
52
+ align-items: flex-end; // bottom sheet on phones
53
+ justify-content: center;
54
+ background: rgba(0, 0, 0, .5);
55
+ opacity: 0;
56
+ transition: opacity var(--zer0-motion-duration, 180ms) var(--zer0-motion-ease-standard, ease);
57
+
58
+ &[hidden] { display: none; }
59
+ &.pf-overlay--open { opacity: 1; }
60
+
61
+ @media (min-width: 576px) {
62
+ align-items: center;
63
+ padding: var(--zer0-space-3, 1rem);
64
+ }
65
+ }
66
+
67
+ .pf-dialog {
68
+ display: flex;
69
+ flex-direction: column;
70
+ width: 100%;
71
+ max-width: 40rem;
72
+ max-height: 92vh;
73
+ overflow: hidden;
74
+ color: var(--bs-body-color);
75
+ background: var(--bs-body-bg);
76
+ border: 1px solid var(--bs-border-color, rgba(0, 0, 0, .15));
77
+ border-radius: 1rem 1rem 0 0;
78
+ transform: translateY(20px);
79
+ transition: transform var(--zer0-motion-duration, 180ms) var(--zer0-motion-ease-standard, ease);
80
+
81
+ .pf-overlay--open & { transform: translateY(0); }
82
+
83
+ @media (min-width: 576px) { border-radius: 1rem; }
84
+ }
85
+
86
+ // --- Header -----------------------------------------------------------------
87
+ .pf-header {
88
+ display: flex;
89
+ align-items: flex-start;
90
+ justify-content: space-between;
91
+ gap: var(--zer0-space-2, .5rem);
92
+ padding: var(--zer0-space-3, 1rem) var(--zer0-space-3, 1rem) var(--zer0-space-2, .5rem);
93
+ border-bottom: 1px solid var(--bs-border-color, rgba(0, 0, 0, .1));
94
+ }
95
+
96
+ .pf-title { display: flex; align-items: center; }
97
+
98
+ .pf-intro { font-size: .875rem; line-height: 1.4; }
99
+
100
+ .pf-close {
101
+ flex-shrink: 0;
102
+ color: var(--bs-body-color);
103
+ background: transparent;
104
+ border: 0;
105
+ opacity: .6;
106
+ &:hover, &:focus-visible { opacity: 1; }
107
+ }
108
+
109
+ // --- Body (scrolls) ---------------------------------------------------------
110
+ .pf-body {
111
+ flex: 1 1 auto;
112
+ overflow-y: auto;
113
+ padding: var(--zer0-space-3, 1rem);
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: var(--zer0-space-3, 1rem);
117
+ }
118
+
119
+ .pf-field { border: 0; padding: 0; margin: 0; min-width: 0; }
120
+
121
+ .pf-legend,
122
+ .pf-label {
123
+ display: block;
124
+ font-size: .8125rem;
125
+ font-weight: 600;
126
+ text-transform: uppercase;
127
+ letter-spacing: .02em;
128
+ color: var(--bs-secondary-color, var(--zer0-color-ink-muted, #6c757d));
129
+ margin-bottom: var(--zer0-space-2, .5rem);
130
+ }
131
+
132
+ .pf-help { font-size: .8125rem; margin: .35rem 0 0; }
133
+
134
+ // --- Type selector ----------------------------------------------------------
135
+ .pf-types-group {
136
+ font-size: .75rem;
137
+ font-weight: 600;
138
+ color: var(--bs-secondary-color, #6c757d);
139
+ margin: var(--zer0-space-2, .5rem) 0 .35rem;
140
+ }
141
+
142
+ .pf-types-grid {
143
+ display: grid;
144
+ grid-template-columns: 1fr;
145
+ gap: .5rem;
146
+
147
+ @media (min-width: 480px) { grid-template-columns: 1fr 1fr; }
148
+ }
149
+
150
+ .pf-type {
151
+ display: flex;
152
+ align-items: flex-start;
153
+ gap: .625rem;
154
+ text-align: left;
155
+ padding: .625rem .75rem;
156
+ background: var(--bs-body-bg);
157
+ border: 1px solid var(--bs-border-color, rgba(0, 0, 0, .15));
158
+ border-radius: .625rem;
159
+ cursor: pointer;
160
+ transition: border-color 120ms ease, background-color 120ms ease;
161
+
162
+ &:hover { border-color: var(--zer0-color-primary, var(--bs-primary)); }
163
+
164
+ &:focus-visible {
165
+ outline: 2px solid var(--zer0-color-primary, var(--bs-primary));
166
+ outline-offset: 2px;
167
+ }
168
+
169
+ &.pf-type--active {
170
+ border-color: var(--zer0-color-primary, var(--bs-primary));
171
+ background: color-mix(in srgb, var(--bs-primary) 10%, transparent);
172
+ box-shadow: inset 0 0 0 1px var(--zer0-color-primary, var(--bs-primary));
173
+ }
174
+ }
175
+
176
+ .pf-type-icon { flex-shrink: 0; margin-top: .1rem; font-size: 1rem; color: var(--zer0-color-primary, var(--bs-primary)); }
177
+ .pf-type-text { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
178
+ .pf-type-label { font-weight: 600; font-size: .9rem; line-height: 1.2; }
179
+ .pf-type-desc { font-size: .78rem; line-height: 1.3; color: var(--bs-secondary-color, #6c757d); }
180
+
181
+ // --- Context / logs ---------------------------------------------------------
182
+ .pf-summary { cursor: pointer; font-weight: 600; font-size: .875rem; }
183
+ .pf-context-count { color: var(--bs-secondary-color, #6c757d); font-weight: 400; }
184
+
185
+ .pf-context-list {
186
+ list-style: none;
187
+ margin: .5rem 0 0;
188
+ padding: 0;
189
+ font-size: .8125rem;
190
+
191
+ li { display: flex; gap: .5rem; padding: .15rem 0; }
192
+ }
193
+ .pf-context-key { flex: 0 0 4rem; color: var(--bs-secondary-color, #6c757d); }
194
+ .pf-context-val { min-width: 0; word-break: break-word; }
195
+
196
+ .pf-logs { margin-top: .75rem; }
197
+ .pf-check { display: flex; align-items: center; gap: .4rem; font-size: .8125rem; font-weight: 500; }
198
+ .pf-logs-preview {
199
+ max-height: 9rem;
200
+ overflow: auto;
201
+ margin: .5rem 0 0;
202
+ padding: .5rem .625rem;
203
+ font-size: .72rem;
204
+ line-height: 1.4;
205
+ white-space: pre-wrap;
206
+ word-break: break-word;
207
+ background: var(--bs-tertiary-bg, rgba(0, 0, 0, .04));
208
+ border: 1px solid var(--bs-border-color, rgba(0, 0, 0, .1));
209
+ border-radius: .5rem;
210
+ }
211
+
212
+ // --- AI enrichment ----------------------------------------------------------
213
+ .pf-ai-head { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem; }
214
+ .pf-ai-hint { font-size: .8125rem; }
215
+ .pf-ai-result { margin-top: .625rem; font-size: .875rem; }
216
+ .pf-ai-card {
217
+ padding: .75rem .875rem;
218
+ background: var(--bs-tertiary-bg, rgba(0, 0, 0, .04));
219
+ border: 1px solid var(--bs-border-color, rgba(0, 0, 0, .1));
220
+ border-radius: .625rem;
221
+
222
+ p { margin: 0 0 .5rem; }
223
+ p:last-child { margin-bottom: 0; }
224
+ }
225
+ .pf-ai-badges .badge { margin-right: .25rem; }
226
+ .pf-ai-questions { margin: .25rem 0 .5rem 1.1rem; padding: 0; }
227
+ .pf-ai-labels code { font-size: .78rem; }
228
+ .pf-ai-error { color: var(--bs-danger-text-emphasis, var(--bs-danger)); }
229
+ .pf-ai-spinner,
230
+ .pf-ai-note i { color: var(--zer0-color-primary, var(--bs-primary)); }
231
+ .pf-ai-spinner {
232
+ display: inline-block;
233
+ width: .85rem;
234
+ height: .85rem;
235
+ border: 2px solid currentColor;
236
+ border-right-color: transparent;
237
+ border-radius: 50%;
238
+ vertical-align: -.1rem;
239
+ animation: pf-spin .7s linear infinite;
240
+ }
241
+ @keyframes pf-spin { to { transform: rotate(360deg); } }
242
+
243
+ // --- Status line ------------------------------------------------------------
244
+ .pf-status:empty { display: none; }
245
+ .pf-status {
246
+ font-size: .8125rem;
247
+ margin: 0;
248
+ padding: .5rem .625rem;
249
+ border-radius: .5rem;
250
+
251
+ &.pf-status--ok { background: var(--bs-success-bg-subtle, #d1e7dd); color: var(--bs-success-text-emphasis, #0a3622); }
252
+ &.pf-status--warn { background: var(--bs-warning-bg-subtle, #fff3cd); color: var(--bs-warning-text-emphasis, #664d03); }
253
+ &.pf-status--pending { background: var(--bs-info-bg-subtle, #cff4fc); color: var(--bs-info-text-emphasis, #055160); }
254
+ a { color: inherit; text-decoration: underline; }
255
+ }
256
+
257
+ // --- Footer -----------------------------------------------------------------
258
+ .pf-footer {
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: space-between;
262
+ gap: var(--zer0-space-2, .5rem);
263
+ padding: var(--zer0-space-2, .5rem) var(--zer0-space-3, 1rem);
264
+ border-top: 1px solid var(--bs-border-color, rgba(0, 0, 0, .1));
265
+ background: var(--bs-body-bg);
266
+ }
267
+ .pf-footer-primary { display: flex; gap: .5rem; }
268
+ .pf-copy { text-decoration: none; }
269
+
270
+ // --- Reduced motion ---------------------------------------------------------
271
+ @media (prefers-reduced-motion: reduce) {
272
+ .pf-fab,
273
+ .pf-overlay,
274
+ .pf-dialog { transition: none; }
275
+ .pf-ai-spinner { animation: none; }
276
+ }