jekyll-theme-zer0 1.18.1 → 1.19.1

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.
@@ -1,41 +1,28 @@
1
- <!--
2
- ===================================================================
3
- AI CHAT - Frontend AI Chatbot with Page Context
4
- ===================================================================
5
-
6
- File: ai-chat.html
7
- Path: _includes/components/ai-chat.html
8
- Purpose: Provides an AI-powered chat widget that includes the
9
- current page context in conversations. Uses the OpenAI
10
- Chat Completions API with an API key injected at build
11
- time from GitHub repository secrets.
12
-
13
- Template Logic:
14
- - Renders a floating chat button and expandable chat panel
15
- - Collects current page metadata (title, description, content,
16
- URL, categories, tags) as system context for the AI
17
- - Sends messages to the configured AI provider endpoint
18
- - Rendering is gated by ai_chat.enabled and either:
19
- - Proxy mode: auth_mode: "proxy" with proxy_ready: true (recommended), or
20
- - Direct mode: a non-empty ai_chat.api_key for client-side API access
21
-
22
- Dependencies:
23
- - Bootstrap 5 for UI components and styling
24
- - Bootstrap Icons for chat button icon
25
- - site.ai_chat configuration from _config.yml
26
-
27
- Security:
28
- - API key is injected at build time, not stored in source
29
- - IMPORTANT: Client-side API keys are visible in page source.
30
- For production, route requests through a server-side proxy
31
- by changing the 'endpoint' config to your proxy URL.
32
-
33
- Accessibility:
34
- - ARIA labels for screen readers
35
- - Keyboard navigation support (Escape to close)
36
- - Focus management when opening/closing the chat panel
37
- ===================================================================
38
- -->
1
+ {%- comment -%}
2
+ Component: ai-chat
3
+ Path: _includes/components/ai-chat.html
4
+ Purpose: Floating AI chat assistant powered by the Claude Messages
5
+ API, grounded in the current page's content, with optional
6
+ GitHub actions (create issues / propose page-improvement
7
+ pull requests) exposed to the model via Claude tool use.
8
+ Params: none configured via site.ai_chat in _config.yml
9
+ Depends on: Bootstrap 5 + Bootstrap Icons, assets/js/ai-chat.js,
10
+ design tokens (_sass/tokens/_spacing.scss, _layers.scss),
11
+ site.ai_chat configuration
12
+ Notes:
13
+ - Renders nothing unless ai_chat.enabled AND a usable auth path
14
+ exists: proxy mode with a deployed proxy (proxy_ready: true,
15
+ recommended) or direct mode with a non-empty api_key.
16
+ - Proxy mode keeps the Anthropic key server-side (see
17
+ templates/deploy/chat-proxy/). Direct mode sends the key from the
18
+ browser (visible in page source) local development only.
19
+ - GitHub actions: github.mode "url" opens pre-filled github.com
20
+ forms (no token anywhere); "proxy" creates issues/PRs through the
21
+ proxy's GitHub endpoints with a server-side token. Creation always
22
+ requires an in-chat confirmation by the user.
23
+ - Accessibility: ARIA dialog semantics, Escape to close, focus
24
+ management, aria-live message log, reduced-motion support.
25
+ {%- endcomment -%}
39
26
 
40
27
  {% assign ai_auth_mode = site.ai_chat.auth_mode | default: 'proxy' %}
41
28
  {% comment %}
@@ -55,6 +42,26 @@
55
42
  {% endif %}
56
43
  {% if ai_render %}
57
44
 
45
+ {% comment %}
46
+ GitHub actions are on by default in "url" mode (pre-filled github.com
47
+ forms need no token). `default:` can't preserve an explicit false, so
48
+ compute the flag with an if-tag.
49
+ {% endcomment %}
50
+ {% assign gh_enabled = true %}
51
+ {% if site.ai_chat.github.enabled == false %}
52
+ {% assign gh_enabled = false %}
53
+ {% endif %}
54
+
55
+ {% comment %}
56
+ local_edit lets the assistant write the current page's source file via the
57
+ dev proxy. It is a development-only capability — enabled in _config_dev.yml,
58
+ off in production. Compute with an if-tag (default false).
59
+ {% endcomment %}
60
+ {% assign local_edit_flag = false %}
61
+ {% if site.ai_chat.local_edit %}
62
+ {% assign local_edit_flag = true %}
63
+ {% endif %}
64
+
58
65
  <!-- AI Chat Toggle Button -->
