jamdesk 1.1.29 → 1.1.31

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 (30) hide show
  1. package/dist/lib/deps.js +3 -3
  2. package/package.json +3 -3
  3. package/vendored/app/[[...slug]]/page.tsx +42 -101
  4. package/vendored/app/api/assets/[...path]/route.ts +2 -2
  5. package/vendored/app/api/chat/[project]/route.ts +76 -35
  6. package/vendored/app/api/isr-health/route.ts +2 -3
  7. package/vendored/app/layout.tsx +2 -0
  8. package/vendored/components/JdReadySentinel.tsx +25 -0
  9. package/vendored/components/navigation/Breadcrumb.tsx +2 -2
  10. package/vendored/components/navigation/Header.tsx +23 -17
  11. package/vendored/components/navigation/LanguageSelector.tsx +7 -4
  12. package/vendored/components/navigation/Sidebar.tsx +28 -37
  13. package/vendored/components/navigation/TabsNav.tsx +1 -1
  14. package/vendored/hooks/useChat.ts +113 -60
  15. package/vendored/hooks/useDelayedNavigationSpinner.ts +94 -0
  16. package/vendored/hooks/useTextStreamPacer.ts +152 -0
  17. package/vendored/lib/chat-prompt.ts +5 -3
  18. package/vendored/lib/docs-types.ts +4 -0
  19. package/vendored/lib/find-first-nav-page.ts +40 -0
  20. package/vendored/lib/hedge-strip.ts +29 -0
  21. package/vendored/lib/middleware-helpers.ts +2 -1
  22. package/vendored/lib/openapi/lang-spec-path.ts +16 -0
  23. package/vendored/lib/page-isr-helpers.ts +4 -1
  24. package/vendored/lib/public-paths-resolver.ts +3 -42
  25. package/vendored/lib/ui-strings.ts +54 -0
  26. package/vendored/schema/docs-schema.json +15 -0
  27. package/vendored/themes/jam/variables.css +7 -7
  28. package/vendored/themes/nebula/variables.css +7 -7
  29. package/vendored/themes/pulsar/variables.css +7 -7
  30. package/vendored/workspace-package-lock.json +18 -18
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ const DEFAULT_DELAY_MS = 500;
6
+
7
+ export interface UseDelayedNavigationSpinnerResult {
8
+ /** Path of the link that was just clicked — set immediately, used for optimistic active highlight. */
9
+ pendingPathname: string | null;
10
+ /** Path of the link that has been "pending" longer than the delay threshold — drives the spinner UI. */
11
+ spinnerPathname: string | null;
12
+ /** Call from the link's onClick handler with the link's href. */
13
+ onNavigate: (url: string) => void;
14
+ }
15
+
16
+ /**
17
+ * Two-stage navigation feedback:
18
+ * 1. Click → `pendingPathname` set immediately (optimistic active highlight).
19
+ * 2. After `delayMs` (default 500ms) → `spinnerPathname` set, signaling
20
+ * "this navigation is taking a while, show a spinner."
21
+ *
22
+ * Both clear when the live `pathname` matches the pending one (navigation
23
+ * resolved) or when the user clicks a different link (the new click cancels
24
+ * the old timer and starts fresh).
25
+ *
26
+ * Same-page hash clicks (`/current#anchor`) are ignored — they don't cause
27
+ * a real navigation, so the timer would never get cleared by `pathname` change.
28
+ *
29
+ * Double-clicks on the same pending link are no-ops — the in-flight timer keeps
30
+ * running so the spinner appears 500ms after the FIRST click, not the second.
31
+ *
32
+ * `onNavigate` has stable identity across pathname changes (live values are read
33
+ * from refs, not closure-captured) so it doesn't bust `React.memo` on the
34
+ * hundreds of `<NavPage>` instances rendered in large docs sites.
35
+ */
36
+ export function useDelayedNavigationSpinner(
37
+ pathname: string | null,
38
+ delayMs: number = DEFAULT_DELAY_MS,
39
+ ): UseDelayedNavigationSpinnerResult {
40
+ const [pendingPathname, setPendingPathname] = useState<string | null>(null);
41
+ const [spinnerPathname, setSpinnerPathname] = useState<string | null>(null);
42
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43
+ const pathnameRef = useRef(pathname);
44
+ const pendingRef = useRef<string | null>(null);
45
+
46
+ useEffect(() => {
47
+ pathnameRef.current = pathname;
48
+ }, [pathname]);
49
+
50
+ const clearTimer = useCallback(() => {
51
+ if (timerRef.current !== null) {
52
+ clearTimeout(timerRef.current);
53
+ timerRef.current = null;
54
+ }
55
+ }, []);
56
+
57
+ const onNavigate = useCallback((url: string) => {
58
+ const path = url.split('#')[0];
59
+
60
+ // Same-page hash click — no real navigation, just clear any leftover state.
61
+ if (path === pathnameRef.current) {
62
+ clearTimer();
63
+ setPendingPathname(null);
64
+ setSpinnerPathname(null);
65
+ pendingRef.current = null;
66
+ return;
67
+ }
68
+
69
+ // Double-click on the same pending link — let the existing timer run.
70
+ if (path === pendingRef.current) {
71
+ return;
72
+ }
73
+
74
+ clearTimer();
75
+ setSpinnerPathname(null);
76
+ setPendingPathname(path);
77
+ pendingRef.current = path;
78
+ timerRef.current = setTimeout(() => {
79
+ setSpinnerPathname(path);
80
+ timerRef.current = null;
81
+ }, delayMs);
82
+ }, [clearTimer, delayMs]);
83
+
84
+ useEffect(() => {
85
+ clearTimer();
86
+ setPendingPathname(null);
87
+ setSpinnerPathname(null);
88
+ pendingRef.current = null;
89
+ }, [pathname, clearTimer]);
90
+
91
+ useEffect(() => () => clearTimer(), [clearTimer]);
92
+
93
+ return { pendingPathname, spinnerPathname, onNavigate };
94
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Rate-limited text reveal pacer for streaming chat responses.
3
+ *
4
+ * Backend SSE deltas often arrive clumped (Haiku generates short answers
5
+ * fast; CDN/middleware sometimes buffers tiny frames together). Without
6
+ * pacing, the React message state updates in one big jump and the user
7
+ * perceives "the response just appeared". This pacer holds incoming text
8
+ * in a buffer and drains it at a bounded rate via requestAnimationFrame.
9
+ *
10
+ * The `now` option exists for deterministic tests: vitest fake timers
11
+ * do NOT fake `performance.now` unless `toFake: ['performance']` is
12
+ * passed, and we want tests that don't reach into that global.
13
+ */
14
+ export interface TextStreamPacerOptions {
15
+ /** Called each frame with newly-revealed text (never the full accumulated string). */
16
+ onReveal: (chunk: string) => void;
17
+ /** Called once after `finish()` has emitted the last buffered characters,
18
+ * or synchronously after `finish()` under reduced-motion. */
19
+ onDrain?: () => void;
20
+ /** Baseline reveal rate. Default 80 chars/sec (~comfortable "writing" speed). */
21
+ baseCharsPerSecond?: number;
22
+ /** Max reveal rate while catching up. Default 400 chars/sec (~2x fast-talker read speed). */
23
+ maxCharsPerSecond?: number;
24
+ /** Buffer length at which we switch from base to max rate. Default 200. */
25
+ catchUpThreshold?: number;
26
+ /** Buffer length at which finish() dumps the whole buffer at once. Default 800.
27
+ * Only applies post-finish — ensures long responses don't take >2s of reveal
28
+ * time after the stream completes. Arriving bursts mid-stream pace normally. */
29
+ instantFlushThreshold?: number;
30
+ /** If true, pacer flushes immediately on every `enqueue` and `finish` fires
31
+ * `onDrain` synchronously. Callers own the `prefers-reduced-motion` check —
32
+ * typically via `useMediaQuery('(prefers-reduced-motion: reduce)')`. Default false. */
33
+ reducedMotion?: boolean;
34
+ /** Clock function. Default `performance.now`. Injectable for tests. */
35
+ now?: () => number;
36
+ }
37
+
38
+ export interface TextStreamPacer {
39
+ /** Add text to the buffer. RAF loop resumes on first enqueue after idle. */
40
+ enqueue(text: string): void;
41
+ /** Signal stream complete — drain remaining buffer at max rate, then fire onDrain. */
42
+ finish(): void;
43
+ /** Stop pacing immediately. Buffered text is discarded; onDrain does not fire. */
44
+ abort(): void;
45
+ }
46
+
47
+ export function createTextStreamPacer(options: TextStreamPacerOptions): TextStreamPacer {
48
+ const {
49
+ onReveal,
50
+ onDrain,
51
+ baseCharsPerSecond = 80,
52
+ maxCharsPerSecond = 400,
53
+ catchUpThreshold = 200,
54
+ instantFlushThreshold = 800,
55
+ reducedMotion = false,
56
+ now = () => performance.now(),
57
+ } = options;
58
+
59
+ let buffer = '';
60
+ let finished = false;
61
+ let aborted = false;
62
+ let rafId: number | null = null;
63
+ let lastFrameMs: number | null = null;
64
+ let fractionalChars = 0;
65
+
66
+ const flushBuffer = () => {
67
+ if (buffer.length === 0) return;
68
+ const chunk = buffer;
69
+ buffer = '';
70
+ onReveal(chunk);
71
+ };
72
+
73
+ const tick = () => {
74
+ rafId = null;
75
+ if (aborted) return;
76
+
77
+ const t = now();
78
+ const elapsedMs = lastFrameMs === null ? 16 : t - lastFrameMs;
79
+ lastFrameMs = t;
80
+
81
+ // Instant flush only applies post-completion — it bounds drain time after
82
+ // finish(), but must not short-circuit pacing while the stream is still
83
+ // arriving (otherwise a fast Haiku burst would appear in one flash).
84
+ if (finished && buffer.length >= instantFlushThreshold) {
85
+ flushBuffer();
86
+ fractionalChars = 0;
87
+ } else {
88
+ // Rate is driven purely by buffer length — don't boost on `finished`,
89
+ // or short responses whose buffer never hits catchUpThreshold would
90
+ // drain in one frame and defeat the typing-pace effect.
91
+ const rate = buffer.length > catchUpThreshold
92
+ ? maxCharsPerSecond
93
+ : baseCharsPerSecond;
94
+
95
+ fractionalChars += (rate * elapsedMs) / 1000;
96
+ const reveal = Math.min(Math.floor(fractionalChars), buffer.length);
97
+ if (reveal > 0) {
98
+ fractionalChars -= reveal;
99
+ const chunk = buffer.slice(0, reveal);
100
+ buffer = buffer.slice(reveal);
101
+ onReveal(chunk);
102
+ }
103
+ }
104
+
105
+ if (buffer.length > 0) {
106
+ schedule();
107
+ return;
108
+ }
109
+
110
+ // Drained.
111
+ lastFrameMs = null;
112
+ fractionalChars = 0;
113
+ if (finished) {
114
+ finished = false; // one-shot
115
+ onDrain?.();
116
+ }
117
+ };
118
+
119
+ const schedule = () => {
120
+ if (rafId !== null || aborted) return;
121
+ rafId = requestAnimationFrame(tick);
122
+ };
123
+
124
+ return {
125
+ enqueue(text) {
126
+ if (aborted || !text) return;
127
+ if (reducedMotion) {
128
+ onReveal(text);
129
+ return;
130
+ }
131
+ buffer += text;
132
+ schedule();
133
+ },
134
+ finish() {
135
+ if (aborted) return;
136
+ if (reducedMotion) {
137
+ onDrain?.();
138
+ return;
139
+ }
140
+ finished = true;
141
+ schedule();
142
+ },
143
+ abort() {
144
+ aborted = true;
145
+ buffer = '';
146
+ if (rafId !== null) {
147
+ cancelAnimationFrame(rafId);
148
+ rafId = null;
149
+ }
150
+ },
151
+ };
152
+ }
@@ -98,11 +98,13 @@ Rules:
98
98
  - Be concise and direct.
99
99
  - Use markdown formatting, including code blocks with language hints when showing code.
100
100
  - Never make up information not in the context.
101
- - Never combine "I don't have information" with a follow-up suggestion in the same response that creates a self-contradiction. Pick one of exactly two response shapes:
102
- (a) If any page in the Documentation context is relevant to the question, answer by referring to that page with a link (e.g., "See [Changelog](.../changelog) for recent releases."). Do not prefix with "I don't have information".
103
- (b) If no relevant page is in context, respond with ONLY "I don't have information about that in the documentation." no "but check", no "you can find", no alternatives.
101
+ - Pick one of exactly two response shapes, based on whether any page in the Documentation context below is relevant to the question:
102
+ (a) RELEVANT PAGE EXISTS answer by referring to it with a markdown link (e.g., "See [Changelog](.../changelog) for recent releases."). Do NOT prefix with "I don't have information".
103
+ (b) NO RELEVANT PAGE respond with this exact sentence and stop: "I don't have information about that in the documentation." Do not add "However", "but", "you could try", or any follow-up suggestion after it. The reply ends at the period.
104
+ WRONG: "I don't have information about that in the documentation. However, if you're asking about X, you could..." ← This is forbidden. If the first sentence is true, stop there.
104
105
  - Never name a page ("the Changelog", "the API reference", "the Settings page", etc.) unless that page appears in the Documentation context below.
105
106
  - Refer to this site as "${projectName}" or just "the documentation". Never expose internal project slugs, repo names, or hostnames (e.g. do not say "jamdesk-docs", "acme-docs", or "<slug>.jamdesk.app") even if they appear in source URLs.
107
+ - RESOLVE IMPLICIT SUBJECTS: when the user's question has no explicit subject (pronouns like "it", "this", "they", or bare noun phrases like "the product", "pricing", "the API"), assume they are asking about ${projectName} unless the conversation clearly establishes a different subject. For example, "how much does it cost" means "how much does ${projectName} cost"; "is it open source" means "is ${projectName} open source". Resolve the reference and answer from the documentation context — do not hedge just because the pronoun wasn't spelled out.
106
108
  - When the context includes API endpoints (e.g. "POST /analytics/post") or technical operations, proactively include a short code example showing the request (HTTP method, endpoint, JSON body) even if the user didn't explicitly ask. If the context has no code-relevant information, skip the example.
107
109
  - Use the pageSlug values (shown in the context as [pageSlug: ...]) when populating cited_page_slugs — not the page title.
108
110
 
@@ -394,6 +394,8 @@ export interface NavigationConfig {
394
394
  */
395
395
  export interface NavbarLink {
396
396
  label: string;
397
+ /** Optional per-language overrides (e.g. `{ "fr": "Blog", "es": "Blog" }`). Falls back to `label`. */
398
+ labels?: Record<string, string>;
397
399
  icon?: IconConfig;
398
400
  href: string;
399
401
  }
@@ -404,6 +406,8 @@ export interface NavbarLink {
404
406
  export interface NavbarPrimary {
405
407
  type: 'button' | 'github';
406
408
  label?: string;
409
+ /** Optional per-language overrides. Falls back to `label` or the built-in default. */
410
+ labels?: Record<string, string>;
407
411
  href: string;
408
412
  }
409
413
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Walk the navigation tree and return the slug of the first leaf page.
3
+ * Used to resolve empty-root and bare-language URLs (`/`, `/fr`, `/es`) to
4
+ * a concrete page in place, without emitting a redirect. Returns null if no
5
+ * leaf page is found.
6
+ */
7
+ export function findFirstNavPage(nav: unknown): string | null {
8
+ if (!nav || typeof nav !== 'object') return null;
9
+ const node = nav as Record<string, unknown>;
10
+ const pages = node.pages;
11
+
12
+ if (Array.isArray(pages)) {
13
+ for (const p of pages) {
14
+ if (typeof p === 'string' && p.length > 0) return '/' + p;
15
+ if (p && typeof p === 'object') {
16
+ const pageVal = (p as Record<string, unknown>).page;
17
+ if (typeof pageVal === 'string' && pageVal.length > 0) {
18
+ return '/' + pageVal;
19
+ }
20
+ const nested = findFirstNavPage(p);
21
+ if (nested) return nested;
22
+ }
23
+ }
24
+ // A group is an atomic container — exhaust its pages rather than
25
+ // falling through to the sibling-key loop below.
26
+ if ('group' in node) return null;
27
+ }
28
+
29
+ for (const key of ['groups', 'tabs', 'anchors', 'versions', 'languages']) {
30
+ const arr = node[key];
31
+ if (Array.isArray(arr)) {
32
+ for (const item of arr) {
33
+ const nested = findFirstNavPage(item);
34
+ if (nested) return nested;
35
+ }
36
+ }
37
+ }
38
+
39
+ return null;
40
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Detects and truncates Claude's canned no-answer sentence
3
+ * ("I don't have information about that in the documentation.") when followed
4
+ * by a self-contradicting "However..." — a prompt-adherence failure mode of
5
+ * Haiku 4.5. Tolerates curly-vs-straight apostrophes and surrounding markdown
6
+ * emphasis.
7
+ *
8
+ * ASSUMPTION: the sentence is reserved for the no-answer reply. A docs page
9
+ * that legitimately contains the phrase would have content silently truncated.
10
+ */
11
+ export const HEDGE_SENTENCE = "I don't have information about that in the documentation.";
12
+
13
+ const HEDGE_REGEX = /I\s+don['\u2018\u2019]t\s+have\s+information\s+about\s+that\s+in\s+the\s+documentation\./i;
14
+
15
+ export function stripHedge(markdown: string): string {
16
+ if (!markdown) return markdown;
17
+ const match = HEDGE_REGEX.exec(markdown);
18
+ if (!match) return markdown;
19
+ return markdown.slice(0, match.index + match[0].length);
20
+ }
21
+
22
+ /**
23
+ * True when the reply is (or contains) the canned no-answer sentence.
24
+ * Used to suppress misleading citation fallbacks on hedge replies.
25
+ */
26
+ export function isHedgeReply(markdown: string): boolean {
27
+ const stripped = stripHedge(markdown);
28
+ return stripped.length !== markdown.length || stripped.trim() === HEDGE_SENTENCE;
29
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { log } from './logger';
9
+ import { isIsrMode } from './page-isr-helpers';
9
10
  import {
10
11
  resolveProjectFromHostname,
11
12
  resolveCustomDomain,
@@ -83,7 +84,7 @@ export async function handleProjectResolution(
83
84
  const hostname = request.headers.get('host') || '';
84
85
 
85
86
  // Skip if not in ISR mode
86
- if (process.env.ISR_MODE !== 'true') {
87
+ if (!isIsrMode()) {
87
88
  return { projectSlug: null, hostAtDocs: true, skip: true };
88
89
  }
89
90
 
@@ -0,0 +1,16 @@
1
+ export function langSpecPath(specPath: string, lang: string): string {
2
+ const lastSlash = specPath.lastIndexOf('/');
3
+ const fileName = specPath.slice(lastSlash + 1);
4
+ const dot = fileName.lastIndexOf('.');
5
+ if (dot === -1) return `${specPath}.${lang}`;
6
+ const base = fileName.slice(0, dot);
7
+ const ext = fileName.slice(dot);
8
+ const dir = specPath.slice(0, lastSlash + 1);
9
+ return `${dir}${base}.${lang}${ext}`;
10
+ }
11
+
12
+ export function candidateSpecPaths(specPath: string, lang: string | undefined): string[] {
13
+ if (!lang || lang === 'en') return [specPath];
14
+ if (/^https?:\/\//i.test(specPath)) return [specPath];
15
+ return [langSpecPath(specPath, lang), specPath];
16
+ }
@@ -9,7 +9,10 @@
9
9
  * Check if ISR mode is enabled.
10
10
  */
11
11
  export function isIsrMode(): boolean {
12
- return process.env.ISR_MODE === 'true';
12
+ // Trim guards against trailing whitespace/newline picked up when the env
13
+ // var is set via the Vercel UI or piped-stdin CLI — a single stray \n
14
+ // silently disables ISR mode and returns 404 for every project.
15
+ return process.env.ISR_MODE?.trim() === 'true';
13
16
  }
14
17
 
15
18
  /**
@@ -1,5 +1,8 @@
1
1
  import type { DocsConfig } from './docs-types.js';
2
2
  import { matchPublicPath } from './glob-match.js';
3
+ import { findFirstNavPage } from './find-first-nav-page.js';
4
+
5
+ export { findFirstNavPage };
3
6
 
4
7
  interface PageWithFrontmatter {
5
8
  slug: string;
@@ -11,48 +14,6 @@ interface Input {
11
14
  pagesWithFrontmatter: PageWithFrontmatter[];
12
15
  }
13
16
 
14
- /**
15
- * Walk the navigation tree and return the slug of the first leaf page.
16
- * Matches the resolution used by `app/[[...slug]]/page.tsx:findFirstPage`
17
- * so specific-pages mode can keep the root path public when the first
18
- * nav page is public. Returns null if no leaf page is found.
19
- */
20
- export function findFirstNavPage(nav: unknown): string | null {
21
- if (!nav || typeof nav !== 'object') return null;
22
- const node = nav as Record<string, unknown>;
23
- const pages = node.pages;
24
-
25
- if (Array.isArray(pages)) {
26
- for (const p of pages) {
27
- if (typeof p === 'string' && p.length > 0) return '/' + p;
28
- if (p && typeof p === 'object') {
29
- const pageVal = (p as Record<string, unknown>).page;
30
- if (typeof pageVal === 'string' && pageVal.length > 0) {
31
- return '/' + pageVal;
32
- }
33
- const nested = findFirstNavPage(p);
34
- if (nested) return nested;
35
- }
36
- }
37
- // A group is an atomic container — exhaust its pages rather than
38
- // falling through to the sibling-key loop below (which is for
39
- // top-level nav shapes like tabs/anchors/languages, not group contents).
40
- if ('group' in node) return null;
41
- }
42
-
43
- for (const key of ['groups', 'tabs', 'anchors', 'versions', 'languages']) {
44
- const arr = node[key];
45
- if (Array.isArray(arr)) {
46
- for (const item of arr) {
47
- const nested = findFirstNavPage(item);
48
- if (nested) return nested;
49
- }
50
- }
51
- }
52
-
53
- return null;
54
- }
55
-
56
17
  function collectPublicGroupPages(
57
18
  nav: unknown,
58
19
  inPublicGroup: boolean = false,
@@ -0,0 +1,54 @@
1
+ import type { LanguageCode } from './docs-types';
2
+
3
+ export interface UiStrings {
4
+ search: string;
5
+ askAi: string;
6
+ more: string;
7
+ toggleMenu: string;
8
+ selectLanguage: string;
9
+ }
10
+
11
+ const EN: UiStrings = {
12
+ search: 'Search',
13
+ askAi: 'Ask AI',
14
+ more: 'More',
15
+ toggleMenu: 'Toggle menu',
16
+ selectLanguage: 'Select language',
17
+ };
18
+
19
+ const ZH: Partial<UiStrings> = {
20
+ search: '搜索',
21
+ askAi: '询问 AI',
22
+ more: '更多',
23
+ toggleMenu: '切换菜单',
24
+ selectLanguage: '选择语言',
25
+ };
26
+
27
+ const OVERRIDES: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
28
+ fr: {
29
+ search: 'Rechercher',
30
+ askAi: "Demander à l'IA",
31
+ more: 'Plus',
32
+ toggleMenu: 'Basculer le menu',
33
+ selectLanguage: 'Choisir la langue',
34
+ },
35
+ es: {
36
+ search: 'Buscar',
37
+ askAi: 'Preguntar a la IA',
38
+ more: 'Más',
39
+ toggleMenu: 'Alternar menú',
40
+ selectLanguage: 'Elegir idioma',
41
+ },
42
+ zh: ZH,
43
+ cn: ZH,
44
+ };
45
+
46
+ // Pre-merge so repeated calls return a stable object reference.
47
+ const MERGED: Partial<Record<LanguageCode, UiStrings>> = {};
48
+ for (const [code, overrides] of Object.entries(OVERRIDES) as [LanguageCode, Partial<UiStrings>][]) {
49
+ MERGED[code] = { ...EN, ...overrides };
50
+ }
51
+
52
+ export function getUiStrings(lang: LanguageCode | undefined): UiStrings {
53
+ return (lang && MERGED[lang]) || EN;
54
+ }
@@ -303,6 +303,11 @@
303
303
  "label": {
304
304
  "type": "string"
305
305
  },
306
+ "labels": {
307
+ "type": "object",
308
+ "additionalProperties": { "type": "string" },
309
+ "description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to label."
310
+ },
306
311
  "icon": {
307
312
  "$ref": "#/definitions/iconSchema",
308
313
  "description": "Icon to display alongside this navigation item"
@@ -331,6 +336,11 @@
331
336
  "label": {
332
337
  "type": "string"
333
338
  },
339
+ "labels": {
340
+ "type": "object",
341
+ "additionalProperties": { "type": "string" },
342
+ "description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to label."
343
+ },
334
344
  "href": {
335
345
  "$ref": "#/definitions/hrefSchema"
336
346
  }
@@ -349,6 +359,11 @@
349
359
  "type": "string",
350
360
  "const": "github"
351
361
  },
362
+ "labels": {
363
+ "type": "object",
364
+ "additionalProperties": { "type": "string" },
365
+ "description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to 'GitHub'."
366
+ },
352
367
  "href": {
353
368
  "$ref": "#/definitions/hrefSchema"
354
369
  }
@@ -782,19 +782,19 @@ body[data-theme="jam"] .prose video {
782
782
  /* Phase 2: Touch Targets for Sidebar */
783
783
  @media (max-width: 1023px) {
784
784
  :root {
785
- --sidebar-item-spacing: 6px;
785
+ --sidebar-item-spacing: 3px;
786
786
  }
787
787
 
788
788
  .sidebar-scroll ul li a {
789
- padding-top: 10px !important;
790
- padding-bottom: 10px !important;
791
- min-height: 44px;
789
+ padding-top: 7px !important;
790
+ padding-bottom: 7px !important;
791
+ min-height: 36px;
792
792
  }
793
793
 
794
794
  .sidebar-scroll .nav-group-l1 {
795
- padding-top: 10px !important;
796
- padding-bottom: 10px !important;
797
- min-height: 44px;
795
+ padding-top: 7px !important;
796
+ padding-bottom: 7px !important;
797
+ min-height: 36px;
798
798
  }
799
799
  }
800
800
 
@@ -141,19 +141,19 @@
141
141
  /* Phase 2: Touch Targets for Sidebar */
142
142
  @media (max-width: 1023px) {
143
143
  :root {
144
- --sidebar-item-spacing: 6px;
144
+ --sidebar-item-spacing: 3px;
145
145
  }
146
146
 
147
147
  .sidebar-scroll ul li a {
148
- padding-top: 10px !important;
149
- padding-bottom: 10px !important;
150
- min-height: 44px;
148
+ padding-top: 7px !important;
149
+ padding-bottom: 7px !important;
150
+ min-height: 36px;
151
151
  }
152
152
 
153
153
  .sidebar-scroll .nav-group-l1 {
154
- padding-top: 10px !important;
155
- padding-bottom: 10px !important;
156
- min-height: 44px;
154
+ padding-top: 7px !important;
155
+ padding-bottom: 7px !important;
156
+ min-height: 36px;
157
157
  }
158
158
  }
159
159
 
@@ -896,19 +896,19 @@ body[data-theme="pulsar"] header [data-theme-toggle] {
896
896
  /* Phase 3: Touch Targets for Sidebar */
897
897
  @media (max-width: 1023px) {
898
898
  :root {
899
- --sidebar-item-spacing: 6px;
899
+ --sidebar-item-spacing: 3px;
900
900
  }
901
901
 
902
902
  .sidebar-scroll ul li a {
903
- padding-top: 10px !important;
904
- padding-bottom: 10px !important;
905
- min-height: 44px;
903
+ padding-top: 7px !important;
904
+ padding-bottom: 7px !important;
905
+ min-height: 36px;
906
906
  }
907
907
 
908
908
  .sidebar-scroll .nav-group-l1 {
909
- padding-top: 10px !important;
910
- padding-bottom: 10px !important;
911
- min-height: 44px;
909
+ padding-top: 7px !important;
910
+ padding-bottom: 7px !important;
911
+ min-height: 36px;
912
912
  }
913
913
  }
914
914