@kirchdev/duxt 0.4.0 → 0.5.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/README.md CHANGED
@@ -5,7 +5,9 @@
5
5
  <img src=".github/assets/wordmark-light.svg" alt="duxt" width="200" />
6
6
  </picture>
7
7
 
8
- **Versioned, multi-repo documentation for Nuxt — one line to extend, no collection boilerplate**
8
+ **Versioned docs, from the repositories you already have**
9
+
10
+ duxt is a Nuxt layer: extend it and your docs/ folder becomes a site, with theme, search, API reference and llms.txt included. Point it at other repositories, or at tags of the same one, and each becomes a version.
9
11
 
10
12
  [![npm Version](https://img.shields.io/npm/v/@kirchdev/duxt.svg?style=flat-square&color=4f46e5)](https://www.npmjs.com/package/@kirchdev/duxt)
11
13
  [![Downloads](https://img.shields.io/npm/dm/@kirchdev/duxt.svg?style=flat-square&color=4f46e5)](https://www.npmjs.com/package/@kirchdev/duxt)
@@ -596,3 +596,21 @@ pre.shiki,
596
596
  main:has(.duxt-flush-footer) + footer {
597
597
  margin-top: 0;
598
598
  }
599
+
600
+ /* The theme toggle reveals the new theme as a growing circle — see
601
+ `useDuxtThemeToggle`. The browser's default cross-fade is switched off so the
602
+ clip is the only animation, and the new snapshot is lifted above the old one
603
+ it opens over. */
604
+ ::view-transition-old(root),
605
+ ::view-transition-new(root) {
606
+ animation: none;
607
+ mix-blend-mode: normal;
608
+ }
609
+
610
+ ::view-transition-old(root) {
611
+ z-index: 1;
612
+ }
613
+
614
+ ::view-transition-new(root) {
615
+ z-index: 9999;
616
+ }
@@ -22,9 +22,7 @@ if (import.meta.prerender) {
22
22
  // The mobile sheet shows the same branch the sidebar does.
23
23
  const { items } = useDuxtSection(navigation);
24
24
 
25
- function toggleTheme() {
26
- colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
27
- }
25
+ const { toggle: toggleTheme } = useDuxtThemeToggle();
28
26
 
29
27
  /**
30
28
  * An entry with neither `to` nor children means "the documentation".
@@ -242,7 +242,7 @@ const rows = computed(() =>
242
242
  dropped re-laid the row it shared, so the button a reader was aiming at
243
243
  slid out from under the pointer between two clicks. On a row of its own
244
244
  the header is fixed and only the row that is actually changing moves. -->
245
- <div class="mt-12 mb-8 space-y-3">
245
+ <div class="mt-12 mb-8">
246
246
  <div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
247
247
  <h2 class="text-lg font-semibold tracking-tight">
248
248
  {{ $t('duxt.changelog.history') }}
@@ -334,45 +334,62 @@ const rows = computed(() =>
334
334
  The row is ALWAYS RENDERED and hidden while empty (`empty:hidden`),
335
335
  rather than held behind a `v-if` that would take the leaving chips
336
336
  with it before they could leave. During a leave it is no longer
337
- empty, so it stays laid out until the last chip is gone. -->
338
- <TransitionGroup
339
- tag="div"
340
- role="group"
341
- :aria-label="$t('duxt.changelog.selected')"
342
- class="flex flex-wrap items-center gap-2 empty:hidden"
343
- enter-active-class="transition duration-200 ease-out motion-reduce:transition-none"
344
- enter-from-class="scale-95 opacity-0"
345
- leave-active-class="transition duration-150 ease-in motion-reduce:transition-none"
346
- leave-to-class="scale-95 opacity-0"
347
- move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
337
+ empty, so it stays laid out until the last chip is gone.
338
+
339
+ ITS HEIGHT ANIMATES TOO. Appearing and vanishing in one step, the row
340
+ pushed the figures and the whole list down by its own height on the
341
+ first choice and pulled them back up on the last — the one jump left
342
+ in a gesture that otherwise moves. `grid-template-rows` from `0fr` to
343
+ `1fr` is the one height transition CSS can run without knowing the
344
+ height, and the spacing lives INSIDE the clipped track so it
345
+ collapses with it. The padding sits on the INNER box because a
346
+ grid item cannot shrink below its own padding, and it keeps a chip's
347
+ focus ring clear of the clip. -->
348
+ <div
349
+ class="grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
350
+ :class="selected.length ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
348
351
  >
349
- <button
350
- v-for="name in selected"
351
- :key="name"
352
- type="button"
353
- class="inline-flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
354
- :class="changelogTone(name).chip"
355
- @click="toggle(name)"
356
- >
357
- <span
358
- aria-hidden="true"
359
- class="size-1.5 rounded-full"
360
- :class="changelogTone(name).dot"
361
- />
362
- {{ changelogLabel(name) }}
363
- <Icon name="lucide:x" class="size-3 opacity-60" />
364
- </button>
365
-
366
- <button
367
- v-if="selected.length"
368
- key="reset"
369
- type="button"
370
- class="cursor-pointer text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
371
- @click="selected = []"
372
- >
373
- {{ $t('duxt.changelog.reset') }}
374
- </button>
375
- </TransitionGroup>
352
+ <div class="-mx-1 min-h-0 overflow-hidden">
353
+ <TransitionGroup
354
+ tag="div"
355
+ role="group"
356
+ :aria-label="$t('duxt.changelog.selected')"
357
+ class="flex flex-wrap items-center gap-2 px-1 pt-3 pb-1"
358
+ enter-active-class="transition duration-200 ease-out motion-reduce:transition-none"
359
+ enter-from-class="scale-95 opacity-0"
360
+ leave-active-class="transition duration-150 ease-in motion-reduce:transition-none"
361
+ leave-to-class="scale-95 opacity-0"
362
+ move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
363
+ >
364
+ <button
365
+ v-for="name in selected"
366
+ :key="name"
367
+ type="button"
368
+ class="inline-flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
369
+ :class="changelogTone(name).chip"
370
+ @click="toggle(name)"
371
+ >
372
+ <span
373
+ aria-hidden="true"
374
+ class="size-1.5 rounded-full"
375
+ :class="changelogTone(name).dot"
376
+ />
377
+ {{ changelogLabel(name) }}
378
+ <Icon name="lucide:x" class="size-3 opacity-60" />
379
+ </button>
380
+
381
+ <button
382
+ v-if="selected.length"
383
+ key="reset"
384
+ type="button"
385
+ class="cursor-pointer text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
386
+ @click="selected = []"
387
+ >
388
+ {{ $t('duxt.changelog.reset') }}
389
+ </button>
390
+ </TransitionGroup>
391
+ </div>
392
+ </div>
376
393
  </div>
377
394
 
378
395
  <!-- WHAT THE LIST CANNOT SAY: how much there is. Two figures set the way
@@ -525,11 +525,18 @@ if (import.meta.server && import.meta.prerender) {
525
525
 
526
526
  <UiDialog v-if="zoomable" v-model:open="open">
527
527
  <!-- The whole viewport, transparent over the overlay: the blur is what
528
- separates the picture from the page, not a border. -->
528
+ separates the picture from the page, not a border.
529
+
530
+ THE DARKENING AND THE BLUR BELONG TO THE OVERLAY, not to this box.
531
+ The content scales in from 95%, so a surface painted here reached
532
+ the viewport's edges only once that animation ended — and for 200ms
533
+ the page's text showed unblurred along the bottom. The overlay only
534
+ fades, so it covers the whole screen from the first frame. -->
529
535
  <UiDialogContent
530
536
  :show-close-button="false"
531
537
  :aria-describedby="undefined"
532
- class="inset-0 grid h-dvh w-screen max-w-none translate-none grid-rows-[auto_minmax(0,1fr)_auto] gap-0 rounded-none border-0 bg-neutral-950/60 p-0 shadow-none backdrop-blur-md sm:max-w-none"
538
+ overlay-class="bg-neutral-950/90 backdrop-blur-md"
539
+ class="inset-0 grid h-dvh w-screen max-w-none translate-none grid-rows-[auto_minmax(0,1fr)_auto] gap-0 rounded-none border-0 bg-transparent p-0 shadow-none sm:max-w-none"
533
540
  @click.self="open = false"
534
541
  @keydown="onKeydown"
535
542
  >
@@ -19,6 +19,8 @@ const props = withDefaults(
19
19
  defineProps<
20
20
  DialogContentProps & {
21
21
  class?: HTMLAttributes['class'];
22
+ /** Classes for the backdrop, which does not scale in with the content. */
23
+ overlayClass?: HTMLAttributes['class'];
22
24
  showCloseButton?: boolean;
23
25
  }
24
26
  >(),
@@ -28,14 +30,19 @@ const props = withDefaults(
28
30
  );
29
31
  const emits = defineEmits<DialogContentEmits>();
30
32
 
31
- const delegatedProps = reactiveOmit(props, 'class', 'showCloseButton');
33
+ const delegatedProps = reactiveOmit(
34
+ props,
35
+ 'class',
36
+ 'overlayClass',
37
+ 'showCloseButton'
38
+ );
32
39
 
33
40
  const forwarded = useForwardPropsEmits(delegatedProps, emits);
34
41
  </script>
35
42
 
36
43
  <template>
37
44
  <DialogPortal>
38
- <DialogOverlay />
45
+ <DialogOverlay :class="overlayClass" />
39
46
  <DialogContent
40
47
  data-slot="dialog-content"
41
48
  v-bind="{ ...$attrs, ...forwarded }"
@@ -49,9 +49,22 @@ export function useActiveHeading(ids: Ref<string[]>, scrollOffset = 96) {
49
49
  return;
50
50
  }
51
51
 
52
+ // THE LINE IS NEVER ABOVE WHERE A JUMP PARKS THE HEADING. A click in the
53
+ // outline scrolls the heading to its own `scroll-margin-top` — 112px, to
54
+ // clear the header AND the section row — and a fixed 96px line sat above
55
+ // that, so the heading just jumped to was not yet "passed" and the entry
56
+ // before it stayed marked. Reading the margin off the heading follows
57
+ // whatever the sticky stack is on this page and at this width; the
58
+ // configured offset remains the floor. The pixel absorbs sub-pixel scroll
59
+ // positions.
52
60
  let current = headings[0]!;
53
61
  for (const heading of headings) {
54
- if (heading.getBoundingClientRect().top > scrollOffset) break;
62
+ const margin = Number.parseFloat(
63
+ getComputedStyle(heading).scrollMarginTop
64
+ );
65
+ const line =
66
+ Math.max(scrollOffset, Number.isNaN(margin) ? 0 : margin) + 1;
67
+ if (heading.getBoundingClientRect().top > line) break;
55
68
  current = heading;
56
69
  }
57
70
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Switch between light and dark, revealing the new theme as a circle that grows
3
+ * out of the button that was pressed.
4
+ *
5
+ * THE VIEW TRANSITIONS API, not a CSS transition on every colour. The browser
6
+ * snapshots the page as it is, the switch happens underneath, and the new page
7
+ * is clipped open on top of the old snapshot — one animation for the whole
8
+ * document, whatever each component paints. Where the API does not exist, or
9
+ * the reader asked for less motion, the theme simply switches.
10
+ *
11
+ * `@nuxtjs/color-mode` applies the class from a watcher, so the callback waits
12
+ * a tick: a snapshot taken before the class moved would reveal the old theme.
13
+ *
14
+ * Both the origin and the radius are PERCENTAGES. The clip resolves against the
15
+ * transition's snapshot box, which on a phone also spans the area behind the
16
+ * hideable address bar and is therefore taller than `innerHeight` — pixels
17
+ * computed from the viewport opened the circle away from the button and
18
+ * stopped it short of the far corner. 150% covers the box from any point in
19
+ * it: a corner needs 100%·√2 ≈ 141%.
20
+ *
21
+ * A transition the browser abandons — a second click, the tab going to the
22
+ * background, a snapshot that takes too long on a long page — rejects its
23
+ * promises. That is a normal outcome, and the theme has switched either way.
24
+ */
25
+ export function useDuxtThemeToggle() {
26
+ const colorMode = useColorMode();
27
+
28
+ function apply() {
29
+ colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
30
+ }
31
+
32
+ async function toggle(event?: MouseEvent) {
33
+ if (
34
+ !document.startViewTransition ||
35
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
36
+ ) {
37
+ apply();
38
+ return;
39
+ }
40
+
41
+ // A keyboard press reports no pointer position; open from the centre then.
42
+ const x = event?.clientX ? (event.clientX / window.innerWidth) * 100 : 50;
43
+ const y = event?.clientY ? (event.clientY / window.innerHeight) * 100 : 50;
44
+
45
+ try {
46
+ const transition = document.startViewTransition(async () => {
47
+ apply();
48
+ await nextTick();
49
+ });
50
+
51
+ await transition.ready;
52
+
53
+ document.documentElement.animate(
54
+ {
55
+ clipPath: [`circle(0% at ${x}% ${y}%)`, `circle(150% at ${x}% ${y}%)`]
56
+ },
57
+ {
58
+ duration: 450,
59
+ easing: 'ease-out',
60
+ pseudoElement: '::view-transition-new(root)'
61
+ }
62
+ );
63
+ } catch {
64
+ // Abandoned transition — see above.
65
+ }
66
+ }
67
+
68
+ return { toggle };
69
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "@kirchdev/duxt",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "description": "duxt is a Nuxt layer: extend it and your docs/ folder becomes a site, with theme, search, API reference and llms.txt included. Point it at other repositories, or at tags of the same one, and each becomes a version.",
6
6
  "keywords": [
7
7
  "content-v3",