Skip to content

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

FileResponsibility
src/pages/Chatbots/CreativePreview.vueModify — Add mobile detection, conditional rendering, mobile format selection override
src/pages/Chatbots/components/BuilderVisuals/Preview/MobileLitePreview.vueCreate — Lightweight preview: raw iframe + CSS layout for 3 display modes
src/pages/Chatbots/components/BuilderVisuals/Preview/MobileFormatDropdown.vueCreate — 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 }]">&#9650;</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 from selectedPreviewCreative.tagFile (line 190)

  • src/constants/creativeTypes.tsCREATIVE_FORMATS.FULLSCREEN is the string "fullscreen"

  • src/utils/creativeUtils.tshasPercentageFormat() and getFormatLabel()

  • src/store/modules/preview.ts — Vuex state shape for selectedPreviewCreative

  • [ ] 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:

  1. Import MobileLitePreview and CREATIVE_FORMATS
  2. Add isMobileDevice data property (evaluated once on mount)
  3. Add mobile format selection override in mounted()
  4. 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 previewCreatives to 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
  1. Open a standalone preview URL for a creative with a fullscreen format
  2. Add ?mobile=true to the URL (or &mobile=true if query params exist)
  3. Verify: creative fills entire viewport, no device frame, no panZoom controls
  4. Verify: logo visible top-left
  5. Verify: format dropdown visible bottom-center (if multiple formats)
  6. Verify: format dropdown has semi-transparent style (no solid background bar)
  • [ ] Step 2: Test fixed format
  1. Switch to a fixed format (e.g. 300x600) via the dropdown
  2. Verify: creative is scaled down to fit, centered vertically and horizontally
  3. Verify: dark background visible around the creative with padding
  4. Verify: dropdown trigger has solid background style
  • [ ] Step 3: Test percent format
  1. Switch to a percent format (e.g. 100% x 250)
  2. Verify: width fills viewport, height is fixed pixel value
  3. Verify: centered vertically
  • [ ] Step 4: Test single-format creative
  1. Open a creative with only one format
  2. Verify: no format dropdown shown
  • [ ] Step 5: Test desktop is unaffected
  1. Remove ?mobile=true from URL
  2. Verify: PreviewPanel renders as before with panZoom, device frames, format bar
  • [ ] Step 6: Test ?mobile=false override
  1. Resize browser to < 768px width
  2. Add ?mobile=false to URL
  3. 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"

Internal documentation