Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
<img src=".github/assets/wordmark-light.svg" alt="duxt" width="200" />
</picture>

**Versioned, multi-repo documentation for Nuxt — one line to extend, no collection boilerplate**
**Versioned docs, from the repositories you already have**

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.

[![npm Version](https://img.shields.io/npm/v/@kirchdev/duxt.svg?style=flat-square&color=4f46e5)](https://www.npmjs.com/package/@kirchdev/duxt)
[![Downloads](https://img.shields.io/npm/dm/@kirchdev/duxt.svg?style=flat-square&color=4f46e5)](https://www.npmjs.com/package/@kirchdev/duxt)
Expand Down
18 changes: 18 additions & 0 deletions app/assets/css/duxt.css
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,21 @@ pre.shiki,
main:has(.duxt-flush-footer) + footer {
margin-top: 0;
}

/* The theme toggle reveals the new theme as a growing circle — see
`useDuxtThemeToggle`. The browser's default cross-fade is switched off so the
clip is the only animation, and the new snapshot is lifted above the old one
it opens over. */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}

::view-transition-old(root) {
z-index: 1;
}

::view-transition-new(root) {
z-index: 9999;
}
4 changes: 1 addition & 3 deletions app/components/DuxtHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ if (import.meta.prerender) {
// The mobile sheet shows the same branch the sidebar does.
const { items } = useDuxtSection(navigation);

function toggleTheme() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
}
const { toggle: toggleTheme } = useDuxtThemeToggle();

/**
* An entry with neither `to` nor children means "the documentation".
Expand Down
95 changes: 56 additions & 39 deletions app/components/content/ChangelogReleases.vue
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ const rows = computed(() =>
dropped re-laid the row it shared, so the button a reader was aiming at
slid out from under the pointer between two clicks. On a row of its own
the header is fixed and only the row that is actually changing moves. -->
<div class="mt-12 mb-8 space-y-3">
<div class="mt-12 mb-8">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('duxt.changelog.history') }}
Expand Down Expand Up @@ -334,45 +334,62 @@ const rows = computed(() =>
The row is ALWAYS RENDERED and hidden while empty (`empty:hidden`),
rather than held behind a `v-if` that would take the leaving chips
with it before they could leave. During a leave it is no longer
empty, so it stays laid out until the last chip is gone. -->
<TransitionGroup
tag="div"
role="group"
:aria-label="$t('duxt.changelog.selected')"
class="flex flex-wrap items-center gap-2 empty:hidden"
enter-active-class="transition duration-200 ease-out motion-reduce:transition-none"
enter-from-class="scale-95 opacity-0"
leave-active-class="transition duration-150 ease-in motion-reduce:transition-none"
leave-to-class="scale-95 opacity-0"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
empty, so it stays laid out until the last chip is gone.

ITS HEIGHT ANIMATES TOO. Appearing and vanishing in one step, the row
pushed the figures and the whole list down by its own height on the
first choice and pulled them back up on the last — the one jump left
in a gesture that otherwise moves. `grid-template-rows` from `0fr` to
`1fr` is the one height transition CSS can run without knowing the
height, and the spacing lives INSIDE the clipped track so it
collapses with it. The padding sits on the INNER box because a
grid item cannot shrink below its own padding, and it keeps a chip's
focus ring clear of the clip. -->
<div
class="grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
:class="selected.length ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<button
v-for="name in selected"
:key="name"
type="button"
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"
:class="changelogTone(name).chip"
@click="toggle(name)"
>
<span
aria-hidden="true"
class="size-1.5 rounded-full"
:class="changelogTone(name).dot"
/>
{{ changelogLabel(name) }}
<Icon name="lucide:x" class="size-3 opacity-60" />
</button>

<button
v-if="selected.length"
key="reset"
type="button"
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"
@click="selected = []"
>
{{ $t('duxt.changelog.reset') }}
</button>
</TransitionGroup>
<div class="-mx-1 min-h-0 overflow-hidden">
<TransitionGroup
tag="div"
role="group"
:aria-label="$t('duxt.changelog.selected')"
class="flex flex-wrap items-center gap-2 px-1 pt-3 pb-1"
enter-active-class="transition duration-200 ease-out motion-reduce:transition-none"
enter-from-class="scale-95 opacity-0"
leave-active-class="transition duration-150 ease-in motion-reduce:transition-none"
leave-to-class="scale-95 opacity-0"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
>
<button
v-for="name in selected"
:key="name"
type="button"
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"
:class="changelogTone(name).chip"
@click="toggle(name)"
>
<span
aria-hidden="true"
class="size-1.5 rounded-full"
:class="changelogTone(name).dot"
/>
{{ changelogLabel(name) }}
<Icon name="lucide:x" class="size-3 opacity-60" />
</button>

<button
v-if="selected.length"
key="reset"
type="button"
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"
@click="selected = []"
>
{{ $t('duxt.changelog.reset') }}
</button>
</TransitionGroup>
</div>
</div>
</div>

<!-- WHAT THE LIST CANNOT SAY: how much there is. Two figures set the way
Expand Down
11 changes: 9 additions & 2 deletions app/components/content/ProseImg.vue
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,18 @@ if (import.meta.server && import.meta.prerender) {

<UiDialog v-if="zoomable" v-model:open="open">
<!-- The whole viewport, transparent over the overlay: the blur is what
separates the picture from the page, not a border. -->
separates the picture from the page, not a border.

THE DARKENING AND THE BLUR BELONG TO THE OVERLAY, not to this box.
The content scales in from 95%, so a surface painted here reached
the viewport's edges only once that animation ended — and for 200ms
the page's text showed unblurred along the bottom. The overlay only
fades, so it covers the whole screen from the first frame. -->
<UiDialogContent
:show-close-button="false"
:aria-describedby="undefined"
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"
overlay-class="bg-neutral-950/90 backdrop-blur-md"
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"
@click.self="open = false"
@keydown="onKeydown"
>
Expand Down
11 changes: 9 additions & 2 deletions app/components/ui/dialog/DialogContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const props = withDefaults(
defineProps<
DialogContentProps & {
class?: HTMLAttributes['class'];
/** Classes for the backdrop, which does not scale in with the content. */
overlayClass?: HTMLAttributes['class'];
showCloseButton?: boolean;
}
>(),
Expand All @@ -28,14 +30,19 @@ const props = withDefaults(
);
const emits = defineEmits<DialogContentEmits>();

const delegatedProps = reactiveOmit(props, 'class', 'showCloseButton');
const delegatedProps = reactiveOmit(
props,
'class',
'overlayClass',
'showCloseButton'
);

const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>

<template>
<DialogPortal>
<DialogOverlay />
<DialogOverlay :class="overlayClass" />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
Expand Down
15 changes: 14 additions & 1 deletion app/composables/useActiveHeading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,22 @@ export function useActiveHeading(ids: Ref<string[]>, scrollOffset = 96) {
return;
}

// THE LINE IS NEVER ABOVE WHERE A JUMP PARKS THE HEADING. A click in the
// outline scrolls the heading to its own `scroll-margin-top` — 112px, to
// clear the header AND the section row — and a fixed 96px line sat above
// that, so the heading just jumped to was not yet "passed" and the entry
// before it stayed marked. Reading the margin off the heading follows
// whatever the sticky stack is on this page and at this width; the
// configured offset remains the floor. The pixel absorbs sub-pixel scroll
// positions.
let current = headings[0]!;
for (const heading of headings) {
if (heading.getBoundingClientRect().top > scrollOffset) break;
const margin = Number.parseFloat(
getComputedStyle(heading).scrollMarginTop
);
const line =
Math.max(scrollOffset, Number.isNaN(margin) ? 0 : margin) + 1;
if (heading.getBoundingClientRect().top > line) break;
current = heading;
}

Expand Down
69 changes: 69 additions & 0 deletions app/composables/useDuxtThemeToggle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Switch between light and dark, revealing the new theme as a circle that grows
* out of the button that was pressed.
*
* THE VIEW TRANSITIONS API, not a CSS transition on every colour. The browser
* snapshots the page as it is, the switch happens underneath, and the new page
* is clipped open on top of the old snapshot — one animation for the whole
* document, whatever each component paints. Where the API does not exist, or
* the reader asked for less motion, the theme simply switches.
*
* `@nuxtjs/color-mode` applies the class from a watcher, so the callback waits
* a tick: a snapshot taken before the class moved would reveal the old theme.
*
* Both the origin and the radius are PERCENTAGES. The clip resolves against the
* transition's snapshot box, which on a phone also spans the area behind the
* hideable address bar and is therefore taller than `innerHeight` — pixels
* computed from the viewport opened the circle away from the button and
* stopped it short of the far corner. 150% covers the box from any point in
* it: a corner needs 100%·√2 ≈ 141%.
*
* A transition the browser abandons — a second click, the tab going to the
* background, a snapshot that takes too long on a long page — rejects its
* promises. That is a normal outcome, and the theme has switched either way.
*/
export function useDuxtThemeToggle() {
const colorMode = useColorMode();

function apply() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
}

async function toggle(event?: MouseEvent) {
if (
!document.startViewTransition ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
apply();
return;
}

// A keyboard press reports no pointer position; open from the centre then.
const x = event?.clientX ? (event.clientX / window.innerWidth) * 100 : 50;
const y = event?.clientY ? (event.clientY / window.innerHeight) * 100 : 50;

try {
const transition = document.startViewTransition(async () => {
apply();
await nextTick();
});

await transition.ready;

document.documentElement.animate(
{
clipPath: [`circle(0% at ${x}% ${y}%)`, `circle(150% at ${x}% ${y}%)`]
},
{
duration: 450,
easing: 'ease-out',
pseudoElement: '::view-transition-new(root)'
}
);
} catch {
// Abandoned transition — see above.
}
}

return { toggle };
}
11 changes: 7 additions & 4 deletions docs/4.reference/6.composables/3.reading/3.use-active-heading.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ It watches the headings rather than the scroll offset, so a short last section i
still reported as reached — the common failure of a scroll-position table of
contents.

The observer uses `duxt.toc.scrollOffset` as the line below the viewport top at
which a heading becomes active. Change that value when a consumer adds fixed UI
above the document; it is independent of `toc.depth`, which decides which
headings enter the table of contents at all.
A heading becomes active once its top has passed a line below the viewport top.
That line is the heading's own `scroll-margin-top` — exactly where a click in
the table of contents parks it, whatever sticky header sits above the page at
that width — and never higher than `duxt.toc.scrollOffset`. Raise the offset
when a consumer adds fixed UI above the document that the headings' scroll
margin does not account for; it is independent of `toc.depth`, which decides
which headings enter the table of contents at all.