feat: add guided onboarding tour

- install driver.js and add 9-step tour covering libraries, lenses,
  collections, search, statistics, appearance, upload, and book bucket
- first step context-aware: points to welcome card on first run, falls
  back to settings nav when libraries already exist
- tour state persisted in user.settings JSONB, survives across devices
- skip on mobile; any dismissal (skip or done) marks tour complete
- theme the tour popover with app design tokens (card, primary, radius)
- add "Take the tour again" button in Settings > Account (desktop only)
This commit is contained in:
neon
2026-04-14 02:54:08 -06:00
parent ad46061db7
commit 0bf0a5b9fa
10 changed files with 370 additions and 18 deletions
+5 -4
View File
@@ -32,12 +32,13 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"driver.js": "^1.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"howler": "^2.2.4", "howler": "^2.2.4",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"reka-ui": "^2.9.5", "reka-ui": "^2.9.6",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
@@ -53,7 +54,7 @@
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.4", "@vitest/coverage-v8": "^4.1.4",
"@vitest/eslint-plugin": "^1.6.15", "@vitest/eslint-plugin": "^1.6.15",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
@@ -61,12 +62,12 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.2.0", "eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.59.0", "eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"oxlint": "~1.59.0", "oxlint": "~1.60.0",
"prettier": "3.8.2", "prettier": "3.8.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
+122
View File
@@ -382,3 +382,125 @@
display: none; display: none;
} }
} }
/* ── Driver.js tour ─────────────────────────────────────────── */
.driver-popover {
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: var(--elevation-xl) !important;
padding: 1rem 1.125rem !important;
min-width: 260px !important;
max-width: 310px !important;
font-family: inherit !important;
}
.driver-popover-title {
font-size: 0.875rem !important;
font-weight: 600 !important;
color: var(--card-foreground) !important;
margin-bottom: 0.25rem !important;
line-height: 1.4 !important;
letter-spacing: -0.01em !important;
}
.driver-popover-description {
font-size: 0.8125rem !important;
line-height: 1.55 !important;
color: var(--muted-foreground) !important;
}
.driver-popover-footer {
margin-top: 0.875rem !important;
gap: 0.5rem !important;
padding-top: 0.625rem !important;
border-top: 1px solid var(--border) !important;
}
.driver-popover-progress-text {
font-size: 0.6875rem !important;
color: var(--muted-foreground) !important;
opacity: 0.65 !important;
font-variant-numeric: tabular-nums !important;
}
.driver-popover-prev-btn,
.driver-popover-prev-btn:focus {
all: unset !important;
display: inline-flex !important;
align-items: center !important;
padding: 0.3125rem 0.625rem !important;
border-radius: calc(var(--radius) - 0.125rem) !important;
border: 1px solid var(--border) !important;
background: transparent !important;
color: var(--foreground) !important;
font-size: 0.8125rem !important;
font-weight: 500 !important;
font-family: inherit !important;
cursor: pointer !important;
transition: background-color 0.15s ease !important;
text-shadow: none !important;
}
.driver-popover-prev-btn:hover {
background: var(--muted) !important;
}
.driver-popover-next-btn,
.driver-popover-next-btn:focus {
all: unset !important;
display: inline-flex !important;
align-items: center !important;
padding: 0.3125rem 0.75rem !important;
border-radius: calc(var(--radius) - 0.125rem) !important;
background: var(--primary) !important;
color: var(--primary-foreground) !important;
border: none !important;
font-size: 0.8125rem !important;
font-weight: 500 !important;
font-family: inherit !important;
cursor: pointer !important;
transition: opacity 0.15s ease !important;
text-shadow: none !important;
}
.driver-popover-next-btn:hover {
opacity: 0.88 !important;
}
.driver-popover-close-btn,
.driver-popover-close-btn:focus {
all: unset !important;
position: absolute !important;
top: 0.625rem !important;
right: 0.75rem !important;
color: var(--muted-foreground) !important;
font-size: 1rem !important;
line-height: 1 !important;
cursor: pointer !important;
transition: color 0.15s ease !important;
opacity: 0.7 !important;
}
.driver-popover-close-btn:hover {
color: var(--foreground) !important;
opacity: 1 !important;
}
/* Arrow: match popover card background on all sides */
.driver-popover-arrow-side-left.driver-popover-arrow {
border-right-color: var(--card) !important;
}
.driver-popover-arrow-side-right.driver-popover-arrow {
border-left-color: var(--card) !important;
}
.driver-popover-arrow-side-top.driver-popover-arrow {
border-bottom-color: var(--card) !important;
}
.driver-popover-arrow-side-bottom.driver-popover-arrow {
border-top-color: var(--card) !important;
}
+6
View File
@@ -312,6 +312,7 @@ function formatBadgeClass(fmt: string): string {
<!-- Center: desktop global search --> <!-- Center: desktop global search -->
<div <div
data-tour="global-search"
class="hidden md:flex flex-1 mx-4 relative items-center transition-all duration-300" class="hidden md:flex flex-1 mx-4 relative items-center transition-all duration-300"
:class="searchFocused || globalSearchQuery ? 'max-w-xl' : 'max-w-sm'" :class="searchFocused || globalSearchQuery ? 'max-w-xl' : 'max-w-sm'"
> >
@@ -504,6 +505,7 @@ function formatBadgeClass(fmt: string): string {
<Tooltip v-if="hasPermission('book_bucket_access')"> <Tooltip v-if="hasPermission('book_bucket_access')">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
data-tour="book-bucket-btn"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="relative h-8 w-8 border transition-colors" class="relative h-8 w-8 border transition-colors"
@@ -531,6 +533,7 @@ function formatBadgeClass(fmt: string): string {
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
data-tour="statistics-btn"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 border transition-colors" class="h-8 w-8 border transition-colors"
@@ -552,6 +555,7 @@ function formatBadgeClass(fmt: string): string {
<Tooltip v-if="hasPermission('library_upload')"> <Tooltip v-if="hasPermission('library_upload')">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
data-tour="upload-button"
variant="ghost" variant="ghost"
size="icon" size="icon"
:class="[ :class="[
@@ -575,6 +579,7 @@ function formatBadgeClass(fmt: string): string {
<TooltipTrigger as-child> <TooltipTrigger as-child>
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button <Button
data-tour="appearance-picker"
variant="ghost" variant="ghost"
size="icon" size="icon"
:class="['h-8 w-8 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors', iconRadiusClass]" :class="['h-8 w-8 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors', iconRadiusClass]"
@@ -611,6 +616,7 @@ function formatBadgeClass(fmt: string): string {
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
data-tour="settings-nav"
variant="ghost" variant="ghost"
size="icon" size="icon"
:class="[ :class="[
+3
View File
@@ -219,6 +219,7 @@ onUnmounted(() => stopLibraryUploadListener())
<!-- Libraries --> <!-- Libraries -->
<SidebarGroup> <SidebarGroup>
<SidebarSectionHeader <SidebarSectionHeader
data-tour="sidebar-libraries"
label="Libraries" label="Libraries"
:is-open="librariesOpen" :is-open="librariesOpen"
:collapsed-count="libraries.length" :collapsed-count="libraries.length"
@@ -300,6 +301,7 @@ onUnmounted(() => stopLibraryUploadListener())
<!-- Lenses --> <!-- Lenses -->
<SidebarGroup> <SidebarGroup>
<SidebarSectionHeader <SidebarSectionHeader
data-tour="sidebar-lenses"
label="Lenses" label="Lenses"
:is-open="lensesOpen" :is-open="lensesOpen"
:collapsed-count="lenses.length" :collapsed-count="lenses.length"
@@ -359,6 +361,7 @@ onUnmounted(() => stopLibraryUploadListener())
<!-- Collections --> <!-- Collections -->
<SidebarGroup> <SidebarGroup>
<SidebarSectionHeader <SidebarSectionHeader
data-tour="sidebar-collections"
label="Collections" label="Collections"
:is-open="collectionsOpen" :is-open="collectionsOpen"
:collapsed-count="collections.length" :collapsed-count="collections.length"
@@ -26,7 +26,10 @@ async function handleSaved(library: Library) {
<template> <template>
<div class="flex items-center justify-center py-16 px-4"> <div class="flex items-center justify-center py-16 px-4">
<div class="relative w-full max-w-md overflow-hidden rounded-2xl border border-primary/40 bg-card/30 shadow-sm backdrop-blur-[1px]"> <div
data-tour="welcome-card"
class="relative w-full max-w-md overflow-hidden rounded-2xl border border-primary/40 bg-card/30 shadow-sm backdrop-blur-[1px]"
>
<!-- Glow backdrop --> <!-- Glow backdrop -->
<div <div
class="pointer-events-none absolute inset-0" class="pointer-events-none absolute inset-0"
@@ -0,0 +1,180 @@
import { driver } from 'driver.js'
import type { DriveStep } from 'driver.js'
import 'driver.js/dist/driver.css'
import { useAuth } from '@/features/auth/composables/useAuth'
import { api } from '@/lib/api'
export function useOnboardingTour() {
const { user, me } = useAuth()
function isMobileViewport(): boolean {
return typeof window !== 'undefined' && window.innerWidth < 768
}
function isTourCompleted(): boolean {
return user.value?.settings?.onboarding?.tourCompleted === true
}
function buildSteps(): DriveStep[] {
const hasWelcomeCard = document.querySelector('[data-tour="welcome-card"]') !== null
const firstStep: DriveStep = hasWelcomeCard
? {
element: '[data-tour="welcome-card"]',
popover: {
title: 'Your library is empty',
description: 'Create a library to start organizing and reading your books. Point it to a folder and it will do the rest.',
side: 'top',
align: 'center',
},
}
: {
element: '[data-tour="settings-nav"]',
popover: {
title: 'Create your first library',
description: 'Head to Settings to create your first library and start scanning your books.',
side: 'right',
align: 'start',
},
}
const candidates: DriveStep[] = [
firstStep,
{
element: '[data-tour="sidebar-libraries"]',
popover: {
title: 'Your libraries live here',
description: 'Once created, your libraries appear here. Click any library to browse its books.',
side: 'right',
align: 'start',
},
},
{
element: '[data-tour="sidebar-lenses"]',
popover: {
title: 'Lenses - smart filters',
description:
'Lenses are saved filter rules that always stay up to date. Define criteria once - like "unread sci-fi" - and browse that slice of your library instantly.',
side: 'right',
align: 'start',
},
},
{
element: '[data-tour="sidebar-collections"]',
popover: {
title: 'Collections - curated lists',
description:
'Collections are manual lists you build yourself - great for reading orders, recommendations, or syncing a specific set of books to your Kobo.',
side: 'right',
align: 'start',
},
},
{
element: '[data-tour="global-search"]',
popover: {
title: 'Search your collection',
description: 'Use the search bar to instantly find any book, author, or series across your library.',
side: 'bottom',
align: 'start',
},
},
{
element: '[data-tour="statistics-btn"]',
popover: {
title: 'Reading statistics',
description:
'Explore 33+ charts covering your reading pace, genre breakdown, session patterns, top authors, and more. Your full reading history at a glance.',
side: 'bottom',
align: 'end',
},
},
{
element: '[data-tour="appearance-picker"]',
popover: {
title: 'Make it yours',
description: 'Customize your theme, accent color, and background from the appearance menu.',
side: 'bottom',
align: 'end',
},
},
{
element: '[data-tour="upload-button"]',
popover: {
title: 'Upload books',
description: 'Upload books directly from your browser. They land in the Book Bucket for metadata review before being added to a library.',
side: 'bottom',
align: 'end',
},
},
{
element: '[data-tour="book-bucket-btn"]',
popover: {
title: 'Book Bucket',
description: 'Uploaded files wait here. Review metadata, set the target library, then finalize to add them to your collection.',
side: 'bottom',
align: 'end',
},
},
]
return candidates.filter((step) => document.querySelector(step.element as string) !== null)
}
function markCompletedLocally(): void {
if (!user.value) return
user.value = {
...user.value,
settings: {
...user.value.settings,
onboarding: { tourCompleted: true },
},
}
}
function persistCompletion(): void {
api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { onboarding: { tourCompleted: true } } }),
}).catch(() => {})
}
function startTour(): void {
const steps = buildSteps()
if (steps.length === 0) return
const driverObj = driver({
showProgress: true,
progressText: '{{current}} of {{total}}',
nextBtnText: 'Next',
prevBtnText: 'Back',
doneBtnText: 'Done',
disableActiveInteraction: true,
onDestroyed: () => {
markCompletedLocally()
persistCompletion()
},
steps,
})
driverObj.drive()
}
function maybeStartTour(): void {
if (isMobileViewport()) return
if (isTourCompleted()) return
startTour()
}
async function resetTour(): Promise<void> {
await api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { onboarding: { tourCompleted: false } } }),
})
await me()
startTour()
}
return { maybeStartTour, startTour, resetTour }
}
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import type { OidcProviderPublic } from '@projectx/types' import type { OidcProviderPublic } from '@projectx/types'
import { ChevronDown, ChevronUp, KeyRound, Link, LinkIcon, Save, Trash2, Upload } from 'lucide-vue-next' import { ChevronDown, ChevronUp, KeyRound, Link, LinkIcon, MapPin, Save, Trash2, Upload } from 'lucide-vue-next'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@@ -11,10 +11,12 @@ import { MAX_PROFILE_AVATAR_BYTES, useProfileAvatar } from '@/features/auth/comp
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog' import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import SettingsPageHeader from './SettingsPageHeader.vue' import SettingsPageHeader from './SettingsPageHeader.vue'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
const { user, me } = useAuth() const { user, me } = useAuth()
const { open: openChangePassword } = useChangePasswordDialog() const { open: openChangePassword } = useChangePasswordDialog()
const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar() const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar()
const { resetTour } = useOnboardingTour()
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const savingProfile = ref(false) const savingProfile = ref(false)
@@ -450,6 +452,22 @@ function closeUnlinkDialog() {
</div> </div>
</div> </div>
<!-- Guided Tour (desktop only) -->
<div class="hidden md:block mt-4">
<section class="rounded-xl border border-border bg-card p-4 md:p-5">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<MapPin class="h-4 w-4 text-muted-foreground shrink-0" />
<div>
<p class="text-sm font-semibold text-foreground">Guided Tour</p>
<p class="text-xs text-muted-foreground mt-0.5">Replay the walkthrough that highlights key features of the app.</p>
</div>
</div>
<button class="settings-btn-outline shrink-0" @click="resetTour">Take the tour again</button>
</div>
</section>
</div>
<!-- Unlink OIDC identity confirmation dialog --> <!-- Unlink OIDC identity confirmation dialog -->
<div v-if="unlinkDialogOpen" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeUnlinkDialog"> <div v-if="unlinkDialogOpen" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeUnlinkDialog">
<button class="absolute inset-0 bg-black/45" @click="closeUnlinkDialog" /> <button class="absolute inset-0 bg-black/45" @click="closeUnlinkDialog" />
+6 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { Settings2 } from 'lucide-vue-next' import { Settings2 } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -9,10 +9,12 @@ import DashboardScroller from '@/features/dashboard/components/DashboardScroller
import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue' import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue'
import DashboardWelcome from '@/features/dashboard/components/DashboardWelcome.vue' import DashboardWelcome from '@/features/dashboard/components/DashboardWelcome.vue'
import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig' import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig'
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
const { hasPermission } = usePermissions() const { hasPermission } = usePermissions()
const { libraries, loading: librariesLoading, fetchLibraries } = useLibraries() const { libraries, loading: librariesLoading, fetchLibraries } = useLibraries()
const { scrollers } = useDashboardConfig() const { scrollers } = useDashboardConfig()
const { maybeStartTour } = useOnboardingTour()
const settingsOpen = ref(false) const settingsOpen = ref(false)
@@ -22,6 +24,9 @@ const hasNoLibraries = computed(() => !librariesLoading.value && libraries.value
onMounted(() => { onMounted(() => {
fetchLibraries() fetchLibraries()
nextTick(() => {
setTimeout(maybeStartTour, 500)
})
}) })
</script> </script>
+14 -11
View File
@@ -1,15 +1,15 @@
export enum OidcErrorCode { export enum OidcErrorCode {
STATE_EXPIRED = 'oidc_state_expired', STATE_EXPIRED = "oidc_state_expired",
TOKEN_EXCHANGE_FAILED = 'oidc_token_exchange_failed', TOKEN_EXCHANGE_FAILED = "oidc_token_exchange_failed",
USER_NOT_PROVISIONED = 'oidc_user_not_provisioned', USER_NOT_PROVISIONED = "oidc_user_not_provisioned",
USER_INACTIVE = 'oidc_user_inactive', USER_INACTIVE = "oidc_user_inactive",
PROVIDER_ERROR = 'oidc_provider_error', PROVIDER_ERROR = "oidc_provider_error",
} }
export const ProvisioningMethod = { export const ProvisioningMethod = {
Local: 'local', Local: "local",
Manual: 'manual', Manual: "manual",
Oidc: 'oidc', Oidc: "oidc",
} as const; } as const;
export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof ProvisioningMethod]; export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof ProvisioningMethod];
@@ -17,6 +17,9 @@ export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof Provis
export interface UserSettings { export interface UserSettings {
syncReaderPreferences?: boolean; syncReaderPreferences?: boolean;
statisticsConfig?: import("./statistics").StatisticsSettings; statisticsConfig?: import("./statistics").StatisticsSettings;
onboarding?: {
tourCompleted?: boolean;
};
} }
export interface AuthUser { export interface AuthUser {
@@ -102,18 +105,18 @@ export interface OidcBaseConfig {
} }
export interface OidcCallbackResult { export interface OidcCallbackResult {
mode: 'login'; mode: "login";
accessToken: string; accessToken: string;
user: AuthUser; user: AuthUser;
} }
export interface OidcLinkResult { export interface OidcLinkResult {
mode: 'link'; mode: "link";
linked: true; linked: true;
} }
export interface OidcPreviewResult { export interface OidcPreviewResult {
mode: 'preview'; mode: "preview";
claims: { claims: {
raw: Record<string, unknown>; raw: Record<string, unknown>;
mapped: { username: string; name: string; email?: string; groups: string[] }; mapped: { username: string; name: string; email?: string; groups: string[] };
+11
View File
@@ -53,6 +53,9 @@ importers:
dompurify: dompurify:
specifier: ^3.4.1 specifier: ^3.4.1
version: 3.4.1 version: 3.4.1
driver.js:
specifier: ^1.4.0
version: 1.4.0
echarts: echarts:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
@@ -5575,6 +5578,12 @@ packages:
} }
engines: { node: ">=12" } engines: { node: ">=12" }
driver.js@1.4.0:
resolution:
{
integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==,
}
drizzle-kit@0.31.10: drizzle-kit@0.31.10:
resolution: resolution:
{ {
@@ -13166,6 +13175,8 @@ snapshots:
dotenv@17.4.1: {} dotenv@17.4.1: {}
driver.js@1.4.0: {}
drizzle-kit@0.31.10: drizzle-kit@0.31.10:
dependencies: dependencies:
"@drizzle-team/brocli": 0.10.2 "@drizzle-team/brocli": 0.10.2