Components
← Mockups

Feedback flow

The beta feedback flow — a sidebar button that opens a 5-question modal, plus the thank-you state at the end.

Entry points

1 surface · 1 store
A · Feedback button

Questionnaire

1 modal · 5 questions
Q1 · text question
Q1 · empty-submit rejection
REJECT STYLE · SYNCS ALL REJECTION CARDS
Q2 · rating question
Q2 · empty-rating rejection
REJECT STYLE · SYNCS ALL REJECTION CARDS
Q3 · rating question
Q4 · checkbox question
Q4 · empty-submit rejection
REJECT STYLE · SYNCS ALL REJECTION CARDS
Q5 · text question (merged)

Thanks

1 state · close-out
Q6 · thank-you state

Store & wiring

1 store · extend in place
useFeedbackStore ui/store/feedback.js — extend the existing store · answers, resume, Q4 shuffle
useFeedbackStore · ui/store/feedback.js — EXISTS; extend in place (keeps modal.show / currentQuestion / progress / updateProgress / submit)
✅ App.vue wiring — ALREADY SHIPPED (block below, reference only)
getProp / setProp · @shared/storage/local.js — same persistence pattern as surface.js
Vue · ui/App.vue — ALREADY IN THE BUILD, reference only (no change)
<FeedbackModal
    :popped="feedback.modal.show"
    @unpop="feedback.hideModal()" />
JS · ui/store/feedback.js — the existing store, extended in place. Keeps modal.show / questions / currentQuestion / progress / updateProgress / submit; adds answers, resume, and the Q4 shuffle. Persistence mirrors surface.js
import { defineStore } from 'pinia';
import { getProp, setProp } from '@shared/storage/local.js'

// Final beta questions — Q1 unchanged; Q2–Q5 replace the old
// speed / usability / features_used / anything_else entries.
// (Per-question cards in Section 2 show each entry with notes.)
const qs = [
    {
        id:          'first_impressions',
        type:        'text',
        title:       'First impressions of the new app?',
        placeholder: 'The good, the bad, the surprising…',
        minLength:   5,
    },
    {
        id:        'easy_to_use',
        type:      'rating',
        title:     'Do you find it easy to use?',
        scaleEnds: ['Hard', 'Easy'],
    },
    {
        id:        'improvement',
        type:      'rating',
        title:     'Is the new app an improvement?',
        scaleEnds: ['Worse', 'Way better'],
    },
    {
        id:      'top_features',
        type:    'checkbox',
        title:   'What features matter most to you?',
        options: [
            { label: 'Improved search results', value: 'search-results' },
            { label: 'Favoriting emoji',        value: 'favorites'      },
            { label: 'Curated emoji lists',     value: 'curated-lists'  },
            { label: 'More recent emoji',       value: 'recents'        },
            { label: 'Copying emoji combos',    value: 'combos'         },
        ],
    },
    {
        id:          'other_comments',
        type:        'text',
        title:       'Any other comments?',
        placeholder: 'Suggestions or feature recommendations…',
        minLength:   5,
    },
]

// Fisher–Yates — unbiased. (Never sort-by-random: it skews the deal.)
const shuffle = (list) => {
    const out = [...list]
    for (let i = out.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1))
        const tmp = out[i]
        out[i] = out[j]
        out[j] = tmp
    }
    return out
}

// Persisted state → storage key. Same manual getProp/setProp
// pattern as surface.js; keys are feedback_-prefixed to keep the
// shared local-storage prop space collision-free.
const persisted = {
    feedback_step:        'feedback_step',
    answers:              'feedback_answers',
    started_at:           'feedback_started_at',
    completed_at:         'feedback_completed_at',
    feature_option_order: 'feedback_option_order',
}

// ⚠ Serialization: on the web build getProp/setProp fall back to
// RAW localStorage, which stringifies everything ("[object Object]",
// "a,b,c", "3"). surface.js gets away with it — it only ever stores
// one scalar string — but answers is an OBJECT, the Q4 order is an
// ARRAY, and the step is a NUMBER. So those two are written as
// JSON.stringify(...) below and decoded here; the step re-Number()s.
// The ISO timestamps are plain strings and round-trip as-is.
const decode = {
    feedback_step:        (v) => Number(v),
    answers:              (v) => typeof v === 'string' ? JSON.parse(v) : v,
    feature_option_order: (v) => typeof v === 'string' ? JSON.parse(v) : v,
}

export const useFeedbackStore = defineStore('feedback', {
  state: () => {
    return {
        modal: {
            show: false
        },
        questions: qs,
        currentQuestion: qs[0],
        progress: { now: 1, total: qs.length },
        submitted: false,

        // NEW — persisted program state
        feedback_step: 1,
        answers: {},
        started_at: null,
        completed_at: null,
        // Q4 render order — array of option VALUES, shuffled ONCE per
        // user on first hydrate, then persisted. Never re-shuffled on
        // back-nav, resume, or reopen: one user always sees one order,
        // and position bias averages out across the cohort.
        feature_option_order: null,
    }
  },
  getters: {
    // Q4 options re-ordered by the persisted shuffle — the modal's
    // checkbox branch binds THIS, never question.options directly.
    featureOptions(state) {
        const opts = qs[3].options
        if (!state.feature_option_order) return opts
        return state.feature_option_order
            .map((v) => opts.find((o) => o.value === v))
            .filter(Boolean)
    },
  },
  actions: {
    showModal() {
        this.modal.show = true
    },
    hideModal() {
        this.modal.show = false
    },
    // Call once at APP START — not lazily on first showModal:
    // resume + back-nav need answers, step and the Q4 deal restored
    // before the modal first opens.
    async hydrate() {
        for (const [field, key] of Object.entries(persisted)) {
            const v = await getProp(key)
            if (v != null) this[field] = (decode[field] ?? ((x) => x))(v)
        }
        if (!this.started_at) {
            this.started_at = new Date().toISOString()
            setProp('feedback_started_at', this.started_at)
        }
        // First hydrate ever → deal the Q4 order once, persist forever.
        if (!this.feature_option_order) {
            this.feature_option_order = shuffle(qs[3].options).map((o) => o.value)
            setProp('feedback_option_order', JSON.stringify(this.feature_option_order))
        }
        if (this.completed_at) this.submitted = true
        this.updateProgress(this.feedback_step)
    },
    setAnswer(id, value) {
        this.answers = { ...this.answers, [id]: value }
        setProp('feedback_answers', JSON.stringify(this.answers))
    },
    updateProgress(val) {
        this.progress.now = val
        this.currentQuestion = this.questions[val - 1] ?? qs[0]
        this.feedback_step = val
        setProp('feedback_step', val)
    },
    submit() {
        // POST { answers, started_at, option_order: feature_option_order }
        // — option_order lets position effects be analyzed server-side.
        // $5 credit is granted server-side on success. The three lines
        // below belong in the POST **success** handler — on failure
        // leave completed_at AND submitted unset (a persisted
        // completed_at would replay as submitted=true on next hydrate
        // and strand the retry). Answers are persisted, so Send is
        // its own retry.
        this.completed_at = new Date().toISOString()
        setProp('feedback_completed_at', this.completed_at)
        this.submitted = true
    }
  }
})