prev-cli 0.22.4 → 0.24.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.
package/dist/cli.js CHANGED
@@ -130,13 +130,18 @@ function isIndexFile(basename) {
130
130
  const lower = basename.toLowerCase();
131
131
  return lower === "index" || lower === "readme";
132
132
  }
133
+ var CONTENT_ROOT_DIRS = ["docs", "documentation", "content", "pages"];
133
134
  function fileToRoute(file) {
134
- const normalizedFile = file.replace(/^\./, "").replace(/\/\./g, "/");
135
+ let normalizedFile = file.replace(/^\./, "").replace(/\/\./g, "/");
136
+ const firstDir = normalizedFile.split("/")[0]?.toLowerCase();
137
+ if (CONTENT_ROOT_DIRS.includes(firstDir)) {
138
+ normalizedFile = normalizedFile.slice(firstDir.length + 1) || normalizedFile;
139
+ }
135
140
  const withoutExt = normalizedFile.replace(/\.mdx?$/, "");
136
141
  const basename = path2.basename(withoutExt).toLowerCase();
137
142
  if (basename === "index" || basename === "readme") {
138
143
  const dir = path2.dirname(withoutExt);
139
- if (dir === ".") {
144
+ if (dir === "." || dir === "") {
140
145
  return "/";
141
146
  }
142
147
  return "/" + dir;
@@ -867,13 +872,14 @@ var cliRoot2 = findCliRoot2();
867
872
  var cliNodeModules = findNodeModules(cliRoot2);
868
873
  var srcRoot2 = path8.join(cliRoot2, "src");
869
874
  async function createViteConfig(options) {
870
- const { rootDir, mode, port, include } = options;
875
+ const { rootDir, mode, port, include, base } = options;
871
876
  const cacheDir = await ensureCacheDir(rootDir);
872
877
  const config = loadConfig(rootDir);
873
878
  return {
874
879
  root: rootDir,
875
880
  mode,
876
881
  cacheDir,
882
+ base: base || "/",
877
883
  customLogger: createFriendlyLogger(),
878
884
  logLevel: mode === "production" ? "silent" : "info",
879
885
  plugins: [
@@ -1118,7 +1124,7 @@ async function findAvailablePort(minPort, maxPort) {
1118
1124
 
1119
1125
  // src/vite/start.ts
1120
1126
  import { exec as exec2 } from "child_process";
1121
- import { existsSync as existsSync6, rmSync as rmSync2 } from "fs";
1127
+ import { existsSync as existsSync6, rmSync as rmSync2, copyFileSync } from "fs";
1122
1128
  import path9 from "path";
1123
1129
  function printWelcome(type) {
1124
1130
  console.log();
@@ -1224,9 +1230,16 @@ async function buildSite(rootDir, options = {}) {
1224
1230
  const config = await createViteConfig({
1225
1231
  rootDir,
1226
1232
  mode: "production",
1227
- include: options.include
1233
+ include: options.include,
1234
+ base: options.base
1228
1235
  });
1229
1236
  await build2(config);
1237
+ const distDir = path9.join(rootDir, "dist");
1238
+ const indexPath = path9.join(distDir, "index.html");
1239
+ const notFoundPath = path9.join(distDir, "404.html");
1240
+ if (existsSync6(indexPath)) {
1241
+ copyFileSync(indexPath, notFoundPath);
1242
+ }
1230
1243
  console.log();
1231
1244
  console.log(" Done! Your site is ready in ./dist");
1232
1245
  console.log(" You can deploy this folder anywhere.");
@@ -1272,6 +1285,7 @@ var { values, positionals } = parseArgs({
1272
1285
  port: { type: "string", short: "p" },
1273
1286
  days: { type: "string", short: "d" },
1274
1287
  cwd: { type: "string", short: "c" },
1288
+ base: { type: "string", short: "b" },
1275
1289
  help: { type: "boolean", short: "h" },
1276
1290
  version: { type: "boolean", short: "v" }
1277
1291
  },
@@ -1301,6 +1315,7 @@ Config subcommands:
1301
1315
  Options:
1302
1316
  -c, --cwd <path> Set working directory
1303
1317
  -p, --port <port> Specify port (dev/preview)
1318
+ -b, --base <path> Base path for deployment (e.g., /repo-name/ for GitHub Pages)
1304
1319
  -d, --days <days> Cache age threshold for clean (default: 30)
1305
1320
  -h, --help Show this help message
1306
1321
  -v, --version Show version number
@@ -1660,7 +1675,7 @@ async function main() {
1660
1675
  await startDev(rootDir, { port, include });
1661
1676
  break;
1662
1677
  case "build":
1663
- await buildSite(rootDir, { include });
1678
+ await buildSite(rootDir, { include, base: values.base });
1664
1679
  break;
1665
1680
  case "preview":
1666
1681
  await previewSite(rootDir, { port, include });
@@ -4,5 +4,6 @@ export interface ConfigOptions {
4
4
  mode: 'development' | 'production';
5
5
  port?: number;
6
6
  include?: string[];
7
+ base?: string;
7
8
  }
8
9
  export declare function createViteConfig(options: ConfigOptions): Promise<InlineConfig>;
@@ -4,6 +4,7 @@ export interface DevOptions {
4
4
  }
5
5
  export interface BuildOptions {
6
6
  include?: string[];
7
+ base?: string;
7
8
  }
8
9
  export declare function startDev(rootDir: string, options?: DevOptions): Promise<import("vite").ViteDevServer>;
9
10
  export declare function buildSite(rootDir: string, options?: BuildOptions): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.22.4",
3
+ "version": "0.24.0",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "scripts": {
37
37
  "build": "bun build src/cli.ts --outdir dist --target node --packages external && tsc --emitDeclarationOnly",
38
+ "build:docs": "bun run build && bun ./dist/cli.js build",
38
39
  "dev": "tsc --watch",
39
40
  "test": "bun test src",
40
41
  "test:integration": "bun run build && bun test test/integration.test.ts",
@@ -22,7 +22,11 @@ const DEVICE_WIDTHS: Record<DeviceMode, number | '100%'> = {
22
22
 
23
23
  export function Preview({ src, height = 400, title, mode = 'wasm', showHeader = false }: PreviewProps) {
24
24
  const [isFullscreen, setIsFullscreen] = useState(false)
25
- const [deviceMode, setDeviceMode] = useState<DeviceMode>('desktop')
25
+ // Default to 'mobile' device mode on mobile viewports to match user's actual environment
26
+ const [deviceMode, setDeviceMode] = useState<DeviceMode>(() => {
27
+ if (typeof window === 'undefined') return 'desktop'
28
+ return window.innerWidth < 768 ? 'mobile' : 'desktop'
29
+ })
26
30
  const [customWidth, setCustomWidth] = useState<number | null>(null)
27
31
  const [showSlider, setShowSlider] = useState(false)
28
32
 
@@ -41,8 +45,11 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
41
45
  const isDev = import.meta.env?.DEV ?? false
42
46
  const effectiveMode = isDev ? mode : 'legacy'
43
47
 
48
+ // Get base URL for proper subpath deployment support
49
+ const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
50
+
44
51
  // URL depends on mode - wasm mode needs src param, legacy uses pre-built files
45
- const previewUrl = effectiveMode === 'wasm' ? `/_preview-runtime?src=${src}` : `/_preview/${src}/`
52
+ const previewUrl = effectiveMode === 'wasm' ? `/_preview-runtime?src=${src}` : `${baseUrl}/_preview/${src}/`
46
53
  const displayTitle = title || src
47
54
 
48
55
  // Calculate current width
@@ -194,95 +201,124 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
194
201
  </button>
195
202
  )
196
203
 
204
+ // Track viewport size for responsive layout
205
+ const [isMobileViewport, setIsMobileViewport] = useState(typeof window !== 'undefined' ? window.innerWidth < 480 : false)
206
+
207
+ useEffect(() => {
208
+ const handleResize = () => setIsMobileViewport(window.innerWidth < 480)
209
+ window.addEventListener('resize', handleResize)
210
+ return () => window.removeEventListener('resize', handleResize)
211
+ }, [])
212
+
197
213
  // DevTools in header - device modes, width slider, fullscreen
198
- const DevTools = () => (
199
- <div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
200
- <IconButton
201
- onClick={() => handleDeviceChange('mobile')}
202
- active={deviceMode === 'mobile' && customWidth === null}
203
- title="Mobile (375px)"
204
- >
205
- <Icon name="mobile" size={16} />
206
- </IconButton>
207
-
208
- <IconButton
209
- onClick={() => handleDeviceChange('tablet')}
210
- active={deviceMode === 'tablet' && customWidth === null}
211
- title="Tablet (768px)"
212
- >
213
- <Icon name="tablet" size={16} />
214
- </IconButton>
215
-
216
- <IconButton
217
- onClick={() => handleDeviceChange('desktop')}
218
- active={deviceMode === 'desktop' && customWidth === null}
219
- title="Desktop (100%)"
220
- >
221
- <Icon name="desktop" size={16} />
222
- </IconButton>
223
-
224
- <div style={{ width: '1px', height: '16px', backgroundColor: 'var(--fd-border, #e4e4e7)', margin: '0 4px' }} />
225
-
226
- {/* Width slider toggle */}
227
- <div style={{ position: 'relative' }}>
214
+ // On mobile: simplified controls with just fullscreen
215
+ // On larger screens: full device controls
216
+ const DevTools = ({ compact = false }: { compact?: boolean }) => {
217
+ // Mobile viewport or compact mode: show minimal controls
218
+ if (isMobileViewport || compact) {
219
+ return (
220
+ <div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
221
+ <IconButton
222
+ onClick={() => setIsFullscreen(!isFullscreen)}
223
+ active={isFullscreen}
224
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
225
+ >
226
+ <Icon name={isFullscreen ? 'minimize' : 'maximize'} size={16} />
227
+ </IconButton>
228
+ </div>
229
+ )
230
+ }
231
+
232
+ // Full controls for larger screens
233
+ return (
234
+ <div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
228
235
  <IconButton
229
- onClick={() => setShowSlider(!showSlider)}
230
- active={showSlider || customWidth !== null}
231
- title="Custom width"
236
+ onClick={() => handleDeviceChange('mobile')}
237
+ active={deviceMode === 'mobile' && customWidth === null}
238
+ title="Mobile (375px)"
232
239
  >
233
- <Icon name="sliders" size={16} />
240
+ <Icon name="mobile" size={16} />
234
241
  </IconButton>
235
242
 
236
- {showSlider && (
237
- <div style={{
238
- position: 'absolute',
239
- top: '100%',
240
- right: 0,
241
- marginTop: '8px',
242
- padding: '12px',
243
- backgroundColor: 'var(--fd-background, #fff)',
244
- borderRadius: '8px',
245
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
246
- border: '1px solid var(--fd-border, #e4e4e7)',
247
- minWidth: '192px',
248
- zIndex: 100,
249
- }}>
250
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
251
- <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #71717a)' }}>
252
- Width: {customWidth ?? currentWidth ?? '100%'}px
253
- </span>
254
- <button
255
- onClick={() => setShowSlider(false)}
256
- style={{ padding: '2px', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fd-muted-foreground, #a1a1aa)' }}
257
- >
258
- <Icon name="x" size={12} />
259
- </button>
260
- </div>
261
- <input
262
- type="range"
263
- min={320}
264
- max={1920}
265
- value={customWidth ?? (typeof currentWidth === 'number' ? currentWidth : 1920)}
266
- onChange={(e) => handleSliderChange(parseInt(e.target.value))}
267
- style={{ width: '100%', accentColor: 'var(--fd-primary, #3b82f6)' }}
268
- />
269
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)', marginTop: '4px' }}>
270
- <span>320px</span>
271
- <span>1920px</span>
243
+ <IconButton
244
+ onClick={() => handleDeviceChange('tablet')}
245
+ active={deviceMode === 'tablet' && customWidth === null}
246
+ title="Tablet (768px)"
247
+ >
248
+ <Icon name="tablet" size={16} />
249
+ </IconButton>
250
+
251
+ <IconButton
252
+ onClick={() => handleDeviceChange('desktop')}
253
+ active={deviceMode === 'desktop' && customWidth === null}
254
+ title="Desktop (100%)"
255
+ >
256
+ <Icon name="desktop" size={16} />
257
+ </IconButton>
258
+
259
+ <div style={{ width: '1px', height: '16px', backgroundColor: 'var(--fd-border, #e4e4e7)', margin: '0 4px' }} />
260
+
261
+ {/* Width slider toggle */}
262
+ <div style={{ position: 'relative' }}>
263
+ <IconButton
264
+ onClick={() => setShowSlider(!showSlider)}
265
+ active={showSlider || customWidth !== null}
266
+ title="Custom width"
267
+ >
268
+ <Icon name="sliders" size={16} />
269
+ </IconButton>
270
+
271
+ {showSlider && (
272
+ <div style={{
273
+ position: 'absolute',
274
+ top: '100%',
275
+ right: 0,
276
+ marginTop: '8px',
277
+ padding: '12px',
278
+ backgroundColor: 'var(--fd-background, #fff)',
279
+ borderRadius: '8px',
280
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
281
+ border: '1px solid var(--fd-border, #e4e4e7)',
282
+ minWidth: '192px',
283
+ zIndex: 100,
284
+ }}>
285
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
286
+ <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #71717a)' }}>
287
+ Width: {customWidth ?? currentWidth ?? '100%'}px
288
+ </span>
289
+ <button
290
+ onClick={() => setShowSlider(false)}
291
+ style={{ padding: '2px', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fd-muted-foreground, #a1a1aa)' }}
292
+ >
293
+ <Icon name="x" size={12} />
294
+ </button>
295
+ </div>
296
+ <input
297
+ type="range"
298
+ min={320}
299
+ max={1920}
300
+ value={customWidth ?? (typeof currentWidth === 'number' ? currentWidth : 1920)}
301
+ onChange={(e) => handleSliderChange(parseInt(e.target.value))}
302
+ style={{ width: '100%', accentColor: 'var(--fd-primary, #3b82f6)' }}
303
+ />
304
+ <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)', marginTop: '4px' }}>
305
+ <span>320px</span>
306
+ <span>1920px</span>
307
+ </div>
272
308
  </div>
273
- </div>
274
- )}
275
- </div>
309
+ )}
310
+ </div>
276
311
 
277
- <IconButton
278
- onClick={() => setIsFullscreen(!isFullscreen)}
279
- active={isFullscreen}
280
- title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
281
- >
282
- <Icon name={isFullscreen ? 'minimize' : 'maximize'} size={16} />
283
- </IconButton>
284
- </div>
285
- )
312
+ <IconButton
313
+ onClick={() => setIsFullscreen(!isFullscreen)}
314
+ active={isFullscreen}
315
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
316
+ >
317
+ <Icon name={isFullscreen ? 'minimize' : 'maximize'} size={16} />
318
+ </IconButton>
319
+ </div>
320
+ )
321
+ }
286
322
 
287
323
  // Register DevTools in toolbar when on detail page (showHeader mode)
288
324
  useEffect(() => {
@@ -466,34 +502,45 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
466
502
  overflow: 'hidden',
467
503
  position: 'relative',
468
504
  }}>
469
- {/* Compact header */}
505
+ {/* Compact header - responsive for mobile */}
470
506
  <div style={{
471
507
  display: 'flex',
472
508
  alignItems: 'center',
473
509
  justifyContent: 'space-between',
474
- padding: '8px 12px',
510
+ padding: isMobileViewport ? '6px 10px' : '8px 12px',
475
511
  backgroundColor: 'var(--fd-card, #fafafa)',
476
512
  borderBottom: '1px solid var(--fd-border, #e4e4e7)',
513
+ gap: '8px',
477
514
  }}>
478
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
479
- <span style={{ fontSize: '14px', fontWeight: 500, color: 'var(--fd-foreground, #52525b)' }}>
515
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, flex: 1 }}>
516
+ <span style={{
517
+ fontSize: isMobileViewport ? '13px' : '14px',
518
+ fontWeight: 500,
519
+ color: 'var(--fd-foreground, #52525b)',
520
+ overflow: 'hidden',
521
+ textOverflow: 'ellipsis',
522
+ whiteSpace: 'nowrap',
523
+ }}>
480
524
  {displayTitle}
481
525
  </span>
482
526
  {mode === 'wasm' && buildStatus === 'building' && (
483
- <Icon name="loader" size={14} style={{ color: 'var(--fd-primary, #3b82f6)', animation: 'spin 1s linear infinite' }} />
527
+ <Icon name="loader" size={14} style={{ color: 'var(--fd-primary, #3b82f6)', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
484
528
  )}
485
529
  {mode === 'wasm' && buildStatus === 'error' && (
486
- <span style={{ fontSize: '12px', color: '#ef4444' }}>Error</span>
530
+ <span style={{ fontSize: '12px', color: '#ef4444', flexShrink: 0 }}>Error</span>
487
531
  )}
488
532
  </div>
489
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
490
- {mode === 'wasm' && buildTime && (
533
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
534
+ {/* Hide build time and width on mobile to save space */}
535
+ {!isMobileViewport && mode === 'wasm' && buildTime && (
491
536
  <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>{buildTime}ms</span>
492
537
  )}
493
- <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>
494
- {currentWidth ? `${currentWidth}px` : '100%'}
495
- </span>
496
- <DevTools />
538
+ {!isMobileViewport && (
539
+ <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>
540
+ {currentWidth ? `${currentWidth}px` : '100%'}
541
+ </span>
542
+ )}
543
+ <DevTools compact={isMobileViewport} />
497
544
  </div>
498
545
  </div>
499
546
 
@@ -120,8 +120,20 @@
120
120
  position: fixed;
121
121
  inset: 0;
122
122
  z-index: 9998;
123
- background: rgba(0, 0, 0, 0.5);
124
- backdrop-filter: blur(4px);
123
+ background: rgba(0, 0, 0, 0.4);
124
+ backdrop-filter: blur(8px);
125
+ -webkit-backdrop-filter: blur(8px);
126
+ animation: fade-in-overlay 0.2s ease;
127
+ }
128
+
129
+ @keyframes fade-in-overlay {
130
+ from { opacity: 0; }
131
+ to { opacity: 1; }
132
+ }
133
+
134
+ @keyframes slide-up-sheet {
135
+ from { transform: translateY(100%); }
136
+ to { transform: translateY(0); }
125
137
  }
126
138
 
127
139
  .toc-overlay-content {
@@ -129,15 +141,31 @@
129
141
  bottom: 0;
130
142
  left: 0;
131
143
  right: 0;
132
- max-height: 80vh;
144
+ max-height: 70vh;
133
145
  background: var(--fd-background);
134
- border-radius: 1rem 1rem 0 0;
146
+ border-radius: 1.25rem 1.25rem 0 0;
135
147
  overflow: hidden;
148
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
149
+ animation: slide-up-sheet 0.25s ease;
150
+ /* Bottom safe area for toolbar overlap prevention */
151
+ padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 72px);
152
+ }
153
+
154
+ /* Bottom sheet drag indicator */
155
+ .toc-overlay-content::before {
156
+ content: '';
157
+ display: block;
158
+ width: 36px;
159
+ height: 4px;
160
+ background: var(--fd-border);
161
+ border-radius: 2px;
162
+ margin: 8px auto;
136
163
  }
137
164
 
138
165
  .toc-overlay-content .toc-nav {
139
- max-height: calc(80vh - 60px);
140
- padding: 1rem;
166
+ max-height: calc(70vh - 120px);
167
+ padding: 0.5rem 1rem 1rem;
168
+ overscroll-behavior: contain;
141
169
  }
142
170
 
143
171
  @media (max-width: 768px) {
@@ -11,12 +11,31 @@
11
11
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
12
12
  cursor: grab;
13
13
  user-select: none;
14
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
14
15
  }
15
16
 
16
17
  .prev-toolbar:active {
17
18
  cursor: grabbing;
18
19
  }
19
20
 
21
+ /* Mobile: center toolbar at bottom with safe area */
22
+ @media (max-width: 768px) {
23
+ .prev-toolbar {
24
+ left: 50% !important;
25
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 16px) !important;
26
+ top: auto !important;
27
+ transform: translateX(-50%);
28
+ cursor: default;
29
+ padding: 0.5rem 0.75rem;
30
+ gap: 0.375rem;
31
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
32
+ }
33
+
34
+ .prev-toolbar:active {
35
+ cursor: default;
36
+ }
37
+ }
38
+
20
39
  .toolbar-btn {
21
40
  display: flex;
22
41
  align-items: center;
@@ -30,6 +49,7 @@
30
49
  cursor: pointer;
31
50
  transition: all 0.15s ease;
32
51
  text-decoration: none;
52
+ -webkit-tap-highlight-color: transparent;
33
53
  }
34
54
 
35
55
  .toolbar-btn:hover {
@@ -37,11 +57,23 @@
37
57
  color: var(--fd-foreground);
38
58
  }
39
59
 
60
+ .toolbar-btn:active {
61
+ transform: scale(0.92);
62
+ }
63
+
40
64
  .toolbar-btn.active {
41
65
  background: var(--fd-accent);
42
66
  color: var(--fd-accent-foreground);
43
67
  }
44
68
 
69
+ /* Mobile: larger touch targets */
70
+ @media (max-width: 768px) {
71
+ .toolbar-btn {
72
+ width: 40px;
73
+ height: 40px;
74
+ }
75
+ }
76
+
45
77
  .toolbar-devtools-slot {
46
78
  display: flex;
47
79
  align-items: center;
@@ -95,4 +127,16 @@
95
127
  .toolbar-btn.desktop-only {
96
128
  display: none;
97
129
  }
130
+
131
+ /* Simplify devtools on mobile - hide separator and some controls */
132
+ .toolbar-devtools-slot {
133
+ gap: 0.25rem;
134
+ margin-left: 0.375rem;
135
+ padding-left: 0.375rem;
136
+ }
137
+
138
+ /* Hide custom width slider button on mobile */
139
+ .toolbar-devtools-slot > div:has(button[title="Custom width"]) {
140
+ display: none;
141
+ }
98
142
  }
@@ -19,20 +19,30 @@ interface ToolbarProps {
19
19
  export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidth, onTocToggle, tocOpen }: ToolbarProps) {
20
20
  const [position, setPosition] = useState({ x: 20, y: typeof window !== 'undefined' ? window.innerHeight - 80 : 600 })
21
21
  const [dragging, setDragging] = useState(false)
22
+ const [isMobile, setIsMobile] = useState(typeof window !== 'undefined' ? window.innerWidth <= 768 : false)
22
23
  const dragStart = useRef({ x: 0, y: 0 })
23
24
  const toolbarRef = useRef<HTMLDivElement>(null)
24
25
  const location = useLocation()
25
26
  const isOnPreviews = location.pathname.startsWith('/previews')
26
27
  const { devToolsContent } = useDevTools()
27
28
 
29
+ // Track mobile state
30
+ useEffect(() => {
31
+ const handleResize = () => setIsMobile(window.innerWidth <= 768)
32
+ window.addEventListener('resize', handleResize)
33
+ return () => window.removeEventListener('resize', handleResize)
34
+ }, [])
35
+
28
36
  const handleMouseDown = (e: React.MouseEvent) => {
37
+ // Disable dragging on mobile
38
+ if (isMobile) return
29
39
  if ((e.target as HTMLElement).closest('button, a')) return
30
40
  setDragging(true)
31
41
  dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y }
32
42
  }
33
43
 
34
44
  useEffect(() => {
35
- if (!dragging) return
45
+ if (!dragging || isMobile) return
36
46
 
37
47
  const handleMouseMove = (e: MouseEvent) => {
38
48
  setPosition({
@@ -49,13 +59,13 @@ export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidt
49
59
  document.removeEventListener('mousemove', handleMouseMove)
50
60
  document.removeEventListener('mouseup', handleMouseUp)
51
61
  }
52
- }, [dragging])
62
+ }, [dragging, isMobile])
53
63
 
54
64
  return (
55
65
  <div
56
66
  ref={toolbarRef}
57
67
  className="prev-toolbar"
58
- style={{ left: position.x, top: position.y }}
68
+ style={isMobile ? undefined : { left: position.x, top: position.y }}
59
69
  onMouseDown={handleMouseDown}
60
70
  >
61
71
  <button
@@ -99,15 +99,15 @@ function PreviewsCatalog() {
99
99
  <h1 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
100
100
  No Previews Found
101
101
  </h1>
102
- <p style={{ color: '#666', marginBottom: '24px' }}>
102
+ <p style={{ color: 'var(--fd-muted-foreground)', marginBottom: '24px' }}>
103
103
  Create your first preview with:
104
104
  </p>
105
105
  <code style={{
106
106
  display: 'inline-block',
107
107
  padding: '12px 20px',
108
- backgroundColor: '#f4f4f5',
108
+ backgroundColor: 'var(--fd-muted)',
109
109
  borderRadius: '8px',
110
- fontFamily: 'monospace',
110
+ fontFamily: 'var(--fd-font-mono)',
111
111
  }}>
112
112
  prev create my-demo
113
113
  </code>
@@ -116,37 +116,33 @@ function PreviewsCatalog() {
116
116
  }
117
117
 
118
118
  return (
119
- <div style={{ padding: '20px' }}>
120
- <div style={{ marginBottom: '32px' }}>
119
+ <div className="previews-catalog">
120
+ <div style={{ marginBottom: '24px' }}>
121
121
  <h1 style={{ fontSize: '28px', fontWeight: 'bold', marginBottom: '8px' }}>
122
122
  Previews
123
123
  </h1>
124
- <p style={{ color: '#666' }}>
124
+ <p style={{ color: 'var(--fd-muted-foreground)', margin: 0 }}>
125
125
  {previews.length} component preview{previews.length !== 1 ? 's' : ''} available.
126
126
  Click any preview to open it.
127
127
  </p>
128
128
  </div>
129
129
 
130
- <div style={{
131
- display: 'grid',
132
- gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
133
- gap: '20px',
134
- }}>
130
+ <div className="previews-grid">
135
131
  {previews.map((preview: { name: string; route: string }) => (
136
132
  <PreviewCard key={preview.name} name={preview.name} />
137
133
  ))}
138
134
  </div>
139
135
 
140
136
  <div style={{
141
- marginTop: '40px',
142
- padding: '16px',
137
+ marginTop: '32px',
138
+ padding: '14px 16px',
143
139
  backgroundColor: 'var(--fd-muted)',
144
140
  border: '1px solid var(--fd-border)',
145
- borderRadius: '8px',
141
+ borderRadius: '10px',
146
142
  }}>
147
143
  <p style={{ margin: 0, fontSize: '14px', color: 'var(--fd-muted-foreground)' }}>
148
- <strong>Tip:</strong> Embed any preview in your MDX docs with{' '}
149
- <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px' }}>
144
+ <strong style={{ color: 'var(--fd-foreground)' }}>Tip:</strong> Embed any preview in your MDX docs with{' '}
145
+ <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px', fontFamily: 'var(--fd-font-mono)' }}>
150
146
  {'<Preview src="name" />'}
151
147
  </code>
152
148
  </p>
@@ -161,10 +157,23 @@ import type { PreviewConfig, PreviewMessage } from '../preview-runtime/types'
161
157
  function PreviewCard({ name }: { name: string }) {
162
158
  const iframeRef = React.useRef<HTMLIFrameElement>(null)
163
159
  const [isLoaded, setIsLoaded] = React.useState(false)
160
+ const [loadError, setLoadError] = React.useState(false)
164
161
 
165
162
  // In production, use pre-built static files; in dev, use WASM runtime
166
163
  const isDev = import.meta.env?.DEV ?? false
167
- const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `/_preview/${name}/`
164
+ const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
165
+ const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `${baseUrl}/_preview/${name}/`
166
+
167
+ // Timeout for loading - show placeholder if too slow
168
+ React.useEffect(() => {
169
+ const timeout = setTimeout(() => {
170
+ if (!isLoaded) {
171
+ setLoadError(true)
172
+ }
173
+ }, 5000) // 5 second timeout
174
+
175
+ return () => clearTimeout(timeout)
176
+ }, [isLoaded])
168
177
 
169
178
  // Set up WASM preview communication for thumbnail (dev mode only)
170
179
  React.useEffect(() => {
@@ -173,6 +182,7 @@ function PreviewCard({ name }: { name: string }) {
173
182
  const iframe = iframeRef.current
174
183
  if (iframe) {
175
184
  iframe.onload = () => setIsLoaded(true)
185
+ iframe.onerror = () => setLoadError(true)
176
186
  }
177
187
  return
178
188
  }
@@ -194,13 +204,17 @@ function PreviewCard({ name }: { name: string }) {
194
204
  iframe.contentWindow?.postMessage({ type: 'init', config } as PreviewMessage, '*')
195
205
  })
196
206
  .catch(() => {
197
- // Silently fail for thumbnails
207
+ setLoadError(true)
198
208
  })
199
209
  }
200
210
 
201
211
  if (msg.type === 'built') {
202
212
  setIsLoaded(true)
203
213
  }
214
+
215
+ if (msg.type === 'error') {
216
+ setLoadError(true)
217
+ }
204
218
  }
205
219
 
206
220
  window.addEventListener('message', handleMessage)
@@ -211,87 +225,38 @@ function PreviewCard({ name }: { name: string }) {
211
225
  }, [name, isDev])
212
226
 
213
227
  return (
214
- <Link
215
- to={`/previews/${name}`}
216
- style={{
217
- display: 'block',
218
- border: '1px solid var(--fd-border)',
219
- borderRadius: '12px',
220
- overflow: 'hidden',
221
- backgroundColor: 'var(--fd-background)',
222
- textDecoration: 'none',
223
- color: 'inherit',
224
- transition: 'box-shadow 0.2s, transform 0.2s',
225
- }}
226
- onMouseOver={(e) => {
227
- e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.12)'
228
- e.currentTarget.style.transform = 'translateY(-2px)'
229
- }}
230
- onMouseOut={(e) => {
231
- e.currentTarget.style.boxShadow = ''
232
- e.currentTarget.style.transform = ''
233
- }}
234
- >
228
+ <Link to={`/previews/${name}`} className="preview-card">
235
229
  {/* Thumbnail preview */}
236
- <div style={{
237
- height: '180px',
238
- overflow: 'hidden',
239
- position: 'relative',
240
- backgroundColor: 'var(--fd-muted)',
241
- pointerEvents: 'none',
242
- }}>
243
- {/* Loading spinner */}
244
- {!isLoaded && (
245
- <div style={{
246
- position: 'absolute',
247
- inset: 0,
248
- display: 'flex',
249
- alignItems: 'center',
250
- justifyContent: 'center',
251
- backgroundColor: 'var(--fd-muted)',
252
- zIndex: 1,
253
- }}>
254
- <div style={{
255
- width: '24px',
256
- height: '24px',
257
- border: '2px solid var(--fd-border)',
258
- borderTopColor: 'var(--fd-primary)',
259
- borderRadius: '50%',
260
- animation: 'spin 1s linear infinite',
261
- }} />
230
+ <div className="preview-card-thumbnail">
231
+ {/* Loading state */}
232
+ {!isLoaded && !loadError && (
233
+ <div className="preview-card-loading">
234
+ <div className="preview-card-spinner" />
235
+ </div>
236
+ )}
237
+ {/* Error/timeout placeholder */}
238
+ {loadError && (
239
+ <div className="preview-card-placeholder">
240
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
241
+ <rect x="3" y="3" width="18" height="18" rx="2" />
242
+ <path d="M9 9l6 6m0-6l-6 6" />
243
+ </svg>
244
+ <span>Preview</span>
262
245
  </div>
263
246
  )}
264
247
  <iframe
265
248
  ref={iframeRef}
266
249
  src={previewUrl}
267
- style={{
268
- border: 'none',
269
- transform: 'scale(0.5)',
270
- transformOrigin: 'top left',
271
- width: '200%',
272
- height: '200%',
273
- opacity: isLoaded ? 1 : 0,
274
- transition: 'opacity 0.3s',
275
- }}
250
+ className="preview-card-iframe"
251
+ style={{ opacity: isLoaded && !loadError ? 1 : 0 }}
276
252
  title={name}
253
+ loading="lazy"
277
254
  />
278
255
  </div>
279
256
  {/* Card footer */}
280
- <div style={{
281
- padding: '12px 16px',
282
- borderTop: '1px solid var(--fd-border)',
283
- backgroundColor: 'var(--fd-card)',
284
- }}>
285
- <h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>
286
- {name}
287
- </h3>
288
- <code style={{
289
- fontSize: '11px',
290
- color: 'var(--fd-muted-foreground)',
291
- fontFamily: 'monospace',
292
- }}>
293
- previews/{name}/
294
- </code>
257
+ <div className="preview-card-footer">
258
+ <h3 className="preview-card-title">{name}</h3>
259
+ <code className="preview-card-path">previews/{name}/</code>
295
260
  </div>
296
261
  </Link>
297
262
  )
@@ -408,8 +373,12 @@ const routeTree = rootRoute.addChildren([
408
373
  ...(indexRedirectRoute ? [indexRedirectRoute] : []),
409
374
  ...pageRoutes,
410
375
  ])
376
+ // Get base path for subpath deployments (e.g., GitHub Pages)
377
+ const basepath = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '') || '/'
378
+
411
379
  const router = createRouter({
412
380
  routeTree,
381
+ basepath,
413
382
  defaultNotFoundComponent: NotFoundPage,
414
383
  })
415
384
 
@@ -1,5 +1,111 @@
1
+ import React from 'react'
2
+ import { Link } from '@tanstack/react-router'
1
3
  import { Preview } from './Preview'
4
+ import { pages } from 'virtual:prev-pages'
5
+
6
+ // Get valid routes from pages
7
+ const validRoutes = new Set(pages.map((p: { route: string }) => p.route))
8
+
9
+ // Also add /previews routes
10
+ validRoutes.add('/previews')
11
+
12
+ // Check if a path is an internal link
13
+ function isInternalLink(href: string): boolean {
14
+ if (!href) return false
15
+ // External links start with http://, https://, mailto:, tel:, etc.
16
+ if (/^(https?:|mailto:|tel:|#)/.test(href)) return false
17
+ // Relative or absolute internal paths
18
+ return href.startsWith('/') || !href.includes(':')
19
+ }
20
+
21
+ // Check if an internal route exists
22
+ function routeExists(href: string): boolean {
23
+ if (!href) return true
24
+ // Remove hash and query string
25
+ const path = href.split(/[?#]/)[0]
26
+ // Check exact match or if it's a valid preview route
27
+ if (validRoutes.has(path)) return true
28
+ // Check if it starts with /previews/ (dynamic preview routes)
29
+ if (path.startsWith('/previews/')) return true
30
+ return false
31
+ }
32
+
33
+ // Custom link component that validates internal links and uses router
34
+ function MdxLink({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
35
+ const isInternal = isInternalLink(href || '')
36
+ const isDev = import.meta.env?.DEV ?? false
37
+
38
+ // For internal links, use TanStack Router's Link
39
+ if (isInternal && href) {
40
+ const exists = routeExists(href)
41
+
42
+ // In dev mode, show warning for dead links
43
+ if (isDev && !exists) {
44
+ return (
45
+ <span
46
+ className="dead-link"
47
+ title={`Dead link: "${href}" does not match any known route`}
48
+ {...props}
49
+ >
50
+ {children}
51
+ <span className="dead-link-icon" aria-label="Dead link">⚠️</span>
52
+ </span>
53
+ )
54
+ }
55
+
56
+ return (
57
+ <Link to={href} {...props}>
58
+ {children}
59
+ </Link>
60
+ )
61
+ }
62
+
63
+ // External links open in new tab
64
+ return (
65
+ <a
66
+ href={href}
67
+ target="_blank"
68
+ rel="noopener noreferrer"
69
+ {...props}
70
+ >
71
+ {children}
72
+ </a>
73
+ )
74
+ }
75
+
76
+ // Responsive table wrapper for horizontal scrolling on mobile
77
+ function MdxTable({ children, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
78
+ const wrapperRef = React.useRef<HTMLDivElement>(null)
79
+
80
+ React.useEffect(() => {
81
+ const wrapper = wrapperRef.current
82
+ if (!wrapper) return
83
+
84
+ // Check if table overflows and add class for scroll indicator
85
+ const checkOverflow = () => {
86
+ const hasOverflow = wrapper.scrollWidth > wrapper.clientWidth
87
+ wrapper.classList.toggle('has-scroll', hasOverflow && wrapper.scrollLeft < wrapper.scrollWidth - wrapper.clientWidth - 1)
88
+ }
89
+
90
+ checkOverflow()
91
+ wrapper.addEventListener('scroll', checkOverflow)
92
+ window.addEventListener('resize', checkOverflow)
93
+
94
+ return () => {
95
+ wrapper.removeEventListener('scroll', checkOverflow)
96
+ window.removeEventListener('resize', checkOverflow)
97
+ }
98
+ }, [])
99
+
100
+ return (
101
+ <div ref={wrapperRef} className="table-wrapper">
102
+ <table {...props}>{children}</table>
103
+ </div>
104
+ )
105
+ }
2
106
 
3
107
  export const mdxComponents = {
4
108
  Preview,
109
+ a: MdxLink,
110
+ table: MdxTable,
5
111
  }
@@ -214,6 +214,27 @@ body {
214
214
  padding-bottom: 3rem !important;
215
215
  }
216
216
 
217
+ /* Mobile: reduce content padding for better use of screen space */
218
+ @media (max-width: 640px) {
219
+ .prev-content {
220
+ padding-left: 1rem !important;
221
+ padding-right: 1rem !important;
222
+ font-size: 0.9375rem;
223
+ }
224
+
225
+ .prev-content h1 {
226
+ font-size: 1.75rem;
227
+ }
228
+
229
+ .prev-content h2 {
230
+ font-size: 1.25rem;
231
+ }
232
+
233
+ .prev-content h3 {
234
+ font-size: 1.125rem;
235
+ }
236
+ }
237
+
217
238
  /* Heading styles with better spacing */
218
239
  .prev-content h1 {
219
240
  font-size: 2.25rem;
@@ -340,14 +361,57 @@ body {
340
361
  color: var(--fd-foreground);
341
362
  }
342
363
 
343
- /* Table styling */
344
- .prev-content table {
364
+ /* Table styling - responsive with horizontal scroll on mobile */
365
+ .prev-content .table-wrapper {
345
366
  width: 100%;
346
367
  margin: 1.75rem 0;
368
+ overflow-x: auto;
369
+ -webkit-overflow-scrolling: touch;
370
+ }
371
+
372
+ /* Scroll indicator gradient for tables on mobile */
373
+ @media (max-width: 640px) {
374
+ .prev-content .table-wrapper {
375
+ position: relative;
376
+ }
377
+
378
+ .prev-content .table-wrapper::after {
379
+ content: '';
380
+ position: absolute;
381
+ top: 0;
382
+ right: 0;
383
+ bottom: 0;
384
+ width: 24px;
385
+ background: linear-gradient(to right, transparent, var(--fd-background));
386
+ pointer-events: none;
387
+ opacity: 0;
388
+ transition: opacity 0.2s ease;
389
+ }
390
+
391
+ .prev-content .table-wrapper.has-scroll::after {
392
+ opacity: 1;
393
+ }
394
+ }
395
+
396
+ .prev-content table {
397
+ width: 100%;
398
+ min-width: max-content;
347
399
  border-collapse: collapse;
348
400
  font-size: 0.925rem;
349
401
  }
350
402
 
403
+ /* Mobile: smaller table font and padding */
404
+ @media (max-width: 640px) {
405
+ .prev-content table {
406
+ font-size: 0.85rem;
407
+ }
408
+
409
+ .prev-content th,
410
+ .prev-content td {
411
+ padding: 0.625rem 0.75rem;
412
+ }
413
+ }
414
+
351
415
  .prev-content thead {
352
416
  border-bottom: 2px solid var(--fd-border);
353
417
  }
@@ -357,6 +421,7 @@ body {
357
421
  text-align: left;
358
422
  font-weight: 600;
359
423
  background: var(--fd-muted);
424
+ white-space: nowrap;
360
425
  }
361
426
 
362
427
  .prev-content th:first-child {
@@ -567,3 +632,185 @@ body {
567
632
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
568
633
  }
569
634
  }
635
+
636
+ /* ===========================================
637
+ Previews Catalog - Mobile-First Grid
638
+ =========================================== */
639
+
640
+ .previews-catalog {
641
+ padding: 16px;
642
+ }
643
+
644
+ @media (min-width: 640px) {
645
+ .previews-catalog {
646
+ padding: 20px;
647
+ }
648
+ }
649
+
650
+ .previews-grid {
651
+ display: grid;
652
+ grid-template-columns: 1fr;
653
+ gap: 16px;
654
+ }
655
+
656
+ @media (min-width: 480px) {
657
+ .previews-grid {
658
+ grid-template-columns: repeat(2, 1fr);
659
+ }
660
+ }
661
+
662
+ @media (min-width: 768px) {
663
+ .previews-grid {
664
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
665
+ gap: 20px;
666
+ }
667
+ }
668
+
669
+ /* Preview card enhancements */
670
+ .previews-grid a {
671
+ display: block;
672
+ border: 1px solid var(--fd-border);
673
+ border-radius: 12px;
674
+ overflow: hidden;
675
+ background-color: var(--fd-background);
676
+ text-decoration: none;
677
+ color: inherit;
678
+ transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
679
+ }
680
+
681
+ .previews-grid a:hover {
682
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
683
+ transform: translateY(-2px);
684
+ border-color: var(--fd-primary);
685
+ }
686
+
687
+ .previews-grid a:active {
688
+ transform: translateY(0);
689
+ }
690
+
691
+ /* Preview Card Component Styles */
692
+ .preview-card-thumbnail {
693
+ height: 140px;
694
+ overflow: hidden;
695
+ position: relative;
696
+ background-color: var(--fd-muted);
697
+ pointer-events: none;
698
+ }
699
+
700
+ @media (min-width: 480px) {
701
+ .preview-card-thumbnail {
702
+ height: 160px;
703
+ }
704
+ }
705
+
706
+ @media (min-width: 768px) {
707
+ .preview-card-thumbnail {
708
+ height: 180px;
709
+ }
710
+ }
711
+
712
+ .preview-card-loading {
713
+ position: absolute;
714
+ inset: 0;
715
+ display: flex;
716
+ align-items: center;
717
+ justify-content: center;
718
+ background-color: var(--fd-muted);
719
+ z-index: 1;
720
+ }
721
+
722
+ .preview-card-spinner {
723
+ width: 24px;
724
+ height: 24px;
725
+ border: 2px solid var(--fd-border);
726
+ border-top-color: var(--fd-primary);
727
+ border-radius: 50%;
728
+ animation: spin 1s linear infinite;
729
+ }
730
+
731
+ .preview-card-placeholder {
732
+ position: absolute;
733
+ inset: 0;
734
+ display: flex;
735
+ flex-direction: column;
736
+ align-items: center;
737
+ justify-content: center;
738
+ gap: 8px;
739
+ background-color: var(--fd-muted);
740
+ color: var(--fd-muted-foreground);
741
+ z-index: 1;
742
+ }
743
+
744
+ .preview-card-placeholder span {
745
+ font-size: 12px;
746
+ font-weight: 500;
747
+ }
748
+
749
+ .preview-card-iframe {
750
+ border: none;
751
+ transform: scale(0.5);
752
+ transform-origin: top left;
753
+ width: 200%;
754
+ height: 200%;
755
+ transition: opacity 0.3s ease;
756
+ }
757
+
758
+ .preview-card-footer {
759
+ padding: 12px 14px;
760
+ border-top: 1px solid var(--fd-border);
761
+ background-color: var(--fd-card);
762
+ }
763
+
764
+ .preview-card-title {
765
+ font-size: 14px;
766
+ font-weight: 600;
767
+ margin: 0 0 2px 0;
768
+ color: var(--fd-foreground);
769
+ }
770
+
771
+ .preview-card-path {
772
+ font-size: 11px;
773
+ color: var(--fd-muted-foreground);
774
+ font-family: var(--fd-font-mono);
775
+ }
776
+
777
+ /* ===========================================
778
+ Dead Link Warning (Dev Mode Only)
779
+ =========================================== */
780
+
781
+ .dead-link {
782
+ color: #dc2626;
783
+ text-decoration: line-through;
784
+ text-decoration-color: #dc2626;
785
+ cursor: not-allowed;
786
+ position: relative;
787
+ }
788
+
789
+ .dead-link-icon {
790
+ font-size: 0.75em;
791
+ margin-left: 0.25em;
792
+ vertical-align: super;
793
+ }
794
+
795
+ /* Tooltip on hover */
796
+ .dead-link::after {
797
+ content: attr(title);
798
+ position: absolute;
799
+ bottom: 100%;
800
+ left: 50%;
801
+ transform: translateX(-50%);
802
+ padding: 6px 10px;
803
+ background: #1f2937;
804
+ color: #fff;
805
+ font-size: 12px;
806
+ border-radius: 6px;
807
+ white-space: nowrap;
808
+ opacity: 0;
809
+ pointer-events: none;
810
+ transition: opacity 0.15s ease;
811
+ z-index: 100;
812
+ }
813
+
814
+ .dead-link:hover::after {
815
+ opacity: 1;
816
+ }