@kirchdev/duxt 0.3.4-dev-main.41-23ded41 → 0.3.5-dev-main.43-3310f4b

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 (219) hide show
  1. package/README.md +24 -9
  2. package/app/app.vue +17 -11
  3. package/app/assets/css/duxt.css +47 -6
  4. package/app/components/DuxtAnnouncements.vue +117 -0
  5. package/app/components/DuxtBrunoEntries.vue +62 -0
  6. package/app/components/DuxtCodeBlock.vue +18 -34
  7. package/app/components/DuxtCodeToolbar.vue +14 -0
  8. package/app/components/DuxtCopyButton.vue +33 -0
  9. package/app/components/DuxtCopyPage.vue +32 -30
  10. package/app/components/DuxtFooter.vue +39 -8
  11. package/app/components/DuxtHeader.vue +107 -48
  12. package/app/components/DuxtLandingProse.vue +1 -1
  13. package/app/components/DuxtLinkLabel.vue +36 -0
  14. package/app/components/DuxtLiveWindow.vue +8 -8
  15. package/app/components/DuxtLocale.vue +10 -1
  16. package/app/components/DuxtNavigation.vue +2 -2
  17. package/app/components/DuxtOpenApiClient.vue +285 -90
  18. package/app/components/DuxtOpenApiMedia.vue +30 -18
  19. package/app/components/DuxtOpenApiSchema.vue +10 -7
  20. package/app/components/DuxtPageFeedback.vue +7 -0
  21. package/app/components/DuxtPageInfo.vue +15 -12
  22. package/app/components/DuxtPageNav.vue +41 -27
  23. package/app/components/DuxtPreviewSkeleton.vue +4 -4
  24. package/app/components/DuxtProgress.vue +2 -14
  25. package/app/components/DuxtSearch.vue +230 -90
  26. package/app/components/DuxtSearchInput.vue +70 -0
  27. package/app/components/DuxtSections.vue +11 -1
  28. package/app/components/DuxtSegmented.vue +64 -0
  29. package/app/components/DuxtShortcuts.vue +16 -11
  30. package/app/components/DuxtShortcutsTrigger.vue +49 -0
  31. package/app/components/DuxtSkipLink.vue +1 -1
  32. package/app/components/DuxtToc.vue +16 -10
  33. package/app/components/DuxtTranslationBanner.vue +1 -1
  34. package/app/components/DuxtVersion.vue +53 -12
  35. package/app/components/DuxtVersionBanner.vue +5 -5
  36. package/app/components/content/AccordionItem.vue +2 -2
  37. package/app/components/content/BrunoOverview.vue +260 -0
  38. package/app/components/content/BrunoRequest.vue +141 -0
  39. package/app/components/content/BrunoRequests.vue +104 -0
  40. package/app/components/content/Callout.vue +5 -5
  41. package/app/components/content/ChangelogReleases.vue +161 -40
  42. package/app/components/content/CodeGroup.vue +41 -37
  43. package/app/components/content/Field.vue +1 -1
  44. package/app/components/content/FileTree.vue +1 -1
  45. package/app/components/content/OpenApiOperation.vue +4 -4
  46. package/app/components/content/OpenApiOperations.vue +1 -1
  47. package/app/components/content/OpenApiOverview.vue +1 -1
  48. package/app/components/content/PackageManagers.vue +42 -58
  49. package/app/components/content/PageCards.vue +7 -4
  50. package/app/components/content/Preview.vue +15 -39
  51. package/app/components/content/ProseImg.vue +596 -49
  52. package/app/components/content/Since.vue +1 -1
  53. package/app/components/content/Steps.vue +6 -4
  54. package/app/components/content/TabGroup.vue +13 -12
  55. package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
  56. package/app/components/ui/command/CommandShortcut.vue +1 -1
  57. package/app/components/ui/dialog/DialogContent.vue +2 -2
  58. package/app/components/ui/dialog/DialogHeader.vue +1 -1
  59. package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
  60. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
  61. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
  63. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
  64. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
  66. package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
  67. package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
  68. package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
  69. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  70. package/app/components/ui/select/SelectItem.vue +2 -2
  71. package/app/components/ui/sheet/SheetContent.vue +29 -3
  72. package/app/components/ui/sidebar/Sidebar.vue +28 -4
  73. package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  74. package/app/components/ui/sidebar/SidebarInset.vue +1 -1
  75. package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  76. package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  77. package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
  78. package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  79. package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
  80. package/app/components/ui/sidebar/index.ts +10 -2
  81. package/app/components/ui/sidebar/utils.ts +0 -1
  82. package/app/components/ui/sonner/Sonner.vue +18 -0
  83. package/app/components/ui/tabs/TabsList.vue +13 -3
  84. package/app/components/ui/tabs/TabsTrigger.vue +24 -3
  85. package/app/components/ui/tooltip/TooltipContent.vue +2 -2
  86. package/app/composables/useActiveHeading.ts +3 -25
  87. package/app/composables/useDuxtAnalytics.ts +50 -0
  88. package/app/composables/useDuxtAnimatedHeight.ts +8 -38
  89. package/app/composables/useDuxtAnnouncements.ts +153 -0
  90. package/app/composables/useDuxtCopy.ts +51 -0
  91. package/app/composables/useDuxtDirection.ts +23 -0
  92. package/app/composables/useDuxtNavigation.ts +28 -8
  93. package/app/composables/useDuxtPath.ts +3 -5
  94. package/app/composables/useDuxtSearch.ts +87 -35
  95. package/app/composables/useDuxtSection.ts +15 -1
  96. package/app/composables/useDuxtSectionRow.ts +6 -5
  97. package/app/composables/useDuxtShortcuts.ts +319 -20
  98. package/app/composables/useDuxtVersion.ts +57 -6
  99. package/app/composables/useDuxtViewportMeasure.ts +37 -0
  100. package/app/composables/useFuzzySearch.ts +9 -4
  101. package/app/composables/useRecentPages.ts +14 -17
  102. package/app/error.vue +112 -74
  103. package/app/layouts/changelog.vue +9 -1
  104. package/app/layouts/default.vue +9 -0
  105. package/app/layouts/docs.vue +9 -1
  106. package/app/layouts/reference.vue +9 -1
  107. package/app/middleware/duxt-section-layout.global.ts +1 -3
  108. package/app/pages/[...slug].vue +146 -35
  109. package/app/pages/index.vue +15 -23
  110. package/app/types/duxt.d.ts +312 -4
  111. package/app/utils/analytics.ts +91 -0
  112. package/app/utils/announcements.ts +205 -0
  113. package/app/utils/changelog.ts +0 -28
  114. package/app/utils/code-highlight.ts +3 -1
  115. package/app/utils/contributor-avatar.ts +27 -0
  116. package/app/utils/direction.ts +43 -0
  117. package/app/utils/duxt-config.ts +43 -0
  118. package/app/utils/duxt-text.ts +10 -0
  119. package/app/utils/format-date.ts +28 -0
  120. package/app/utils/generated-sections.ts +68 -12
  121. package/app/utils/generated-toc.ts +14 -7
  122. package/app/utils/locale-path.ts +43 -0
  123. package/app/utils/navigation-tree.ts +51 -1
  124. package/app/utils/nearest-page.ts +8 -0
  125. package/app/utils/openapi.ts +16 -11
  126. package/app/utils/page-controls.ts +215 -0
  127. package/app/utils/pill.ts +61 -0
  128. package/app/utils/prose-image.ts +128 -0
  129. package/app/utils/search-context.ts +110 -0
  130. package/app/utils/search-display.ts +248 -0
  131. package/app/utils/section-scope.ts +65 -4
  132. package/app/utils/stored-json.ts +31 -0
  133. package/app/utils/version-choices.ts +6 -13
  134. package/app/utils/version-paths.ts +21 -0
  135. package/bin/duxt-cache-key.mjs +61 -0
  136. package/bin/duxt-og-cache.mjs +58 -0
  137. package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
  138. package/bruno-model.ts +169 -0
  139. package/bruno-parse.ts +656 -0
  140. package/bruno-zip.ts +195 -0
  141. package/cli.ts +240 -0
  142. package/duxt-app-config.ts +18 -1
  143. package/frontmatter.ts +49 -0
  144. package/git-contributors.ts +322 -0
  145. package/i18n/locales/de/duxt/announcement.json +7 -0
  146. package/i18n/locales/de/duxt/bruno.json +25 -0
  147. package/i18n/locales/de/duxt/changelog.json +1 -1
  148. package/i18n/locales/de/duxt/code.json +2 -1
  149. package/i18n/locales/de/duxt/page.json +9 -0
  150. package/i18n/locales/de/duxt/shortcuts.json +1 -0
  151. package/i18n/locales/de/duxt/theme.json +2 -1
  152. package/i18n/locales/en/duxt/announcement.json +7 -0
  153. package/i18n/locales/en/duxt/bruno.json +25 -0
  154. package/i18n/locales/en/duxt/changelog.json +1 -1
  155. package/i18n/locales/en/duxt/code.json +2 -1
  156. package/i18n/locales/en/duxt/page.json +9 -0
  157. package/i18n/locales/en/duxt/shortcuts.json +1 -0
  158. package/i18n/locales/en/duxt/theme.json +2 -1
  159. package/i18n/locales/es/duxt/announcement.json +7 -0
  160. package/i18n/locales/es/duxt/bruno.json +25 -0
  161. package/i18n/locales/es/duxt/changelog.json +1 -1
  162. package/i18n/locales/es/duxt/code.json +2 -1
  163. package/i18n/locales/es/duxt/page.json +9 -0
  164. package/i18n/locales/es/duxt/shortcuts.json +1 -0
  165. package/i18n/locales/es/duxt/theme.json +2 -1
  166. package/i18n/locales/fr/duxt/announcement.json +7 -0
  167. package/i18n/locales/fr/duxt/bruno.json +25 -0
  168. package/i18n/locales/fr/duxt/changelog.json +1 -1
  169. package/i18n/locales/fr/duxt/code.json +2 -1
  170. package/i18n/locales/fr/duxt/page.json +9 -0
  171. package/i18n/locales/fr/duxt/shortcuts.json +1 -0
  172. package/i18n/locales/fr/duxt/theme.json +2 -1
  173. package/i18n/locales/pt/duxt/announcement.json +7 -0
  174. package/i18n/locales/pt/duxt/bruno.json +25 -0
  175. package/i18n/locales/pt/duxt/changelog.json +1 -1
  176. package/i18n/locales/pt/duxt/code.json +2 -1
  177. package/i18n/locales/pt/duxt/page.json +9 -0
  178. package/i18n/locales/pt/duxt/shortcuts.json +1 -0
  179. package/i18n/locales/pt/duxt/theme.json +2 -1
  180. package/modules/bruno.ts +118 -0
  181. package/modules/config.ts +26 -0
  182. package/modules/devtools.ts +2 -3
  183. package/modules/git-meta.ts +16 -47
  184. package/modules/search-records.ts +132 -0
  185. package/nuxt.config.ts +81 -7
  186. package/og-image-cache.ts +742 -0
  187. package/package.json +41 -9
  188. package/public/devtools/checks.html +2 -2
  189. package/public/devtools/config.html +59 -4
  190. package/public/devtools/pages.html +8 -1
  191. package/public/devtools/paths.html +1 -1
  192. package/public/devtools/sources.html +2 -2
  193. package/public/devtools/versions.html +3 -0
  194. package/report.ts +28 -19
  195. package/search-records.ts +380 -0
  196. package/section-input.ts +142 -0
  197. package/section-markdown.ts +67 -0
  198. package/section-reports.ts +10 -4
  199. package/sections-bruno.ts +571 -0
  200. package/sections-changelog.ts +76 -43
  201. package/sections-openapi.ts +12 -83
  202. package/sections-resolve.ts +174 -8
  203. package/sections.ts +33 -10
  204. package/server/devtools/shell.ts +1 -1
  205. package/server/mcp/tools/list-pages.ts +77 -36
  206. package/server/mcp/tools/list-versions.ts +84 -0
  207. package/server/mcp/tools/read-page.ts +80 -26
  208. package/server/mcp/tools/search-docs.ts +87 -53
  209. package/server/middleware/raw-markdown.ts +1 -1
  210. package/server/routes/llms-full.txt.get.ts +6 -8
  211. package/server/routes/llms.txt.get.ts +1 -1
  212. package/server/utils/duxt-server-text.ts +0 -13
  213. package/server/utils/llms-pages.ts +24 -9
  214. package/server/utils/mcp-docs.ts +577 -0
  215. package/sources-cache.ts +376 -0
  216. package/sources-git.ts +186 -31
  217. package/sources-resolve.ts +59 -1
  218. package/sources.ts +122 -13
  219. package/tfplugindocs.ts +158 -0
