diff --git a/frontend/.tmp-pw-retrofit.mjs b/frontend/.tmp-pw-retrofit.mjs deleted file mode 100644 index f5f33406..00000000 --- a/frontend/.tmp-pw-retrofit.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { chromium } from '@playwright/test' -import { writeFileSync } from 'node:fs' -const OUT = 'D:/Temp/claude/D--JobRoomz-KArtSell-Aegis/da832daa-83e6-4660-8d4b-a698136f10e3/scratchpad/pw-capture' -const BASE = 'http://127.0.0.1:5173' -const pages = [ - ['31-sell-decision', '/research/sell-decision'], - ['32-data-quality', '/ops/data-quality'], - ['33-rebalance', '/portfolio/rebalance'], - ['34-market-ingestion', '/ops/market-data-ingestion'], - ['35-ingestion-status', '/ops/market-data-history'], - ['36-risk-dashboard', '/portfolio/risk'], -] - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) -const consoleLog = [] -const pageErrors = [] -page.on('console', msg => { if (msg.type() === 'error' || msg.type() === 'warning') consoleLog.push(`[${msg.type()}] ${msg.text()}`) }) -page.on('pageerror', err => pageErrors.push(String(err))) - -for (const [name, path] of pages) { - consoleLog.push(`--- ${name} ${path} ---`) - await page.goto(BASE + path, { waitUntil: 'networkidle' }).catch(e => pageErrors.push(`goto ${path}: ${e}`)) - await page.waitForTimeout(400) - await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true }) -} - -writeFileSync(`${OUT}/retrofit-console.log`, consoleLog.join('\n')) -writeFileSync(`${OUT}/retrofit-page-errors.log`, pageErrors.join('\n')) -await browser.close() -console.log('DONE', 'console:', consoleLog.length, 'errors:', pageErrors.length) diff --git a/frontend/src/shared/composables/useAnimatedCollapse.ts b/frontend/src/shared/composables/useAnimatedCollapse.ts new file mode 100644 index 00000000..a71b1bdd --- /dev/null +++ b/frontend/src/shared/composables/useAnimatedCollapse.ts @@ -0,0 +1,82 @@ +/** + * useAnimatedCollapse - Reusable collapse/expand animation logic + * SOLID: Single Responsibility - handles animation logic only + * Not coupled to UI framework + */ + +import { ref, computed, Ref } from 'vue' + +export interface AnimateCollapseOptions { + duration?: number // ms + easing?: string // CSS easing function +} + +export function useAnimatedCollapse( + initialState: boolean = false, + options: AnimateCollapseOptions = {} +) { + const { duration = 300, easing = 'ease-in-out' } = options + + const isCollapsed = ref(initialState) + const isAnimating = ref(false) + + const toggle = async () => { + if (isAnimating.value) return + + isAnimating.value = true + isCollapsed.value = !isCollapsed.value + + // Allow CSS animation to complete + await new Promise(resolve => setTimeout(resolve, duration)) + isAnimating.value = false + } + + const expand = async () => { + if (!isCollapsed.value || isAnimating.value) return + await toggle() + } + + const collapse = async () => { + if (isCollapsed.value || isAnimating.value) return + await toggle() + } + + return { + isCollapsed: computed(() => isCollapsed.value), + isAnimating: computed(() => isAnimating.value), + toggle, + expand, + collapse, + animationDuration: duration, + animationEasing: easing, + } +} + +export interface AnimateSectionToggleOptions extends AnimateCollapseOptions {} + +/** + * useAnimatedSectionToggle - For toggling individual sections in sidebar + * SOLID: Composition over inheritance + */ +export function useAnimatedSectionToggle(id: string, options: AnimateSectionToggleOptions = {}) { + const expanded = ref(true) + const isAnimating = ref(false) + const { duration = 250, easing = 'ease-in-out' } = options + + const toggle = async () => { + if (isAnimating.value) return + + isAnimating.value = true + expanded.value = !expanded.value + + await new Promise(resolve => setTimeout(resolve, duration)) + isAnimating.value = false + } + + return { + id, + expanded: computed(() => expanded.value), + isAnimating: computed(() => isAnimating.value), + toggle, + } +} diff --git a/frontend/src/shared/shell/KsAppShell.vue b/frontend/src/shared/shell/KsAppShell.vue index 096fb234..2421c75b 100644 --- a/frontend/src/shared/shell/KsAppShell.vue +++ b/frontend/src/shared/shell/KsAppShell.vue @@ -1,118 +1,160 @@ - λ³Έλ¬ΈμΌλ‘ 건λλ°κΈ° - - - - - - - - ν - βΊ - {{ typeof route.meta.title === 'string' ? route.meta.title : route.path }} - + + λ³Έλ¬ΈμΌλ‘ μ΄λ + + + + + + + + + + + + + + + + - - v{{ appVersion }} Β· UI contract 4.0 - + + + + + + diff --git a/frontend/src/shared/shell/KsFooter.vue b/frontend/src/shared/shell/KsFooter.vue new file mode 100644 index 00000000..ef7ffdfa --- /dev/null +++ b/frontend/src/shared/shell/KsFooter.vue @@ -0,0 +1,86 @@ + + + + + + + diff --git a/frontend/src/shared/shell/KsHeader.vue b/frontend/src/shared/shell/KsHeader.vue new file mode 100644 index 00000000..79125145 --- /dev/null +++ b/frontend/src/shared/shell/KsHeader.vue @@ -0,0 +1,552 @@ + + + + + + + + + β° + + + β° + + + π + K-ArtSell + + + + + + + + π + + + + + + + + π + + + + + + π + + {{ unreadCount }} + + + + + + μλ¦Ό + + + {{ notif.message }} + {{ notif.time }} + + + + + + + + + + {{ currentUser.avatar }} + {{ currentUser.name }} + βΌ + + + + + + + {{ currentUser.avatar }} + + {{ currentUser.name }} + {{ currentUser.email }} + + + + + + + βοΈ μ€μ + + + π€ νλ‘ν + + + π λ¬Έμ + + + + πͺ λ‘κ·Έμμ + + + + + + + + + + diff --git a/frontend/src/shared/shell/KsNotifications.vue b/frontend/src/shared/shell/KsNotifications.vue new file mode 100644 index 00000000..d290fc23 --- /dev/null +++ b/frontend/src/shared/shell/KsNotifications.vue @@ -0,0 +1,179 @@ + + + + + + + {{ getNotificationIcon(notif.type) }} + {{ notif.message }} + + β + + + + + + + diff --git a/frontend/src/shared/shell/KsSidebar.vue b/frontend/src/shared/shell/KsSidebar.vue new file mode 100644 index 00000000..71264e80 --- /dev/null +++ b/frontend/src/shared/shell/KsSidebar.vue @@ -0,0 +1,434 @@ + + + + + + + diff --git a/frontend/src/shared/shell/KsTabs.vue b/frontend/src/shared/shell/KsTabs.vue new file mode 100644 index 00000000..dee3de36 --- /dev/null +++ b/frontend/src/shared/shell/KsTabs.vue @@ -0,0 +1,229 @@ + + + + + + + {{ tab.icon }} + {{ tab.title }} + + β + + + + + + + + ββ + + + βββ + + + + + + diff --git a/frontend/src/shared/shell/layoutStore.ts b/frontend/src/shared/shell/layoutStore.ts new file mode 100644 index 00000000..0de4f6a3 --- /dev/null +++ b/frontend/src/shared/shell/layoutStore.ts @@ -0,0 +1,192 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface OpenedTab { + id: string + title: string + path: string + icon: string + timestamp: number +} + +export const useLayoutStore = defineStore('layout', () => { + // Sidebar state + const sidebarCollapsed = ref( + localStorage.getItem('sidebar-collapsed') === 'true' + ) + const mobileMenuOpen = ref(false) + + // Tabs state + const openedTabs = ref([]) + const activeTabId = ref(null) + const maxTabs = ref(8) // Maximum number of open tabs + + // Notifications + const notifications = ref>([]) + + // Theme + const isDarkMode = ref( + localStorage.getItem('theme') === 'dark' || + window.matchMedia('(prefers-color-scheme: dark)').matches + ) + + // Save sidebar state + const toggleSidebar = () => { + sidebarCollapsed.value = !sidebarCollapsed.value + localStorage.setItem('sidebar-collapsed', String(sidebarCollapsed.value)) + } + + // Tab management + const addTab = (tab: Omit) => { + // Check if tab already exists + const existing = openedTabs.value.find(t => t.path === tab.path) + if (existing) { + activeTabId.value = existing.id + return + } + + // Respect max tabs limit + if (openedTabs.value.length >= maxTabs.value) { + openedTabs.value.shift() // Remove oldest tab + } + + const newTab: OpenedTab = { + ...tab, + timestamp: Date.now(), + } + openedTabs.value.push(newTab) + activeTabId.value = tab.id + saveTabs() + } + + const closeTab = (tabId: string) => { + const index = openedTabs.value.findIndex(t => t.id === tabId) + if (index === -1) return + + openedTabs.value.splice(index, 1) + + // Switch to next tab if closed tab was active + if (activeTabId.value === tabId) { + activeTabId.value = openedTabs.value[Math.max(0, index - 1)]?.id || null + } + + saveTabs() + } + + const closeAllTabs = () => { + openedTabs.value = [] + activeTabId.value = null + localStorage.removeItem('opened-tabs') + } + + const closeOtherTabs = (tabId: string) => { + openedTabs.value = openedTabs.value.filter(t => t.id === tabId) + activeTabId.value = tabId + saveTabs() + } + + const saveTabs = () => { + localStorage.setItem('opened-tabs', JSON.stringify(openedTabs.value)) + } + + const loadTabs = () => { + const saved = localStorage.getItem('opened-tabs') + if (saved) { + try { + openedTabs.value = JSON.parse(saved) + if (openedTabs.value.length > 0) { + activeTabId.value = openedTabs.value[0].id + } + } catch { + openedTabs.value = [] + } + } + } + + // Notification management + const addNotification = ( + message: string, + type: 'success' | 'error' | 'warning' | 'info' = 'info', + duration = 3000 + ) => { + const id = `notif-${Date.now()}-${Math.random()}` + const notif = { + id, + type, + message, + timestamp: Date.now(), + autoClose: true, + duration, + } + notifications.value.push(notif) + + if (duration > 0) { + setTimeout(() => { + removeNotification(id) + }, duration) + } + } + + const removeNotification = (id: string) => { + const index = notifications.value.findIndex(n => n.id === id) + if (index !== -1) { + notifications.value.splice(index, 1) + } + } + + // Theme management + const setDarkMode = (dark: boolean) => { + isDarkMode.value = dark + localStorage.setItem('theme', dark ? 'dark' : 'light') + const root = document.documentElement + root.setAttribute('data-theme', dark ? 'dark' : 'light') + } + + const toggleTheme = () => { + setDarkMode(!isDarkMode.value) + } + + // Computed + const hasOpenedTabs = computed(() => openedTabs.value.length > 0) + + // Load persisted state + loadTabs() + if (isDarkMode.value) { + const root = document.documentElement + root.setAttribute('data-theme', 'dark') + } + + return { + // Sidebar + sidebarCollapsed, + mobileMenuOpen, + toggleSidebar, + + // Tabs + openedTabs, + activeTabId, + hasOpenedTabs, + addTab, + closeTab, + closeAllTabs, + closeOtherTabs, + saveTabs, + + // Notifications + notifications, + addNotification, + removeNotification, + + // Theme + isDarkMode, + setDarkMode, + toggleTheme, + } +}) diff --git a/frontend/src/shared/ui/components/KsButton.vue b/frontend/src/shared/ui/components/KsButton.vue index 618161cc..eb0c9477 100644 --- a/frontend/src/shared/ui/components/KsButton.vue +++ b/frontend/src/shared/ui/components/KsButton.vue @@ -1,5 +1,6 @@ diff --git a/frontend/src/shared/utils/componentLogger.ts b/frontend/src/shared/utils/componentLogger.ts new file mode 100644 index 00000000..eaf2f7f8 --- /dev/null +++ b/frontend/src/shared/utils/componentLogger.ts @@ -0,0 +1,94 @@ +/** + * Component Logger β Real-time component health monitoring + * Logs component rendering, prop changes, errors + */ + +export interface ComponentLogEntry { + timestamp: string + component: string + action: string + details?: Record + status: 'success' | 'error' | 'warning' +} + +class ComponentLogger { + private logs: ComponentLogEntry[] = [] + private maxLogs = 500 + + log(component: string, action: string, details?: Record, status: 'success' | 'error' | 'warning' = 'success') { + const entry: ComponentLogEntry = { + timestamp: new Date().toISOString(), + component, + action, + details, + status, + } + + this.logs.push(entry) + + // Keep log size manageable + if (this.logs.length > this.maxLogs) { + this.logs.shift() + } + + // Log to console in dev mode + if (import.meta.env.DEV) { + const statusEmoji = status === 'success' ? 'β ' : status === 'error' ? 'β' : 'β οΈ' + console.log( + `${statusEmoji} [${component}] ${action}`, + details ? details : '' + ) + } + + return entry + } + + error(component: string, action: string, error: Error | string, details?: Record) { + return this.log(component, action, { + ...details, + error: error instanceof Error ? error.message : error, + }, 'error') + } + + warning(component: string, action: string, details?: Record) { + return this.log(component, action, details, 'warning') + } + + getLogs() { + return [...this.logs] + } + + getLogsByComponent(componentName: string) { + return this.logs.filter(log => log.component === componentName) + } + + clearLogs() { + this.logs = [] + } + + exportLogs() { + return JSON.stringify(this.logs, null, 2) + } + + getStats() { + return { + totalLogs: this.logs.length, + byComponent: this.logs.reduce((acc, log) => { + acc[log.component] = (acc[log.component] || 0) + 1 + return acc + }, {} as Record), + byStatus: { + success: this.logs.filter(l => l.status === 'success').length, + error: this.logs.filter(l => l.status === 'error').length, + warning: this.logs.filter(l => l.status === 'warning').length, + }, + } + } +} + +export const componentLogger = new ComponentLogger() + +// Expose globally for debugging +if (import.meta.env.DEV && typeof window !== 'undefined') { + (window as any).__componentLogger = componentLogger +} diff --git a/frontend/test-pages-fixed.mjs b/frontend/test-pages-fixed.mjs deleted file mode 100644 index 35e3268c..00000000 --- a/frontend/test-pages-fixed.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import { chromium } from 'playwright'; -import fs from 'fs'; -import path from 'path'; - -const BASE_URL = 'http://localhost:5174'; -const OUTPUT_DIR = './test-results'; - -if (!fs.existsSync(OUTPUT_DIR)) { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); -} - -const pages = [ - { - name: 'ShadowRunQueue', - url: '/model-ops/shadow-run-jobs', - selectors: ['.shadow-run-queue', '.stats', '.filters', '.jobs-list'] - }, - { - name: 'ModelList', - url: '/model-ops/models-master', - selectors: ['.model-list', '.filters', '.content', '.master-list'] - }, - { - name: 'ApprovalQueue', - url: '/governance/approvals', - selectors: ['.approval-queue', '.stats', '.filters', '.content'] - } -]; - -async function testPage(browser, pageConfig) { - console.log(`\nTesting: ${pageConfig.name}`); - const page = await browser.newPage(); - - const errors = []; - page.on('console', msg => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - - page.on('response', res => { - if (res.status() >= 500) { - errors.push(`HTTP ${res.status()}: ${res.url()}`); - } - }); - - try { - const response = await page.goto(`${BASE_URL}${pageConfig.url}`, { - waitUntil: 'domcontentloaded', - timeout: 15000 - }); - - console.log(` Response: ${response.status()}`); - - // Wait for rendering - await page.waitForTimeout(2000); - - // Check DOM elements - let foundSelectors = 0; - for (const selector of pageConfig.selectors) { - const element = await page.locator(selector).first(); - const visible = await element.isVisible().catch(() => false); - if (visible) { - foundSelectors++; - console.log(` β Found: ${selector}`); - } else { - console.log(` β Missing: ${selector}`); - } - } - - // Screenshot - const screenshotPath = path.join(OUTPUT_DIR, `${pageConfig.name}.png`); - await page.screenshot({ path: screenshotPath, fullPage: true }); - console.log(` πΈ Screenshot: ${screenshotPath}`); - - // Save HTML - const htmlPath = path.join(OUTPUT_DIR, `${pageConfig.name}.html`); - const html = await page.content(); - fs.writeFileSync(htmlPath, html); - console.log(` π DOM: ${htmlPath}`); - - const success = errors.length === 0 && foundSelectors >= pageConfig.selectors.length * 0.8; - console.log(` Result: ${success ? 'β PASS' : 'β FAIL'}`); - - if (errors.length > 0) { - console.log(` Errors: ${errors.join(', ')}`); - } - - await page.close(); - return { success, name: pageConfig.name, errors, selectors: foundSelectors }; - - } catch (err) { - console.log(` β Error: ${err.message}`); - await page.close(); - return { success: false, name: pageConfig.name, errors: [err.message], selectors: 0 }; - } -} - -async function main() { - console.log('π Playwright Page Tests\n'); - - const browser = await chromium.launch({ headless: true }); - const results = []; - - for (const pageConfig of pages) { - const result = await testPage(browser, pageConfig); - results.push(result); - } - - await browser.close(); - - // Summary - console.log('\n' + '='.repeat(50)); - console.log('SUMMARY'); - console.log('='.repeat(50)); - - for (const r of results) { - console.log(`${r.success ? 'β ' : 'β'} ${r.name}`); - } - - const allPassed = results.every(r => r.success); - console.log('\n' + (allPassed ? 'β ALL TESTS PASSED' : 'β SOME TESTS FAILED')); - - process.exit(allPassed ? 0 : 1); -} - -main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/frontend/test-pages.mjs b/frontend/test-pages.mjs deleted file mode 100644 index e6840c74..00000000 --- a/frontend/test-pages.mjs +++ /dev/null @@ -1,187 +0,0 @@ -import { chromium } from 'playwright'; -import fs from 'fs'; -import path from 'path'; - -const BASE_URL = 'http://localhost:5174'; -const OUTPUT_DIR = './test-results'; - -// Create output directory -if (!fs.existsSync(OUTPUT_DIR)) { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); -} - -const pages = [ - { - name: 'Shadow Run Queue (T06)', - url: '/model-ops/shadow-run-jobs', - checks: [ - 'Shadow Run Jobs', - 'Pending', - 'Completed', - 'Failed', - 'KbxScreenFrame', - 'KbxQueueTemplate' - ] - }, - { - name: 'Models Master-Detail (T02)', - url: '/model-ops/models-master', - checks: [ - 'Models', - 'Model', - 'Performance Metrics', - 'PBO', - 'DSR', - 'KbxMasterTemplate' - ] - }, - { - name: 'Approval Queue (T03)', - url: '/governance/approvals', - checks: [ - 'Approval Queue', - 'Pending', - 'Approved', - 'Rejected', - 'Review & Approval', - 'KbxTransactionTemplate' - ] - } -]; - -async function testPage(browser, page) { - console.log(`\n${'='.repeat(60)}`); - console.log(`Testing: ${page.name}`); - console.log(`URL: ${BASE_URL}${page.url}`); - console.log('='.repeat(60)); - - const browserPage = await browser.newPage(); - - // Capture console errors - let errors = []; - browserPage.on('console', msg => { - if (msg.type() === 'error') { - errors.push(msg.text()); - console.log(`β Console Error: ${msg.text()}`); - } - }); - - // Capture page errors - let pageErrors = []; - browserPage.on('pageerror', err => { - pageErrors.push(err.toString()); - console.log(`β Page Error: ${err.message}`); - }); - - try { - // Navigate to page - await browserPage.goto(`${BASE_URL}${page.url}`, { waitUntil: 'networkidle' }); - console.log('β Page loaded'); - - // Wait for content - await browserPage.waitForTimeout(2000); - - // Check for expected content - let foundChecks = []; - for (const check of page.checks) { - const found = await browserPage.locator(`text="${check}"`).count() > 0 || - await browserPage.content().includes(check); - if (found) { - foundChecks.push(check); - console.log(`β Found: "${check}"`); - } else { - console.log(`β Missing: "${check}"`); - } - } - - // Get page title - const title = await browserPage.title(); - console.log(`π Title: ${title}`); - - // Check DOM structure - const html = await browserPage.content(); - const hasVueApp = html.includes('id="app"'); - const hasKbxComponents = html.includes('kbx-'); - console.log(`Vue App: ${hasVueApp ? 'β ' : 'β'}`); - console.log(`KBX Components: ${hasKbxComponents ? 'β ' : 'β'}`); - - // Screenshot - const screenshotPath = path.join(OUTPUT_DIR, `${page.name.replace(/\s+/g, '-').toLowerCase()}.png`); - await browserPage.screenshot({ path: screenshotPath, fullPage: true }); - console.log(`πΈ Screenshot: ${screenshotPath}`); - - // Save DOM - const domPath = path.join(OUTPUT_DIR, `${page.name.replace(/\s+/g, '-').toLowerCase()}.html`); - fs.writeFileSync(domPath, html); - console.log(`π DOM saved: ${domPath}`); - - // Summary - const checksPassed = foundChecks.length; - const checksTotal = page.checks.length; - const passRate = Math.round((checksPassed / checksTotal) * 100); - console.log(`\nπ Content Check: ${checksPassed}/${checksTotal} (${passRate}%)`); - console.log(`β Errors: ${errors.length + pageErrors.length}`); - - await browserPage.close(); - - return { - success: errors.length === 0 && pageErrors.length === 0 && passRate >= 80, - name: page.name, - url: page.url, - errors: errors.concat(pageErrors), - contentChecks: { passed: checksPassed, total: checksTotal }, - screenshot: screenshotPath - }; - } catch (err) { - console.log(`β Test Failed: ${err.message}`); - await browserPage.close(); - return { - success: false, - name: page.name, - url: page.url, - errors: [err.message], - contentChecks: { passed: 0, total: page.checks.length } - }; - } -} - -async function main() { - const browser = await chromium.launch(); - const results = []; - - console.log('\nπ Starting Playwright Tests\n'); - console.log(`Base URL: ${BASE_URL}`); - console.log(`Output: ${OUTPUT_DIR}\n`); - - for (const page of pages) { - const result = await testPage(browser, page); - results.push(result); - } - - await browser.close(); - - // Summary - console.log(`\n${'='.repeat(60)}`); - console.log('TEST SUMMARY'); - console.log('='.repeat(60)); - - for (const result of results) { - const status = result.success ? 'β ' : 'β'; - console.log(`\n${status} ${result.name}`); - console.log(` URL: ${result.url}`); - console.log(` Content: ${result.contentChecks.passed}/${result.contentChecks.total}`); - console.log(` Errors: ${result.errors.length}`); - if (result.errors.length > 0) { - result.errors.forEach(err => console.log(` - ${err}`)); - } - } - - const allPassed = results.every(r => r.success); - console.log(`\n${'='.repeat(60)}`); - console.log(allPassed ? 'β ALL TESTS PASSED' : 'β SOME TESTS FAILED'); - console.log('='.repeat(60)); - - process.exit(allPassed ? 0 : 1); -} - -main().catch(console.error); diff --git a/frontend/validation-results.json b/frontend/validation-results.json new file mode 100644 index 00000000..273bb4ef --- /dev/null +++ b/frontend/validation-results.json @@ -0,0 +1,80 @@ +{ + "componentTests": [ + { + "name": "Header", + "visible": true, + "count": 2 + }, + { + "name": "Sidebar", + "visible": true, + "count": 1 + }, + { + "name": "Main", + "visible": true, + "count": 2 + }, + { + "name": "Footer", + "visible": true, + "count": 1 + }, + { + "name": "Button", + "visible": false, + "count": 14 + }, + { + "name": "Input", + "visible": true, + "count": 1 + } + ], + "tokenTests": [ + { + "token": "spacing4", + "value": "16px", + "defined": true + }, + { + "token": "fontSize", + "value": "16px", + "defined": true + }, + { + "token": "colorPrimary", + "value": "#3b82f6", + "defined": true + }, + { + "token": "borderRadius", + "value": "4px", + "defined": true + } + ], + "interactionTests": [ + { + "action": "Input fill", + "status": "pass" + } + ], + "responsiveTests": [ + { + "viewport": "Desktop", + "size": "1920x1080", + "contentVisible": true + }, + { + "viewport": "Tablet", + "size": "768x1024", + "contentVisible": true + }, + { + "viewport": "Mobile", + "size": "375x667", + "contentVisible": true + } + ], + "issuesFound": [] +} \ No newline at end of file