The beta feedback flow — a sidebar button that opens a 5-question modal, plus the thank-you state at the end.
<FeedbackButton> · catalog<script setup>
const emit = defineEmits(['open'])
</script>
<template>
<button class="feedback-button" @click="emit('open')">
<span class="feedback-button__icon jp-font">💬</span>
<span class="feedback-button__text">
Give feedback
<span class="feedback-button__sub">You're in the beta — help shape it</span>
</span>
</button>
</template>
<FeedbackButton v-if="betaActive" @open="feedback.showModal()" />
<style scoped>
.feedback-button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: var(--r-md);
background: var(--c-surface-highlight);
text-align: left;
cursor: pointer;
transition: filter .2s;
&:hover {
filter: brightness(.97);
}
}
.feedback-button__icon {
font-size: 1.6rem;
}
.feedback-button__text {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
color: var(--c-text-primary);
}
.feedback-button__sub {
display: block;
font-weight: 400;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
</style>
<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
{
id: 'first_impressions',
type: 'text',
title: 'First impressions of the new app?',
placeholder: 'The good, the bad, the surprising…',
minLength: 5,
},
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
A few words is required
<FeedbackModal> — reject logic ships in FeedbackModal.vue — full source in this drawer, no new component<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
// Next is NEVER disabled. An invalid submit is rejected with feedback.
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
const next = () => {
if (!isValid.value) {
// drop + re-add so the shake restarts on every rejected press
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
watch(value, () => { rejected.value = false }, { deep: true })
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
<FeedbackModal> as Q1 — the rating branch<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
{
id: 'easy_to_use',
type: 'rating',
title: 'Do you find it easy to use?',
scaleEnds: ['Hard', 'Easy'],
},
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
Tap a rating to continue
<FeedbackModal> — same reject-on-Next logic as Q1's rejection card; the rating branch (`value == null`) and its error copy already ship in FeedbackModal.vue — full source in this drawer<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
<FeedbackModal> + RatingScale as Q2 — config only, no new CSS<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
{
id: 'improvement',
type: 'rating',
title: 'Is the new app an improvement?',
scaleEnds: ['Worse', 'Way better'],
},
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
<CheckboxInputGroup> · ui/input/CheckboxInputGroup.vue<FeedbackModal> — the checkbox branch + single-column list CSS<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
{
id: 'top_features',
type: 'checkbox',
title: 'What features matter most to you?',
// This array is the CANONICAL (data) order — it is never the
// render order. The modal binds `feedback.featureOptions`, which
// re-orders these by the persisted per-user shuffle (store card).
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' },
],
},
<!-- Inside FeedbackModal.vue, the checkbox branch binds the
shuffled getter, not question.options: -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<style scoped>
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
}
</style>
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
Pick at least one to continue
<FeedbackModal> — same reject-on-Next logic; the checkbox branch (`value.length >= 1`) and its error copy already ship in FeedbackModal.vue — full source in this drawer<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
<FeedbackModal> as Q1 — text variant, no new CSS<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
{
id: 'other_comments',
type: 'text',
title: 'Any other comments?',
placeholder: 'Suggestions or feature recommendations…',
minLength: 5,
},
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
$5 credit added to your account
v-else / feedback.submitted branch of FeedbackModal.vue — already a TemplateModalMessage emoji="🎉" in the build. ONE change: the boxed <CreditValue> + long body paragraph become the inline coins.png credit line (design call — same asset, no boxed/yellow card).<FeedbackModal> · ui/modals/FeedbackModal.vue — EXISTS as WIP; this spec completes it (diff list in notes)<PopModal> + <TemplateModalMessage> (#footer slot) · ui/components/pop/<Pill leading="green" background="glass"> = the "Question n of 5" eyebrow · ui/components/Pill.vue<PrimaryButton> · <Text> · <Stepper> · <ComboField> · <FieldRegion> · <CheckboxInputGroup>useFeedbackStore · ui/store/feedback.js — extended; spec in the store card (Section 4)<RatingScale> — 1–5 pill input atom, the ONLY new component · scoped CSS in this drawer · catalog<script setup>
import { computed, ref, watch } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import RatingScale from '@/components/RatingScale.vue' // 🟡 new atom — scoped CSS in this drawer
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import { useFeedbackStore } from '@/store/feedback'
const feedback = useFeedbackStore()
const value = ref(feedback.currentQuestion.type === 'checkbox' ? [] : null)
// fresh input per question; back-nav restores the saved answer
watch(() => feedback.currentQuestion, (q) => {
value.value = feedback.answers[q.id] ?? (q.type === 'checkbox' ? [] : null)
rejected.value = false
})
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
// reject-on-Next — Next is never disabled; an invalid submit shakes
// the input and shows the inline error instead (see rejection card)
const rejected = ref(false)
const errorText = computed(() => ({
text: 'A few words is required',
rating: 'Tap a rating to continue',
checkbox: 'Pick at least one to continue',
}[feedback.currentQuestion.type]))
watch(value, () => { rejected.value = false }, { deep: true })
const back = () => {
feedback.updateProgress(feedback.progress.now - 1)
}
const next = () => {
if (!isValid.value) {
rejected.value = false
requestAnimationFrame(() => { rejected.value = true })
return
}
feedback.setAnswer(feedback.currentQuestion.id, value.value)
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji=""
:class="{ 'is-rejected': rejected }">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'"
style="text-align: left" class="feedback-micro-prompt__field">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<RatingScale v-else-if="feedback.currentQuestion.type === 'rating'"
class="feedback-micro-prompt__field"
v-model="value"
:ends="feedback.currentQuestion.scaleEnds" />
<!-- checkbox variant — options come PRE-SHUFFLED from the store -->
<div v-else-if="feedback.currentQuestion.type === 'checkbox'"
class="feedback-micro-prompt__field feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="feedback.featureOptions"
v-model="value"
layout="col"
/>
</div>
<!-- reject feedback — Next pressed with no valid answer -->
<Text v-if="rejected" tag="p" type="body/s-med"
class="feedback-micro-prompt__error" role="alert"
:text="errorText" />
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Back"
:disabled="feedback.progress.now === 1"
@click="back()" />
<PrimaryButton variant="green" size="md"
:label="feedback.progress.now === feedback.progress.total ? 'Send' : 'Next'"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary" text="Thanks for shaping the beta" />
<p class="feedback-micro-prompt__thanks-credit">
<img class="feedback-micro-prompt__thanks-coin"
src="https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png" alt="" />
<Text tag="span" type="body/m-reg" color="text-secondary"
text="$5 credit added to your account" />
</p>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
/* was an orphaned `&__checkbox-grid` (no parent selector, matched
nothing) — flat class, single column: the five labels are too
long for the old 1fr 1fr grid */
.feedback-micro-prompt__checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
/* reject-on-Next — shake whichever input variant is showing and
tint the field destructive; the error line sits tight below */
.is-rejected .feedback-micro-prompt__field {
animation: micro-prompt-shake .4s ease;
:deep(textarea) {
border-color: var(--c-text-destructive);
}
/* rating variant — RatingScale's pills take the same subtle border */
:deep(.feedback-micro-prompt__pill) {
border-color: var(--c-text-destructive);
}
/* checkbox variant — each CheckboxInput row, same border */
:deep(.checkbox-input) {
border-color: var(--c-text-destructive);
}
}
.feedback-micro-prompt__error {
margin: -6px 0 0;
color: var(--c-text-destructive);
text-align: center;
}
/* thanks state — inline coin credit line (see Q6 card) */
.feedback-micro-prompt__thanks-credit {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
margin: 10px auto;
}
.feedback-micro-prompt__thanks-coin {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
@keyframes micro-prompt-shake {
0%, 100% { translate: 0; }
20% { translate: -6px; }
40% { translate: 6px; }
60% { translate: -3px; }
80% { translate: 3px; }
}
</style>
<style scoped>
.feedback-micro-prompt__scale {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.feedback-micro-prompt__scale-end {
flex: none;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: var(--c-text-tertiary);
}
.feedback-micro-prompt__pills {
display: flex;
gap: 6px;
flex: 1;
}
.feedback-micro-prompt__pill {
flex: 1;
height: 38px;
border-radius: var(--r-pill);
border: 1px solid var(--c-border-primary);
background: var(--c-button-gray-bg);
color: var(--c-text-primary);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
cursor: pointer;
transition: background .15s, border-color .15s;
&:hover {
background: var(--c-button-gray-bg-2);
}
/* active = same recipe as variant:green PrimaryButton */
&.is-active {
background: var(--c-button-green-bg);
color: var(--metal-950);
border-color: var(--c-button-green-bg);
}
}
</style>
useFeedbackStore · ui/store/feedback.js — EXISTS; extend in place (keeps modal.show / currentQuestion / progress / updateProgress / submit)getProp / setProp · @shared/storage/local.js — same persistence pattern as surface.js<FeedbackModal
:popped="feedback.modal.show"
@unpop="feedback.hideModal()" />
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
}
}
})