59
66
  <button id="aiChatToggle"
60
67
  class="ai-chat-toggle btn btn-primary rounded-circle shadow-lg"
@@ -62,8 +69,8 @@
62
69
  aria-label="Open AI chat assistant"
63
70
  aria-expanded="false"
64
71
  aria-controls="aiChatPanel">
65
- <i class="{{ site.ai_chat.icon | default: 'bi-robot' }} ai-chat-icon-open" aria-hidden="true"></i>
66
- <i class="bi-x-lg ai-chat-icon-close d-none" aria-hidden="true"></i>
72
+ <i class="bi {{ site.ai_chat.icon | default: 'bi-robot' }} ai-chat-icon-open" aria-hidden="true"></i>
73
+ <i class="bi bi-x-lg ai-chat-icon-close d-none" aria-hidden="true"></i>
67
74
  </button>
68
75
 
69
76
  <!-- AI Chat Panel -->
@@ -76,14 +83,14 @@
76
83
  <!-- Chat Header -->
77
84
  <div class="ai-chat-header d-flex align-items-center justify-content-between p-3">
78
85
  <div class="d-flex align-items-center gap-2">
79
- <i class="{{ site.ai_chat.icon | default: 'bi-robot' }} fs-5" aria-hidden="true"></i>
86
+ <i class="bi {{ site.ai_chat.icon | default: 'bi-robot' }} fs-5" aria-hidden="true"></i>
80
87
  <h6 id="aiChatTitle" class="mb-0 fw-semibold">Zer0 Assistant</h6>
81
88
  </div>
82
89
  <button type="button"
83
90
  class="btn btn-sm btn-outline-light border-0"
84
91
  id="aiChatClose"
85
92
  aria-label="Close chat">
86
- <i class="bi-x-lg" aria-hidden="true"></i>
93
+ <i class="bi bi-x-lg" aria-hidden="true"></i>
87
94
  </button>
88
95
  </div>
89
96
 
@@ -92,6 +99,29 @@
92
99
  <!-- Welcome message inserted by JS -->
93
100
  </div>
94
101
 
102
+ {% if gh_enabled %}
103
+ <!-- Quick actions (GitHub-backed flows) -->
104
+ <div class="ai-chat-chips px-3 pb-2 d-flex gap-2 flex-wrap">
105
+ <button type="button"
106
+ class="ai-chat-chip btn btn-sm btn-outline-secondary rounded-pill"
107
+ data-prompt="I'd like to report a problem with this page.">
108
+ <i class="bi bi-bug" aria-hidden="true"></i> Report an issue
109
+ </button>
110
+ <button type="button"
111
+ class="ai-chat-chip btn btn-sm btn-outline-secondary rounded-pill"
112
+ data-prompt="I have a suggestion to improve this page's content or design.">
113
+ <i class="bi bi-lightbulb" aria-hidden="true"></i> Suggest an improvement
114
+ </button>
115
+ {% if local_edit_flag %}
116
+ <button type="button"
117
+ class="ai-chat-chip btn btn-sm btn-outline-secondary rounded-pill"
118
+ data-prompt="Edit this page directly: ">
119
+ <i class="bi bi-pencil-square" aria-hidden="true"></i> Edit this page
120
+ </button>
121
+ {% endif %}
122
+ </div>
123
+ {% endif %}
124
+
95
125
  <!-- Chat Input -->
96
126
  <div class="ai-chat-input p-3">
97
127
  <form id="aiChatForm" class="d-flex gap-2" autocomplete="off">
@@ -106,7 +136,7 @@
106
136
  class="btn btn-primary btn-sm"
107
137
  id="aiChatSend"