@@ -17,18 +17,40 @@ const poweredBy = computed(() =>
17
17
  ? { version: duxt.layerVersion, to: duxt.layerRepository }
18
18
  : undefined
19
19
  );
20
+
21
+ /**
22
+ * However many the consumer configured — never assumed to be two.
23
+ *
24
+ * Named rather than inlined because the row has to know whether it is empty:
25
+ * on the stacked mobile layout an empty row is still a flex item, and the
26
+ * column's gap would draw a blank line under the attribution for links that do
27
+ * not exist.
28
+ */
29
+ const legal = computed(() => duxt.footer?.legal ?? []);
20
30
  </script>
21
31
 
22
32
  <template>
23
- <!-- One line, and only what a page footer owes: whose site this is, and the
24
- links the law asks for. Navigation lives in the header and the sidebar,
25
- so repeating it down here buys nothing. -->
33
+ <!-- Only what a page footer owes: whose site this is, and the links the law
34
+ asks for. Navigation lives in the header and the sidebar, so repeating it
35
+ down here buys nothing.
36
+
37
+ ONE ROW FROM `sm` UP, A CENTERED STACK BELOW IT. The three groups are the
38
+ same three either way — only the axis and the alignment change, which is
39
+ why this is two sets of utilities on the existing markup rather than a
40
+ second template. Narrow, the groups have unrelated widths and left-
41
+ aligning them reads as a ragged column rather than a composition, so the
42
+ stack is centered and takes the order the eye wants: whose site, whose
43
+ copyright, what built it, what the law asks for. -->
26
44
  <footer class="mt-16 border-t">
