Appearance
Mobile Lite Preview Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the heavy panZoom-based standalone preview with a lightweight mobile-optimized component on mobile devices.
Architecture: CreativePreview.vue detects mobile via pointer: coarse + viewport fallback and conditionally renders either the existing PreviewPanel (desktop) or a new MobileLitePreview (mobile). MobileLitePreview renders a raw <iframe> with CSS-only layout — no panZoom, no device frames, no resize observers. A MobileFormatDropdown reads formats from Vuex and allows switching.
Note on raw iframe vs PreviewIframe: The spec mentions reusing PreviewIframe, but that component internally renders MobilePreviewContainer (iPhone frames, resize handles, dummy website backgrounds) for fullscreen/percentage formats — exactly the overhead we want to avoid on mobile. A raw <iframe> with selectedPreviewCreative.tagFile as src is intentionally simpler. The only thing from PreviewIframe we need is the URL (tagFile), which we read directly from Vuex.
Tech Stack: Vue 2 Options API, Vuex, SCSS, CSS dvh/dvw units
Spec: docs/superpowers/specs/2026-04-16-mobile-lite-preview-design.md
File Structure
| File | Responsibility |
|---|---|
src/pages/Chatbots/CreativePreview.vue | Modify — Add mobile detection, conditional rendering, mobile format selection override |
src/pages/Chatbots/components/BuilderVisuals/Preview/MobileLitePreview.vue | Create — Lightweight preview: raw iframe + CSS layout for 3 display modes |
src/pages/Chatbots/components/BuilderVisuals/Preview/MobileFormatDropdown.vue | Create — Dropdown format selector reading from Vuex |
Chunk 1: MobileFormatDropdown
Task 1: Create MobileFormatDropdown component
This component reads previewCreatives and selectedPreviewCreative from Vuex, shows a button with the current format label, and opens a dropdown list on tap.
Files:
Create:
src/pages/Chatbots/components/BuilderVisuals/Preview/MobileFormatDropdown.vue[ ] Step 1: Create MobileFormatDropdown.vue
vue
<template>
<div
v-if="previewCreatives && previewCreatives.length > 1"
class="mobile-format-dropdown"
>
<button
type="button"
:class="['dropdown-trigger', { 'dropdown-trigger--fullscreen': isFullscreen }]"
@click="toggleDropdown"
>
{{ currentLabel }}
<span :class="['dropdown-arrow', { open: isOpen }]">▲</span>
</button>
<transition name="dropdown-fade">
<div
v-if="isOpen"
class="dropdown-list"
>
<button
v-for="creative in previewCreatives"
:key="creative.id"
type="button"
:class="['dropdown-item', { selected: creative.id === selectedCreativeId }]"
@click="selectFormat(creative)"
>
{{ getFormatLabel(creative.format) }}
</button>
</div>
</transition>
</div>
</template>
<script lang="ts">
import { getFormatLabel } from '@/utils/creativeUtils'
import { mapMutations, mapState } from 'vuex'
import type { State } from '@/store'
export default {
name: 'MobileFormatDropdown',
props: {
isFullscreen: {
type: Boolean,
default: false,
},
},
data() {
return {
isOpen: false,
}
},
computed: {
...mapState({
previewCreatives: ({ preview }: State) => preview.previewCreatives,
selectedPreviewCreative: ({ preview }: State) => preview.selectedPreviewCreative,
}),
selectedCreativeId(): string {
return this.selectedPreviewCreative?.id
},
currentLabel(): string {
if (!this.selectedPreviewCreative) return ''
return getFormatLabel(this.selectedPreviewCreative.format)
},
},
methods: {
...mapMutations(['setSelectedPreviewCreative']),
getFormatLabel,
toggleDropdown() {
this.isOpen = !this.isOpen
},
selectFormat(creative) {
this.setSelectedPreviewCreative(creative.id)
this.isOpen = false
},
handleOutsideClick(event: MouseEvent) {
if (!this.$el.contains(event.target as Node)) {
this.isOpen = false
}
},
},
mounted() {
document.addEventListener('click', this.handleOutsideClick)
},
beforeDestroy() {
document.removeEventListener('click', this.handleOutsideClick)
},
}
</script>
<style scoped lang="scss">
.mobile-format-dropdown {
position: fixed;
bottom: $size-16;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.dropdown-trigger {
@include text('base', 'm', 'odd', 'regular', 500);
display: flex;
align-items: center;
gap: $size-4;
padding: $size-8 $size-16;
border: none;
background-color: $grey-05;
color: $grey-55;
border-radius: $border-radius-base;
user-select: none;
outline: none;
white-space: nowrap;
&--fullscreen {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
color: $white-color;
}
}
.dropdown-arrow {
font-size: 8px;
transition: transform $transition-quick;
&.open {
transform: rotate(180deg);
}
}
.dropdown-list {
position: absolute;
bottom: calc(100% + $size-4);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
background-color: $grey-05;
border-radius: $border-radius-base;
overflow: hidden;
min-width: 100%;
}
.dropdown-item {
@include text('base', 'm', 'odd', 'regular', 500);
padding: $size-8 $size-16;
border: none;
color: $grey-55;
background-color: $grey-05;
outline: none;
white-space: nowrap;
text-align: center;
&.selected {
background-color: #ffdd57;
color: $grey-15;
}
}
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity $transition-quick;
}
.dropdown-fade-enter,
.dropdown-fade-leave-to {
opacity: 0;
}
</style>- [ ] Step 2: Verify lint passes
Run: npm run lint -- --no-fix --ext .vue src/pages/Chatbots/components/BuilderVisuals/Preview/MobileFormatDropdown.vue Expected: No errors
- [ ] Step 3: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Preview/MobileFormatDropdown.vue
git commit -m "feat: add MobileFormatDropdown component for mobile preview"Chunk 2: MobileLitePreview
Task 2: Create MobileLitePreview component
The core lightweight preview. Renders a raw <iframe> using selectedPreviewCreative.tagFile as src. Three CSS display modes based on format type.
Files:
- Create:
src/pages/Chatbots/components/BuilderVisuals/Preview/MobileLitePreview.vue
Key reference files (read before implementing):
src/pages/Chatbots/components/BuilderVisuals/Preview/PreviewIframe.vue— iframe URL comes fromselectedPreviewCreative.tagFile(line 190)src/constants/creativeTypes.ts—CREATIVE_FORMATS.FULLSCREENis the string"fullscreen"src/utils/creativeUtils.ts—hasPercentageFormat()andgetFormatLabel()src/store/modules/preview.ts— Vuex state shape forselectedPreviewCreative[ ] Step 1: Create MobileLitePreview.vue
vue
<template>
<div :class="['mobile-lite-preview', `mode-${displayMode}`]">
<div class="logo-overlay">
<img
v-if="customLogo"
:src="customLogo"
class="preview-logo"
alt="Logo"
>
<img
v-else
src="@/assets/images/icon/cavai-logo-small.svg?url"
class="preview-logo preview-logo--default"
alt="Cavai"
>
</div>
<div
class="iframe-wrapper"
:style="iframeWrapperStyle"
>
<iframe
ref="iframe"
class="preview-iframe"
:src="iframeSrc"
:style="iframeStyle"
:width="iframeWidth"
:height="iframeHeight"
allow="autoplay"
/>
</div>
<MobileFormatDropdown :is-fullscreen="displayMode === 'fullscreen'" />
</div>
</template>
<script lang="ts">
import { CREATIVE_FORMATS } from '@/constants/creativeTypes'
import { hasPercentageFormat } from '@/utils/creativeUtils'
import MobileFormatDropdown from './MobileFormatDropdown.vue'
import { mapState } from 'vuex'
import type { State } from '@/store'
import type { CreativeFormat, ExactFormat } from '@/types/creative'
const SIDE_PADDING = 32
export default {
name: 'MobileLitePreview',
components: { MobileFormatDropdown },
props: {
creativeType: {
type: String,
default: '',
},
chatbotHash: {
type: String,
default: '',
},
customLogo: {
type: String,
default: '',
},
},
data() {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
}
},
computed: {
...mapState({
selectedPreviewCreative: ({ preview }: State) => preview.selectedPreviewCreative,
}),
format(): CreativeFormat {
return this.selectedPreviewCreative?.format
},
iframeSrc(): string {
return this.selectedPreviewCreative?.tagFile || ''
},
isFullscreen(): boolean {
return this.format === CREATIVE_FORMATS.FULLSCREEN
},
isPercentage(): boolean {
if (this.isFullscreen) return false
return hasPercentageFormat(this.format)
},
displayMode(): 'fullscreen' | 'percent' | 'fixed' {
if (this.isFullscreen) return 'fullscreen'
if (this.isPercentage) return 'percent'
return 'fixed'
},
exactFormat(): ExactFormat | null {
if (!this.format || typeof this.format === 'string') return null
return this.format as ExactFormat
},
iframeWidth(): number | string {
if (this.displayMode === 'fullscreen') return '100%'
if (!this.exactFormat) return '100%'
const w = this.exactFormat.width
if (typeof w === 'string') return '100%'
return w
},
iframeHeight(): number | string {
if (this.displayMode === 'fullscreen') return '100%'
if (!this.exactFormat) return '100%'
const h = this.exactFormat.height
if (typeof h === 'string') return h
return h
},
fixedScale(): number {
if (this.displayMode !== 'fixed' || !this.exactFormat) return 1
const w = this.exactFormat.width as number
const h = this.exactFormat.height as number
return Math.min(
(this.viewportWidth - SIDE_PADDING) / w,
(this.viewportHeight - SIDE_PADDING) / h,
1,
)
},
iframeWrapperStyle(): Record<string, string> {
if (this.displayMode === 'fullscreen') {
return {
width: '100dvw',
height: '100dvh',
}
}
if (this.displayMode === 'percent') {
const w = this.exactFormat?.width
const h = this.exactFormat?.height
return {
width: typeof w === 'string' ? w : `${w}px`,
height: typeof h === 'string' ? `${h}` : `${h}px`,
}
}
// Fixed: apply scale transform
const w = this.exactFormat?.width as number
const h = this.exactFormat?.height as number
return {
width: `${w}px`,
height: `${h}px`,
transform: `scale(${this.fixedScale})`,
transformOrigin: 'center center',
}
},
iframeStyle(): Record<string, string> {
if (this.displayMode === 'fullscreen') {
return {
width: '100dvw',
height: '100dvh',
}
}
return {}
},
},
methods: {
onResize() {
this.viewportWidth = window.innerWidth
this.viewportHeight = window.innerHeight
},
},
mounted() {
window.addEventListener('resize', this.onResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize)
},
}
</script>
<style scoped lang="scss">
.mobile-lite-preview {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: $grey-05;
overflow: hidden;
}
.logo-overlay {
position: fixed;
top: $size-16;
left: $size-16;
z-index: 10;
}
.preview-logo {
height: 40px;
&--default {
height: 30px;
}
}
.iframe-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.mode-fullscreen .iframe-wrapper {
width: 100dvw;
height: 100dvh;
}
.preview-iframe {
border: none;
display: block;
}
.mode-fullscreen .preview-iframe {
width: 100dvw;
height: 100dvh;
}
</style>- [ ] Step 2: Verify lint passes
Run: npm run lint -- --no-fix --ext .vue src/pages/Chatbots/components/BuilderVisuals/Preview/MobileLitePreview.vue Expected: No errors
- [ ] Step 3: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Preview/MobileLitePreview.vue
git commit -m "feat: add MobileLitePreview component with three display modes"Chunk 3: CreativePreview Integration
Task 3: Add mobile detection and conditional rendering to CreativePreview
Wire up CreativePreview.vue to detect mobile and render the appropriate component.
Files:
- Modify:
src/pages/Chatbots/CreativePreview.vue
Reference: Current file is 237 lines. The changes are:
- Import MobileLitePreview and CREATIVE_FORMATS
- Add
isMobileDevicedata property (evaluated once on mount) - Add mobile format selection override in
mounted() - Conditionally render MobileLitePreview vs PreviewPanel in template
- [ ] Step 1: Add imports and component registration
In the <script> section, add imports after the existing imports (after line 62):
ts
import MobileLitePreview from '@/pages/Chatbots/components/BuilderVisuals/Preview/MobileLitePreview.vue'
import { CREATIVE_FORMATS } from '@/constants/creativeTypes'Add MobileLitePreview to the components object:
ts
components: {
DevTools,
Icon,
MobileLitePreview,
PreviewPanel,
},- [ ] Step 2: Add mobile detection
Add isMobileDevice to the data() return object (not computed — evaluated once at component creation, which is correct since device type doesn't change mid-session):
ts
isMobileDevice: (() => {
const params = new URLSearchParams(window.location.search)
const mobileOverride = params.get('mobile')
if (mobileOverride === 'true') return true
if (mobileOverride === 'false') return false
const hasCoarsePointer = window.matchMedia('(pointer: coarse)').matches
const isNarrowViewport = window.innerWidth <= 768
return hasCoarsePointer || isNarrowViewport
})(),Note: This uses an IIFE because data() runs before the component is mounted, and this.$route is not yet available. Reading window.location.search directly is simpler and works at data-init time.
- [ ] Step 3: Add
previewCreativesto mapState and add mobile format selection
Add previewCreatives to the existing mapState:
ts
...mapState({
selectedPreviewCreative: ({ preview }: State) => preview.selectedPreviewCreative,
previewCreatives: ({ preview }: State) => preview.previewCreatives,
}),In mounted(), after line 170 (where this.selectedFormatId = id and this.format = format are set), add the mobile format override:
ts
// Override default format selection on mobile: prefer fullscreen, then closest to viewport width
if (this.isMobileDevice && this.previewCreatives?.length > 1) {
const fullscreenCreative = this.previewCreatives.find(
(c) => c.format === CREATIVE_FORMATS.FULLSCREEN,
)
if (fullscreenCreative) {
this.$store.commit('setSelectedPreviewCreative', fullscreenCreative.id)
this.selectedFormatId = fullscreenCreative.id
this.format = fullscreenCreative.format
this.maxWidth = Number(fullscreenCreative.format?.width) || null
this.maxHeight = Number(fullscreenCreative.format?.height) || null
} else {
// No fullscreen — pick format closest to viewport width
const vw = window.innerWidth
let bestCreative = null
let bestDiff = Infinity
for (const c of this.previewCreatives) {
if (!c.format || typeof c.format === 'string') continue
const w = typeof c.format.width === 'number' ? c.format.width : vw
const diff = Math.abs(w - vw)
if (diff < bestDiff) {
bestDiff = diff
bestCreative = c
}
}
if (bestCreative) {
this.$store.commit('setSelectedPreviewCreative', bestCreative.id)
this.selectedFormatId = bestCreative.id
this.format = bestCreative.format
this.maxWidth = Number(bestCreative.format?.width) || null
this.maxHeight = Number(bestCreative.format?.height) || null
}
}
}- [ ] Step 4: Update template for conditional rendering
Replace the existing preview-container div (lines 28-44) with:
html
<div
v-show="!creativesBuilding"
class="preview-container"
>
<MobileLitePreview
v-if="isMobileDevice"
:creative-type="type"
:chatbot-hash="hash"
:custom-logo="customLogo"
/>
<PreviewPanel
v-else
ref="previewIframe"
:creative-id="selectedFormatId"
:chatbot-hash="hash"
:creative-type="type"
:creative-format="format"
:max-width="maxWidth"
:max-height="maxHeight"
:zoom-level="1"
:preview-formats="previewFormats"
show-preview
is-standalone-preview
/>
</div>- [ ] Step 5: Hide back-to-builder button on mobile
Wrap the existing router-link (lines 3-25) with v-if="!isMobileDevice":
html
<router-link
v-if="!isMobileDevice"
v-slot="{ navigate }"
:to="builderUrl"
>
<!-- existing button content unchanged -->
</router-link>The logo on mobile is shown by MobileLitePreview's own logo overlay instead.
- [ ] Step 6: Verify lint passes
Run: npm run lint -- --no-fix --ext .vue src/pages/Chatbots/CreativePreview.vue Expected: No errors
- [ ] Step 7: Verify build succeeds
Run: npm run build Expected: Build succeeds without errors
- [ ] Step 8: Commit
bash
git add src/pages/Chatbots/CreativePreview.vue
git commit -m "feat: add mobile detection and conditional MobileLitePreview rendering"Chunk 4: Manual Testing & Polish
Task 4: Test and fix issues
Test all three display modes using ?mobile=true on desktop.
- [ ] Step 1: Test fullscreen format
- Open a standalone preview URL for a creative with a fullscreen format
- Add
?mobile=trueto the URL (or&mobile=trueif query params exist) - Verify: creative fills entire viewport, no device frame, no panZoom controls
- Verify: logo visible top-left
- Verify: format dropdown visible bottom-center (if multiple formats)
- Verify: format dropdown has semi-transparent style (no solid background bar)
- [ ] Step 2: Test fixed format
- Switch to a fixed format (e.g. 300x600) via the dropdown
- Verify: creative is scaled down to fit, centered vertically and horizontally
- Verify: dark background visible around the creative with padding
- Verify: dropdown trigger has solid background style
- [ ] Step 3: Test percent format
- Switch to a percent format (e.g. 100% x 250)
- Verify: width fills viewport, height is fixed pixel value
- Verify: centered vertically
- [ ] Step 4: Test single-format creative
- Open a creative with only one format
- Verify: no format dropdown shown
- [ ] Step 5: Test desktop is unaffected
- Remove
?mobile=truefrom URL - Verify: PreviewPanel renders as before with panZoom, device frames, format bar
- [ ] Step 6: Test
?mobile=falseoverride
- Resize browser to < 768px width
- Add
?mobile=falseto URL - Verify: desktop PreviewPanel renders despite narrow viewport
- [ ] Step 7: Fix any issues found
Apply fixes as needed based on testing.
- [ ] Step 8: Commit fixes
bash
git add -A
git commit -m "fix: polish mobile lite preview after manual testing"