108
138
  aria-label="Send message">
109
- <i class="bi-send" aria-hidden="true"></i>
139
+ <i class="bi bi-send" aria-hidden="true"></i>
110
140
  </button>
111
141
  </form>
112
142
  <p class="ai-chat-disclaimer text-muted mt-1 mb-0">
@@ -121,12 +151,14 @@
121
151
  "page_title": {{ page.title | default: site.title | jsonify }},
122
152
  "page_description": {{ page.description | default: page.excerpt | strip_html | default: "" | jsonify }},
123
153
  "page_url": {{ page.url | absolute_url | jsonify }},
154
+ "page_path": {{ page.path | default: "" | jsonify }},
124
155
  "page_categories": {{ page.categories | default: "" | jsonify }},
125
156
  "page_tags": {{ page.tags | default: "" | jsonify }},
126
157
  "page_layout": {{ page.layout | default: "" | jsonify }},
127
158
  "page_date": {{ page.date | date: "%Y-%m-%d" | default: "" | jsonify }},
128
159
  "site_title": {{ site.title | jsonify }},
129
- "site_description": {{ site.description | strip_html | strip | truncate: 200 | jsonify }}
160
+ "site_description": {{ site.description | strip_html | strip | truncate: 200 | jsonify }},
161
+ "repository": {{ site.repository | join: "" | jsonify }}
130
162
  }
131
163
  </script>
132
164
 
@@ -138,308 +170,49 @@
138
170
  {% assign context_length = site.ai_chat.context_max_length | default: 2000 %}
139
171
  <script id="aiChatPageContent" type="text/plain">{{ page_text | truncate: context_length | replace: '</script>', '<\/script>' }}</script>
140
172
 
