feat(frontend): Velxio iframe fullscreen + playground route + SvelteKit service worker

- VelxioTab: add allowfullscreen to Velxio editor iframe
- lesson.css: flex/shrink fixes for .velxio-iframe on mobile
- +layout.svelte: hide Navbar/Footer on /playground full-viewport route
- Add /playground route: Velxio editor iframe + DeployFAB bridge
- Migrate static sw.js to SvelteKit module service-worker.ts
This commit is contained in:
a2nr 2026-07-18 17:26:59 +07:00
parent d6bb2073ac
commit 0cbac64c6f
6 changed files with 145 additions and 13 deletions

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { auth } from '$stores/auth';
import { theme } from '$stores/theme';
import Navbar from '$components/Navbar.svelte';
@ -12,12 +13,19 @@
theme.init();
auth.init();
});
// Suppress Navbar/Footer on full-viewport routes (e.g. /playground)
let isFullViewport = $derived($page.url.pathname === '/playground');
</script>
<Navbar />
{#if !isFullViewport}
<Navbar />
{/if}
<main class="container" style="flex: 1; padding-block: 1.5rem;">
<main class="container" style="flex: 1; padding-block: {isFullViewport ? '0' : '1.5rem'}; overflow: {isFullViewport ? 'hidden' : 'visible'};">
{@render children()}
</main>
<Footer />
{#if !isFullViewport}
<Footer />
{/if}

View File

@ -33,6 +33,7 @@
class="velxio-iframe"
src="/velxio/editor?embed=true{hasArduinoCode ? '' : '&hideEditor=true'}&lockComponents=true"
onload={(e) => onSetupBridge(e.currentTarget as HTMLIFrameElement)}
allow="cross-origin-isolated"
allow="cross-origin-isolated; fullscreen"
allowfullscreen
></iframe>
{/if}

View File

@ -204,7 +204,8 @@
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.2s ease;
}
.editor-area.mobile-hidden {
@ -244,6 +245,10 @@
.editor-area.mobile-full .cm-scroller {
flex: 1;
}
.editor-area.mobile-full .velxio-iframe {
flex: 1;
min-height: 0;
}
/* ── Tab panels ────────────────────────────────────────── */
.tab-panel {
@ -300,9 +305,25 @@
.velxio-iframe {
flex: 1;
width: 100%;
min-height: 400px;
min-height: 0; /* CHANGED: was 400px — allow shrink in flex */
border: none;
border-radius: var(--radius);
touch-action: pan-y; /* allow vertical pan inside iframe */
overscroll-behavior: contain; /* prevent overscroll bounce */
}
@media (max-width: 768px) {
.velxio-iframe {
min-height: 0; /* CHANGED: was 200px — let flex sizing work fully */
}
/* Ensure sheet never exceeds visual viewport when keyboard open */
.editor-area.mobile-sheet {
max-height: 100vh; /* fallback for browsers without dvh */
max-height: 100dvh; /* dynamic viewport height */
}
.editor-area.mobile-full {
height: 100dvh;
max-height: 100dvh;
}
}
.velxio-fallback {
padding: 2rem;

View File

@ -0,0 +1,97 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import DeployFAB from '$components/DeployFAB.svelte';
let velxioIframe = $state<HTMLIFrameElement | null>(null);
type DeployFABHandle = { setHex: (hex: string | null) => void };
let fabComponent = $state<DeployFABHandle | null>(null);
const iframeSrc = '/velxio/editor?embed=true&desktopLayout=true';
function handleMessage(e: MessageEvent) {
if (!e.data?.type) return;
if (e.data.type === 'velxio:hex_ready') {
fabComponent?.setHex(e.data.hex);
}
}
function pushSerialToIframe(text: string) {
if (!velxioIframe?.contentWindow) return;
velxioIframe.contentWindow.postMessage({
type: 'elemes:push_serial',
text,
boardId: null
}, window.location.origin);
}
function notifySerialStart() {
velxioIframe?.contentWindow?.postMessage({ type: 'elemes:start_hardware_serial' }, window.location.origin);
}
function notifySerialStop() {
velxioIframe?.contentWindow?.postMessage({ type: 'elemes:stop_hardware_serial' }, window.location.origin);
}
onMount(() => {
window.addEventListener('message', handleMessage);
});
onDestroy(() => {
window.removeEventListener('message', handleMessage);
});
</script>
<svelte:head>
<title>Developer Playground — Velxio</title>
</svelte:head>
<div class="playground-page">
<iframe
bind:this={velxioIframe}
src={iframeSrc}
title="Velxio Editor"
allow="bluetooth; clipboard-read; clipboard-write; fullscreen"
class="velxio-iframe"
allowfullscreen
></iframe>
<DeployFAB
bind:this={fabComponent}
{velxioIframe}
onPushSerial={pushSerialToIframe}
onSerialStart={notifySerialStart}
onStopSerial={notifySerialStop}
/>
</div>
<style>
.playground-page {
width: 100%;
height: 100dvh;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.velxio-iframe {
width: 100%;
flex: 1;
min-height: 0; /* critical: allows flex child to shrink below content size */
border: none;
display: block;
touch-action: pan-y; /* allow vertical pan inside iframe */
}
@media (max-width: 768px) {
.playground-page {
overflow-y: auto;
height: auto; /* allow page to grow */
min-height: 100dvh; /* but always at least fill viewport */
}
.velxio-iframe {
max-height: none; /* iframe can grow taller than viewport */
height: auto;
}
}
</style>

View File

@ -0,0 +1,3 @@
// Disable SSR — Web Bluetooth & iframe PostMessage need client-side
export const prerender = false;
export const ssr = false;

View File

@ -1,5 +1,7 @@
// static/sw.js
const CACHE_VERSION = 'elemes-v14';
// src/service-worker.ts — SvelteKit Service Worker
import { APP_VERSION } from '$lib/version';
const CACHE_VERSION = `elemes-v${APP_VERSION}`;
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
@ -75,9 +77,9 @@ self.addEventListener('fetch', (event) => {
return;
}
// StaleWhileRevalidate: HTML pages
// NetworkFirst: HTML pages (new routes need network first)
if (request.headers.get('accept')?.includes('text/html')) {
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
event.respondWith(networkFirst(request, STATIC_CACHE, 1440));
return;
}
@ -90,7 +92,7 @@ self.addEventListener('fetch', (event) => {
// ── Helpers ──────────────────────────────────────────────────────
async function cacheFirst(request, cacheName, maxAgeDays = 7) {
async function cacheFirst(request: Request, cacheName: string, maxAgeDays = 7): Promise<Response> {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
@ -99,7 +101,7 @@ async function cacheFirst(request, cacheName, maxAgeDays = 7) {
return response;
}
async function networkFirst(request, cacheName, maxAgeMinutes = 60) {
async function networkFirst(request: Request, cacheName: string, maxAgeMinutes = 60): Promise<Response> {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
@ -115,7 +117,7 @@ async function networkFirst(request, cacheName, maxAgeMinutes = 60) {
}
}
async function staleWhileRevalidate(request, cacheName) {
async function staleWhileRevalidate(request: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const networkFetch = fetch(request).then(response => {