feat: migrate Dialog to shadcn-vue compound API

Also: fixes DialogContent/DialogFooter's close/action buttons to use our
actual Button variant set (secondary) instead of the CLI's default
ghost/outline variants, which don't exist on our re-themed Button.
Adds an eslint override so shadcn-vue's single-word ui/ component names
(Button.vue, Dialog.vue, ...) aren't flagged by vue/multi-word-component-names.
This was the last PrimeVue-wrapped component, so lib/index.ts no longer
imports anything from primevue or uses the ptThemeable helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew McPeak 2026-07-11 14:57:06 -04:00
parent 4755f9ab6e
commit 7bb554aeb0
18 changed files with 451 additions and 135 deletions

View file

@ -22,6 +22,16 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'], ...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended, vueTsConfigs.recommended,
{
// shadcn-vue's generated ui/ components use single-word names by convention
// (Button.vue, Input.vue, ...) so their file names match the CLI's own output.
name: 'app/shadcn-vue-component-names',
files: ['**/components/ui/**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
},
{ {
...pluginVitest.configs.recommended, ...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'], files: ['src/**/__tests__/*'],

View file

@ -21,3 +21,15 @@ export {
SelectLabel, SelectLabel,
SelectSeparator, SelectSeparator,
} from './ui/select' } from './ui/select'
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} from './ui/dialog'

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue';
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
as-child
>
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent hover:bg-black/10" size="icon">
<XIcon class="size-3.5" />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
>
<slot />
</DialogDescription>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { DialogClose } from "reka-ui"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
showCloseButton?: boolean
}>(), {
showCloseButton: false,
})
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="secondary">
Close
</Button>
</DialogClose>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('gap-2 flex flex-col', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
>
<slot />
</DialogOverlay>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue';
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<XIcon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
>
<slot />
</DialogTitle>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View file

@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View file

@ -33,6 +33,15 @@ export {
CardFooter, CardFooter,
CardAction, CardAction,
Dialog, Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert, Alert,
AlertTitle, AlertTitle,
AlertDescription, AlertDescription,

View file

@ -1,8 +1,5 @@
import type { App } from 'vue' import type { App } from 'vue'
import { import { ThemeLibrary, type Themeable, ThemeEngine } from '../engine'
Dialog as PrimeDialog,
} from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { import {
NychLoadingIcon, NychLoadingIcon,
Button, Button,
@ -36,6 +33,16 @@ import {
SelectGroup, SelectGroup,
SelectLabel, SelectLabel,
SelectSeparator, SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} from '../components' } from '../components'
export { export {
@ -70,42 +77,18 @@ export {
SelectGroup, SelectGroup,
SelectLabel, SelectLabel,
SelectSeparator, SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} }
/**
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
*
* The engine injects the base class (`nych-<base>`) onto `pt.root`. This factory layers a
* `nych-<base>-<section>` class onto every additional section so multi-part components
* (Checkbox box, Select overlay, Card body, ) can be fully styled in unstyled mode, where
* PrimeVue emits no classes of its own.
*/
const ptThemeable = <T extends LibComponent>(
component: T,
base: string,
sections: string[] = [],
): Themeable<T> => ({
component,
unstyled: true as Themeable<T>['unstyled'],
classes: [`nych-${base}`],
injectionKeys: { class: 'pt' as Key<T> },
propMutator: ((props: Record<string, unknown>) => {
const pt = (props.pt as Record<string, { class?: string }>) ?? {}
for (const section of sections) {
const existing = pt[section]?.class
pt[section] = {
...(pt[section] ?? {}),
class: [existing, `nych-${base}-${section}`].filter(Boolean).join(' '),
}
}
props.pt = pt
return props
}) as Themeable<T>['propMutator'],
})
const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = { const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
component: NychLoadingIcon, component: NychLoadingIcon,
unstyled: false, unstyled: false,
@ -114,21 +97,12 @@ const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
const nychthemeron: ThemeLibrary = { const nychthemeron: ThemeLibrary = {
components: { components: {
LoadingIcon: loadingIconThemeable, LoadingIcon: loadingIconThemeable,
Dialog: ptThemeable(PrimeDialog, 'dialog', [
'mask',
'header',
'title',
'headerActions',
'content',
'footer',
]),
}, },
} }
const _engine = new ThemeEngine(nychthemeron) const _engine = new ThemeEngine(nychthemeron)
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon') export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
export const createNychthemeron = (): { export const createNychthemeron = (): {
Button: typeof Button Button: typeof Button
@ -155,7 +129,16 @@ export const createNychthemeron = (): {
CardContent: typeof CardContent CardContent: typeof CardContent
CardFooter: typeof CardFooter CardFooter: typeof CardFooter
CardAction: typeof CardAction CardAction: typeof CardAction
Dialog: typeof PrimeDialog Dialog: typeof Dialog
DialogTrigger: typeof DialogTrigger
DialogContent: typeof DialogContent
DialogHeader: typeof DialogHeader
DialogTitle: typeof DialogTitle
DialogDescription: typeof DialogDescription
DialogFooter: typeof DialogFooter
DialogClose: typeof DialogClose
DialogOverlay: typeof DialogOverlay
DialogScrollContent: typeof DialogScrollContent
Alert: typeof Alert Alert: typeof Alert
AlertTitle: typeof AlertTitle AlertTitle: typeof AlertTitle
AlertDescription: typeof AlertDescription AlertDescription: typeof AlertDescription
@ -188,6 +171,15 @@ export const createNychthemeron = (): {
CardFooter, CardFooter,
CardAction, CardAction,
Dialog, Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert, Alert,
AlertTitle, AlertTitle,
AlertDescription, AlertDescription,
@ -219,6 +211,15 @@ export const createNychthemeron = (): {
app.component('NychCardFooter', CardFooter) app.component('NychCardFooter', CardFooter)
app.component('NychCardAction', CardAction) app.component('NychCardAction', CardAction)
app.component('NychDialog', Dialog) app.component('NychDialog', Dialog)
app.component('NychDialogTrigger', DialogTrigger)
app.component('NychDialogContent', DialogContent)
app.component('NychDialogHeader', DialogHeader)
app.component('NychDialogTitle', DialogTitle)
app.component('NychDialogDescription', DialogDescription)
app.component('NychDialogFooter', DialogFooter)
app.component('NychDialogClose', DialogClose)
app.component('NychDialogOverlay', DialogOverlay)
app.component('NychDialogScrollContent', DialogScrollContent)
app.component('NychAlert', Alert) app.component('NychAlert', Alert)
app.component('NychAlertTitle', AlertTitle) app.component('NychAlertTitle', AlertTitle)
app.component('NychAlertDescription', AlertDescription) app.component('NychAlertDescription', AlertDescription)

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { h, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { Dialog, DialogContent, DialogTitle } from '../src/lib'
describe('Dialog', () => {
it('does not render content when closed', async () => {
mount(Dialog, {
props: { open: false },
slots: {
default: () => h(DialogContent, undefined, {
default: () => h(DialogTitle, undefined, { default: () => 'Oracle of Delphi' }),
}),
},
attachTo: document.body,
})
await nextTick()
expect(document.body.textContent).not.toContain('Oracle of Delphi')
})
it('renders content in a teleported portal when open', async () => {
mount(Dialog, {
props: { open: true },
slots: {
default: () => h(DialogContent, undefined, {
default: () => h(DialogTitle, undefined, { default: () => 'Oracle of Delphi' }),
}),
},
attachTo: document.body,
})
await nextTick()
await nextTick()
expect(document.body.textContent).toContain('Oracle of Delphi')
})
})

View file

@ -1,35 +1,6 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import type { App } from 'vue' import type { App } from 'vue'
import { import { createNychthemeron, LoadingIcon } from '../src/index'
createNychthemeron,
LoadingIcon,
Dialog,
} from '../src/index'
//eslint-disable-next-line
const callSetup = (component: any, props: Record<string, unknown>) => {
const mergedProps: Record<string, unknown> = {}
if (component.props) {
for (const [key, propDef] of Object.entries(component.props)) {
if (typeof propDef === 'object' && propDef !== null && 'default' in propDef) {
mergedProps[key] = (propDef as { default: unknown }).default
}
}
}
Object.assign(mergedProps, props)
if (component.setup) {
const result = component.setup(mergedProps, { slots: {}, attrs: {} })
if (typeof result === 'function') {
const vnode = result()
return (vnode?.props as Record<string, unknown>) || {}
}
return result
}
return mergedProps
}
describe('createNychthemeron', () => { describe('createNychthemeron', () => {
it('returns a Vue plugin with install method', () => { it('returns a Vue plugin with install method', () => {
@ -57,26 +28,3 @@ describe('LoadingIcon', () => {
expect(LoadingIcon).toBeDefined() expect(LoadingIcon).toBeDefined()
}) })
}) })
describe('themed pt components', () => {
const cases = [
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
]
it.each(cases)('$name is a valid Vue component', ({ component }) => {
expect(component).toBeDefined()
expect(typeof (component as Record<string, unknown>).setup).toBe('function')
})
it.each(cases)('$name injects its base class onto pt.root', ({ component, base }) => {
const result = callSetup(component, { pt: {} })
expect(result.pt?.root?.class).toContain(base)
})
it.each(cases)('$name does not leak engine internal props', ({ component }) => {
const result = callSetup(component, { pt: {} })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
})

View file

@ -1,7 +1,15 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue' import { ref } from 'vue'
import { Dialog as NychDialog, Button as NychButton } from '@nychthemeron/library' import {
Dialog as NychDialog,
DialogTrigger as NychDialogTrigger,
DialogContent as NychDialogContent,
DialogHeader as NychDialogHeader,
DialogTitle as NychDialogTitle,
DialogFooter as NychDialogFooter,
Button as NychButton,
} from '@nychthemeron/library'
const meta = { const meta = {
title: 'Components/Dialog', title: 'Components/Dialog',
@ -17,47 +25,52 @@ type Story = StoryObj<typeof meta>
export const Default: Story = { export const Default: Story = {
render: () => ({ render: () => ({
components: { NychDialog, NychButton }, components: { NychDialog, NychDialogTrigger, NychDialogContent, NychDialogHeader, NychDialogTitle, NychDialogFooter, NychButton },
setup() {
const visible = ref(false)
return { visible }
},
template: ` template: `
<div> <NychDialog>
<NychButton label="Summon the Oracle" @click="visible = true" /> <NychDialogTrigger as-child>
<NychDialog v-model:visible="visible" modal header="Oracle of Delphi" style="width: 28rem;"> <NychButton>Summon the Oracle</NychButton>
<p style="margin: 0;"> </NychDialogTrigger>
<NychDialogContent style="width: 28rem;">
<NychDialogHeader>
<NychDialogTitle>Oracle of Delphi</NychDialogTitle>
</NychDialogHeader>
<p>
Seekers travelled from across the Aegean to hear the Pythia speak the Seekers travelled from across the Aegean to hear the Pythia speak the
will of Apollo in riddling verse. Approach, and ask your question. will of Apollo in riddling verse. Approach, and ask your question.
</p> </p>
<template #footer> <NychDialogFooter>
<NychButton label="Withdraw" severity="secondary" @click="visible = false" /> <NychButton variant="secondary">Withdraw</NychButton>
<NychButton label="Ask" @click="visible = false" /> <NychButton>Ask</NychButton>
</template> </NychDialogFooter>
</NychDialog> </NychDialogContent>
</div> </NychDialog>
`, `,
}), }),
} }
// Rendered open by default so the dialog surface is visible in docs/snapshots.
export const Open: Story = { export const Open: Story = {
render: () => ({ render: () => ({
components: { NychDialog, NychButton }, components: { NychDialog, NychDialogContent, NychDialogHeader, NychDialogTitle, NychDialogFooter, NychButton },
setup() { setup() {
const visible = ref(true) const open = ref(true)
return { visible } return { open }
}, },
template: ` template: `
<NychDialog v-model:visible="visible" modal header="Passage of the Styx" style="width: 28rem;"> <NychDialog v-model:open="open">
<p style="margin: 0;"> <NychDialogContent style="width: 28rem;">
Charon ferries the souls of the dead across the river that divides the <NychDialogHeader>
world of the living from the world of the dead. One obol is required. <NychDialogTitle>Passage of the Styx</NychDialogTitle>
</p> </NychDialogHeader>
<template #footer> <p>
<NychButton label="Turn back" severity="secondary" @click="visible = false" /> Charon ferries the souls of the dead across the river that divides the
<NychButton label="Pay the toll" @click="visible = false" /> world of the living from the world of the dead. One obol is required.
</template> </p>
<NychDialogFooter>
<NychButton variant="secondary" @click="open = false">Turn back</NychButton>
<NychButton @click="open = false">Pay the toll</NychButton>
</NychDialogFooter>
</NychDialogContent>
</NychDialog> </NychDialog>
`, `,
}), }),
@ -65,14 +78,19 @@ export const Open: Story = {
export const WithoutFooter: Story = { export const WithoutFooter: Story = {
render: () => ({ render: () => ({
components: { NychDialog }, components: { NychDialog, NychDialogContent, NychDialogHeader, NychDialogTitle },
setup() { setup() {
const visible = ref(true) const open = ref(true)
return { visible } return { open }
}, },
template: ` template: `
<NychDialog v-model:visible="visible" modal header="An Omen" style="width: 26rem;"> <NychDialog v-model:open="open">
<p style="margin: 0;">A crow has settled upon the western gate. Interpret it as you will.</p> <NychDialogContent style="width: 26rem;">
<NychDialogHeader>
<NychDialogTitle>An Omen</NychDialogTitle>
</NychDialogHeader>
<p>A crow has settled upon the western gate. Interpret it as you will.</p>
</NychDialogContent>
</NychDialog> </NychDialog>
`, `,
}), }),