feat(tutorial): update tutorial labels and modal titles to improve clarity
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s

- Changed "Skip tour" button to "Cancel" in various tutorial overlays.
- Updated modal titles from "Confirm Task" to "Confirm Penalty" where applicable.
- Modified tutorial step call-to-action labels from "Got it" and "Next" to "Continue" for consistency.
- Added tests for new tutorial overlay behavior and button interactions.
- Refactored tutorial controller to allow ignoring tutorial enabled state in step visibility checks.
- Enhanced user profile tutorial restart functionality with appropriate modal messages.
- Updated user profile and task confirmation dialog components to reflect new titles and messages.
- Adjusted configuration files for local development environments.
This commit is contained in:
2026-07-25 12:56:18 -04:00
parent cc1189cd9b
commit 8e4f89f4ec
22 changed files with 347 additions and 118 deletions
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "qYJH5LMH5O4UK92tdAp7R49rzGACFvTMVj29S-Pzygc", "value": "hW7OJbUjHr4XntQRPtefKNhvvctK014R6pzLz_vyJ8g",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1792435594.088715, "expires": 1792774142.289068,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI2NmNlMzgzMy0zMGQ3LTRjMTItYjRhMi1iNmIxMjcxYTI1YzEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.jkyiFTxBQ0oatY6jCAWFG88E8e6mCrDDlN_8I56RMBA", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI5MjUwNzY1MS00YjlmLTRkMTMtOTgxYS1hMzA2ZThjY2JmNzciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg5NDJ9.15hDvKf9u95pMzyeSbrHesFBDGgTwnxV1FD175p72-w",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1784670394.088677, "expires": 1785008942.288088,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1784659593888}" "value": "{\"type\":\"logout\",\"at\":1784998141942}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1784832394248}" "value": "{\"expiresAt\":1785170942553}"
} }
] ]
} }
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "LZA3Bk15HR3io_q8vfyqD3wlRoyV-GJwY-MjWvtUQzg", "value": "ND-PngBFXIuDrD3eLsaPs1mWqwzusUSI6nPw-M3JjJo",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1792435594.060453, "expires": 1792774029.131567,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjYjQyMzZkNC1kNDRlLTQ0YjMtOTYzYi0xYTUwMWEzZjM2NTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.pPd0JYgaAyn4_0YPOlxKoX1B9Ka4Vq2M3CpNcl7EDAU", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkM2E5OWI5MC1kZDdlLTQ5MjQtYTZkZS1hMWU1OTcxMTE3NDciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg4Mjl9.ey7BplN-ghoJ8OGMkdOJavVAYVM_bv_xRBrtL02ASjQ",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1784670394.06041, "expires": 1785008829.130873,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1784659593886}" "value": "{\"type\":\"logout\",\"at\":1784998028750}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1784832394232}" "value": "{\"expiresAt\":1785170829369}"
} }
] ]
} }
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "MYMeAyxeWQy7LVHrJsvF-10FozU5Ko_QoEvmV3bHwPc", "value": "PbjRbjt9aqRjxoNjBDVJTuDsEEzgUPoplaNgzhOOwdQ",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1792435592.056481, "expires": 1792774174.76082,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJiODJhZDU2Mi03MjMwLTRkN2EtODU3NS04N2FhNzE1ZWNhMjQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTJ9.iEbCtV3csb9AUWoXL4MJ-GLL1yiJDumvYmKr-vyuwBg", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4YzU1MWFiNi1lNmMxLTQ1MzgtOTEzNS0xNDgwN2M2NjM0OTQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg5NzR9._hCAKJRJgq4gcDKppcjkcWf9nX94wjBfa4J94Lh2ZlI",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1784670392.056434, "expires": 1785008974.760293,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1784659591903}" "value": "{\"type\":\"logout\",\"at\":1784998174529}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1784832392197}" "value": "{\"expiresAt\":1785170974948}"
} }
] ]
} }
@@ -15,7 +15,7 @@ test.describe('Create Child', () => {
// Navigate to parent list and wait for it to fully load before clicking Add Child // Navigate to parent list and wait for it to fully load before clicking Add Child
await gotoParentList(page) await gotoParentList(page)
// Dismiss tutorial overlay if present — it intercepts pointer events // Dismiss tutorial overlay if present — it intercepts pointer events
const skip = page.getByRole('button', { name: 'Skip tour' }) const skip = page.getByRole('button', { name: 'Cancel' })
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) { if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
await skip.click() await skip.click()
} }
@@ -5,7 +5,7 @@ test.describe('Parent profile button temporary parent mode', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/parent') await page.goto('/parent')
// Dismiss tutorial overlay if present — it intercepts pointer events // Dismiss tutorial overlay if present — it intercepts pointer events
const skip = page.getByRole('button', { name: 'Skip tour' }) const skip = page.getByRole('button', { name: 'Cancel' })
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) { if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
await skip.click() await skip.click()
} }
@@ -5,7 +5,7 @@ test.describe('Parent profile button permanent parent mode', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/parent') await page.goto('/parent')
// Dismiss tutorial overlay if present — it intercepts pointer events // Dismiss tutorial overlay if present — it intercepts pointer events
const skip = page.getByRole('button', { name: 'Skip tour' }) const skip = page.getByRole('button', { name: 'Cancel' })
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) { if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
await skip.click() await skip.click()
} }
@@ -244,7 +244,7 @@ test.describe('Dialog help button and titles', () => {
await card.click() await card.click()
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 }) await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
await expect(page.locator('.modal-title')).toHaveText('Confirm Task') await expect(page.locator('.modal-title')).toHaveText('Confirm Penalty')
await expect(helpButton(page)).not.toBeVisible() await expect(helpButton(page)).not.toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole('button', { name: 'Cancel' }).click()
@@ -146,7 +146,7 @@ async function expectTutorialCard(page: Page, title: string): Promise<void> {
async function dismissTutorial(page: Page): Promise<void> { async function dismissTutorial(page: Page): Promise<void> {
const card = getTutorialCard(page) const card = getTutorialCard(page)
if (await card.isVisible({ timeout: 1000 }).catch(() => false)) { if (await card.isVisible({ timeout: 1000 }).catch(() => false)) {
// Use "Skip tour" to clear the active step and drain the queue in one // Use "Cancel" to clear the active step and drain the queue in one
// action, avoiding chained steps that would keep the card visible. // action, avoiding chained steps that would keep the card visible.
await page.locator('.tutorial-root .btn-skip').click() await page.locator('.tutorial-root .btn-skip').click()
await expect(card).not.toBeVisible({ timeout: 5000 }) await expect(card).not.toBeVisible({ timeout: 5000 })
+69 -7
View File
@@ -34,16 +34,17 @@ vi.mock('../services/pushSubscription', () => ({
getPushPermissionState: vi.fn().mockReturnValue('default'), getPushPermissionState: vi.fn().mockReturnValue('default'),
})) }))
function stubUserProfile() { const mockResetAllProgress = vi.fn().mockResolvedValue(undefined)
return { vi.mock('@/tutorial/controller', () => ({
template: '<div class="user-profile"><slot /></div>', tutorialEnabled: { value: true },
} setTutorialEnabled: vi.fn(),
} resetAllProgress: () => mockResetAllProgress(),
}))
function stubModalDialog() { function stubModalDialog() {
return { return {
template: '<div class="mock-modal" v-if="show"><slot /></div>', template: '<div class="mock-modal"><h2 v-if="title">{{ title }}</h2><slot /></div>',
props: ['show'], props: ['title'],
} }
} }
@@ -607,3 +608,64 @@ describe('UserProfile - Notification Toggles', () => {
expect(body.push_notifications_enabled).toBe(true) expect(body.push_notifications_enabled).toBe(true)
}) })
}) })
describe('UserProfile - Tutorial restart', () => {
let wrapper: VueWrapper<any>
beforeEach(() => {
vi.clearAllMocks()
mockResetAllProgress.mockClear()
;(global.fetch as any).mockClear()
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: true,
}),
})
wrapper = mount(UserProfile, {
global: {
plugins: [mockRouter],
stubs: {
ProfileSection: stubProfileSection(),
ImagePicker: stubImagePicker(),
ToggleField: stubToggleField(),
ModalDialog: stubModalDialog(),
},
},
})
})
it('restart confirm modal uses "Tutorial Restart" title and explanatory wording', async () => {
await flushPromises()
await nextTick()
await wrapper.vm.openRestartConfirm()
await nextTick()
const modal = wrapper.find('.mock-modal')
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('Tutorial Restart')
expect(modal.text()).toContain('Start the tour again')
})
it('confirming restart resets tutorial progress and shows success modal', async () => {
await flushPromises()
await nextTick()
await wrapper.vm.openRestartConfirm()
await wrapper.vm.confirmRestartTutorial()
await flushPromises()
await nextTick()
expect(mockResetAllProgress).toHaveBeenCalledTimes(1)
const modal = wrapper.find('.mock-modal')
expect(modal.text()).toContain('Tutorial Restart')
expect(modal.text()).toContain('Tutorial mode has been restarted')
})
})
@@ -28,9 +28,11 @@ const props = defineProps<{
childName?: string childName?: string
}>() }>()
const dialogTitle = computed(() => const dialogTitle = computed(() => {
props.task?.type === 'kindness' ? 'Confirm Act' : 'Confirm Task', if (props.task?.type === 'kindness') return 'Confirm Act'
) if (props.task?.type === 'penalty') return 'Confirm Penalty'
return 'Confirm Task'
})
defineEmits<{ defineEmits<{
confirm: [] confirm: []
@@ -40,4 +40,20 @@ describe('TaskConfirmDialog', () => {
expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Act') expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Act')
}) })
it('renders "Confirm Penalty" title for penalties', () => {
const task: Task = {
id: 'task-3',
name: 'No Screen Time',
type: 'penalty',
points: 5,
image_id: '',
}
const wrapper = mount(TaskConfirmDialog, {
props: { task, childName: 'Test Child' },
global: { stubs: { ModalDialog: ModalDialogStub } },
})
expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Penalty')
})
}) })
@@ -156,7 +156,7 @@
<!-- Restart confirmation --> <!-- Restart confirmation -->
<ModalDialog <ModalDialog
v-if="showRestartConfirm" v-if="showRestartConfirm"
title="Restart tutorial?" title="Tutorial Restart"
@close="showRestartConfirm = false" @close="showRestartConfirm = false"
> >
<div class="modal-message"> <div class="modal-message">
@@ -169,8 +169,12 @@
</ModalDialog> </ModalDialog>
<!-- Restart success --> <!-- Restart success -->
<ModalDialog v-if="showRestartSuccess" title="Tour reset" @close="showRestartSuccess = false"> <ModalDialog
<div class="modal-message">Tour reset — here we go!</div> v-if="showRestartSuccess"
title="Tutorial Restart"
@close="showRestartSuccess = false"
>
<div class="modal-message">Tutorial mode has been restarted</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button> <button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
</div> </div>
+2 -3
View File
@@ -14,7 +14,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { activeStep, maybeShow, tutorialEnabled, clearChainProgress, modalTutorialStepId, helpButtonHidden } from './controller' import { activeStep, maybeShow, clearChainProgress, modalTutorialStepId, helpButtonHidden } from './controller'
// Map route names to the tutorial step that should re-fire when the user taps `?`. // Map route names to the tutorial step that should re-fire when the user taps `?`.
// Keep this list lean — only routes that have a tutorial step actually wired. // Keep this list lean — only routes that have a tutorial step actually wired.
@@ -57,7 +57,6 @@ const targetStepId = computed<string | null>(() => {
const visible = computed(() => { const visible = computed(() => {
if (helpButtonHidden.value) return false if (helpButtonHidden.value) return false
if (!tutorialEnabled.value) return false
return targetStepId.value !== null return targetStepId.value !== null
}) })
@@ -69,7 +68,7 @@ function onClick() {
// Temporarily clear local "seen" for the whole chain so every step replays. // Temporarily clear local "seen" for the whole chain so every step replays.
// The server state is left alone; on dismiss `markStepSeen` simply no-ops. // The server state is left alone; on dismiss `markStepSeen` simply no-ops.
clearChainProgress(id) clearChainProgress(id)
maybeShow(id) maybeShow(id, null, true)
} }
</script> </script>
+3 -9
View File
@@ -38,7 +38,7 @@
<p :id="bodyId" class="body" aria-live="polite">{{ step.def.body }}</p> <p :id="bodyId" class="body" aria-live="polite">{{ step.def.body }}</p>
<div class="actions"> <div class="actions">
<button type="button" class="btn btn-skip" @click="onSkip" ref="skipBtn"> <button type="button" class="btn btn-skip" @click="onSkip" ref="skipBtn">
Skip tour Cancel
</button> </button>
<button type="button" class="btn btn-primary" @click="onPrimary" ref="primaryBtn"> <button type="button" class="btn btn-primary" @click="onPrimary" ref="primaryBtn">
{{ primaryLabel }} {{ primaryLabel }}
@@ -66,12 +66,6 @@ const viewport = ref<{ w: number; h: number }>({
w: typeof window !== 'undefined' ? window.innerWidth : 1024, w: typeof window !== 'undefined' ? window.innerWidth : 1024,
h: typeof window !== 'undefined' ? window.innerHeight : 768, h: typeof window !== 'undefined' ? window.innerHeight : 768,
}) })
const reducedMotion = ref(
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
)
const step = computed<ActiveStep | null>(() => activeStep.value) const step = computed<ActiveStep | null>(() => activeStep.value)
const titleId = 'tutorial-title' const titleId = 'tutorial-title'
const bodyId = 'tutorial-body' const bodyId = 'tutorial-body'
@@ -80,9 +74,9 @@ const isMobile = computed(() => viewport.value.w <= MOBILE_BREAKPOINT)
const primaryLabel = computed(() => { const primaryLabel = computed(() => {
const def = step.value?.def const def = step.value?.def
if (!def) return 'Got it' if (!def) return 'Continue'
if (def.ctaLabel) return def.ctaLabel if (def.ctaLabel) return def.ctaLabel
return def.next ? 'Next' : 'Got it' return 'Continue'
}) })
function resolveAnchor(): HTMLElement | null { function resolveAnchor(): HTMLElement | null {
@@ -24,6 +24,12 @@ describe('HelpButton', () => {
expect(wrapper.find('.help-fab').exists()).toBe(true) expect(wrapper.find('.help-fab').exists()).toBe(true)
}) })
it('renders even when tutorial tips are disabled, so the help system stays reachable', () => {
tutorialEnabled.value = false
const wrapper = mount(HelpButton)
expect(wrapper.find('.help-fab').exists()).toBe(true)
})
it('hides while helpButtonHidden is true', async () => { it('hides while helpButtonHidden is true', async () => {
const wrapper = mount(HelpButton) const wrapper = mount(HelpButton)
expect(wrapper.find('.help-fab').exists()).toBe(true) expect(wrapper.find('.help-fab').exists()).toBe(true)
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import TutorialOverlay from '../TutorialOverlay.vue'
import * as controller from '../controller'
import { stepRegistry, type StepDef } from '../steps'
const baseStep: StepDef = stepRegistry['setup-parent-pin']!
describe('TutorialOverlay', () => {
beforeEach(() => {
controller.activeStep.value = null
controller.sessionSkipped.value = false
global.fetch = vi.fn().mockResolvedValue({ ok: true })
})
afterEach(() => {
controller.activeStep.value = null
document.body.innerHTML = ''
})
function mountOverlay() {
return mount(TutorialOverlay, { attachTo: document.body })
}
it('renders "Continue" and "Cancel" labels by default', async () => {
controller.activeStep.value = {
def: baseStep,
anchor: null,
}
const wrapper = mountOverlay()
await nextTick()
const root = document.body.querySelector('.tutorial-root')
expect(root).not.toBeNull()
expect(root!.querySelector('.btn-primary')?.textContent?.trim()).toBe('Continue')
expect(root!.querySelector('.btn-skip')?.textContent?.trim()).toBe('Cancel')
wrapper.unmount()
})
it('uses a custom CTA label when the step defines one', async () => {
controller.activeStep.value = {
def: { ...baseStep, ctaLabel: 'Got it' },
anchor: null,
}
const wrapper = mountOverlay()
await nextTick()
const root = document.body.querySelector('.tutorial-root')
expect(root!.querySelector('.btn-primary')?.textContent?.trim()).toBe('Got it')
wrapper.unmount()
})
it('calls dismissActive(true) when the primary button is clicked', async () => {
const spy = vi.spyOn(controller, 'dismissActive')
controller.activeStep.value = {
def: baseStep,
anchor: null,
}
const wrapper = mountOverlay()
await nextTick()
const root = document.body.querySelector('.tutorial-root')
await root!.querySelector('.btn-primary')!.dispatchEvent(new Event('click'))
expect(spy).toHaveBeenCalledWith(true)
spy.mockRestore()
wrapper.unmount()
})
it('calls skipSession when the Cancel button is clicked', async () => {
const spy = vi.spyOn(controller, 'skipSession')
controller.activeStep.value = {
def: baseStep,
anchor: null,
}
const wrapper = mountOverlay()
await nextTick()
const root = document.body.querySelector('.tutorial-root')
await root!.querySelector('.btn-skip')!.dispatchEvent(new Event('click'))
expect(spy).toHaveBeenCalled()
spy.mockRestore()
wrapper.unmount()
})
})
+8 -4
View File
@@ -79,8 +79,8 @@ export function hydrateFromProfile(profile: {
} }
} }
export function shouldShowStep(id: string): boolean { export function shouldShowStep(id: string, ignoreEnabled = false): boolean {
if (!tutorialEnabled.value) return false if (!ignoreEnabled && !tutorialEnabled.value) return false
if (sessionSkipped.value) return false if (sessionSkipped.value) return false
if (tutorialProgress.value[id]) return false if (tutorialProgress.value[id]) return false
return true return true
@@ -116,12 +116,16 @@ function promote() {
* If anchor is provided but the element is missing at promotion time, the step is dropped. * If anchor is provided but the element is missing at promotion time, the step is dropped.
* If anchor is null, the step renders as a centered modal-style card. * If anchor is null, the step renders as a centered modal-style card.
*/ */
export function maybeShow(id: string, anchor: AnchorSource = null) { export function maybeShow(
id: string,
anchor: AnchorSource = null,
force = false,
) {
if (!stepRegistry[id]) { if (!stepRegistry[id]) {
if (import.meta.env.DEV) console.warn(`[tutorial] Unknown step id: ${id}`) if (import.meta.env.DEV) console.warn(`[tutorial] Unknown step id: ${id}`)
return return
} }
if (!shouldShowStep(id)) return if (!shouldShowStep(id, force)) return
if (activeStep.value?.def.id === id) return if (activeStep.value?.def.id === id) return
if (queue.some((q) => q.def.id === id)) return if (queue.some((q) => q.def.id === id)) return
queue.push({ def: stepRegistry[id], anchor }) queue.push({ def: stepRegistry[id], anchor })
+54 -54
View File
@@ -7,7 +7,7 @@ export interface StepDef {
/** Chain another step to fire after this one is dismissed. */ /** Chain another step to fire after this one is dismissed. */
next?: string next?: string
placement?: Placement placement?: Placement
/** Primary button label. Defaults to "Got it" (or "Next" when `next` is set). */ /** Primary button label. Defaults to "Continue". */
ctaLabel?: string ctaLabel?: string
/** /**
* Fallback CSS selector used to resolve the anchor element when a step is * Fallback CSS selector used to resolve the anchor element when a step is
@@ -30,14 +30,14 @@ export const stepRegistry: Record<string, StepDef> = {
title: 'Welcome! Set up your parent PIN', title: 'Welcome! Set up your parent PIN',
body: 'Tap your avatar to set up a 46 digit PIN. The PIN keeps parent mode (where you add chores and rewards) safe from little fingers.', body: 'Tap your avatar to set up a 46 digit PIN. The PIN keeps parent mode (where you add chores and rewards) safe from little fingers.',
placement: 'below', placement: 'below',
ctaLabel: 'Got it', ctaLabel: 'Continue',
}, },
'create-child': { 'create-child': {
id: 'create-child', id: 'create-child',
title: 'Add your child', title: 'Add your child',
body: "Let's add your kiddo. Tap the + button to create a profile — you can pick a fun avatar together.", body: "Let's add your kiddo. Tap the + button to create a profile — you can pick a fun avatar together.",
placement: 'above', placement: 'above',
ctaLabel: 'Got it', ctaLabel: 'Continue',
anchorSelector: '.fab', anchorSelector: '.fab',
}, },
'parent-children-list': { 'parent-children-list': {
@@ -46,7 +46,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'This is your children list. Each card shows one of your kids — their photo, age, and points. Tap + to add a new child.', body: 'This is your children list. Each card shows one of your kids — their photo, age, and points. Tap + to add a new child.',
placement: 'above', placement: 'above',
next: 'child-points', next: 'child-points',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.fab', anchorSelector: '.fab',
}, },
'child-points': { 'child-points': {
@@ -55,7 +55,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'This is how many points your child has earned. They spend these on rewards you assign. Tap the child card to see their details and manage assignments.', body: 'This is how many points your child has earned. They spend these on rewards you assign. Tap the child card to see their details and manage assignments.',
placement: 'below', placement: 'below',
next: 'child-click', next: 'child-click',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.points', anchorSelector: '.points',
}, },
'child-click': { 'child-click': {
@@ -64,7 +64,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap any child card to open their page. There you can assign chores, kindness acts, rewards, routines, and penalties.', body: 'Tap any child card to open their page. There you can assign chores, kindness acts, rewards, routines, and penalties.',
placement: 'below', placement: 'below',
next: 'child-kebab', next: 'child-kebab',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.card', anchorSelector: '.card',
}, },
'create-chore': { 'create-chore': {
@@ -72,7 +72,7 @@ export const stepRegistry: Record<string, StepDef> = {
title: 'Create your chore', title: 'Create your chore',
body: 'Tap + to add a chore — give it a name, choose points, and pick a picture so your child can spot it easily.', body: 'Tap + to add a chore — give it a name, choose points, and pick a picture so your child can spot it easily.',
placement: 'above', placement: 'above',
ctaLabel: 'Got it', ctaLabel: 'Continue',
}, },
// ── Create-flow JIT hints ──────────────────────────────────────────────── // ── Create-flow JIT hints ────────────────────────────────────────────────
@@ -82,7 +82,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Give the chore a short, clear name your child will understand — like "Clean your room" or "Feed the dog".', body: 'Give the chore a short, clear name your child will understand — like "Clean your room" or "Feed the dog".',
placement: 'below', placement: 'below',
next: 'edit-chore-points', next: 'edit-chore-points',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#name', anchorSelector: 'input#name',
}, },
'edit-chore-points': { 'edit-chore-points': {
@@ -91,7 +91,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'How many points is this chore worth? Bigger or harder chores can be worth more. Your child spends these points on rewards.', body: 'How many points is this chore worth? Bigger or harder chores can be worth more. Your child spends these points on rewards.',
placement: 'below', placement: 'below',
next: 'edit-chore-image', next: 'edit-chore-image',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#points', anchorSelector: 'input#points',
}, },
'edit-chore-image': { 'edit-chore-image': {
@@ -120,7 +120,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap one or more days to schedule this chore or routine. You can choose a single day or several — whatever fits your week.', body: 'Tap one or more days to schedule this chore or routine. You can choose a single day or several — whatever fits your week.',
placement: 'below', placement: 'below',
next: 'schedule-days-deadline', next: 'schedule-days-deadline',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.day-chips', anchorSelector: '.day-chips',
}, },
'schedule-days-deadline': { 'schedule-days-deadline': {
@@ -129,7 +129,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'This is the time your child needs to finish by. Tap "Clear" to remove the deadline — then it can be done anytime that day.', body: 'This is the time your child needs to finish by. Tap "Clear" to remove the deadline — then it can be done anytime that day.',
placement: 'auto', placement: 'auto',
next: 'schedule-days-exception', next: 'schedule-days-exception',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.default-deadline-row', anchorSelector: '.default-deadline-row',
}, },
'schedule-days-exception': { 'schedule-days-exception': {
@@ -138,7 +138,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'If one day needs a different deadline, tap "Set different time" for that day. Useful for early-dismissal days or busy afternoons.', body: 'If one day needs a different deadline, tap "Set different time" for that day. Useful for early-dismissal days or busy afternoons.',
placement: 'auto', placement: 'auto',
next: 'schedule-enable-toggle', next: 'schedule-enable-toggle',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.exception-row', anchorSelector: '.exception-row',
}, },
@@ -149,7 +149,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Set how many days pass between each time this chore or routine appears. Every 1 day means daily; every 7 days means weekly.', body: 'Set how many days pass between each time this chore or routine appears. Every 1 day means daily; every 7 days means weekly.',
placement: 'below', placement: 'below',
next: 'schedule-interval-start', next: 'schedule-interval-start',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.stepper', anchorSelector: '.stepper',
}, },
'schedule-interval-start': { 'schedule-interval-start': {
@@ -158,7 +158,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Pick the first day this chore or routine is expected. The schedule counts forward from here.', body: 'Pick the first day this chore or routine is expected. The schedule counts forward from here.',
placement: 'below', placement: 'below',
next: 'schedule-interval-deadline', next: 'schedule-interval-deadline',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="schedule-interval-start"]', anchorSelector: '[data-tutorial="schedule-interval-start"]',
}, },
'schedule-interval-deadline': { 'schedule-interval-deadline': {
@@ -167,7 +167,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'This is the time your child needs to finish by. Tap "Clear" to remove the deadline — then it can be done anytime that day.', body: 'This is the time your child needs to finish by. Tap "Clear" to remove the deadline — then it can be done anytime that day.',
placement: 'auto', placement: 'auto',
next: 'schedule-enable-toggle', next: 'schedule-enable-toggle',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.interval-time-row', anchorSelector: '.interval-time-row',
}, },
@@ -187,7 +187,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap the + button to add a new chore. Give it a name, choose points, and pick a picture so your child can spot it easily.', body: 'Tap the + button to add a new chore. Give it a name, choose points, and pick a picture so your child can spot it easily.',
placement: 'above', placement: 'above',
next: 'list-edit-hint', next: 'list-edit-hint',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.fab', anchorSelector: '.fab',
}, },
'list-kindness-help': { 'list-kindness-help': {
@@ -196,7 +196,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap the + button to add a new kindness act. Give it a name, choose points, and pick a picture so your child can spot it easily.', body: 'Tap the + button to add a new kindness act. Give it a name, choose points, and pick a picture so your child can spot it easily.',
placement: 'above', placement: 'above',
next: 'list-edit-hint', next: 'list-edit-hint',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.fab', anchorSelector: '.fab',
}, },
'list-penalty-help': { 'list-penalty-help': {
@@ -205,7 +205,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap the + button to add a new penalty. Give it a name, choose points, and pick a picture so your child can spot it easily.', body: 'Tap the + button to add a new penalty. Give it a name, choose points, and pick a picture so your child can spot it easily.',
placement: 'above', placement: 'above',
next: 'list-edit-hint', next: 'list-edit-hint',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.fab', anchorSelector: '.fab',
}, },
'list-reward-help': { 'list-reward-help': {
@@ -214,7 +214,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap the + button to add a new reward. Give it a name, choose a cost, and pick a picture so your child can spot it easily.', body: 'Tap the + button to add a new reward. Give it a name, choose a cost, and pick a picture so your child can spot it easily.',
placement: 'above', placement: 'above',
next: 'list-edit-hint', next: 'list-edit-hint',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.fab', anchorSelector: '.fab',
}, },
'list-routine-help': { 'list-routine-help': {
@@ -223,7 +223,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap the + button to add a new routine. Give it a name, choose points, and pick a picture so your child can spot it easily.', body: 'Tap the + button to add a new routine. Give it a name, choose points, and pick a picture so your child can spot it easily.',
placement: 'above', placement: 'above',
next: 'list-edit-hint', next: 'list-edit-hint',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.fab', anchorSelector: '.fab',
}, },
'list-edit-hint': { 'list-edit-hint': {
@@ -232,7 +232,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap any item in the list to open it and make changes — rename it, adjust points, or swap the picture.', body: 'Tap any item in the list to open it and make changes — rename it, adjust points, or swap the picture.',
placement: 'below', placement: 'below',
next: 'list-delete-hint', next: 'list-delete-hint',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item', anchorSelector: '.list-item, .routine-item',
}, },
@@ -248,7 +248,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Give it a clear name your child will understand — like "Share your toys" or "Help a friend."', body: 'Give it a clear name your child will understand — like "Share your toys" or "Help a friend."',
placement: 'below', placement: 'below',
next: 'edit-kindness-points', next: 'edit-kindness-points',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#name', anchorSelector: 'input#name',
}, },
'edit-kindness-points': { 'edit-kindness-points': {
@@ -257,7 +257,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'How many points is this kindness worth? These work just like chore points — your child saves them up for rewards.', body: 'How many points is this kindness worth? These work just like chore points — your child saves them up for rewards.',
placement: 'below', placement: 'below',
next: 'edit-kindness-image', next: 'edit-kindness-image',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#points', anchorSelector: 'input#points',
}, },
'edit-kindness-image': { 'edit-kindness-image': {
@@ -275,7 +275,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Give the penalty a clear name your child will understand — like "No screen time" or "Early bedtime."', body: 'Give the penalty a clear name your child will understand — like "No screen time" or "Early bedtime."',
placement: 'below', placement: 'below',
next: 'edit-penalty-points', next: 'edit-penalty-points',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#name', anchorSelector: 'input#name',
}, },
'edit-penalty-points': { 'edit-penalty-points': {
@@ -284,7 +284,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'How many points does this penalty cost? When the rule is broken, these points are taken away.', body: 'How many points does this penalty cost? When the rule is broken, these points are taken away.',
placement: 'below', placement: 'below',
next: 'edit-penalty-image', next: 'edit-penalty-image',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#points', anchorSelector: 'input#points',
}, },
'edit-penalty-image': { 'edit-penalty-image': {
@@ -302,7 +302,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Give the reward a name your child will get excited about — like "Movie night" or "Ice cream treat."', body: 'Give the reward a name your child will get excited about — like "Movie night" or "Ice cream treat."',
placement: 'below', placement: 'below',
next: 'edit-reward-description', next: 'edit-reward-description',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#name', anchorSelector: 'input#name',
}, },
'edit-reward-description': { 'edit-reward-description': {
@@ -311,7 +311,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Add a quick description so you remember the details — like "Pick the movie together" or "Any flavor they want."', body: 'Add a quick description so you remember the details — like "Pick the movie together" or "Any flavor they want."',
placement: 'below', placement: 'below',
next: 'edit-reward-cost', next: 'edit-reward-cost',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#description', anchorSelector: 'input#description',
}, },
'edit-reward-cost': { 'edit-reward-cost': {
@@ -320,7 +320,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'How many points does this reward cost? Make it feel achievable — small rewards can cost less, big ones more.', body: 'How many points does this reward cost? Make it feel achievable — small rewards can cost less, big ones more.',
placement: 'below', placement: 'below',
next: 'edit-reward-image', next: 'edit-reward-image',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#cost', anchorSelector: 'input#cost',
}, },
'edit-reward-image': { 'edit-reward-image': {
@@ -338,7 +338,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: "Enter your child's name or nickname — whatever they like to be called.", body: "Enter your child's name or nickname — whatever they like to be called.",
placement: 'below', placement: 'below',
next: 'edit-child-age', next: 'edit-child-age',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#name', anchorSelector: 'input#name',
}, },
'edit-child-age': { 'edit-child-age': {
@@ -347,7 +347,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'How old is your child? This helps tailor the experience to their level.', body: 'How old is your child? This helps tailor the experience to their level.',
placement: 'below', placement: 'below',
next: 'edit-child-image', next: 'edit-child-image',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#age', anchorSelector: 'input#age',
}, },
'edit-child-image': { 'edit-child-image': {
@@ -365,7 +365,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Give the routine a name your child will understand — like "Morning routine" or "Bedtime checklist."', body: 'Give the routine a name your child will understand — like "Morning routine" or "Bedtime checklist."',
placement: 'below', placement: 'below',
next: 'edit-routine-points', next: 'edit-routine-points',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#name', anchorSelector: 'input#name',
}, },
'edit-routine-points': { 'edit-routine-points': {
@@ -374,7 +374,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'How many points is the whole routine worth? Your child earns these when they finish every step.', body: 'How many points is the whole routine worth? Your child earns these when they finish every step.',
placement: 'below', placement: 'below',
next: 'edit-routine-image', next: 'edit-routine-image',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: 'input#points', anchorSelector: 'input#points',
}, },
'edit-routine-image': { 'edit-routine-image': {
@@ -383,7 +383,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Choose an image so your child can spot this routine easily. Tap + or the camera to add one.', body: 'Choose an image so your child can spot this routine easily. Tap + or the camera to add one.',
placement: 'above', placement: 'above',
next: 'create-routine-add-task', next: 'create-routine-add-task',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.picker', anchorSelector: '.picker',
}, },
@@ -405,7 +405,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap to add each step. Order them the way you want your child to do them — they can be reordered later by dragging.', body: 'Tap to add each step. Order them the way you want your child to do them — they can be reordered later by dragging.',
placement: 'above', placement: 'above',
next: 'routine-task-reorder', next: 'routine-task-reorder',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.add-task-trigger', anchorSelector: '.add-task-trigger',
}, },
'routine-task-reorder': { 'routine-task-reorder': {
@@ -414,7 +414,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Drag the handle up or down to change the order your child sees the tasks in.', body: 'Drag the handle up or down to change the order your child sees the tasks in.',
placement: 'auto', placement: 'auto',
next: 'routine-task-edit', next: 'routine-task-edit',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="routine-task-reorder"]', anchorSelector: '[data-tutorial="routine-task-reorder"]',
}, },
'routine-task-edit': { 'routine-task-edit': {
@@ -423,7 +423,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap Edit to change a task name or picture. Useful for fixing typos or updating the look.', body: 'Tap Edit to change a task name or picture. Useful for fixing typos or updating the look.',
placement: 'auto', placement: 'auto',
next: 'routine-task-delete', next: 'routine-task-delete',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="routine-task-edit"]', anchorSelector: '[data-tutorial="routine-task-edit"]',
}, },
'routine-task-delete': { 'routine-task-delete': {
@@ -455,7 +455,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Here you can assign chores, rewards, and routines, see their points, and review their progress. Scroll down to see each section.', body: 'Here you can assign chores, rewards, and routines, see their points, and review their progress. Scroll down to see each section.',
placement: 'auto', placement: 'auto',
next: 'assign-chore', next: 'assign-chore',
ctaLabel: 'Next', ctaLabel: 'Continue',
}, },
'assign-chore': { 'assign-chore': {
id: 'assign-chore', id: 'assign-chore',
@@ -463,7 +463,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap to pick which chores this child does. Assigned chores show up in their view. You can give the same chore to multiple kids.', body: 'Tap to pick which chores this child does. Assigned chores show up in their view. You can give the same chore to multiple kids.',
placement: 'above', placement: 'above',
next: 'assign-kindness', next: 'assign-kindness',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(1)', anchorSelector: '.assign-buttons button:nth-child(1)',
}, },
'assign-kindness': { 'assign-kindness': {
@@ -472,7 +472,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Kindness acts reward thoughtful behavior — sharing, helping, saying please. Tap to pick which ones this child can do.', body: 'Kindness acts reward thoughtful behavior — sharing, helping, saying please. Tap to pick which ones this child can do.',
placement: 'above', placement: 'above',
next: 'assign-reward', next: 'assign-reward',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(4)', anchorSelector: '.assign-buttons button:nth-child(4)',
}, },
'assign-reward': { 'assign-reward': {
@@ -481,7 +481,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: "Pick which rewards this child can spend points on. They'll only see rewards you assign here.", body: "Pick which rewards this child can spend points on. They'll only see rewards you assign here.",
placement: 'above', placement: 'above',
next: 'assign-routine', next: 'assign-routine',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(3)', anchorSelector: '.assign-buttons button:nth-child(3)',
}, },
'assign-routine': { 'assign-routine': {
@@ -490,7 +490,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Routines you assign show up on this childs page as a checklist they can work through, step by step.', body: 'Routines you assign show up on this childs page as a checklist they can work through, step by step.',
placement: 'above', placement: 'above',
next: 'assign-penalty', next: 'assign-penalty',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(2)', anchorSelector: '.assign-buttons button:nth-child(2)',
}, },
'assign-penalty': { 'assign-penalty': {
@@ -499,7 +499,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Penalties subtract points when rules are broken. Use them sparingly — they work best alongside lots of positive tasks.', body: 'Penalties subtract points when rules are broken. Use them sparingly — they work best alongside lots of positive tasks.',
placement: 'above', placement: 'above',
next: 'item-kebab-overview', next: 'item-kebab-overview',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(5)', anchorSelector: '.assign-buttons button:nth-child(5)',
}, },
@@ -518,7 +518,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap any chore to check or uncheck it. Checked chores are assigned to this child and show up in their view.', body: 'Tap any chore to check or uncheck it. Checked chores are assigned to this child and show up in their view.',
placement: 'below', placement: 'below',
next: 'assign-submit', next: 'assign-submit',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item', anchorSelector: '.list-item, .routine-item',
}, },
'assign-kindness-list': { 'assign-kindness-list': {
@@ -527,7 +527,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap any kindness act to check or uncheck it. Checked acts are assigned to this child and show up in their view.', body: 'Tap any kindness act to check or uncheck it. Checked acts are assigned to this child and show up in their view.',
placement: 'below', placement: 'below',
next: 'assign-submit', next: 'assign-submit',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item', anchorSelector: '.list-item, .routine-item',
}, },
'assign-penalty-list': { 'assign-penalty-list': {
@@ -536,7 +536,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap any penalty to check or uncheck it. Checked penalties are assigned to this child and show up in their view.', body: 'Tap any penalty to check or uncheck it. Checked penalties are assigned to this child and show up in their view.',
placement: 'below', placement: 'below',
next: 'assign-submit', next: 'assign-submit',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item', anchorSelector: '.list-item, .routine-item',
}, },
'assign-reward-list': { 'assign-reward-list': {
@@ -545,7 +545,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap any reward to check or uncheck it. Checked rewards are assigned to this child and show up in their view.', body: 'Tap any reward to check or uncheck it. Checked rewards are assigned to this child and show up in their view.',
placement: 'below', placement: 'below',
next: 'assign-submit', next: 'assign-submit',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item', anchorSelector: '.list-item, .routine-item',
}, },
'assign-routine-list': { 'assign-routine-list': {
@@ -554,7 +554,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Tap any routine to check or uncheck it. Checked routines are assigned to this child and show up in their view.', body: 'Tap any routine to check or uncheck it. Checked routines are assigned to this child and show up in their view.',
placement: 'below', placement: 'below',
next: 'assign-submit', next: 'assign-submit',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item', anchorSelector: '.list-item, .routine-item',
}, },
'assign-submit': { 'assign-submit': {
@@ -591,10 +591,10 @@ export const stepRegistry: Record<string, StepDef> = {
'chore-kebab-menu': { 'chore-kebab-menu': {
id: 'chore-kebab-menu', id: 'chore-kebab-menu',
title: 'Chore actions', title: 'Chore actions',
body: 'Edit the points or change the schedule for this chore. Tap Next to see each option.', body: 'Edit the points or change the schedule for this chore. Tap Continue to see each option.',
placement: 'auto', placement: 'auto',
next: 'chore-edit-points', next: 'chore-edit-points',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.kebab-menu', anchorSelector: '.kebab-menu',
}, },
'chore-edit-points': { 'chore-edit-points': {
@@ -603,7 +603,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Change how many points this chore is worth. Higher points for harder chores.', body: 'Change how many points this chore is worth. Higher points for harder chores.',
placement: 'auto', placement: 'auto',
next: 'chore-schedule', next: 'chore-schedule',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '[data-tutorial~="chore-edit-points"]', anchorSelector: '[data-tutorial~="chore-edit-points"]',
}, },
'chore-schedule': { 'chore-schedule': {
@@ -644,10 +644,10 @@ export const stepRegistry: Record<string, StepDef> = {
'routine-kebab-menu': { 'routine-kebab-menu': {
id: 'routine-kebab-menu', id: 'routine-kebab-menu',
title: 'Routine actions', title: 'Routine actions',
body: 'Edit the routine, adjust points, or change the schedule. Tap Next to see each option.', body: 'Edit the routine, adjust points, or change the schedule. Tap Continue to see each option.',
placement: 'auto', placement: 'auto',
next: 'routine-edit', next: 'routine-edit',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '.kebab-menu', anchorSelector: '.kebab-menu',
}, },
'routine-edit': { 'routine-edit': {
@@ -656,7 +656,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Open the routine to add, remove, or reorder the tasks inside it.', body: 'Open the routine to add, remove, or reorder the tasks inside it.',
placement: 'auto', placement: 'auto',
next: 'routine-edit-points', next: 'routine-edit-points',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="routine-edit"]', anchorSelector: '[data-tutorial="routine-edit"]',
}, },
'routine-edit-points': { 'routine-edit-points': {
@@ -665,7 +665,7 @@ export const stepRegistry: Record<string, StepDef> = {
body: 'Change how many points the whole routine is worth. Your child earns these when they finish every step.', body: 'Change how many points the whole routine is worth. Your child earns these when they finish every step.',
placement: 'auto', placement: 'auto',
next: 'routine-schedule', next: 'routine-schedule',
ctaLabel: 'Next', ctaLabel: 'Continue',
anchorSelector: '[data-tutorial~="routine-edit-points"]', anchorSelector: '[data-tutorial~="routine-edit-points"]',
}, },
'routine-schedule': { 'routine-schedule': {
+7 -12
View File
@@ -5,20 +5,15 @@
"enabled": true, "enabled": true,
"type": "local", "type": "local",
"command": [ "command": [
"docker", "ssh",
"run", "-T",
"-i", "devserver.lan",
"--rm", "/home/ryan/gitea-mcp-wrapper.sh"
"-e",
"GITEA_ACCESS_TOKEN",
"-e",
"GITEA_HOST",
"docker.gitea.com/gitea-mcp-server"
], ],
"environment": { "environment": {
"GITEA_HOST": "https://git.ryankegel.com", "GITEA_HOST": "https://git.ryankegel.com"
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}" },
} "timeout": 30000
} }
} }
} }
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"gitea-mcp": {
"enabled": true,
"type": "local",
"command": [
"docker",
"run",
"-i",
"--rm",
"-e",
"GITEA_ACCESS_TOKEN",
"-e",
"GITEA_HOST",
"docker.gitea.com/gitea-mcp-server"
],
"environment": {
"GITEA_HOST": "https://git.ryankegel.com",
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"gitea-mcp": {
"enabled": true,
"type": "local",
"command": [
"ssh",
"-T",
"devserver.lan",
"/home/ryan/gitea-mcp-wrapper.sh"
],
"environment": {
"GITEA_HOST": "https://git.ryankegel.com"
},
"timeout": 30000
}
}
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""Detect OS and copy the correct opencode.json config."""
import platform
import shutil
if platform.system() == "Darwin":
src = "opencode.mac.json"
else:
src = "opencode.windows.json"
shutil.copy(src, "opencode.json")
print(f"Copied {src} -> opencode.json")