27
45
  <div
28
- class="mx-auto flex max-w-[90rem] flex-col gap-3 px-4 py-5 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between lg:px-8"
46
+ class="mx-auto flex max-w-[90rem] flex-col items-center gap-2 px-4 py-5 text-center text-sm text-muted-foreground sm:flex-row sm:justify-between sm:gap-3 sm:text-start lg:px-8"
29
47
  >
48
+ <!-- Brand and copyright: two centered lines below `sm`, one wrapping row
49
+ above it. Splitting them is the whole reason the stack has four lines
50
+ and not three — sharing a row, they wrap against each other at the
51
+ narrow widths where there is least room to spare. -->
30
52
  <div
31
- class="flex flex-wrap items-center gap-x-4 gap-y-1 sm:flex-1 sm:basis-0"
53
+ class="flex flex-col items-center gap-2 sm:flex-1 sm:basis-0 sm:flex-row sm:flex-wrap sm:gap-x-4 sm:gap-y-1"
32
54
  >
33
55
  <NuxtLink
34
56
  :to="localeLink('/')"
@@ -66,12 +88,21 @@ const poweredBy = computed(() =>
66
88
 
67
89
  <!-- A consumer's legal links belong to the consumer: the layer offers the
68
90
  row and ships nothing in it, because an imprint is never the
69
- template's to claim. -->
91
+ template's to claim. However many there are, they stay one wrapping
92
+ row and wrap together.
93
+
94
+ HIDDEN RATHER THAN DROPPED, and only below `sm`. An empty row must
95
+ not draw a blank line under the attribution, but `v-if` would take
96
+ the element out of the desktop row too — and there its
97
+ `flex-1 basis-0` is a third of what holds the attribution in the
98
+ middle of the page. Removing it would re-center that line, which is
99
+ a change to the desktop layout and not this one's to make. -->
70
100
  <div
71
- class="flex flex-wrap items-center gap-x-4 gap-y-1 sm:flex-1 sm:basis-0 sm:justify-end"
101
+ class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 sm:flex-1 sm:basis-0 sm:justify-end"
102
+ :class="{ 'max-sm:hidden': !legal.length }"
72
103
  >
73
104
  <NuxtLink
74
- v-for="link in duxt.footer?.legal ?? []"
105
+ v-for="link in legal"
75
106
  :key="link.to"
76
107
  :to="localeLink(link.to)"
77
108
  :target="link.external ? '_blank' : undefined"
@@ -3,6 +3,11 @@ const duxt = useDuxtConfig();
3
3
  const colorMode = useColorMode();
4
4
  const path = useDuxtPath();
5
5
  const localeLink = useDuxtLink();
6
+ const { t } = useI18n();
7
+
8
+ const themeLabel = computed(() =>
9
+ colorMode.value === 'dark' ? t('duxt.theme.toLight') : t('duxt.theme.toDark')
10
+ );
6
11
 
7
12
  const { data: navigation } = await useDuxtNavigation();
8
13
 
@@ -57,11 +62,18 @@ function entryActive(link: DuxtLink) {
57
62
  // entry stands for the documentation, and on a site with a second source the
58
63
  // list also holds that source's parts — which would light "Docs" on a page
59
64
  // that is not documentation at all, beside the entry that really owns it.
60
- return sectionsForPath(
61
- duxt.sections ?? [],
62
- duxt.resolvedSources ?? [],
63
- '/'
64
- ).some((section) => isActive(section.to));
65
+ //
66
+ // AT THE READER'S EDITION, and only inside the root area: the sections are
67
+ // written at the default edition, so on `/v0.2.0/concepts` none of them
68
+ // prefixed the page and "Docs" went dark on every other version.
69
+ const sources = duxt.resolvedSources ?? [];
70
+ if (areaForPath(path.value, sources) !== areaForPath('/', sources)) {
71
+ return false;
72
+ }
73
+
74
+ return sectionsForPath(duxt.sections ?? [], sources, path.value).some(
75
+ (section) => isActive(section.to)
76
+ );
65
77
  }
66
78
 
67
79
  /**
@@ -153,8 +165,8 @@ function current(to?: string) {
153
165
  <Icon name="lucide:menu" class="size-5" />
154
166
  </UiButton>
155
167
  </UiSheetTrigger>
156
- <UiSheetContent side="left" class="flex w-80 flex-col gap-0 p-0">
157
- <UiSheetHeader class="border-b pr-14">
168
+ <UiSheetContent side="start" class="flex w-80 flex-col gap-0 p-0">
169
+ <UiSheetHeader class="border-b pe-14">
158
170
  <UiSheetTitle class="flex items-center gap-2">
159
171
  <NuxtLink
160
172
  :to="localeLink('/')"
@@ -220,12 +232,12 @@ function current(to?: string) {
220
232
  <span class="truncate">{{ link.label }}</span>
221
233
  <Icon
222
234
  name="lucide:chevron-right"
223
- class="ml-auto size-3.5 transition-transform group-data-[state=open]:rotate-90"
235
+ class="ms-auto size-3.5 transition-transform group-data-[state=open]:rotate-90"
224
236
  />
225
237
  </UiCollapsibleTrigger>
226
238
 
227
239
  <UiCollapsibleContent>
228
- <ul class="mt-0.5 ml-3.5 space-y-0.5 border-l pl-2.5">
240
+ <ul class="mt-0.5 ms-3.5 space-y-0.5 border-s ps-2.5">
229
241
  <li
230
242
  v-for="child in link.children"
231
243
  :key="child.to ?? asText(child.label)"
@@ -356,19 +368,29 @@ function current(to?: string) {
356
368
  >
357
369
  <DuxtLocale />
358
370
 
359
- <UiButton
360
- variant="ghost"
361
- size="icon"
362
- :aria-label="$t('duxt.theme.toggle')"
363
- @click="toggleTheme"
364
- >
365
- <Icon
366
- :name="
367
- colorMode.value === 'dark' ? 'lucide:sun' : 'lucide:moon'
368
- "
369
- class="size-4"
370
- />
371
- </UiButton>
371
+ <UiTooltip>
372
+ <UiTooltipTrigger as-child>
373
+ <UiButton
374
+ variant="ghost"
375
+ size="icon"
376
+ :aria-label="themeLabel"
377
+ @click="toggleTheme"
378
+ >
379
+ <Icon
380
+ :name="
381
+ colorMode.value === 'dark'
382
+ ? 'lucide:sun'
383
+ : 'lucide:moon'
384
+ "
385
+ class="size-4"
386
+ />
387
+ </UiButton>
388
+ </UiTooltipTrigger>
389
+
390
+ <UiTooltipContent side="top">
391
+ {{ themeLabel }}
392
+ </UiTooltipContent>
393
+ </UiTooltip>
372
394
  </UiSheetFooter>
373
395
  </UiSheetContent>
374
396
  </UiSheet>
@@ -422,7 +444,14 @@ function current(to?: string) {
422
444
  class="mt-0.5 size-4 shrink-0"
423
445
  />
424
446
  <span class="min-w-0">
425
- <span class="block font-medium">{{ child.label }}</span>
447
+ <!-- The same arrow the sheet draws for an external
448
+ child, held to the label's last word. -->
449
+ <span class="block font-medium">
450
+ <DuxtLinkLabel
451
+ :label="child.label"
452
+ :external="child.external"
453
+ />
454
+ </span>
426
455
  <span
427
456
  v-if="child.description"
428
457
  class="block text-xs text-muted-foreground"
@@ -450,6 +479,11 @@ function current(to?: string) {
450
479
  :target="link.external ? '_blank' : undefined"
451
480
  >
452
481
  {{ link.label }}
482
+ <Icon
483
+ v-if="link.external"
484
+ name="lucide:arrow-up-right"
485
+ class="size-3 opacity-50"
486
+ />
453
487
  </NuxtLink>
454
488
  </UiButton>
455
489
  </template>
@@ -472,39 +506,64 @@ function current(to?: string) {
472
506
  <DuxtSearch />
473
507
  </div>
474
508
 
509
+ <!-- Desktop only, at the same lg split as the burger and the search: a
510
+ touch device has no keyboard, so a sheet of keys is nothing to
511
+ offer it — and the mobile sheet carries no copy either, since a
512
+ sheet is a dialog and a dialog is where the global keys stand down.
513
+ From lg up it stays the last control in this row that may give way:
514
+ on a site with `shortcuts.singleCharacter: false` it is the key
515
+ sheet's ONLY entry point. -->
516
+ <DuxtShortcutsTrigger class="hidden lg:inline-flex" />
517
+
475
518
  <!-- The project links give way first: they are the only icons here the
476
519
  sheet can carry as ordinary rows, where the locale and the theme
477
520
  are controls that have to stay reachable in one tap. -->
478
- <UiButton
479
- v-for="link in duxt.links ?? []"
480
- :key="link.to"
481
- as-child
482
- variant="ghost"
483
- size="icon"
484
- class="hidden lg:inline-flex"
485
- :aria-label="link.label"
486
- >
487
- <a :href="link.to" target="_blank" rel="noopener">
488
- <Icon v-if="link.icon" :name="link.icon" class="size-4" />
489
- </a>
490
- </UiButton>
521
+ <UiTooltip v-for="link in duxt.links ?? []" :key="link.to">
522
+ <UiTooltipTrigger as-child>
523
+ <UiButton
524
+ as-child
525
+ variant="ghost"
526
+ size="icon"
527
+ class="hidden lg:inline-flex"
528
+ :aria-label="link.label"
529
+ >
530
+ <a :href="link.to" target="_blank" rel="noopener">
531
+ <Icon v-if="link.icon" :name="link.icon" class="size-4" />
532
+ </a>
533
+ </UiButton>
534
+ </UiTooltipTrigger>
535
+
536
+ <UiTooltipContent side="bottom">
537
+ {{ link.label }}
538
+ </UiTooltipContent>
539
+ </UiTooltip>
491
540
 
492
541
  <div class="hidden sm:block">
493
542
  <DuxtLocale />
494
543
  </div>
495
544
 
496
- <UiButton
497
- variant="ghost"
498
- size="icon"
499
- class="hidden sm:inline-flex"
500
- :aria-label="$t('duxt.theme.toggle')"
501
- @click="toggleTheme"
502
- >
503
- <Icon
504
- :name="colorMode.value === 'dark' ? 'lucide:sun' : 'lucide:moon'"
505
- class="size-4"
506
- />
507
- </UiButton>
545
+ <UiTooltip>
546
+ <UiTooltipTrigger as-child>
547
+ <UiButton
548
+ variant="ghost"
549
+ size="icon"
550
+ class="hidden sm:inline-flex"
551
+ :aria-label="themeLabel"
552
+ @click="toggleTheme"
553
+ >
554
+ <Icon
555
+ :name="
556
+ colorMode.value === 'dark' ? 'lucide:sun' : 'lucide:moon'
557
+ "
558
+ class="size-4"
559
+ />
560
+ </UiButton>
561
+ </UiTooltipTrigger>
562
+
563
+ <UiTooltipContent side="bottom">
564
+ {{ themeLabel }}
565
+ </UiTooltipContent>
566
+ </UiTooltip>
508
567
  </div>
509
568
  </div>
510
569
  </header>
@@ -81,7 +81,7 @@ const localeLink = useDuxtLink();
81
81
  ? 'lucide:arrow-up-right'
82
82
  : 'lucide:arrow-right'
83
83
  "
84
- class="size-4"
84
+ class="size-4 rtl:-scale-x-100"
85
85
  />
86
86
  </NuxtLink>
87
87
  </UiButton>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A link's label with the external arrow held to its LAST WORD.
4
+ *
5
+ * The arrow is an inline box, so a label that exactly filled its line pushed
6
+ * the arrow alone onto the next one — "Star on GitHub" on one line and a stray
7
+ * `↗` under it. Wrapping the last word and the arrow in one `nowrap` span makes
8
+ * them one unbreakable piece: when the line is full, the word moves down WITH
9
+ * its arrow. The rest of the label still wraps normally.
10
+ */
11
+ const props = defineProps<{
12
+ label: string;
13
+ external?: boolean;
14
+ }>();
15
+
16
+ const parts = computed(() => {
17
+ const trimmed = props.label.trim();
18
+ const at = trimmed.lastIndexOf(' ');
19
+
20
+ return at === -1
21
+ ? { head: '', tail: trimmed }
22
+ : { head: trimmed.slice(0, at + 1), tail: trimmed.slice(at + 1) };
23
+ });
24
+ </script>
25
+
26
+ <template>
27
+ <template v-if="external">
28
+ {{ parts.head
29
+ }}<span class="whitespace-nowrap"
30
+ >{{ parts.tail }}&nbsp;<Icon
31
+ name="lucide:arrow-up-right"
32
+ class="inline-block size-3 align-middle opacity-50"
33
+ /></span>
34
+ </template>
35
+ <template v-else>{{ label }}</template>
36
+ </template>
@@ -232,12 +232,17 @@ const title = computed(
232
232
  <!-- The tab bar, where there is more than one page to show. Real buttons
233
233
  with `aria-selected` rather than a styled div: this is a tablist, the
234
234
  arrow keys are expected to work, and they do because the buttons are
235
- focusable in order. -->
235
+ focusable in order.
236
+
237
+ NOT BELOW `sm`. On a phone the strip was a second navigation bar
238
+ stacked over the framed site's own header, eating a window that is
239
+ already short — and the page inside has its own menu for getting to
240
+ every one of those pages. -->
236
241
  <div
237
242
  v-if="tabs.length > 1"
238
243
  role="tablist"
239
244
  :aria-label="$t('duxt.defaults.landing.demoTitle')"
240
- class="flex gap-1 overflow-x-auto border-b bg-muted/30 px-2 py-1.5"
245
+ class="hidden gap-1 overflow-x-auto border-b bg-muted/30 px-2 py-1.5 sm:flex"
241
246
  >
242
247
  <button
243
248
  v-for="(tab, index) in tabs"
@@ -245,12 +250,7 @@ const title = computed(
245
250
  type="button"
246
251
  role="tab"
247
252
  :aria-selected="index === active"
248
- class="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
249
- :class="
250
- index === active
251
- ? 'bg-background text-foreground shadow-sm'
252
- : 'text-muted-foreground hover:bg-background/60 hover:text-foreground'
253
- "
253
+ :class="duxtPill(index === active, { size: 'lg' })"
254
254
  @click="select(index)"
255
255
  >
256
256
  <Icon v-if="tab.icon" :name="tab.icon" class="size-4" />
@@ -44,11 +44,20 @@ function flagFor(code: string): string | null {
44
44
 
45
45
  <template>
46
46
  <UiDropdownMenu v-if="available.length > 1">
47
+ <!-- `title`, NOT a tooltip. The tooltip version swapped the button for a
48
+ second element whenever the menu closed (to keep the tooltip from
49
+ reopening on the returned focus), and reka's dropdown keeps the
50
+ element it was first anchored to: the NEXT open positioned itself
51
+ against a node no longer in the document and landed in the top-left
52
+ corner. Gildstone hit the same drift with tooltips on its collapsed
53
+ sidebar dropdowns and fixed it the same way (9122050). A trigger has
54
+ to stay one element for the life of the menu. -->
47
55
  <UiDropdownMenuTrigger as-child>
48
56
  <UiButton
49
57
  variant="ghost"
50
58
  size="icon"
51
59
  :aria-label="$t('duxt.locale.switch')"
60
+ :title="$t('duxt.locale.switch')"
52
61
  >
53
62
  <Icon
54
63
  v-if="current?.flag"
@@ -76,7 +85,7 @@ function flagFor(code: string): string | null {
76
85
  <span class="truncate text-sm">{{ entry.label }}</span>
77
86
  <Icon
78
87
  name="lucide:check"
79
- class="ml-auto size-3.5 shrink-0"
88
+ class="ms-auto size-3.5 shrink-0"
80
89
  :class="entry.code === locale ? '' : 'opacity-0'"
81
90
  />
82
91
  </NuxtLink>
@@ -74,12 +74,12 @@ function setOpen(item: ContentNavigationItem, value: boolean) {
74
74
  <span class="truncate">{{ item.title }}</span>
75
75
  <Icon
76
76
  name="lucide:chevron-right"
77
- class="ml-auto size-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
77
+ class="ms-auto size-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
78
78
  />
79
79
  </UiCollapsibleTrigger>
80
80
 
81
81
  <UiCollapsibleContent>
82
- <ul class="mt-0.5 ml-3.5 space-y-0.5 border-l pl-2.5">
82
+ <ul class="mt-0.5 ms-3.5 space-y-0.5 border-s ps-2.5">
83
83
  <li v-for="child in item.children" :key="child.path">
84
84
  <DuxtNavigationLink :item="child" />
85
85
  </li>