141
- <script>
142
- // AI Chat Widget
143
- (function() {
144
- 'use strict';
145
-
146
- // Configuration from Jekyll
147
- const CONFIG = {
148
- endpoint: {{ site.ai_chat.endpoint | default: "/api/chat" | jsonify }},
149
- authMode: {{ site.ai_chat.auth_mode | default: "proxy" | jsonify }},
150
- apiKey: {{ site.ai_chat.api_key | jsonify }},
151
- model: {{ site.ai_chat.model | default: "gpt-4o-mini" | jsonify }},
152
- maxTokens: {{ site.ai_chat.max_tokens | default: 1024 }},
153
- temperature: {{ site.ai_chat.temperature | default: 0.7 }},
154
- strictContext: {{ site.ai_chat.strict_context | default: true | jsonify }},
155
- outOfScopeMessage: {{ site.ai_chat.out_of_scope_message | default: "I can only answer from the content on this page." | jsonify }},
156
- systemPrompt: {{ site.ai_chat.system_prompt | default: "You are a helpful assistant." | jsonify }},
157
- welcomeMessage: {{ site.ai_chat.welcome_message | default: "Hi! Ask me anything about this page." | jsonify }}
158
- };
159
-
160
- // State
161
- let isOpen = false;
162
- let isLoading = false;
163
- let lastSendTime = 0;
164
- const SEND_COOLDOWN_MS = 1000; // Minimum time between sends
165
- const MAX_MESSAGES = 40; // Maximum number of messages to retain in history
166
- const _messages = [];
167
- const messages = new Proxy(_messages, {
168
- set(target, prop, value) {
169
- const result = Reflect.set(target, prop, value);
170
-
171
- // When the array grows (numeric index or length change), enforce the cap
172
- if (prop === 'length' || !Number.isNaN(Number(prop))) {
173
- const excess = target.length - MAX_MESSAGES;
174
- if (excess > 0) {
175
- // Remove oldest messages, keep only the most recent MAX_MESSAGES
176
- target.splice(0, excess);
177
- }
178
- }
179
-
180
- return result;
181
- }
182
- });
183
- // DOM Elements
184
- const toggle = document.getElementById('aiChatToggle');
185
- const panel = document.getElementById('aiChatPanel');
186
- const messagesContainer = document.getElementById('aiChatMessages');
187
- const form = document.getElementById('aiChatForm');
188
- const input = document.getElementById('aiChatInput');
189
- const closeBtn = document.getElementById('aiChatClose');
190
- const iconOpen = toggle.querySelector('.ai-chat-icon-open');
191
- const iconClose = toggle.querySelector('.ai-chat-icon-close');
192
-
193
- // Build page context for system message
194
- function getPageContext() {
195
- let context = '';
196
- try {
197
- const meta = JSON.parse(document.getElementById('aiChatPageContext').textContent);
198
- const pageContent = document.getElementById('aiChatPageContent').textContent.trim();
199
-
200
- context = 'Current page context:\n';
201
- if (meta.page_title) context += '- Title: ' + meta.page_title + '\n';
202
- if (meta.page_description) context += '- Description: ' + meta.page_description + '\n';
203
- if (meta.page_url) context += '- URL: ' + meta.page_url + '\n';
204
- if (meta.page_categories && meta.page_categories.length) {
205
- context += '- Categories: ' + (Array.isArray(meta.page_categories) ? meta.page_categories.join(', ') : meta.page_categories) + '\n';
206
- }
207
- if (meta.page_tags && meta.page_tags.length) {
208
- context += '- Tags: ' + (Array.isArray(meta.page_tags) ? meta.page_tags.join(', ') : meta.page_tags) + '\n';
209
- }
210
- if (meta.page_date) context += '- Date: ' + meta.page_date + '\n';
211
- if (meta.site_title) context += '- Site: ' + meta.site_title + '\n';
212
- if (pageContent) context += '\nPage content:\n' + pageContent + '\n';
213
- } catch (e) {
214
- console.warn('AI Chat: Could not parse page context', e);
215
- }
216
- return context;
217
- }
218
-
219
- // Build system message with page context
220
- function getSystemMessage() {
221
- const pageContext = getPageContext();
222
- let systemContent = CONFIG.systemPrompt;
223
- if (CONFIG.strictContext) {
224
- systemContent += '\n\nGrounding rules:\n'
225
- + '- Answer ONLY using the provided page context.\n'
226
- + '- If the answer is not in the page context, reply exactly with: "' + CONFIG.outOfScopeMessage + '"\n'
227
- + '- Do not invent facts, links, or features.\n';
228
- }
229
- if (pageContext) {
230
- systemContent += '\n\n' + pageContext;
231
- }
232
- return { role: 'system', content: systemContent };
233
- }
234
-
235
- function escapeHtml(text) {
236
- return String(text)
237
- .replace(/&/g, '&amp;')
238
- .replace(/</g, '&lt;')
239
- .replace(/>/g, '&gt;')
240
- .replace(/\"/g, '&quot;')
241
- .replace(/'/g, '&#39;');
242
- }
243
-
244
- function renderAssistantMarkdown(raw) {
245
- // Escape first, then allow a small markdown subset safely.
246
- let safe = escapeHtml(raw || '');
247
-
248
- safe = safe
249
- .replace(/^###\s+(.+)$/gm, '<strong>$1</strong>')
250
- .replace(/^##\s+(.+)$/gm, '<strong>$1</strong>')
251
- .replace(/^#\s+(.+)$/gm, '<strong>$1</strong>')
252
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
253
- .replace(/`([^`]+)`/g, '<code>$1</code>')
254
- .replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
255
- .replace(/^\s*[-*]\s+(.+)$/gm, '• $1')
256
- .replace(/\n/g, '<br>');
257
-
258
- return safe;
259
- }
260
-
261
- // Create a message element
262
- function createMessageEl(role, content) {
263
- const wrapper = document.createElement('div');
264
- wrapper.className = 'ai-chat-message ai-chat-message--' + role + ' mb-2';
265
-
266
- const bubble = document.createElement('div');
267
- bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
268
- if (role === 'assistant') {
269
- bubble.innerHTML = renderAssistantMarkdown(content);
270
- } else {
271
- bubble.textContent = content;
272
- }
273
-
274
- wrapper.appendChild(bubble);
275
- return wrapper;
276
- }
277
-
278
- // Create a loading indicator
279
- function createLoadingEl() {
280
- const wrapper = document.createElement('div');
281
- wrapper.className = 'ai-chat-message ai-chat-message--assistant mb-2';
282
- wrapper.id = 'aiChatLoading';
283
-
284
- const bubble = document.createElement('div');
285
- bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
286
- bubble.innerHTML = '<span class="ai-chat-typing"><span>.</span><span>.</span><span>.</span></span>';
287
-
288
- wrapper.appendChild(bubble);
289
- return wrapper;
290
- }
291
-
292
- // Append message to chat
293
- function appendMessage(role, content) {
294
- const el = createMessageEl(role, content);
295
- messagesContainer.appendChild(el);
296
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
297
- return el;
298
- }
299
-
300
- // Remove loading indicator
301
- function removeLoading() {
302
- const loading = document.getElementById('aiChatLoading');
303
- if (loading) loading.remove();
304
- }
305
-
306
- // Send message to AI
307
- async function sendMessage(userMessage) {
308
- if (isLoading || !userMessage.trim()) return;
309
-
310
- // Enforce cooldown between sends
311
- const now = Date.now();
312
- if (now - lastSendTime < SEND_COOLDOWN_MS) return;
313
- lastSendTime = now;
314
-
315
- // Add user message to UI and history
316
- appendMessage('user', userMessage);
317
- messages.push({ role: 'user', content: userMessage });
318
-
319
- // Show loading
320
- isLoading = true;
321
- input.disabled = true;
322
- messagesContainer.appendChild(createLoadingEl());
323
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
324
-
325
- try {
326
- const response = await fetch(CONFIG.endpoint, {
327
- method: 'POST',
328
- headers: (function() {
329
- const requestHeaders = {
330
- 'Content-Type': 'application/json'
331
- };
332
- if (CONFIG.authMode === 'direct' && CONFIG.apiKey) {
333
- requestHeaders.Authorization = 'Bearer ' + CONFIG.apiKey;
334
- }
335
- return requestHeaders;
336
- })(),
337
- body: JSON.stringify({
338
- model: CONFIG.model,
339
- messages: [getSystemMessage(), ...messages],
340
- max_tokens: CONFIG.maxTokens,
341
- temperature: CONFIG.temperature
342
- })
343
- });
344
-
345
- removeLoading();
346
-
347
- if (!response.ok) {
348
- const errorData = await response.json().catch(function() { return {}; });
349
- var statusMessages = {
350
- 401: 'Authentication failed. The API key may be invalid or missing.',
351
- 429: 'Rate limit exceeded. Please wait a moment and try again.',
352
- 500: 'The AI service is temporarily unavailable. Please try again later.',
353
- 503: 'The AI service is temporarily unavailable. Please try again later.'
354
- };
355
- throw new Error(errorData.error?.message || statusMessages[response.status] || 'API request failed (' + response.status + ')');
356
- }
357
-
358
- const data = await response.json();
359
- let assistantMessage = data.choices?.[0]?.message?.content || 'Sorry, I could not generate a response.';
360
- assistantMessage = String(assistantMessage).trim() || CONFIG.outOfScopeMessage;
361
-
362
- // Add assistant message to UI and history
363
- appendMessage('assistant', assistantMessage);
364
- messages.push({ role: 'assistant', content: assistantMessage });
365
-
366
- } catch (error) {
367
- removeLoading();
368
- appendMessage('assistant', 'Sorry, something went wrong: ' + error.message);
369
- console.error('AI Chat error:', error);
370
- } finally {
371
- isLoading = false;
372
- input.disabled = false;
373
- input.focus();
374
- }
375
- }
376
-
377
- // Toggle chat panel
378
- function toggleChat() {
379
- isOpen = !isOpen;
380
- panel.classList.toggle('ai-chat-panel--open', isOpen);
381
- panel.setAttribute('aria-hidden', !isOpen);
382
- toggle.setAttribute('aria-expanded', isOpen);
383
- iconOpen.classList.toggle('d-none', isOpen);
384
- iconClose.classList.toggle('d-none', !isOpen);
385
-
386
- if (isOpen) {
387
- // Focus input when opening (short delay for CSS transition to start)
388
- setTimeout(function() { input.focus(); }, 50);
389
- }
390
- }
391
-
392
- // Initialize
393
- function init() {
394
- // Show welcome message
395
- appendMessage('assistant', CONFIG.welcomeMessage);
396
-
397
- // Event: toggle button
398
- toggle.addEventListener('click', toggleChat);
399
-
400
- // Event: close button
401
- closeBtn.addEventListener('click', toggleChat);
402
-
403
- // Event: form submit
404
- form.addEventListener('submit', function(e) {
405
- e.preventDefault();
406
- const msg = input.value.trim();
407
- if (msg) {
408
- input.value = '';
409
- sendMessage(msg);
410
- }
411
- });
412
-
413
- // Event: Escape key closes chat
414
- document.addEventListener('keydown', function(e) {
415
- if (e.key === 'Escape' && isOpen) {
416
- toggleChat();
417
- }
418
- });
419
-
420
- // Event: click outside closes chat
421
- document.addEventListener('click', function(e) {
422
- if (isOpen && !panel.contains(e.target) && !toggle.contains(e.target)) {
423
- toggleChat();
424
- }
425
- });
426
- }
427
-
428
- // Start when DOM is ready
429
- if (document.readyState === 'loading') {
430
- document.addEventListener('DOMContentLoaded', init);
431
- } else {
432
- init();
173
+ {% comment %}
174
+ Widget configuration consumed by assets/js/ai-chat.js. The Anthropic
175
+ key appears here only in direct mode (local development); proxy mode
176
+ leaves it empty and the proxy injects credentials server-side.
177
+ {% endcomment %}
178
+ <script id="aiChatConfig" type="application/json">
179
+ {
180
+ "endpoint": {{ site.ai_chat.endpoint | default: "/api/chat" | jsonify }},
181
+ "authMode": {{ ai_auth_mode | jsonify }},
182
+ "apiKey": {{ site.ai_chat.api_key | jsonify }},
183
+ "anthropicVersion": {{ site.ai_chat.anthropic_version | default: "2023-06-01" | jsonify }},
184
+ "model": {{ site.ai_chat.model | default: "claude-opus-4-8" | jsonify }},
185
+ "maxTokens": {{ site.ai_chat.max_tokens | default: 1024 }},
186
+ "strictContext": {{ site.ai_chat.strict_context | default: true | jsonify }},
187
+ "outOfScopeMessage": {{ site.ai_chat.out_of_scope_message | default: "I can only answer from the content on this page." | jsonify }},
188
+ "systemPrompt": {{ site.ai_chat.system_prompt | default: "You are a helpful assistant." | jsonify }},
189
+ "welcomeMessage": {{ site.ai_chat.welcome_message | default: "Hi! Ask me anything about this page." | jsonify }},
190
+ "localEdit": {{ local_edit_flag }},
191
+ "localEditEndpoint": {{ site.ai_chat.local_edit_endpoint | default: "/api/page" | jsonify }},
192
+ "github": {
193
+ "enabled": {{ gh_enabled }},
194
+ "mode": {{ site.ai_chat.github.mode | default: "url" | jsonify }},
195
+ "endpoint": {{ site.ai_chat.github.endpoint | default: "/api/github" | jsonify }},
196
+ "repository": {{ site.repository | join: "" | jsonify }},
197
+ "baseBranch": {{ site.ai_chat.github.base_branch | default: "main" | jsonify }},
198
+ "defaultLabels": {% if site.ai_chat.github.default_labels %}{{ site.ai_chat.github.default_labels | jsonify }}{% else %}[]{% endif %},
199
+ "prBranchPrefix": {{ site.ai_chat.github.pr_branch_prefix | default: "chat/" | jsonify }}
433
200
  }
434
- })();
201
+ }
435
202
  </script>
436
203
 
204
+ <script defer src="{{ '/assets/js/ai-chat.js' | relative_url }}"></script>
205
+
437
206
  <style>
438
207
  /* AI Chat Toggle Button */
439
208
  /* FAB stack slot above back-to-top, below the TOC FAB — driven by the
440
209
  design tokens (_sass/tokens/_spacing.scss, _layers.scss) so all
441
210
  floating buttons share one offset/stacking system. */
442
- .ai-chat-toggle {
211
+ /* ID selectors (like #backToTopBtn) are required for the fixed-position
212
+ elements: body children otherwise match the higher-specificity
213
+ `.zer0-bg-body > :not(...)` elevation rule in _sass/theme/_backgrounds.scss,
214
+ which forces position: relative. */
215
+ #aiChatToggle {
443
216
  position: fixed;
444
217
  bottom: calc(var(--zer0-space-fab-offset, 1rem) + var(--zer0-space-fab-size, 3.5rem) + var(--zer0-space-fab-gap, 0.75rem));
445
218
  right: var(--zer0-space-fab-offset, 1rem);
@@ -453,18 +226,18 @@
453
226
  transition: transform 0.2s ease, box-shadow 0.2s ease;
454
227
  }
455
228
 
456
- .ai-chat-toggle:hover {
229
+ #aiChatToggle:hover {
457
230
  transform: scale(1.1);
458
231
  }
459
232
 
460
233
  /* AI Chat Panel */
461
- .ai-chat-panel {
234
+ #aiChatPanel {
462
235
  position: fixed;
463
236
  bottom: calc(var(--zer0-space-fab-offset, 1rem) + 2 * (var(--zer0-space-fab-size, 3.5rem) + var(--zer0-space-fab-gap, 0.75rem)));
464
237
  right: var(--zer0-space-fab-offset, 1rem);
465
238
  z-index: var(--zer0-layer-fab-chat, 1052);
466
239
  width: 360px;
467
- max-height: 500px;
240
+ max-height: 540px;
468
241
  display: flex;
469
242
  flex-direction: column;
470
243
  background: var(--bs-body-bg, #212529);
@@ -475,7 +248,7 @@
475
248
  transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
476
249
  }
477
250
 
478
- .ai-chat-panel--open {
251
+ #aiChatPanel.ai-chat-panel--open {
479
252
  opacity: 1;
480
253
  visibility: visible;
481
254
  transform: translateY(0) scale(1);
@@ -518,6 +291,30 @@
518
291
  word-wrap: break-word;
519
292
  }
520
293
 
294
+ /* Quick-action chips */
295
+ .ai-chat-chips {
296
+ flex-shrink: 0;
297
+ }
298
+
299
+ .ai-chat-chips .ai-chat-chip {
300
+ font-size: 0.75rem;
301
+ }
302
+
303
+ /* Action cards (tool confirmations + created issue/PR links) */
304
+ .ai-chat-action-card {
305
+ background: var(--bs-tertiary-bg, #2b3035);
306
+ border: 1px solid var(--bs-border-color, #495057);
307
+ color: var(--bs-body-color, #dee2e6);
308
+ }
309
+
310
+ .ai-chat-action-card--resolved {
311
+ opacity: 0.7;
312
+ }
313
+
314
+ .ai-chat-action-card .ai-chat-action-meta {
315
+ overflow-wrap: anywhere;
316
+ }
317
+
521
318
  /* Chat Input */
522
319
  .ai-chat-input {
523
320
  border-top: 1px solid var(--bs-border-color, #495057);
@@ -552,14 +349,14 @@
552
349
 
553
350
  /* Mobile Responsive */
554
351
  @media (max-width: 576px) {
555
- .ai-chat-panel {
352
+ #aiChatPanel {
556
353
  width: calc(100vw - 2rem);
557
354
  right: 1rem;
558
355
  bottom: 8rem;
559
356
  max-height: 60vh;
560
357
  }
561
358
 
562
- .ai-chat-toggle {
359
+ #aiChatToggle {
563
360
  bottom: 4.5rem;
564
361
  right: 1rem;
565
362
  }
@@ -567,8 +364,8 @@
567
364
 
568
365
  /* Respect reduced motion */
569
366
  @media (prefers-reduced-motion: reduce) {
570
- .ai-chat-panel,
571
- .ai-chat-toggle,
367
+ #aiChatPanel,
368
+ #aiChatToggle,
572
369
  .ai-chat-typing span {
573
370
  transition: none !important;
574
371
  animation: none !important;
@@ -30,7 +30,8 @@
30
30
  <!-- ANALYTICS AND TRACKING -->
31
31
  <!-- ================================ -->
32
32
  <!-- Google Tag Manager - Must be in head section for proper tracking -->
33
- {% include analytics/google-tag-manager-head.html %}
33
+ <!-- Only load in production builds (JEKYLL_ENV=production); the include also guards against dev hostnames -->
34
+ {% if jekyll.environment == "production" %}{% include analytics/google-tag-manager-head.html %}{% endif %}
34
35
 
35
36
  <!-- ================================ -->
36
37
  <!-- JAVASCRIPT LIBRARIES -->
@@ -118,7 +119,8 @@ window.MathJax = {
118
119
  <!-- ANALYTICS INTEGRATION -->
119
120
  <!-- ========================== -->
120
121
  <!-- Google Analytics tracking - Configured in _config.yml -->
121
- {% include analytics/google-analytics.html %}
122
+ <!-- Only load in production AND when an ID is configured; the include also guards against dev hostnames -->
123
+ {% if jekyll.environment == "production" and site.google_analytics %}{% include analytics/google-analytics.html %}{% endif %}
122
124
 
123
125
 
124
126
  <!-- ================================ -->
data/_layouts/home.html CHANGED
@@ -28,6 +28,13 @@ layout: root
28
28
  - Set layout: home in page frontmatter
29
29
  - Optionally include title: "Page Title" for display
30
30
  - Content can include hero sections, feature highlights, etc.
31
+
32
+ Front matter options:
33
+ - title: displayed as <h1> (and used for SEO) when present
34
+ - hide_title:true keep `title` for SEO but suppress the visible <h1>
35
+ (useful when a custom hero provides its own heading)
36
+ - rss_subscribe:false hide the RSS subscribe link on landing/showcase homepages
37
+ Both default to the original behavior, so existing homepages are unaffected.
31
38
 
32
39
  Design Philosophy:
33
40
  - Minimal structure allows for maximum content flexibility
@@ -43,9 +50,12 @@ layout: root
43
50
  <!-- ========================== -->
44
51
  <!-- OPTIONAL PAGE TITLE -->
45
52
  <!-- ========================== -->
46
- <!-- Display title only if specified in page frontmatter -->
47
- <!-- Usage: Add 'title: "Homepage Title"' to page frontmatter -->
48
- {%- if page.title -%}
53
+ <!-- Display title only if specified in page frontmatter. -->
54
+ <!-- Usage: Add 'title: "Homepage Title"' to page frontmatter. -->
55
+ <!-- Landing/showcase pages can keep `title` for SEO but suppress the -->
56
+ <!-- visible heading (e.g. when a custom hero supplies its own <h1>) -->
57
+ <!-- with `hide_title: true`. -->
58
+ {%- if page.title and page.hide_title != true -%}
49
59
  <h1 class="page-heading">{{ page.title }}</h1>
50
60
  {%- endif -%}
51
61
 
@@ -59,8 +69,11 @@ layout: root
59
69
  <!-- ========================== -->
60
70
  <!-- RSS SUBSCRIPTION LINK -->
61
71
  <!-- ========================== -->
62
- <!-- Provides easy access to RSS feed for blog updates -->
72
+ <!-- Provides easy access to RSS feed for blog updates. -->
73
+ <!-- Opt out on landing/showcase homepages with `rss_subscribe: false`. -->
74
+ {%- unless page.rss_subscribe == false -%}
63
75
  <p class="rss-subscribe">
64
76
  subscribe <a href="{{ "/feed.xml" | relative_url }}">via RSS</a>
65
77
  </p>
78
+ {%- endunless -%}
66
79
  </div>