From 344d78609af118b6650b7890e94d4395a4c12dc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 13:02:36 +0000 Subject: [PATCH 1/3] docs: proposal for a reflection / support surface Translate the external (Super Productivity-flavored) brainstorming handover onto minded's actual surfaces and pick a direction. Key conclusions: - minded is a doomscroll interrupter, not a task manager, so handover directions B (brain-dump inbox), C (park projects until a date) and most of D (planning/summary tone) are weak fits or category errors here. - Direction A ("broaden what's in the way") is the only strong fit and is already 90% supported by the existing question system (chips, isDontSaveAnswer, the letGo standalone-question pattern, the grounding off-ramp). - Spec a minimal, reversible first slice: soften the UnderstandingProcrastination framing, add an ephemeral present-moment "what's in the way right now?" question with depleted-state chips, and route the depleted answer toward a supportive off-ramp instead of push-through. - Resolves the handover's open questions (no i18n layer exists; questions already are the editable config) and passes minded's documented 90% bar. Awaiting maintainer sign-off on copy before any build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017hbr17HwUxeZzYA3c2icis --- docs/reflection-support-surface.md | 168 +++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/reflection-support-surface.md diff --git a/docs/reflection-support-surface.md b/docs/reflection-support-surface.md new file mode 100644 index 00000000..594b2712 --- /dev/null +++ b/docs/reflection-support-surface.md @@ -0,0 +1,168 @@ +# Concept: a reflection / support surface for hard, low-output stretches + +Status: **proposal / awaiting sign-off.** This translates an external +brainstorming handover (written in Super Productivity's vocabulary) onto +minded's actual surfaces, picks a direction, and specifies a minimal first +slice. Nothing here is committed to a release. Copy is illustrative and needs +maintainer sign-off before wiring — in this app the wording *is* the product. + +Companion to `reflective-companion-concept.md`. That note asks "what can we +*reflect back* to the user?"; this one asks "how do we *carry load* for a +depleted user and make a low-output stretch feel legitimate?" Same 90% bar, +same no-scoreboard guardrails. + +## The intent, in one line + +When someone is in a high-load phase (new parenthood, burnout, ADHD-style +overload), minded's job shifts from "help me notice the pull" to *also* +"help me carry less and not feel like I'm failing on a hard week" — by +**externalizing load** and **normalizing a near-empty day**, never by +measuring the user. + +## First, the honest translation + +The handover assumes a **task manager** (Super Productivity): a procrastination +dialog, a daily-planning flow, a daily summary, projects/tags you can park. +minded is a **doomscroll interrupter** built around the sun. So three of the +four proposed directions are partial or category errors here, and saying so is +most of the value: + +| Handover direction | Fit for minded | Why | +|---|---|---| +| **A. Broaden "what's in the way"** | ✅ Strong | minded already has the reflective-question system (`src/shared/data/questions.ts`), an intervention flow that shows one (`getQuestionSmart.ts` → `Question.tsx`), `chips` for no-typing answers, and `isDontSaveAnswer` for ephemerality. Everything A needs already exists. | +| **B. Frictionless brain-dump / inbox** | ⚠️ Weak | minded is not a capture app and has no inbox/task list. The only brain-dump is the Android-only `SleepWindDown` ("Wind-Down Brain Dump"). A general inbox would fragment the app and violate its minimalism principle ("remove before you add"). **Recommend: do not build.** If anything, widen the existing wind-down dump — not a new module. | +| **C. "Lighter stretch" / park projects until a date** | ❌ N/A | There are no projects/tasks/tags in minded to shelve. The data model has per-day usage and ephemeral answers, nothing to "pause until ." This is a Super Productivity feature with no referent here. | +| **D. Gentle planning / summary tone** | ⚠️ Marginal | minded has no daily-planning or end-of-day-summary screen. The nearest surfaces are dashboard question categories (`RefocusHelperToday` = "Finding Focus Today", `GoodPlansToday`). Tone is already gentle. Small copy nudges are possible but low-value on their own. | + +Also resolved from the handover's open questions: +- **i18n**: there is **no i18n layer** in minded today — all question copy is raw + English strings in the `t:` field. So "German/English aware from the start" is + moot; new copy is English, consistent with the rest of the app. +- **Editable prompt copy without code changes**: questions already *are* the + config — plain data in `questions.ts`. The maintainer edits strings there; no + separate config mechanism is needed or worth adding. + +**Recommendation: pursue Direction A only, in a minded-native form. Decline +B and C; fold D's tone idea into A rather than shipping it alone.** + +## What "Direction A, minded-native" actually is + +Today, during an intervention the app may show one reflective question +(`getQuestionSmart`), the user answers, it fades, and the flow proceeds to the +sun gesture → intent/time selection. There is **no supportive branch**: every +answer leads to the same "now choose how long you'll browse" push-through. The +`UnderstandingProcrastination` category even leans slightly clinical +("What do you think is a factor that **enables your procrastination**"). + +The change is to make **"it's just a hard day" a first-class, supported +answer** at the moment of interruption — one that routes *toward setting the +phone down*, not toward configuring a browsing session. + +Three moving parts, smallest first: + +1. **Soften the framing (pure content).** Reframe the + `UnderstandingProcrastination` lead-ins away from the accusatory register + toward present-moment, non-judgmental phrasing — e.g. "What's in the way + right now?" instead of "What enables your procrastination?". This is a + one-file edit in `questions.ts` (`t:` strings + the category `dashboardTxt`), + fully reversible. + +2. **Make depletion a first-class answer (content + existing `chips`).** Add a + present-moment, intervention-only, **ephemeral** question whose `chips` name + the depleted states as legitimate answers: + `["I'm tired", "overwhelmed", "can't focus", "just a hard day"]` plus the + built-in "Something else…" typed path. Mark it `isDontSaveAnswer: true` and + `isSkipOnDashboard: true` — present-moment only, never logged, never a + record to fall behind on (this is exactly how `HBH7`/`HAU7` and the let-go + question already behave). Naming the state *is* the value; we store nothing. + +3. **Route the depleted answer to a supportive off-ramp, not push-through (tiny + flow touch).** This is the only non-content part. When one of the + "depleted" chips is chosen, instead of proceeding to intent/time selection, + show a single legitimizing line and offer the *already-built* grounding sit + as the gentle exit: + + > "That's a real answer. A near-empty stretch is a fine plan — nothing here + > needs you right now. Want to set the phone down for a minute?" + + The off-ramp reuses the existing **grounding** offer ("Stay a while?", + `grounding.const.ts`) or simply a fade-to-close. No new meditation surface, + no new component — we are re-pointing one branch of the existing flow at a + surface that already exists. The escape hatch (the sun) is untouched and + always available. + +Why reuse grounding/letGo rather than invent a response engine: those surfaces +already embody "permission to do less" — grounding (drag down) = "Stay a while? +just sit", letGo (fling up) = name a thing and release it, neither charges the +answer. The supportive branch is the intervention flow *borrowing* that exit. + +## Passing minded's 90% bar + +Direction A clears the bar in `reflective-companion-concept.md` precisely +because the **user names their own present state** — the app never infers it: + +1. **Observed/self-reported, not inferred.** A tapped "I'm tired" chip is a + first-person, present-tense statement the user volunteered. We are not + guessing a feeling from a stale timestamp (the cut "mood linkage" failure). +2. **Present moment only.** The question fires *at* the interruption and is + discarded immediately. Nothing persists, so nothing can go stale. +3. **No anxiety/scarcity/guilt.** The response *lowers* the bar ("a near-empty + stretch is a fine plan"). It is the opposite of "you've used up your budget." +4. **The helpful response is obvious.** Set the phone down / sit for a minute — + not advice we're unsure of. +5. **Rare and dismissible.** It rides the existing intervention cadence and the + sun escape hatch; it never blocks and auto-fades like every other offer. + +And it honours the anti-features: no mood score, no streak, no history of "hard +days", nothing leaves the device, no scheduled reflection nag. + +## Minimal first slice (what I'd ship to validate the tone) + +Deliberately the smallest reversible thing that tests the voice before any flow +work: + +1. **Content-only PR**, no flow changes: + - Soften `UnderstandingProcrastination` copy + `dashboardTxt` in + `questions.ts` (step 1 above). + - Add the ephemeral "what's in the way right now?" question with the + depleted-state `chips`, `isDontSaveAnswer: true`, `isSkipOnDashboard: true` + (step 2). Modeled exactly on the existing `LET_GO_QUESTION` standalone + pattern (`letGo/letGo.const.ts`) and the `HBH7` ephemeral pattern. + - New `QID` for the question, following the existing id convention. + - Confirm via the answer-save path (`Question.tsx:69-70`, + `syncDataInterface.saveAnswer`) that `isDontSaveAnswer` keeps it out of + storage — i.e. verify nothing is persisted as a metric. + + This alone makes "a hard day" a *nameable* answer at the interruption, with + zero risk and full reversibility. + +2. **Follow-up PR (only if slice 1 feels right):** the supportive off-ramp + (step 3) — re-point the depleted-chip branch at the grounding offer / a + supportive fade instead of intent-time selection. This is the only code that + touches the flow and deserves its own review. + +Ship behind the lightest possible opt-in, or simply as part of the existing +intervention question rotation (it self-limits via the normal cadence). + +## Open questions for the maintainer + +1. **Sign off the copy.** The lead-in reframe and the depleted chips/response + line are the product — exact wording needs your voice before wiring. Drafts + above are placeholders. +2. **Off-ramp target.** For the depleted branch, prefer (a) reuse the grounding + "Stay a while?" offer, or (b) just a supportive line + fade-to-close? (a) is + richer, (b) is lighter. Recommendation: start with (b), it adds less. +3. **Host surface.** Confirm the intervention question flow is the right host + (vs. a dedicated gesture surface like letGo). Recommendation: intervention + flow — it's where "what's in the way right now?" is actually true. +4. **Decline B and C explicitly?** Confirm we're not building a general inbox + (B) or project-parking (C), since neither fits minded's model. The + load-carrying intent is served by A's "permission to do less" branch instead. + +## North star + +The surface's job is to *carry what the user shouldn't have to* and to *make a +hard, low-output stretch feel legitimate* — never to measure the user. When in +doubt, choose the option that adds less. For minded that means: no new module, +no stored answer, one softened question and one supportive branch that points +at a calm exit the app already has. From 7ca5c122e2df92824d1056a6b2555bb05e90c502 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 13:28:21 +0000 Subject: [PATCH 2/3] feat: soften "what's in the way" framing + add depleted-state answer Slice 1 (content-only) of the reflection / support surface proposal. - Soften the UnderstandingProcrastination framing away from the accusatory register: category heading -> "What's in the Way"; reword UP1 and UP2 to present-moment, non-judgmental phrasing. - Add UP8: a present-moment, intervention-only, ephemeral question "What's in the way right now" with depleted-state chips (tired / overwhelmed / can't focus / just a hard day). Marked isDontSaveAnswer + isSkipOnDashboard, so naming the state is the whole value and nothing is persisted as a metric. The chips are an intentional, inline-documented exception to the general "no chips on emotionally-loaded prompts" guidance: nothing is stored, so there is no self-report record to skew, and the goal is to legitimize naming the state, not to measure it. Clears the app's documented ~90% bar (self-reported, present-moment, no anxiety/scarcity/guilt, dismissible). The supportive off-ramp that routes a depleted answer away from push-through (slice 2) is held for maintainer sign-off. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017hbr17HwUxeZzYA3c2icis --- docs/reflection-support-surface.md | 27 ++++++++++++++++++++----- extension/src/shared/data/questionId.ts | 1 + extension/src/shared/data/questions.ts | 21 ++++++++++++++++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/reflection-support-surface.md b/docs/reflection-support-surface.md index 594b2712..06fea256 100644 --- a/docs/reflection-support-surface.md +++ b/docs/reflection-support-surface.md @@ -1,10 +1,27 @@ # Concept: a reflection / support surface for hard, low-output stretches -Status: **proposal / awaiting sign-off.** This translates an external -brainstorming handover (written in Super Productivity's vocabulary) onto -minded's actual surfaces, picks a direction, and specifies a minimal first -slice. Nothing here is committed to a release. Copy is illustrative and needs -maintainer sign-off before wiring — in this app the wording *is* the product. +Status: **slice 1 implemented (content-only); slice 2 awaiting sign-off.** This +translates an external brainstorming handover (written in Super Productivity's +vocabulary) onto minded's actual surfaces, picks a direction, and specifies a +minimal first slice. The content-only slice 1 below has been shipped on this +branch; the supportive off-ramp (slice 2) is held for sign-off. Copy is still +tunable — it's plain data in `questions.ts` — and in this app the wording *is* +the product, so adjust freely. + +**Slice 1, as implemented** (`questions.ts`, `questionId.ts`): +- Softened the `UnderstandingProcrastination` framing: category heading + "Understanding Procrastination" → "What's in the Way"; UP1 "What do you think + is a factor that enables your procrastination" → "What tends to get in the way + for you"; UP2 "Why are you visiting this website" → "What brings you to this + website right now". +- Added `UP8`: a present-moment, intervention-only, **ephemeral** question + "What's in the way right now" with depleted-state chips + (`I'm tired` / `overwhelmed` / `can't focus` / `just a hard day`), + `isDontSaveAnswer: true` + `isSkipOnDashboard: true`. Nothing is persisted. +- Note: UP8's chips are an intentional, documented exception to the general + "no chips on emotionally-loaded prompts" guidance — justified inline because + the answer is never stored (no self-report record to skew) and the goal is to + legitimize *naming* the state, not measure it. Companion to `reflective-companion-concept.md`. That note asks "what can we *reflect back* to the user?"; this one asks "how do we *carry load* for a diff --git a/extension/src/shared/data/questionId.ts b/extension/src/shared/data/questionId.ts index e86236e7..0d390bc9 100644 --- a/extension/src/shared/data/questionId.ts +++ b/extension/src/shared/data/questionId.ts @@ -120,6 +120,7 @@ export enum QID { UP5 = "UP5", UP6 = "UP6", UP7 = "UP7", + UP8 = "UP8", IN1 = "IN1", IN2 = "IN2", diff --git a/extension/src/shared/data/questions.ts b/extension/src/shared/data/questions.ts index b6dd36d9..cc562bed 100644 --- a/extension/src/shared/data/questions.ts +++ b/extension/src/shared/data/questions.ts @@ -659,13 +659,28 @@ export const QUESTION_CATEGORIES: { }, [QuestionCategoryId.UnderstandingProcrastination]: { questions: [ + { + // Present-moment, never saved, intervention-only. The depleted-state + // chips deliberately make "it's just a hard day" a first-class answer + // at the moment of interruption. This is a narrow, intentional + // exception to the general "no chips on emotionally-loaded prompts" + // guidance on `Question.chips`: because the answer is never persisted + // (`isDontSaveAnswer`) there is no self-report record to skew, and the + // point is to legitimize *naming* the state, not to measure it. Naming + // it is the whole value. + id: QID.UP8, + t: "What's in the way right now", + chips: ["I'm tired", "overwhelmed", "can't focus", "just a hard day"], + isDontSaveAnswer: true, + isSkipOnDashboard: true, + }, { id: QID.UP1, - t: "What do you think is a factor that enables your procrastination", + t: "What tends to get in the way for you", }, { id: QID.UP2, - t: "Why are you visiting this website", + t: "What brings you to this website right now", limitTo: ["BrowserExtension"], }, { id: QID.UP3, t: "Where does my time tend to go" }, @@ -686,7 +701,7 @@ export const QUESTION_CATEGORIES: { }, ], isMorningCategory: true, - dashboardTxt: "Understanding Procrastination", + dashboardTxt: "What's in the Way", }, [QuestionCategoryId.Insomnia]: { questions: [ From c0f26d97db9cd195ffcc514f906112014fd24e40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 14:15:55 +0000 Subject: [PATCH 3/3] feat: supportive off-ramp for depleted-state answers (slice 2) When a depleted-state chip is tapped on UP8 ("What's in the way right now"), acknowledge it with one supportive line and ease out to the resting companion sun, instead of proceeding into the sun-instructions -> choose-a-browse-session flow. This resolves the "name-and-abandon" dissonance the review flagged: naming "I'm tired" now leads toward setting things down, not pushing through. - Add a content-driven `supportiveResponse` field on `Question`. When set, a tapped chip routes to the off-ramp via the existing onSkip -> handleSkip fade (the established resting-sun exit) rather than onSuccess. Never force-closes the tab, never grants a timed session. - Scope is narrow on purpose: only chips take the exit. A typed "Something else..." answer is genuinely something else and follows the normal flow (this also sidesteps handleSkip's active-editing guard). - Transitions stay soft: question/chips fade out, then the line fades in (reusing slideUpFadeIn); the resting-sun hand-off keeps its existing fade. - Lock the data contract with supportiveResponse.test.ts (pairs with chips + isDontSaveAnswer; UP8 carries the full present-moment contract). Also folds in review fixes from slice 1: UP8 moved to the end of the category (ascending-id order), comment condensed, and UP1 reworded to "What tends to hold you back" to remove the UP1/UP8 overlap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017hbr17HwUxeZzYA3c2icis --- docs/reflection-support-surface.md | 57 +++-- .../components/interaction/Question.scss | 27 +++ .../components/interaction/Question.tsx | 217 +++++++++++------- .../data/__tests__/supportiveResponse.test.ts | 43 ++++ extension/src/shared/data/questions.ts | 46 ++-- 5 files changed, 274 insertions(+), 116 deletions(-) create mode 100644 extension/src/shared/data/__tests__/supportiveResponse.test.ts diff --git a/docs/reflection-support-surface.md b/docs/reflection-support-surface.md index 06fea256..2b440f92 100644 --- a/docs/reflection-support-surface.md +++ b/docs/reflection-support-surface.md @@ -1,19 +1,17 @@ # Concept: a reflection / support surface for hard, low-output stretches -Status: **slice 1 implemented (content-only); slice 2 awaiting sign-off.** This -translates an external brainstorming handover (written in Super Productivity's -vocabulary) onto minded's actual surfaces, picks a direction, and specifies a -minimal first slice. The content-only slice 1 below has been shipped on this -branch; the supportive off-ramp (slice 2) is held for sign-off. Copy is still -tunable — it's plain data in `questions.ts` — and in this app the wording *is* -the product, so adjust freely. +Status: **slices 1 + 2 implemented.** This translates an external brainstorming +handover (written in Super Productivity's vocabulary) onto minded's actual +surfaces, picks a direction, and ships a minimal slice. Copy is still tunable — +it's plain data in `questions.ts` — and in this app the wording *is* the +product, so adjust freely. **Slice 1, as implemented** (`questions.ts`, `questionId.ts`): - Softened the `UnderstandingProcrastination` framing: category heading "Understanding Procrastination" → "What's in the Way"; UP1 "What do you think - is a factor that enables your procrastination" → "What tends to get in the way - for you"; UP2 "Why are you visiting this website" → "What brings you to this - website right now". + is a factor that enables your procrastination" → "What tends to hold you back"; + UP2 "Why are you visiting this website" → "What brings you to this website + right now". - Added `UP8`: a present-moment, intervention-only, **ephemeral** question "What's in the way right now" with depleted-state chips (`I'm tired` / `overwhelmed` / `can't focus` / `just a hard day`), @@ -23,6 +21,21 @@ the product, so adjust freely. the answer is never stored (no self-report record to skew) and the goal is to legitimize *naming* the state, not measure it. +**Slice 2, as implemented** (`questions.ts`, `Question.tsx`, `Question.scss`): +- Added a content-driven `supportiveResponse` field on `Question`. When set, + tapping a chip shows that one supportive line and eases out to the resting + companion sun (via the existing `onSkip` → `handleSkip` fade) instead of + proceeding into the sun-instructions → choose-a-browse-session flow. This is + the off-ramp: it acknowledges the named state and gives permission to set + things down, never pushing through and never force-closing the tab. +- UP8's line: *"That's a real answer. Nothing here needs you right now — it's + okay to set it down and rest."* +- Scope is deliberately narrow: only the depleted-state **chips** take this + exit. A typed "Something else…" answer is genuinely something else and follows + the normal flow (this also sidesteps `handleSkip`'s active-editing guard). +- Transitions stay soft: the question/chips fade out, then the line fades in + (reusing `slideUpFadeIn`); the resting-sun hand-off keeps its existing fade. + Companion to `reflective-companion-concept.md`. That note asks "what can we *reflect back* to the user?"; this one asks "how do we *carry load* for a depleted user and make a low-output stretch feel legitimate?" Same 90% bar, @@ -153,10 +166,11 @@ work: This alone makes "a hard day" a *nameable* answer at the interruption, with zero risk and full reversibility. -2. **Follow-up PR (only if slice 1 feels right):** the supportive off-ramp - (step 3) — re-point the depleted-chip branch at the grounding offer / a - supportive fade instead of intent-time selection. This is the only code that - touches the flow and deserves its own review. +2. **Follow-up — the supportive off-ramp (now built, slice 2):** the + depleted-chip branch eases out to the resting companion sun behind a single + supportive line, rather than proceeding to intent/time selection. Chose the + lighter "supportive line + fade-to-resting-sun" over reusing the grounding + offer (the grounding "Stay a while?" offer is gated to the dashboard). Ship behind the lightest possible opt-in, or simply as part of the existing intervention question rotation (it self-limits via the normal cadence). @@ -164,14 +178,13 @@ intervention question rotation (it self-limits via the normal cadence). ## Open questions for the maintainer 1. **Sign off the copy.** The lead-in reframe and the depleted chips/response - line are the product — exact wording needs your voice before wiring. Drafts - above are placeholders. -2. **Off-ramp target.** For the depleted branch, prefer (a) reuse the grounding - "Stay a while?" offer, or (b) just a supportive line + fade-to-close? (a) is - richer, (b) is lighter. Recommendation: start with (b), it adds less. -3. **Host surface.** Confirm the intervention question flow is the right host - (vs. a dedicated gesture surface like letGo). Recommendation: intervention - flow — it's where "what's in the way right now?" is actually true. + line are the product — exact wording is easy to tune (plain data in + `questions.ts`). Current strings are reasonable defaults, not final. +2. **Off-ramp target — decided.** Shipped the lighter option: a supportive line + then a soft fade to the resting sun (not the dashboard grounding offer, which + is gesture-gated to the dashboard, and not a forced tab-close). +3. **Host surface — decided.** The intervention question flow hosts it — it's + where "what's in the way right now?" is actually true. 4. **Decline B and C explicitly?** Confirm we're not building a general inbox (B) or project-parking (C), since neither fits minded's model. The load-carrying intent is served by A's "permission to do less" branch instead. diff --git a/extension/src/shared/components/interaction/Question.scss b/extension/src/shared/components/interaction/Question.scss index 8c56da19..430b391e 100644 --- a/extension/src/shared/components/interaction/Question.scss +++ b/extension/src/shared/components/interaction/Question.scss @@ -27,6 +27,33 @@ } } +// The question + chips/input fade out softly before the supportive line takes +// over (see Question.tsx runSupportiveExit) — never a hard cut. Matches the +// chips' own exit timing. +.question-content { + transition: opacity 0.25s ease-out; + + &.is-exiting { + opacity: 0; + pointer-events: none; + } +} + +// The supportive line shown when a depleted-state chip is tapped. Reuses the +// same gentle rise the question text uses, so it eases in rather than snapping. +.question-supportive { + display: flex; + align-items: flex-end; + justify-content: center; + color: var(--c-fg-full-emphasis); + + > span { + opacity: 0; + transform: translateY(15px); + animation: slideUpFadeIn 0.6s ease-out forwards; + } +} + @keyframes slideUpFadeIn { 0% { transform: translateY(15px); diff --git a/extension/src/shared/components/interaction/Question.tsx b/extension/src/shared/components/interaction/Question.tsx index 665ca933..c68967f0 100644 --- a/extension/src/shared/components/interaction/Question.tsx +++ b/extension/src/shared/components/interaction/Question.tsx @@ -1,5 +1,5 @@ /* @refresh reload */ -import { createSignal, For, JSX, Show } from "solid-js"; +import { createSignal, For, JSX, onCleanup, Show } from "solid-js"; import { QuestionForPrompt } from "@src/shared/data/questions"; import { Answer } from "@src/dataInterface/syncData"; import { @@ -15,6 +15,11 @@ import Btn from "@src/shared/components/ui/Btn"; // `.question-chips.is-exiting` opacity transition in Question.scss. const CHIPS_FADE_MS = 250; +// How long the supportive line lingers before easing out to the resting sun — +// long enough to read a sentence, short enough not to demand attention. In the +// same register as the grounding flow's "Be proud!" praise. +const SUPPORTIVE_RESPONSE_MS = 3200; + export const Question: (props: { initialQuestion: QuestionForPrompt; answers: Answer[]; @@ -82,6 +87,45 @@ export const Question: (props: { const hasChips = (question.chips?.length ?? 0) > 0; const [getChipsExiting, setChipsExiting] = createSignal(false); + // Supportive off-ramp (see `Question.supportiveResponse`): a tapped chip names + // a depleted state, so instead of proceeding into the choose-a-session flow we + // show one supportive line and ease out to the resting sun via onSkip. The + // existing content fades first, then the line fades in — never a hard cut. + const [getSupportiveMsg, setSupportiveMsg] = createSignal( + null, + ); + const [getIsContentExiting, setIsContentExiting] = createSignal(false); + let supportiveSwapTimeout: number | undefined; + let supportiveExitTimeout: number | undefined; + onCleanup(() => { + if (supportiveSwapTimeout) window.clearTimeout(supportiveSwapTimeout); + if (supportiveExitTimeout) window.clearTimeout(supportiveExitTimeout); + }); + + const runSupportiveExit = (chip: string) => { + props.onCancelCountdown(); + const answerTxt = question.prompt ? `${question.prompt} ${chip}` : chip; + // Honour the save guard like any answer; UP8 sets isDontSaveAnswer, so the + // named state leaves no record — naming it is the whole value. + if (!question.isDontSaveAnswer) { + void saveAnswer({ + questionCategoryId: question.categoryId, + qid: question.id, + val: answerTxt, + ts: Date.now(), + id: nanoid(), + }); + } + setIsContentExiting(true); + supportiveSwapTimeout = window.setTimeout(() => { + setSupportiveMsg(question.supportiveResponse ?? null); + }, CHIPS_FADE_MS); + supportiveExitTimeout = window.setTimeout( + () => props.onSkip(), + CHIPS_FADE_MS + SUPPORTIVE_RESPONSE_MS, + ); + }; + // "Something else…" swaps the chips for the text input. Fade the chips out // first rather than snapping them away — calmness is the product, so even // this small swap softens (matches the input's own fade-in). @@ -95,6 +139,10 @@ export const Question: (props: { // has a prompt prefix, append the chip to it so the saved text matches the // pre-filled input exactly. Submits immediately — taps are the whole point. const submitChip = (chip: string) => { + if (question.supportiveResponse) { + runSupportiveExit(chip); + return; + } props.onCancelCountdown(); const answerTxt = question.prompt ? `${question.prompt} ${chip}` : chip; void submitAnswer(answerTxt); @@ -102,94 +150,107 @@ export const Question: (props: { return (
-
{ - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - revealInput(); - } - } - } - > - {formatQuestionText(question.t)} -
- - + +
+ {getSupportiveMsg()} +
+
+ +
- - {(chip) => ( - // `toggle` is borrowed only for its compact pill shape — these - // chips submit-and-dismiss and never show a selected state. - submitChip(chip)}> - {chip} - - )} - - { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + revealInput(); + } + } + } > - Something else… - -
-
+ {formatQuestionText(question.t)} +
-
- {/* Free-text questions (no chips) get a faint pen icon where the + +
+ + {(chip) => ( + // `toggle` is borrowed only for its compact pill shape — these + // chips submit-and-dismiss and never show a selected state. + submitChip(chip)}> + {chip} + + )} + + + Something else… + +
+
+ +
+ {/* Free-text questions (no chips) get a faint pen icon where the textarea's first line lands: it shows you can tap to write, and where, then fades out as the field fades in. Chips carry their own tappable affordance, so they skip the hint. The question div above is the labelled button, so the hint is decorative (aria-hidden) — a touch/mouse affordance only. */} - - - + + + -
- props.onValueChange?.(val)} - onSubmit={submitAnswer} - /> +
+ props.onValueChange?.(val)} + onSubmit={submitAnswer} + /> +
+
-
+ ); }; diff --git a/extension/src/shared/data/__tests__/supportiveResponse.test.ts b/extension/src/shared/data/__tests__/supportiveResponse.test.ts new file mode 100644 index 00000000..5bf8224a --- /dev/null +++ b/extension/src/shared/data/__tests__/supportiveResponse.test.ts @@ -0,0 +1,43 @@ +import { QUESTIONS } from "@src/shared/data/questions"; +import { QID } from "@src/shared/data/questionId"; + +// Guards the data contract for the supportive off-ramp (Question.tsx +// runSupportiveExit). A question with `supportiveResponse` routes a tapped chip +// to a calm exit instead of the choose-a-session flow, so the pairing it relies +// on must hold. Component rendering isn't unit-tested (node env), so the data is +// where we hold the line. +describe("supportiveResponse questions", () => { + const withSupportive = QUESTIONS.filter( + (q) => q.supportiveResponse !== undefined, + ); + + it("exists on at least one question", () => { + expect(withSupportive.length).toBeGreaterThan(0); + }); + + it("only pairs with chips + isDontSaveAnswer", () => { + // The off-ramp is triggered by a tapped chip, and naming a depleted state + // must leave no record — so both are required wherever supportiveResponse + // is set (see the field's doc-comment in questions.ts). + for (const q of withSupportive) { + expect(q.chips?.length ?? 0).toBeGreaterThanOrEqual(2); + expect(q.isDontSaveAnswer).toBe(true); + } + }); + + it("has a non-blank, single-line message", () => { + for (const q of withSupportive) { + expect(q.supportiveResponse!.trim().length).toBeGreaterThan(0); + expect(q.supportiveResponse!).not.toContain("\n"); + } + }); + + it("UP8 carries the full present-moment contract", () => { + const up8 = QUESTIONS.find((q) => q.id === QID.UP8); + expect(up8).toBeDefined(); + expect(up8!.chips?.length).toBeGreaterThanOrEqual(2); + expect(up8!.supportiveResponse).toBeTruthy(); + expect(up8!.isDontSaveAnswer).toBe(true); + expect(up8!.isSkipOnDashboard).toBe(true); + }); +}); diff --git a/extension/src/shared/data/questions.ts b/extension/src/shared/data/questions.ts index cc562bed..278a1223 100644 --- a/extension/src/shared/data/questions.ts +++ b/extension/src/shared/data/questions.ts @@ -67,11 +67,26 @@ export interface Question { * always reveals it. When `prompt` is set, the tapped chip is appended to it, * exactly as a typed continuation would be, so the saved answer reads the same * whether tapped or typed. + * + * The one narrow exception to "never emotionally-loaded" is a question that + * also sets `supportiveResponse` + `isDontSaveAnswer` (see UP8): there the + * charged chips are the point — naming a depleted state — and because nothing + * is persisted there is no self-report record to skew. */ chips?: string[]; limitTo?: LimitToOpts; isSkipOnDashboard?: boolean; isDontSaveAnswer?: boolean; + /** + * When set, tapping a chip on this question does NOT proceed into the normal + * post-question flow (sun instructions → choosing a browse session). Instead + * it shows this one supportive line and eases out to the resting companion + * sun — acknowledging the named state and giving permission to set things + * down, never pushing through. Only the chips take this exit; a typed + * "Something else…" answer is genuinely something else and follows the normal + * flow. Pair with `isDontSaveAnswer` so naming the state leaves no record. + */ + supportiveResponse?: string; } export interface QuestionForPrompt extends Question { @@ -659,24 +674,9 @@ export const QUESTION_CATEGORIES: { }, [QuestionCategoryId.UnderstandingProcrastination]: { questions: [ - { - // Present-moment, never saved, intervention-only. The depleted-state - // chips deliberately make "it's just a hard day" a first-class answer - // at the moment of interruption. This is a narrow, intentional - // exception to the general "no chips on emotionally-loaded prompts" - // guidance on `Question.chips`: because the answer is never persisted - // (`isDontSaveAnswer`) there is no self-report record to skew, and the - // point is to legitimize *naming* the state, not to measure it. Naming - // it is the whole value. - id: QID.UP8, - t: "What's in the way right now", - chips: ["I'm tired", "overwhelmed", "can't focus", "just a hard day"], - isDontSaveAnswer: true, - isSkipOnDashboard: true, - }, { id: QID.UP1, - t: "What tends to get in the way for you", + t: "What tends to hold you back", }, { id: QID.UP2, @@ -699,6 +699,20 @@ export const QUESTION_CATEGORIES: { id: QID.UP7, t: "What emotions does this task bring up", }, + { + // Intervention-only and never saved: the depleted-state chips make + // "it's just a hard day" a first-class answer in the moment. A narrow, + // intentional exception to the "no chips on emotionally-loaded prompts" + // guidance on `Question.chips` — nothing is persisted, so there is no + // self-report record to skew; naming the state is the whole value. + id: QID.UP8, + t: "What's in the way right now", + chips: ["I'm tired", "overwhelmed", "can't focus", "just a hard day"], + supportiveResponse: + "That's a real answer. Nothing here needs you right now — it's okay to set it down and rest.", + isDontSaveAnswer: true, + isSkipOnDashboard: true, + }, ], isMorningCategory: true, dashboardTxt: "What's in the Way",