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": [
{
"name": "refresh_token",
"value": "qYJH5LMH5O4UK92tdAp7R49rzGACFvTMVj29S-Pzygc",
"value": "hW7OJbUjHr4XntQRPtefKNhvvctK014R6pzLz_vyJ8g",
"domain": "localhost",
"path": "/api/auth",
"expires": 1792435594.088715,
"expires": 1792774142.289068,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI2NmNlMzgzMy0zMGQ3LTRjMTItYjRhMi1iNmIxMjcxYTI1YzEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.jkyiFTxBQ0oatY6jCAWFG88E8e6mCrDDlN_8I56RMBA",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI5MjUwNzY1MS00YjlmLTRkMTMtOTgxYS1hMzA2ZThjY2JmNzciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg5NDJ9.15hDvKf9u95pMzyeSbrHesFBDGgTwnxV1FD175p72-w",
"domain": "localhost",
"path": "/",
"expires": 1784670394.088677,
"expires": 1785008942.288088,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1784659593888}"
"value": "{\"type\":\"logout\",\"at\":1784998141942}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1784832394248}"
"value": "{\"expiresAt\":1785170942553}"
}
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "LZA3Bk15HR3io_q8vfyqD3wlRoyV-GJwY-MjWvtUQzg",
"value": "ND-PngBFXIuDrD3eLsaPs1mWqwzusUSI6nPw-M3JjJo",
"domain": "localhost",
"path": "/api/auth",
"expires": 1792435594.060453,
"expires": 1792774029.131567,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjYjQyMzZkNC1kNDRlLTQ0YjMtOTYzYi0xYTUwMWEzZjM2NTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.pPd0JYgaAyn4_0YPOlxKoX1B9Ka4Vq2M3CpNcl7EDAU",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkM2E5OWI5MC1kZDdlLTQ5MjQtYTZkZS1hMWU1OTcxMTE3NDciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg4Mjl9.ey7BplN-ghoJ8OGMkdOJavVAYVM_bv_xRBrtL02ASjQ",
"domain": "localhost",
"path": "/",
"expires": 1784670394.06041,
"expires": 1785008829.130873,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1784659593886}"
"value": "{\"type\":\"logout\",\"at\":1784998028750}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1784832394232}"
"value": "{\"expiresAt\":1785170829369}"
}
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "MYMeAyxeWQy7LVHrJsvF-10FozU5Ko_QoEvmV3bHwPc",
"value": "PbjRbjt9aqRjxoNjBDVJTuDsEEzgUPoplaNgzhOOwdQ",
"domain": "localhost",
"path": "/api/auth",
"expires": 1792435592.056481,
"expires": 1792774174.76082,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJiODJhZDU2Mi03MjMwLTRkN2EtODU3NS04N2FhNzE1ZWNhMjQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTJ9.iEbCtV3csb9AUWoXL4MJ-GLL1yiJDumvYmKr-vyuwBg",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4YzU1MWFiNi1lNmMxLTQ1MzgtOTEzNS0xNDgwN2M2NjM0OTQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg5NzR9._hCAKJRJgq4gcDKppcjkcWf9nX94wjBfa4J94Lh2ZlI",
"domain": "localhost",
"path": "/",
"expires": 1784670392.056434,
"expires": 1785008974.760293,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1784659591903}"
"value": "{\"type\":\"logout\",\"at\":1784998174529}"
},
{
"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
await gotoParentList(page)
// 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)) {
await skip.click()
}
@@ -5,7 +5,7 @@ test.describe('Parent profile button temporary parent mode', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/parent')
// 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)) {
await skip.click()
}
@@ -5,7 +5,7 @@ test.describe('Parent profile button permanent parent mode', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/parent')
// 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)) {
await skip.click()
}
@@ -244,7 +244,7 @@ test.describe('Dialog help button and titles', () => {
await card.click()
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 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> {
const card = getTutorialCard(page)
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.
await page.locator('.tutorial-root .btn-skip').click()
await expect(card).not.toBeVisible({ timeout: 5000 })
+69 -7
View File
@@ -34,16 +34,17 @@ vi.mock('../services/pushSubscription', () => ({
getPushPermissionState: vi.fn().mockReturnValue('default'),
}))
function stubUserProfile() {
return {
template: '<div class="user-profile"><slot /></div>',
}
}
const mockResetAllProgress = vi.fn().mockResolvedValue(undefined)
vi.mock('@/tutorial/controller', () => ({
tutorialEnabled: { value: true },
setTutorialEnabled: vi.fn(),
resetAllProgress: () => mockResetAllProgress(),
}))
function stubModalDialog() {
return {
template: '<div class="mock-modal" v-if="show"><slot /></div>',
props: ['show'],
template: '<div class="mock-modal"><h2 v-if="title">{{ title }}</h2><slot /></div>',
props: ['title'],
}
}
@@ -607,3 +608,64 @@ describe('UserProfile - Notification Toggles', () => {
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
}>()
const dialogTitle = computed(() =>
props.task?.type === 'kindness' ? 'Confirm Act' : 'Confirm Task',
)
const dialogTitle = computed(() => {
if (props.task?.type === 'kindness') return 'Confirm Act'
if (props.task?.type === 'penalty') return 'Confirm Penalty'
return 'Confirm Task'
})
defineEmits<{
confirm: []
@@ -40,4 +40,20 @@ describe('TaskConfirmDialog', () => {
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 -->
<ModalDialog
v-if="showRestartConfirm"
title="Restart tutorial?"
title="Tutorial Restart"
@close="showRestartConfirm = false"
>
<div class="modal-message">
@@ -169,8 +169,12 @@
</ModalDialog>
<!-- Restart success -->
<ModalDialog v-if="showRestartSuccess" title="Tour reset" @close="showRestartSuccess = false">
<div class="modal-message">Tour reset — here we go!</div>
<ModalDialog
v-if="showRestartSuccess"
title="Tutorial Restart"
@close="showRestartSuccess = false"
>
<div class="modal-message">Tutorial mode has been restarted</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
</div>
+2 -3
View File
@@ -14,7 +14,7 @@
<script setup lang="ts">
import { computed } from 'vue'
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 `?`.
// 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(() => {
if (helpButtonHidden.value) return false
if (!tutorialEnabled.value) return false
return targetStepId.value !== null
})
@@ -69,7 +68,7 @@ function onClick() {
// Temporarily clear local "seen" for the whole chain so every step replays.
// The server state is left alone; on dismiss `markStepSeen` simply no-ops.
clearChainProgress(id)
maybeShow(id)
maybeShow(id, null, true)
}
</script>
+3 -9
View File
@@ -38,7 +38,7 @@
<p :id="bodyId" class="body" aria-live="polite">{{ step.def.body }}</p>
<div class="actions">
<button type="button" class="btn btn-skip" @click="onSkip" ref="skipBtn">
Skip tour
Cancel
</button>
<button type="button" class="btn btn-primary" @click="onPrimary" ref="primaryBtn">
{{ primaryLabel }}
@@ -66,12 +66,6 @@ const viewport = ref<{ w: number; h: number }>({
w: typeof window !== 'undefined' ? window.innerWidth : 1024,
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 titleId = 'tutorial-title'
const bodyId = 'tutorial-body'
@@ -80,9 +74,9 @@ const isMobile = computed(() => viewport.value.w <= MOBILE_BREAKPOINT)
const primaryLabel = computed(() => {
const def = step.value?.def
if (!def) return 'Got it'
if (!def) return 'Continue'
if (def.ctaLabel) return def.ctaLabel
return def.next ? 'Next' : 'Got it'
return 'Continue'
})
function resolveAnchor(): HTMLElement | null {
@@ -24,6 +24,12 @@ describe('HelpButton', () => {
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 () => {
const wrapper = mount(HelpButton)
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 {
if (!tutorialEnabled.value) return false
export function shouldShowStep(id: string, ignoreEnabled = false): boolean {
if (!ignoreEnabled && !tutorialEnabled.value) return false
if (sessionSkipped.value) return false
if (tutorialProgress.value[id]) return false
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 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 (import.meta.env.DEV) console.warn(`[tutorial] Unknown step id: ${id}`)
return
}
if (!shouldShowStep(id)) return
if (!shouldShowStep(id, force)) return
if (activeStep.value?.def.id === id) return
if (queue.some((q) => q.def.id === id)) return
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. */
next?: string
placement?: Placement
/** Primary button label. Defaults to "Got it" (or "Next" when `next` is set). */
/** Primary button label. Defaults to "Continue". */
ctaLabel?: string
/**
* 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',
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',
ctaLabel: 'Got it',
ctaLabel: 'Continue',
},
'create-child': {
id: 'create-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.",
placement: 'above',
ctaLabel: 'Got it',
ctaLabel: 'Continue',
anchorSelector: '.fab',
},
'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.',
placement: 'above',
next: 'child-points',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.fab',
},
'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.',
placement: 'below',
next: 'child-click',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.points',
},
'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.',
placement: 'below',
next: 'child-kebab',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.card',
},
'create-chore': {
@@ -72,7 +72,7 @@ export const stepRegistry: Record<string, StepDef> = {
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.',
placement: 'above',
ctaLabel: 'Got it',
ctaLabel: 'Continue',
},
// ── 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".',
placement: 'below',
next: 'edit-chore-points',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#name',
},
'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.',
placement: 'below',
next: 'edit-chore-image',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#points',
},
'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.',
placement: 'below',
next: 'schedule-days-deadline',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.day-chips',
},
'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.',
placement: 'auto',
next: 'schedule-days-exception',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.default-deadline-row',
},
'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.',
placement: 'auto',
next: 'schedule-enable-toggle',
ctaLabel: 'Next',
ctaLabel: 'Continue',
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.',
placement: 'below',
next: 'schedule-interval-start',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.stepper',
},
'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.',
placement: 'below',
next: 'schedule-interval-deadline',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="schedule-interval-start"]',
},
'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.',
placement: 'auto',
next: 'schedule-enable-toggle',
ctaLabel: 'Next',
ctaLabel: 'Continue',
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.',
placement: 'above',
next: 'list-edit-hint',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.fab',
},
'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.',
placement: 'above',
next: 'list-edit-hint',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.fab',
},
'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.',
placement: 'above',
next: 'list-edit-hint',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.fab',
},
'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.',
placement: 'above',
next: 'list-edit-hint',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.fab',
},
'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.',
placement: 'above',
next: 'list-edit-hint',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.fab',
},
'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.',
placement: 'below',
next: 'list-delete-hint',
ctaLabel: 'Next',
ctaLabel: 'Continue',
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."',
placement: 'below',
next: 'edit-kindness-points',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#name',
},
'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.',
placement: 'below',
next: 'edit-kindness-image',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#points',
},
'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."',
placement: 'below',
next: 'edit-penalty-points',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#name',
},
'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.',
placement: 'below',
next: 'edit-penalty-image',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#points',
},
'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."',
placement: 'below',
next: 'edit-reward-description',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#name',
},
'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."',
placement: 'below',
next: 'edit-reward-cost',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#description',
},
'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.',
placement: 'below',
next: 'edit-reward-image',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#cost',
},
'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.",
placement: 'below',
next: 'edit-child-age',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#name',
},
'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.',
placement: 'below',
next: 'edit-child-image',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#age',
},
'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."',
placement: 'below',
next: 'edit-routine-points',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#name',
},
'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.',
placement: 'below',
next: 'edit-routine-image',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: 'input#points',
},
'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.',
placement: 'above',
next: 'create-routine-add-task',
ctaLabel: 'Next',
ctaLabel: 'Continue',
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.',
placement: 'above',
next: 'routine-task-reorder',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.add-task-trigger',
},
'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.',
placement: 'auto',
next: 'routine-task-edit',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="routine-task-reorder"]',
},
'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.',
placement: 'auto',
next: 'routine-task-delete',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="routine-task-edit"]',
},
'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.',
placement: 'auto',
next: 'assign-chore',
ctaLabel: 'Next',
ctaLabel: 'Continue',
},
'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.',
placement: 'above',
next: 'assign-kindness',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(1)',
},
'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.',
placement: 'above',
next: 'assign-reward',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(4)',
},
'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.",
placement: 'above',
next: 'assign-routine',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(3)',
},
'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.',
placement: 'above',
next: 'assign-penalty',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.assign-buttons button:nth-child(2)',
},
'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.',
placement: 'above',
next: 'item-kebab-overview',
ctaLabel: 'Next',
ctaLabel: 'Continue',
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.',
placement: 'below',
next: 'assign-submit',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item',
},
'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.',
placement: 'below',
next: 'assign-submit',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item',
},
'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.',
placement: 'below',
next: 'assign-submit',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item',
},
'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.',
placement: 'below',
next: 'assign-submit',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item',
},
'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.',
placement: 'below',
next: 'assign-submit',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.list-item, .routine-item',
},
'assign-submit': {
@@ -591,10 +591,10 @@ export const stepRegistry: Record<string, StepDef> = {
'chore-kebab-menu': {
id: 'chore-kebab-menu',
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',
next: 'chore-edit-points',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.kebab-menu',
},
'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.',
placement: 'auto',
next: 'chore-schedule',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '[data-tutorial~="chore-edit-points"]',
},
'chore-schedule': {
@@ -644,10 +644,10 @@ export const stepRegistry: Record<string, StepDef> = {
'routine-kebab-menu': {
id: 'routine-kebab-menu',
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',
next: 'routine-edit',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '.kebab-menu',
},
'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.',
placement: 'auto',
next: 'routine-edit-points',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '[data-tutorial="routine-edit"]',
},
'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.',
placement: 'auto',
next: 'routine-schedule',
ctaLabel: 'Next',
ctaLabel: 'Continue',
anchorSelector: '[data-tutorial~="routine-edit-points"]',
},
'routine-schedule': {
+7 -12
View File
@@ -5,20 +5,15 @@
"enabled": true,
"type": "local",
"command": [
"docker",
"run",
"-i",
"--rm",
"-e",
"GITEA_ACCESS_TOKEN",
"-e",
"GITEA_HOST",
"docker.gitea.com/gitea-mcp-server"
"ssh",
"-T",
"devserver.lan",
"/home/ryan/gitea-mcp-wrapper.sh"
],
"environment": {
"GITEA_HOST": "https://git.ryankegel.com",
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
}
"GITEA_HOST": "https://git.ryankegel.com"
},
"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")