jamdesk 1.1.139 → 1.1.141

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.
@@ -7,7 +7,7 @@ describe('suggestConfigImprovements (CLI)', () => {
7
7
  expect(suggestions).toHaveLength(3);
8
8
  expect(suggestions.map((s) => s.link)).toEqual([
9
9
  'https://jamdesk.com/docs/customization/branding#favicon',
10
- 'https://jamdesk.com/docs/customization/branding#site-name',
10
+ 'https://jamdesk.com/docs/customization/branding#description',
11
11
  'https://jamdesk.com/docs/customization/branding#logo',
12
12
  ]);
13
13
  });
@@ -24,7 +24,7 @@ describe('suggestConfigImprovements (CLI)', () => {
24
24
  it('empty object logo and whitespace description count as missing', () => {
25
25
  const suggestions = suggestConfigImprovements({ favicon: '/f.svg', logo: {}, description: ' ' });
26
26
  expect(suggestions.map((s) => s.link)).toEqual([
27
- 'https://jamdesk.com/docs/customization/branding#site-name',
27
+ 'https://jamdesk.com/docs/customization/branding#description',
28
28
  'https://jamdesk.com/docs/customization/branding#logo',
29
29
  ]);
30
30
  });
@@ -1 +1 @@
1
- {"version":3,"file":"config-suggestions.test.js","sourceRoot":"","sources":["../../../src/__tests__/unit/config-suggestions.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,yBAAyB,EAAyB,MAAM,iCAAiC,CAAC;AAEnG,MAAM,QAAQ,GAAG,CAAC,MAAwB,EAAE,EAAE,CAC5C,yBAAyB,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;AAE1D,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,MAAM,WAAW,GAAG,yBAAyB,CAAC,EAAE,CAAC,CAAC;QAClD,MAAM,CAAC,WAAW,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACpC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;YAC7C,yDAAyD;YACzD,2DAA2D;YAC3D,sDAAsD;SACvD,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,yBAAyB,CAAC;YAC/B,OAAO,EAAE,cAAc;YACvB,IAAI,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE;YAC5B,WAAW,EAAE,MAAM;SACpB,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClG,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACvE,MAAM,WAAW,GAAG,yBAAyB,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;QAClG,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;YAC7C,2DAA2D;YAC3D,sDAAsD;SACvD,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAC/C,MAAM,WAAW,GAAG,yBAAyB,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC;QACxH,MAAM,CAAC,WAAW,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACpC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kFAAkF,EAAE,GAAG,EAAE;QAC1F,MAAM,CAAC,yBAAyB,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;YAClE,kGAAkG;YAClG,6FAA6F;YAC7F,sFAAsF;SACvF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"config-suggestions.test.js","sourceRoot":"","sources":["../../../src/__tests__/unit/config-suggestions.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,yBAAyB,EAAyB,MAAM,iCAAiC,CAAC;AAEnG,MAAM,QAAQ,GAAG,CAAC,MAAwB,EAAE,EAAE,CAC5C,yBAAyB,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;AAE1D,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,MAAM,WAAW,GAAG,yBAAyB,CAAC,EAAE,CAAC,CAAC;QAClD,MAAM,CAAC,WAAW,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACpC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;YAC7C,yDAAyD;YACzD,6DAA6D;YAC7D,sDAAsD;SACvD,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,yBAAyB,CAAC;YAC/B,OAAO,EAAE,cAAc;YACvB,IAAI,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE;YAC5B,WAAW,EAAE,MAAM;SACpB,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClG,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACvE,MAAM,WAAW,GAAG,yBAAyB,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;QAClG,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;YAC7C,6DAA6D;YAC7D,sDAAsD;SACvD,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAC/C,MAAM,WAAW,GAAG,yBAAyB,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC;QACxH,MAAM,CAAC,WAAW,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACpC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kFAAkF,EAAE,GAAG,EAAE;QAC1F,MAAM,CAAC,yBAAyB,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;YAClE,kGAAkG;YAClG,6FAA6F;YAC7F,sFAAsF;SACvF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"config-suggestions.d.ts","sourceRoot":"","sources":["../../src/lib/config-suggestions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD,IAAI,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAWD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,gBAAgB,GAAG,gBAAgB,EAAE,CAuBtF"}
1
+ {"version":3,"file":"config-suggestions.d.ts","sourceRoot":"","sources":["../../src/lib/config-suggestions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD,IAAI,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAWD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,gBAAgB,GAAG,gBAAgB,EAAE,CAqBtF"}
@@ -29,9 +29,7 @@ export function suggestConfigImprovements(config) {
29
29
  if (!config.description || config.description.trim() === '') {
30
30
  suggestions.push({
31
31
  message: 'Add a site description to improve how your docs appear in search results and social shares.',
32
- // #site-name is deliberate: description is documented inside the docs
33
- // page's "Site Name" section (no #description anchor exists yet).
34
- link: `${BRANDING_DOCS_URL}#site-name`,
32
+ link: `${BRANDING_DOCS_URL}#description`,
35
33
  });
36
34
  }
37
35
  if (!hasImageValue(config.logo)) {
@@ -1 +1 @@
1
- {"version":3,"file":"config-suggestions.js","sourceRoot":"","sources":["../../src/lib/config-suggestions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAiBH,MAAM,iBAAiB,GAAG,iDAAiD,CAAC;AAE5E,8EAA8E;AAC9E,SAAS,aAAa,CAAC,KAA6D;IAClF,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACzB,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;IAC1D,OAAO,OAAO,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;AAC5D,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,MAAwB;IAChE,MAAM,WAAW,GAAuB,EAAE,CAAC;IAC3C,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QACnC,WAAW,CAAC,IAAI,CAAC;YACf,OAAO,EAAE,kGAAkG;YAC3G,IAAI,EAAE,GAAG,iBAAiB,UAAU;SACrC,CAAC,CAAC;IACL,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,WAAW,IAAI,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC5D,WAAW,CAAC,IAAI,CAAC;YACf,OAAO,EAAE,6FAA6F;YACtG,sEAAsE;YACtE,kEAAkE;YAClE,IAAI,EAAE,GAAG,iBAAiB,YAAY;SACvC,CAAC,CAAC;IACL,CAAC;IACD,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;QAChC,WAAW,CAAC,IAAI,CAAC;YACf,OAAO,EAAE,sFAAsF;YAC/F,IAAI,EAAE,GAAG,iBAAiB,OAAO;SAClC,CAAC,CAAC;IACL,CAAC;IACD,OAAO,WAAW,CAAC;AACrB,CAAC"}
1
+ {"version":3,"file":"config-suggestions.js","sourceRoot":"","sources":["../../src/lib/config-suggestions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAiBH,MAAM,iBAAiB,GAAG,iDAAiD,CAAC;AAE5E,8EAA8E;AAC9E,SAAS,aAAa,CAAC,KAA6D;IAClF,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACzB,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;IAC1D,OAAO,OAAO,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;AAC5D,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,MAAwB;IAChE,MAAM,WAAW,GAAuB,EAAE,CAAC;IAC3C,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QACnC,WAAW,CAAC,IAAI,CAAC;YACf,OAAO,EAAE,kGAAkG;YAC3G,IAAI,EAAE,GAAG,iBAAiB,UAAU;SACrC,CAAC,CAAC;IACL,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,WAAW,IAAI,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC5D,WAAW,CAAC,IAAI,CAAC;YACf,OAAO,EAAE,6FAA6F;YACtG,IAAI,EAAE,GAAG,iBAAiB,cAAc;SACzC,CAAC,CAAC;IACL,CAAC;IACD,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;QAChC,WAAW,CAAC,IAAI,CAAC;YACf,OAAO,EAAE,sFAAsF;YAC/F,IAAI,EAAE,GAAG,iBAAiB,OAAO;SAClC,CAAC,CAAC;IACL,CAAC;IACD,OAAO,WAAW,CAAC;AACrB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.139",
3
+ "version": "1.1.141",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -47,8 +47,29 @@ interface ColorPalette {
47
47
  ganttSections: string[];
48
48
  ganttGridLine: string;
49
49
  gitBranchColors: string[];
50
+ pieSliceStroke: string;
50
51
  }
51
52
 
53
+ // Mermaid's built-in palettes (including 'neutral') color pie slices with
54
+ // washed-out, near-white grays: low-contrast on a light background and wiped to
55
+ // transparent by the dark-mode path-fill inversion below — so slices render with
56
+ // no visible color in either mode. We override slice + legend fills with a
57
+ // distinct, mid-tone palette that reads on both backgrounds, mirroring the
58
+ // curated colors already used for gantt bars and git branches. Every entry has at
59
+ // least one channel < 200, so isLightColor() never flags them.
60
+ const PIE_SLICE_COLORS = [
61
+ '#3b82f6', // blue
62
+ '#ef4444', // red
63
+ '#22c55e', // green
64
+ '#f59e0b', // amber
65
+ '#a855f7', // purple
66
+ '#14b8a6', // teal
67
+ '#ec4899', // pink
68
+ '#f97316', // orange
69
+ '#64748b', // slate
70
+ '#84cc16', // lime
71
+ ];
72
+
52
73
  const darkPalette: ColorPalette = {
53
74
  text: '#e5e7eb',
54
75
  line: '#9ca3af',
@@ -59,6 +80,7 @@ const darkPalette: ColorPalette = {
59
80
  ganttSections: ['#1e1e3f', '#2d2d1e', '#1e2d1e'],
60
81
  ganttGridLine: '#374151',
61
82
  gitBranchColors: ['#3b82f6', '#eab308', '#22c55e', '#f97316', '#ec4899', '#a78bfa'],
83
+ pieSliceStroke: '#1f2937', // dark slate — crisp separation between slices on a dark bg
62
84
  };
63
85
 
64
86
  const lightPalette: ColorPalette = {
@@ -71,6 +93,7 @@ const lightPalette: ColorPalette = {
71
93
  ganttSections: ['#f0f0ff', '#fff8e6', '#f0fff0'],
72
94
  ganttGridLine: '#e5e7eb',
73
95
  gitBranchColors: ['#3b82f6', '#eab308', '#22c55e', '#f97316', '#ec4899', '#8b5cf6'],
96
+ pieSliceStroke: '#ffffff', // white — crisp separation between slices on a light bg
74
97
  };
75
98
 
76
99
  // Styling helper utilities
@@ -129,7 +152,46 @@ function applyClassDiagramStyles(svgEl: SVGElement, palette: ColorPalette): void
129
152
  }
130
153
 
131
154
  function applyPieChartStyles(svgEl: SVGElement, palette: ColorPalette): void {
155
+ // Title and legend text follow the theme text color.
132
156
  styleElements(svgEl, '.pieLabel, .legend text, .pieTitleText', { fill: palette.text });
157
+
158
+ const slices = svgEl.querySelectorAll<SVGPathElement>('path.pieCircle');
159
+ if (slices.length === 0) return; // not a pie chart
160
+
161
+ // Re-color slices and legend swatches from PIE_SLICE_COLORS. Each distinct
162
+ // original (washed-out) color maps to one vivid color, recorded as the slices
163
+ // are walked, then reused for the legend so a slice and its legend entry —
164
+ // which share mermaid's original ordinal color — get the same override.
165
+ const colorMap = new Map<string, string>();
166
+ let nextColor = 0;
167
+ const overrideFor = (rawFill: string | null | undefined): string => {
168
+ const key = normalizeColor(rawFill) ?? `pos-${nextColor}`;
169
+ let vivid = colorMap.get(key);
170
+ if (!vivid) {
171
+ vivid = PIE_SLICE_COLORS[nextColor % PIE_SLICE_COLORS.length];
172
+ nextColor += 1;
173
+ colorMap.set(key, vivid);
174
+ }
175
+ return vivid;
176
+ };
177
+
178
+ slices.forEach((slice) => {
179
+ const vivid = overrideFor(slice.style.fill || slice.getAttribute('fill'));
180
+ slice.style.fill = vivid;
181
+ slice.style.stroke = palette.pieSliceStroke;
182
+ slice.style.strokeWidth = '2';
183
+ });
184
+
185
+ // Legend swatches: <g class="legend"> > rect, fill set via inline style.
186
+ svgEl.querySelectorAll<SVGRectElement>('.legend rect').forEach((swatch) => {
187
+ const vivid = overrideFor(swatch.style.fill || swatch.getAttribute('fill'));
188
+ swatch.style.fill = vivid;
189
+ swatch.style.stroke = vivid;
190
+ });
191
+
192
+ // Percentage labels sit on top of the slices — white reads on every palette
193
+ // tone. The dark-mode text inversion spares text.slice so this survives there.
194
+ styleElements(svgEl, 'text.slice', { fill: '#ffffff' });
133
195
  }
134
196
 
135
197
  // Check if an RGB color is light (r,g,b > threshold)
@@ -147,6 +209,26 @@ function isDarkColor(rgbString: string, threshold = 150): boolean {
147
209
  return r < threshold && g < threshold && b < threshold;
148
210
  }
149
211
 
212
+ // Normalize a CSS color (hex or rgb()) to a canonical "r,g,b" string. A pie
213
+ // slice's fill arrives as a hex attribute (e.g. "#ECECFF") while its matching
214
+ // legend swatch's fill is an inline style the browser reports as "rgb(...)" —
215
+ // normalizing lets the same underlying color compare equal across both formats,
216
+ // so a slice and its legend entry get the same override color. Returns null when
217
+ // the value can't be parsed (caller falls back to a positional key).
218
+ function normalizeColor(value: string | null | undefined): string | null {
219
+ if (!value) return null;
220
+ const v = value.trim();
221
+ const rgb = v.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
222
+ if (rgb) return `${+rgb[1]},${+rgb[2]},${+rgb[3]}`;
223
+ let hex = v.replace(/^#/, '');
224
+ if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
225
+ if (hex.length === 6 && /^[0-9a-f]{6}$/i.test(hex)) {
226
+ const n = parseInt(hex, 16);
227
+ return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;
228
+ }
229
+ return null;
230
+ }
231
+
150
232
  // Shared styles applied to both light and dark modes
151
233
  function applyCommonStyles(svgEl: SVGElement, palette: ColorPalette): void {
152
234
  // Cluster/subgraph backgrounds
@@ -174,15 +256,22 @@ function applyCommonStyles(svgEl: SVGElement, palette: ColorPalette): void {
174
256
 
175
257
  // Dark mode requires additional color inversions
176
258
  function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void {
177
- // Make background rects transparent
259
+ // Make background rects transparent. Pie legend swatches (.legend rect) are
260
+ // re-colored in applyPieChartStyles — leave their fills alone.
178
261
  svgEl.querySelectorAll('rect').forEach((rect) => {
179
- if (!rect.closest('.cluster') && !rect.closest('.node') && !rect.classList.contains('actor')) {
262
+ if (
263
+ !rect.closest('.cluster') &&
264
+ !rect.closest('.node') &&
265
+ !rect.classList.contains('actor') &&
266
+ !rect.closest('.legend')
267
+ ) {
180
268
  (rect as SVGElement).style.fill = 'transparent';
181
269
  }
182
270
  });
183
271
 
184
- // Invert text colors
185
- styleElements(svgEl, 'text, .nodeLabel, .edgeLabel, .label, tspan', { fill: palette.text });
272
+ // Invert text colors. text.slice (pie percentage labels) is excluded so the
273
+ // white set in applyPieChartStyles stays legible on the colored slices.
274
+ styleElements(svgEl, 'text:not(.slice), .nodeLabel, .edgeLabel, .label, tspan', { fill: palette.text });
186
275
  styleElements(svgEl, 'text.actor, .messageText, .labelText, .loopText, .noteText', { fill: palette.text });
187
276
 
188
277
  // Invert foreignObject text and clear light backgrounds
@@ -198,8 +287,10 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
198
287
  }
199
288
  });
200
289
 
201
- // Invert lines and paths
290
+ // Invert lines and paths. Pie slices (path.pieCircle) carry their own colors
291
+ // from applyPieChartStyles — skip them so the fill isn't wiped to transparent.
202
292
  svgEl.querySelectorAll('path, line').forEach((el) => {
293
+ if ((el as Element).classList.contains('pieCircle')) return;
203
294
  const computed = window.getComputedStyle(el);
204
295
  if (computed.stroke && computed.stroke !== 'none') {
205
296
  (el as SVGElement).style.stroke = palette.line;
@@ -44,9 +44,7 @@ const SUGGESTION_DEFS: SuggestionDef[] = [
44
44
  {
45
45
  type: 'missing_description',
46
46
  message: 'Add a site description to improve how your docs appear in search results and social shares.',
47
- // #site-name is deliberate: description is documented inside the docs
48
- // page's "Site Name" section (no #description anchor exists yet).
49
- link: `${BRANDING_DOCS_URL}#site-name`,
47
+ link: `${BRANDING_DOCS_URL}#description`,
50
48
  isMissing: (c) => !c.description || c.description.trim() === '',
51
49
  },
52
50
  {
@@ -52,15 +52,88 @@ const MIN_SCORE = 0.3;
52
52
  /** Max chunks per page — raised from 3 to 4 to match broader topK retrieval budget */
53
53
  const MAX_CHUNKS_PER_PAGE = 4;
54
54
 
55
- /** Create a namespaced Upstash Vector index for a project. */
56
- function getNamespace(projectId: string) {
55
+ /**
56
+ * Hard deadline for the build-time embedding write (reset + all upsert batches
57
+ * combined). A transient Upstash stall must fail this step FAST — it is
58
+ * non-fatal to the build (build.ts swallows it) — instead of hanging the single
59
+ * Cloud Run build instance until the 21-minute stale-build watchdog reaps it as
60
+ * a false "timed out" failure (kapptivate, 2026-06-11). Healthy writes finish in
61
+ * <15s; 90s tolerates a slow-but-alive Upstash while still failing ~14× faster
62
+ * than the watchdog.
63
+ */
64
+ const UPSERT_TIMEOUT_MS = 90_000;
65
+
66
+ /**
67
+ * Hard deadline for a live chat/search retrieval. Queries normally finish in
68
+ * <500ms; bound them so a stalled Upstash cannot hang the chat SSE request. The
69
+ * chat/search routes already treat a rejected query gracefully (rewrite path
70
+ * resolves to [], original-query reject → 503 chat / 502 docs-search), so a fast
71
+ * reject is preferable to an open-ended hang.
72
+ */
73
+ const QUERY_TIMEOUT_MS = 10_000;
74
+
75
+ /**
76
+ * Create a namespaced Upstash Vector index for a project.
77
+ *
78
+ * `signal` is REQUIRED on purpose: it must come from a withDeadline() wrapper.
79
+ * A bare getNamespace() would issue UNBOUNDED reset/upsert/query requests — the
80
+ * exact hang this module guards against (kapptivate, 2026-06-11) — so making the
81
+ * param non-optional forces every (current and future) caller through a deadline
82
+ * at compile time rather than by remembering a comment. The signal is forwarded
83
+ * to the SDK so an aborted deadline closes the underlying socket.
84
+ */
85
+ function getNamespace(projectId: string, signal: AbortSignal) {
57
86
  const index = new Index({
58
87
  url: process.env.UPSTASH_VECTOR_REST_URL!,
59
88
  token: process.env.UPSTASH_VECTOR_REST_TOKEN!,
89
+ signal,
60
90
  });
61
91
  return index.namespace(projectId);
62
92
  }
63
93
 
94
+ /**
95
+ * Run an Upstash operation under a hard deadline.
96
+ *
97
+ * The Promise.race timer is the GUARANTEE: it rejects the caller at `ms` no
98
+ * matter what the SDK does. The AbortSignal handed to `op` is best-effort —
99
+ * @upstash/vector 1.2.3 does NOT propagate an aborted fetch as a rejection; its
100
+ * request loop catches the AbortError and synthesizes a 200 "success". What the
101
+ * signal still buys us is closing the underlying socket so a hung connection
102
+ * does not linger past the deadline. So: timer = correctness, signal = resource
103
+ * hygiene — do NOT remove the timer believing the signal suffices.
104
+ *
105
+ * On timeout this REJECTS — callers decide whether that is fatal. A genuine
106
+ * pre-deadline rejection (e.g. the SDK exhausting its retries on a flapping
107
+ * endpoint) propagates as-is so the real Upstash error is logged rather than a
108
+ * generic timeout.
109
+ */
110
+ async function withDeadline<T>(
111
+ ms: number,
112
+ operation: string,
113
+ op: (signal: AbortSignal) => Promise<T>,
114
+ ): Promise<T> {
115
+ const controller = new AbortController();
116
+ let timer: ReturnType<typeof setTimeout> | undefined;
117
+ const deadline = new Promise<never>((_, reject) => {
118
+ timer = setTimeout(() => {
119
+ controller.abort();
120
+ reject(new Error(`Upstash ${operation} timed out after ${ms}ms`));
121
+ }, ms);
122
+ });
123
+ const work = op(controller.signal);
124
+ // Swallow the late settlement of the losing promise so it cannot bubble as an
125
+ // unhandledRejection after the deadline already won the race. (Load-bearing in
126
+ // prod for the genuine-error case; on an abort the SDK resolves `work` with a
127
+ // synthetic 200, so this no-ops.) The genuine error still reaches the caller
128
+ // via the race below.
129
+ work.catch(() => {});
130
+ try {
131
+ return await Promise.race([work, deadline]);
132
+ } finally {
133
+ clearTimeout(timer);
134
+ }
135
+ }
136
+
64
137
  /**
65
138
  * Replace all vectors for a project with fresh chunks.
66
139
  *
@@ -76,33 +149,35 @@ export async function upsertChunks(
76
149
  projectId: string,
77
150
  chunks: EmbeddingChunk[],
78
151
  ): Promise<void> {
79
- const ns = getNamespace(projectId);
80
-
81
- await ns.reset();
82
-
83
- for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
84
- const batch = chunks.slice(i, i + BATCH_SIZE);
85
- await ns.upsert(
86
- batch.map(c => {
87
- const metadata: ChunkMetadata = {
88
- pageSlug: c.pageSlug,
89
- sectionHeading: c.sectionHeading,
90
- pageTitle: c.pageTitle,
91
- content: c.content.length > MAX_METADATA_CONTENT_CHARS
92
- ? c.content.slice(0, MAX_METADATA_CONTENT_CHARS) + '...'
93
- : c.content,
94
- };
95
- if (c.locale) metadata.locale = c.locale; // omit when null
96
- return {
97
- id: c.id,
98
- // Prefix + body goes to Upstash for embedding/BM25; metadata.content
99
- // stays prefix-free so consumers display clean body text.
100
- data: c.prefix + c.content,
101
- metadata,
102
- };
103
- }),
104
- );
105
- }
152
+ await withDeadline(UPSERT_TIMEOUT_MS, 'upsert', async signal => {
153
+ const ns = getNamespace(projectId, signal);
154
+
155
+ await ns.reset();
156
+
157
+ for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
158
+ const batch = chunks.slice(i, i + BATCH_SIZE);
159
+ await ns.upsert(
160
+ batch.map(c => {
161
+ const metadata: ChunkMetadata = {
162
+ pageSlug: c.pageSlug,
163
+ sectionHeading: c.sectionHeading,
164
+ pageTitle: c.pageTitle,
165
+ content: c.content.length > MAX_METADATA_CONTENT_CHARS
166
+ ? c.content.slice(0, MAX_METADATA_CONTENT_CHARS) + '...'
167
+ : c.content,
168
+ };
169
+ if (c.locale) metadata.locale = c.locale; // omit when null
170
+ return {
171
+ id: c.id,
172
+ // Prefix + body goes to Upstash for embedding/BM25; metadata.content
173
+ // stays prefix-free so consumers display clean body text.
174
+ data: c.prefix + c.content,
175
+ metadata,
176
+ };
177
+ }),
178
+ );
179
+ }
180
+ });
106
181
  }
107
182
 
108
183
  /**
@@ -205,48 +280,50 @@ export async function querySimilarChunks(
205
280
  topK = 5,
206
281
  options: { locale?: string } = {},
207
282
  ): Promise<Array<ChunkMetadata & { score: number }>> {
208
- const ns = getNamespace(projectId);
209
- const locale = options.locale ? normalizeLocaleForFilter(options.locale) : undefined;
210
- // Defense-in-depth: A5 rejects malformed locales at the API boundary. If a
211
- // truthy locale here normalizes to empty, A5's guard was bypassed (test or
212
- // internal caller) surface it loudly rather than silently dropping the filter.
213
- if (options.locale && !locale) {
214
- logger.warn('vector-store: locale normalized to empty — filter skipped', { rawLocale: options.locale });
215
- }
216
- const filter = locale ? buildLocaleFilter(locale) : undefined;
217
- // When filtering, raise effective topK by ~33% so we still get ~topK chunks
218
- // back from a mixed-language namespace where filtering cuts the candidate set.
219
- const effectiveTopK = filter ? Math.ceil(topK * 1.33) : topK;
220
- const queryParams = { topK: effectiveTopK, includeMetadata: true as const, filter };
221
-
222
- const topicQuery = extractTopicQuery(queryText);
223
-
224
- // Dual-query: topic query is the PRIMARY source (better topical relevance);
225
- // the full query fills remaining slots with unique results only.
226
- let merged: Array<ChunkMetadata & { score: number }>;
227
- if (topicQuery) {
228
- const [fullResults, topicResults] = await Promise.all([
229
- queryWithFallback(ns, { data: queryText, ...queryParams }),
230
- queryWithFallback(ns, { data: topicQuery, ...queryParams }),
231
- ]);
232
- merged = filterAndMerge([topicResults, fullResults], topK);
233
- } else {
234
- const results = await queryWithFallback(ns, { data: queryText, ...queryParams });
235
- merged = filterAndMerge([results], topK);
236
- }
283
+ return withDeadline(QUERY_TIMEOUT_MS, 'query', async signal => {
284
+ const ns = getNamespace(projectId, signal);
285
+ const locale = options.locale ? normalizeLocaleForFilter(options.locale) : undefined;
286
+ // Defense-in-depth: A5 rejects malformed locales at the API boundary. If a
287
+ // truthy locale here normalizes to empty, A5's guard was bypassed (test or
288
+ // internal caller) — surface it loudly rather than silently dropping the filter.
289
+ if (options.locale && !locale) {
290
+ logger.warn('vector-store: locale normalized to empty — filter skipped', { rawLocale: options.locale });
291
+ }
292
+ const filter = locale ? buildLocaleFilter(locale) : undefined;
293
+ // When filtering, raise effective topK by ~33% so we still get ~topK chunks
294
+ // back from a mixed-language namespace where filtering cuts the candidate set.
295
+ const effectiveTopK = filter ? Math.ceil(topK * 1.33) : topK;
296
+ const queryParams = { topK: effectiveTopK, includeMetadata: true as const, filter };
297
+
298
+ const topicQuery = extractTopicQuery(queryText);
299
+
300
+ // Dual-query: topic query is the PRIMARY source (better topical relevance);
301
+ // the full query fills remaining slots with unique results only.
302
+ let merged: Array<ChunkMetadata & { score: number }>;
303
+ if (topicQuery) {
304
+ const [fullResults, topicResults] = await Promise.all([
305
+ queryWithFallback(ns, { data: queryText, ...queryParams }),
306
+ queryWithFallback(ns, { data: topicQuery, ...queryParams }),
307
+ ]);
308
+ merged = filterAndMerge([topicResults, fullResults], topK);
309
+ } else {
310
+ const results = await queryWithFallback(ns, { data: queryText, ...queryParams });
311
+ merged = filterAndMerge([results], topK);
312
+ }
237
313
 
238
- // Telemetry: a filtered query that returns materially fewer chunks than
239
- // requested signals the locale filter is hurting recall (project skewed away
240
- // from the requested locale, or filter syntax matched a near-empty subset).
241
- // Surface it so we can decide whether to widen the carve-out, increase the
242
- // 1.33× boost, or rebuild the project's index.
243
- if (filter && merged.length < Math.ceil(topK / 2)) {
244
- logger.warn('vector-store: locale filter under-fills topK', {
245
- projectId, locale, returned: merged.length, requested: topK,
246
- });
247
- }
314
+ // Telemetry: a filtered query that returns materially fewer chunks than
315
+ // requested signals the locale filter is hurting recall (project skewed away
316
+ // from the requested locale, or filter syntax matched a near-empty subset).
317
+ // Surface it so we can decide whether to widen the carve-out, increase the
318
+ // 1.33× boost, or rebuild the project's index.
319
+ if (filter && merged.length < Math.ceil(topK / 2)) {
320
+ logger.warn('vector-store: locale filter under-fills topK', {
321
+ projectId, locale, returned: merged.length, requested: topK,
322
+ });
323
+ }
248
324
 
249
- return merged;
325
+ return merged;
326
+ });
250
327
  }
251
328
 
252
329
  /**
@@ -309,6 +386,13 @@ export async function deleteProjectNamespace(slug: string): Promise<boolean> {
309
386
  // Ctor failures (e.g., a future SDK adding URL-format validation) are
310
387
  // misconfiguration, not transient errors — let them propagate so ops see
311
388
  // a 500 instead of a silent `vector_orphan_on_delete`.
389
+ //
390
+ // Deliberately UNBOUNDED (unlike upsertChunks/querySimilarChunks, which go
391
+ // through withDeadline + getNamespace): this is a single best-effort delete on
392
+ // the request-scoped dashboard delete handler — NOT the shared Cloud Run build
393
+ // instance — so a stall here cannot wedge other builds or trip the stale-build
394
+ // watchdog. A hung delete is bounded by the platform request timeout. Don't
395
+ // "fix" the asymmetry by reflex; bounding it would need its own deadline wiring.
312
396
  const index = new Index({ url, token });
313
397
  try {
314
398
  await index.deleteNamespace(slug);
@@ -1997,9 +1997,9 @@
1997
1997
  }
1998
1998
  },
1999
1999
  "node_modules/acorn": {
2000
- "version": "8.16.0",
2001
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
2002
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
2000
+ "version": "8.17.0",
2001
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
2002
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
2003
2003
  "license": "MIT",
2004
2004
  "bin": {
2005
2005
  "acorn": "bin/acorn"
@@ -2145,9 +2145,9 @@
2145
2145
  }
2146
2146
  },
2147
2147
  "node_modules/baseline-browser-mapping": {
2148
- "version": "2.10.35",
2149
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
2150
- "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
2148
+ "version": "2.10.36",
2149
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.36.tgz",
2150
+ "integrity": "sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==",
2151
2151
  "license": "Apache-2.0",
2152
2152
  "bin": {
2153
2153
  "baseline-browser-mapping": "dist/cli.cjs"
@@ -2208,9 +2208,9 @@
2208
2208
  "license": "MIT"
2209
2209
  },
2210
2210
  "node_modules/caniuse-lite": {
2211
- "version": "1.0.30001797",
2212
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
2213
- "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
2211
+ "version": "1.0.30001799",
2212
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
2213
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
2214
2214
  "funding": [
2215
2215
  {
2216
2216
  "type": "opencollective",
@@ -2945,9 +2945,9 @@
2945
2945
  "license": "ISC"
2946
2946
  },
2947
2947
  "node_modules/enhanced-resolve": {
2948
- "version": "5.23.0",
2949
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
2950
- "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==",
2948
+ "version": "5.24.0",
2949
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz",
2950
+ "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==",
2951
2951
  "license": "MIT",
2952
2952
  "dependencies": {
2953
2953
  "graceful-fs": "^4.2.4",