pull js and css out of index.html

Hunter
Monday, December 22nd, 2025 at 9:43 PM

index.html+5-743
main.jsnew
styles.cssnew

index.html
 6  	<title>Web Workshop</title>
 7  	<link rel="icon" href="resources/rollerskate.png">
 8  	<link rel="manifest" href="manifest.json">
 9 +	<link rel="stylesheet" href="styles.css">
10  	<script type="module">
11  		import {EditorView, keymap, placeholder, lineNumbers, Decoration} from "https://esm.sh/@codemirror/view@6"
12  		import {EditorState, Compartment} from "https://esm.sh/@codemirror/state@6"

16  		import {githubDark} from "https://esm.sh/@fsegurai/codemirror-theme-github-dark"
17  		import {indentUnit} from "https://esm.sh/@codemirror/language@6"
18  		import {search, searchKeymap, closeSearchPanel, openSearchPanel} from "https://esm.sh/@codemirror/search@6"
   -		
19 +
20  		window.CodeMirror = {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, undoDepth, redoDepth, history, historyKeymap, closeBrackets, closeBracketsKeymap, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers, Decoration};
21  	</script>
   -	<style>
   -		:root {
   -			--text-color: #f8f8f2;
   -			--editor-bg: #16181b;
   -			scrollbar-color: grey var(--editor-bg);
   -		}
   -
   -		* {
   -			margin: 0;
   -			padding: 0;
   -			box-sizing: border-box;
   -		}
   -
   -		html, body {
   -			overscroll-behavior: none;
   -			background-color: var(--editor-bg);
   -		}
   -
   -		html {
   -			height: 100%;
   -		}
   -
   -		body {
   -			font-family: monospace;
   -			background: var(--bg-color);
   -			color: var(--text-color);
   -			height: 100%;
   -			display: flex;
   -			overflow: hidden;
   -		}
   -
   -		.editor-pane {
   -			width: 50%;
   -			height: 100%;
   -			position: relative;
   -			transition: opacity 0.1s ease-out; /* Added transition for smoother opacity hack */
   -		}
   -
   -		.preview-pane {
   -			width: 50%;
   -			height: 100%;
   -			position: relative;
   -		}
   -
   -		.preview-pane.fullscreen {
   -			position: fixed;
   -			top: 0;
   -			left: 0;
   -			width: 100vw;
   -			height: 100vh;
   -			z-index: 9999;
   -			background: white;
   -		}
   -
   -		/* Hide editor when preview is fullscreen */
   -		.editor-pane.hidden {
   -			display: none;
   -		}
   -
   -		#editor {
   -			width: 100%;
   -			height: 100%;
   -			font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
   -			font-size: 21px;
   -		}
   -
   -		/* CodeMirror styling */
   -		.cm-editor {
   -			width: 100%;
   -			height: 100%;
   -			background: var(--editor-bg);
   -			color: var(--text-color);
   -		}
   -
   -		.cm-placeholder {
   -			color: #666;
   -			opacity: 0.6;
   -		}
   -
   -		.cm-content {
   -			padding: 15px;
   -			font-size: 21px;
   -			line-height: 1.6;
   -		}
   -
   -		.cm-gutters {
   -			font-size: 21px;
   -			line-height: 1.6;
   -			user-select: none; 
   -		}
   -
   -		.cm-lineNumbers .cm-gutterElement {
   -			line-height: 1.6;
   -			font-size: 21px;
   -		}
   -
   -		.ͼ1 .cm-lineNumbers .cm-gutterElement {
   -			padding-right: 0;
   -			padding-left: 8px;
   -		}
   -
   -		.cm-focused {
   -			outline: none;
   -		}
   -
   -		/* Force tab size to 2 spaces - override theme defaults */
   -		.cm-editor .cm-content,
   -		.cm-editor .cm-line,
   -		.cm-editor {
   -			tab-size: 2 !important;
   -			-moz-tab-size: 2 !important;
   -		}
   -
   -		/* Disable overscroll on all CodeMirror elements */
   -		.cm-editor, .cm-content, .cm-scroller, .cm-scrollElement {
   -			overscroll-behavior: none !important;
   -		}
   -
   -		/* Simplify search panel - hide all buttons, labels, and replace input */
   -		.cm-search button {
   -			display: none !important;
   -		}
   -
   -		.cm-search {
   -			background-color: #16181b;
   -		}
   -		
   -		.cm-search label {
   -			display: none !important;
   -		}
   -		
   -		.cm-search input[name="replace"] {
   -			display: none !important;
   -		}
   -		
   -		/* Style search input to match editor text size */
   -		.cm-search input[name="search"] {
   -			font-size: 21px !important;
   -			width: 100% !important;
   -			border-radius: 50px !important;
   -			padding-left: 0.8em !important;
   -			padding-right: 0.8em !important;
   -			padding-bottom: 7px !important;
   -		}
   -
   -		.cm-panels-bottom {
   -			border-radius: 0px !important;
   -		}
   -		
   -		.cm-panels-bottom {
   -			border-top: none !important;
   -
   -		}
   -
   -		#preview {
   -			width: 100%;
   -			height: 100%;
   -			border: none;
   -			background: white;
   -			overflow: auto;
   -		}
   -
   -		/* Vertical layout for narrow screens (height > width) */
   -		@media (max-aspect-ratio: 1/1) {
   -			body {
   -				flex-direction: column;
   -			}
   -
   -			.editor-pane {
   -				width: 100%;
   -				height: 50%;
   -				order: 2;
   -			}
   -
   -			.preview-pane {
   -				width: 100%;
   -				height: 50%;
   -				order: 1;
   -				padding-top: env(safe-area-inset-top);
   -			}
   -		}
   -
   -		/* Mobile: fullscreen editor when keyboard is open */
   -		@media (pointer: coarse), (pointer: none) {
   -			body.mobile-keyboard-open .preview-pane {
   -				display: none;
   -			}
   -
   -			body.mobile-keyboard-open .editor-pane {
   -				position: fixed;
   -				top: calc(var(--visual-viewport-offset-top, 0px));
   -				left: 0;
   -				width: 100%;
   -				height: var(--visual-viewport-height, 100%);
   -				max-height: var(--visual-viewport-height, 100%);
   -				order: 1;
   -				overscroll-behavior: none;
   -				overflow: hidden;
   -			}
   -
   -			body.mobile-keyboard-open .editor-pane #editor,
   -			body.mobile-keyboard-open .editor-pane .cm-editor {
   -				height: 100% !important;
   -				max-height: 100% !important;
   -			}
   -		}
   -
   -	</style>
22  </head>
23  <body>
24  	<div class="editor-pane">
25  		<div id="editor"></div>
26  	</div>
   -	
27 +
28  	<div class="preview-pane">
29  		<iframe id="preview"></iframe>
30  	</div>
31  
   -	<script type="module">
   -		let updateTimer;
   -		let editorView;
   -		const preview = document.getElementById('preview');
   -
   -		// Stock images loaded from manifest - can be referenced by bare filename
   -		let stockImages = new Map(); // lowercase -> original filename
   -
   -		// Load stock image list from manifest
   -		const stockImagesReady = fetch('resource-manifest.json')
   -			.then(r => r.json())
   -			.then(manifest => {
   -				manifest.images.forEach(path => {
   -					const filename = path.split('/').pop();
   -					stockImages.set(filename.toLowerCase(), filename);
   -				});
   -			})
   -			.catch(() => console.log('Could not load resource manifest'));
   -
   -		// Rewrite bare image filenames to use images/ prefix (case-insensitive, uses correct case)
   -		function rewriteBareImageSrcs(html) {
   -			return html.replace(/(<img\s[^>]*\bsrc\s*=\s*["'])([^"'/:]+\.(gif|png|jpg|jpeg|svg|webp))(["'])/gi,
   -				(match, before, filename, ext, after) => {
   -					const original = stockImages.get(filename.toLowerCase());
   -					if (original) {
   -						return before + 'images/' + original + after;
   -					}
   -					return match;
   -				});
   -		}
   -
   -		// Expand <images> tags into gallery table
   -		function expandImagesTag(html) {
   -			return html.replace(/<images\s*\/?>/gi, () => {
   -				const images = Array.from(stockImages.values()).sort();
   -				if (images.length === 0) return '<p>No images available</p>';
   -				const rows = images.map(filename =>
   -					`<tr><td>${filename}</td><td style="text-align:center;"><img src="images/${filename}"></td></tr>`
   -				).join('');
   -				return `<table border="1" cellpadding="8" cellspacing="0" style="width:100%;"><colgroup><col style="width:50%"><col style="width:50%"></colgroup>${rows}</table>`;
   -			});
   -		}
   -		const editorPane = document.querySelector('.editor-pane');
   -		const previewPane = document.querySelector('.preview-pane');
   -		const storageKey = 'html-lab-content';
   -		let isFullscreen = false;
   -		let showLineNumbers = false;
   -		let enableLineWrapping = false;
   -		let lineNumbersCompartment;
   -		let lineWrappingCompartment;
   -
   -		function toggleFullscreen() {
   -			
   -			isFullscreen = !isFullscreen;
   -			
   -			if (isFullscreen) {
   -				previewPane.classList.add('fullscreen');
   -				editorPane.classList.add('hidden');
   -			} else {
   -				previewPane.classList.remove('fullscreen');
   -				editorPane.classList.remove('hidden');
   -			}
   -			
   -			// Update button title in iframe
   -			try {
   -				const toggleButton = preview.contentDocument.getElementById('fullscreenToggle');
   -				if (toggleButton) {
   -					toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen';
   -				}
   -			} catch (e) {
   -				// Ignore cross-origin errors
   -			}
   -		}
   -
   -
   -		// Listen for messages from iframe
   -		window.addEventListener('message', function(event) {
   -			if (event.data === 'toggleFullscreen') {
   -				toggleFullscreen();
   -			}
   -		});
   -
   -		function extractTitleAndFavicon(htmlCode) {
   -			const parser = new DOMParser();
   -			const doc = parser.parseFromString(htmlCode, 'text/html');
   -			
   -			// Extract title
   -			const titleElement = doc.querySelector('title');
   -			const title = titleElement ? titleElement.textContent.trim() : null;
   -			
   -			// Extract favicon
   -			const faviconSelectors = [
   -				'link[rel="icon"]',
   -				'link[rel="shortcut icon"]', 
   -				'link[rel="apple-touch-icon"]',
   -				'link[rel="mask-icon"]'
   -			];
   -			
   -			let favicon = null;
   -			for (const selector of faviconSelectors) {
   -				const faviconElement = doc.querySelector(selector);
   -				if (faviconElement && faviconElement.href) {
   -					favicon = faviconElement.href;
   -					break;
   -				}
   -			}
   -			
   -			return { title, favicon };
   -		}
   -
   -		function updateMainPageTitleAndFavicon(title, favicon) {
   -			// Update title
   -			if (title) {
   -				document.title = title;
   -			} else {
   -				document.title = 'Web Workshop';
   -			}
   -			
   -			// Update favicon
   -			let faviconLink = document.querySelector('link[rel="icon"]');
   -			if (!faviconLink) {
   -				faviconLink = document.createElement('link');
   -				faviconLink.rel = 'icon';
   -				document.head.appendChild(faviconLink);
   -			}
   -			
   -			if (favicon) {
   -				faviconLink.href = favicon;
   -			} else {
   -				faviconLink.href = 'resources/rollerskate.png';
   -			}
   -		}
   -
   -		function updatePreview() {
   -			// Skip preview updates while mobile keyboard is open and editor is focused
   -			// This prevents keyboard layer resets on Android
   -			if (isMobileDevice() && isEditorFocused && document.body.classList.contains('mobile-keyboard-open')) {
   -				return;
   -			}
   -			
   -			const code = editorView.state.doc.toString();
   -			
   -			// Extract and update title and favicon from user's HTML
   -			const { title, favicon } = extractTitleAndFavicon(code);
   -			updateMainPageTitleAndFavicon(title, favicon);
   -			
   -			// Store scroll position before updating
   -			let scrollX = 0, scrollY = 0;
   -			try {
   -				if (preview.contentWindow?.scrollX !== undefined) {
   -					scrollX = preview.contentWindow.scrollX;
   -					scrollY = preview.contentWindow.scrollY;
   -				}
   -			} catch (e) {
   -				// Ignore cross-origin errors
   -			}
   -			
   -			// Use srcdoc to create a completely fresh document context
   -			// Expand <images> tag and rewrite bare stock image filenames
   -			const processedCode = expandImagesTag(rewriteBareImageSrcs(code.trim())) || '<!DOCTYPE html><html><head></head><body></body></html>';
   -			preview.srcdoc = processedCode;
   -			
   -			// Add our functionality after the iframe loads
   -			const onLoad = () => {
   -				try {
   -					const doc = preview.contentDocument;
   -					if (!doc) return;
   -					
   -					// Add CSS for overscroll and button
   -					const style = doc.createElement('style');
   -					style.textContent = '* { overscroll-behavior: none !important; } .iframe-fullscreen-toggle { position: fixed; top: 5px; right: 5px; z-index: 10000; background: rgba(0, 0, 0, 0.2); color: white; border: none; border-radius: 4px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.2s; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -webkit-tap-highlight-color: transparent; outline: none; user-select: none; } .iframe-fullscreen-toggle svg { filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3)); opacity: 0.8; transition: opacity 0.2s; } @media (hover: hover) and (pointer: fine) { .iframe-fullscreen-toggle:hover { background: rgba(0, 0, 0, 0.35); } .iframe-fullscreen-toggle:hover svg { opacity: 1; } }';
   -					doc.head.appendChild(style);
   -
   -					// Create fullscreen button
   -					const existingButton = doc.getElementById('fullscreenToggle');
   -					if (existingButton) existingButton.remove();
   -
   -					const button = doc.createElement('button');
   -					button.id = 'fullscreenToggle';
   -					button.className = 'iframe-fullscreen-toggle';
   -					button.title = 'Toggle fullscreen';
   -
   -					// Use inline SVG to avoid being affected by user's img styles
   -					button.innerHTML = '<svg width="20" height="20" viewBox="0 0 14 14" fill="white"><path d="M 7,14 H 5 v 5 h 5 V 17 H 7 Z M 5,10 H 7 V 7 h 3 V 5 H 5 Z m 12,7 h -3 v 2 h 5 V 14 H 17 Z M 14,5 v 2 h 3 v 3 h 2 V 5 Z" transform="translate(-5,-5)"/></svg>';
   -					
   -					button.addEventListener('click', function() {
   -						parent.postMessage('toggleFullscreen', '*');
   -					});
   -					
   -					if (doc.body) {
   -						doc.body.appendChild(button);
   -					}
   -
   -					// Restore scroll position
   -					setTimeout(() => {
   -						try {
   -							preview.contentWindow?.scrollTo(scrollX, scrollY);
   -						} catch (e) {
   -							// Ignore cross-origin errors
   -						}
   -					}, 10);
   -				} catch (e) {
   -					// Ignore cross-origin errors
   -				}
   -				
   -				preview.removeEventListener('load', onLoad);
   -			};
   -			preview.addEventListener('load', onLoad);
   -		}
   -
   -		function saveToStorage() {
   -			try {
   -				localStorage.setItem(storageKey, editorView.state.doc.toString());
   -			} catch (e) {
   -				console.warn('Could not save to localStorage:', e);
   -			}
   -		}
   -
   -		function loadFromStorage() {
   -			try {
   -				return localStorage.getItem(storageKey) || '';
   -			} catch (e) {
   -				console.warn('Could not load from localStorage:', e);
   -				return '';
   -			}
   -		}
   -
   -		function loadEditorSettings() {
   -			try {
   -				showLineNumbers = localStorage.getItem('editor-line-numbers') === 'true';
   -				enableLineWrapping = localStorage.getItem('editor-line-wrapping') !== 'false';
   -			} catch (e) {
   -				console.warn('Could not load editor settings from localStorage:', e);
   -				showLineNumbers = false;
   -				enableLineWrapping = true;
   -			}
   -		}
   -
   -		function saveEditorSetting(key, value) {
   -			try {
   -				localStorage.setItem(key, value.toString());
   -			} catch (e) {
   -				console.warn('Could not save editor setting to localStorage:', e);
   -			}
   -		}
   -
   -		function toggleLineNumbers() {
   -			const {lineNumbers} = window.CodeMirror;
   -			showLineNumbers = !showLineNumbers;
   -			saveEditorSetting('editor-line-numbers', showLineNumbers);
   -			
   -			editorView.dispatch({
   -				effects: lineNumbersCompartment.reconfigure(showLineNumbers ? lineNumbers() : [])
   -			});
   -		}
   -
   -		function createLineWrappingExtension() {
   -			const {EditorView, Decoration} = window.CodeMirror;
   -			
   -			return [
   -				EditorView.lineWrapping,
   -				EditorView.decorations.of((view) => {
   -					const decorations = [];
   -					
   -					for (let {from, to} of view.visibleRanges) {
   -						for (let pos = from; pos <= to;) {
   -							const line = view.state.doc.lineAt(pos);
   -							const lineText = line.text;
   -							
   -							// Calculate indentation level (count leading whitespace)
   -							let indentChars = 0;
   -							for (let i = 0; i < lineText.length; i++) {
   -								if (lineText[i] === '\t') {
   -									indentChars += 2; // Convert tab to 2 spaces for calculation
   -								} else if (lineText[i] === ' ') {
   -									indentChars += 1;
   -								} else {
   -									break;
   -								}
   -							}
   -							
   -							// Apply hanging indent if line has indentation
   -							if (indentChars > 0) {
   -								const indentDecoration = Decoration.line({
   -									attributes: {
   -										style: `text-indent: -${indentChars}ch; padding-left: calc(${indentChars}ch + 6px);`
   -									}
   -								});
   -								decorations.push(indentDecoration.range(line.from));
   -							}
   -							
   -							pos = line.to + 1;
   -						}
   -					}
   -					
   -					return decorations.length > 0 ? Decoration.set(decorations) : Decoration.none;
   -				}),
   -			];
   -		}
   -
   -		function toggleLineWrapping() {
   -			enableLineWrapping = !enableLineWrapping;
   -			saveEditorSetting('editor-line-wrapping', enableLineWrapping);
   -			
   -			const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : [];
   -			
   -			editorView.dispatch({
   -				effects: lineWrappingCompartment.reconfigure(lineWrappingExtension)
   -			});
   -		}
   -
   -		// File operations
   -		window.saveFile = function() {
   -			const blob = new Blob([editorView.state.doc.toString()], { type: 'text/html' });
   -			const url = URL.createObjectURL(blob);
   -			const a = document.createElement('a');
   -			a.href = url;
   -			a.download = 'index.html';
   -			a.click();
   -			URL.revokeObjectURL(url);
   -		};
   -
   -		window.loadFile = function() {
   -			const input = document.createElement('input');
   -			input.type = 'file';
   -			input.accept = '.html,.htm';
   -			input.onchange = function(event) {
   -				const file = event.target.files[0];
   -				if (!file) return;
   -				
   -				const reader = new FileReader();
   -				reader.onload = function(e) {
   -					editorView.dispatch({
   -						changes: { from: 0, to: editorView.state.doc.length, insert: e.target.result }
   -					});
   -					saveToStorage();
   -					updatePreview();
   -				};
   -				reader.readAsText(file);
   -			};
   -			input.click();
   -		};
   -
   -		// Wait for CodeMirror to be available
   -		function initializeCodeMirror() {
   -			if (!window.CodeMirror) {
   -				setTimeout(initializeCodeMirror, 100);
   -				return;
   -			}
   -
   -			const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, undoDepth, redoDepth, history, historyKeymap, closeBrackets, closeBracketsKeymap, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers, Decoration, DecorationSet} = window.CodeMirror;
   -			
   -			// Load saved content and editor settings
   -			const savedContent = loadFromStorage();
   -			loadEditorSettings();
   -			
   -			// Create compartments for dynamic extensions
   -			lineNumbersCompartment = new Compartment();
   -			lineWrappingCompartment = new Compartment();
   -			
   -			const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : [];
   -			
   -			// Create CodeMirror editor
   -			editorView = new EditorView({
   -				state: EditorState.create({
   -					doc: savedContent,
   -					extensions: [
   -						history(),
   -						search(),
   -						closeBrackets(),
   -						keymap.of([
   -							{key: "Mod-z", run: undo},
   -							{key: "Mod-y", run: redo},
   -							{key: "Mod-Shift-z", run: redo},
   -							{key: "Mod-o", run: () => { window.loadFile(); return true; }},
   -							{key: "Mod-s", run: () => { window.saveFile(); return true; }},
   -							{key: "F1", run: () => { toggleLineNumbers(); return true; }},
   -							{key: "F2", run: () => { toggleLineWrapping(); return true; }},
   -							indentWithTab, 
   -							...searchKeymap.filter(binding => binding.key !== "Mod-f"),
   -							...defaultKeymap
   -						]),
   -						html(),
   -						githubDark,
   -						indentUnit.of("\t"),
   -						placeholder("Build something with HTML..."),
   -						EditorView.updateListener.of((update) => {
   -							if (update.docChanged) {
   -								clearTimeout(updateTimer);
   -								updateTimer = setTimeout(updatePreview, 600);
   -								saveToStorage();
   -							}
   -						}),
   -						// Disable text correction and autocomplete
   -						EditorView.contentAttributes.of({
   -							'autocomplete': 'off',
   -							'autocorrect': 'off',
   -							'autocapitalize': 'off',
   -							'spellcheck': 'false'
   -						}),
   -						lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []),
   -						lineWrappingCompartment.of(initialLineWrappingExtension)
   -					]
   -				}),
   -				parent: document.getElementById('editor')
   -			});
   -
   -			// Initial render (wait for stock images manifest to load first)
   -			stockImagesReady.then(() => updatePreview());
   -
   -			// Track editor focus and handle keyboard dismissal
   -			editorView.contentDOM.addEventListener('focus', () => { isEditorFocused = true; });
   -			
   -			editorView.contentDOM.addEventListener('blur', () => { 
   -				isEditorFocused = false; 
   -
   -				// If we are on mobile and the keyboard mode is active, 
   -				// exit immediately. Do not wait for visualViewport resize.
   -				if (isMobileDevice() && document.body.classList.contains('mobile-keyboard-open')) {
   -					
   -					// 1. Hide editor immediately to prevent the "stutter/jump" visual
   -					editorPane.style.opacity = '0';
   -					
   -					// 2. Remove the class to trigger layout engine (100% -> 50% height)
   -					document.body.classList.remove('mobile-keyboard-open');
   -					
   -					// 3. Wait for layout to settle, then restore scroll and opacity
   -					requestAnimationFrame(() => requestAnimationFrame(() => {
   -						if (editorView) {
   -							// Scroll cursor into view in the new 50% layout
   -							const pos = editorView.state.selection.main.head;
   -							const lineBlock = editorView.lineBlockAt(pos);
   -							const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2);
   -							editorView.scrollDOM.scrollTop = Math.max(0, targetScroll);
   -						}
   -						// Reveal the editor
   -						editorPane.style.opacity = '';
   -
   -						// Update preview with any changes made while keyboard was open
   -						updatePreview();
   -					}));
   -				}
   -			});
   -
   -			// Focus the editor
   -			editorView.focus();
   -			
   -			// Global keydown handler for Cmd+F toggle
   -			document.addEventListener('keydown', function(e) {
   -				if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
   -					e.preventDefault();
   -					closeSearchPanel(editorView) || openSearchPanel(editorView);
   -				}
   -			});
   -		}
   -
   -		// Mobile keyboard detection
   -		function isMobileDevice() {
   -			return window.matchMedia("(pointer: coarse), (pointer: none)").matches;
   -		}
   -
   -		let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
   -		let isEditorFocused = false;
   -
   -		function updateViewportVariables() {
   -			const vv = window.visualViewport;
   -			if (vv) {
   -				document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`);
   -				document.documentElement.style.setProperty('--visual-viewport-offset-top', `${vv.offsetTop}px`);
   -			}
   -		}
   -
   -		function handleViewportChange() {
   -			if (!isMobileDevice()) return;
   -
   -			const currentHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
   -			const heightDifference = initialViewportHeight - currentHeight;
   -			const isKeyboardOpen = heightDifference > 150;
   -
   -			updateViewportVariables();
   -
   -			if (isKeyboardOpen && isEditorFocused) {
   -				document.body.classList.add('mobile-keyboard-open');
   -			} else if (!isKeyboardOpen) {
   -				// Check if the class is still there (it might have been removed by blur already)
   -				const wasKeyboardOpen = document.body.classList.contains('mobile-keyboard-open');
   -
   -				if (wasKeyboardOpen) {
   -					// This is a fallback in case blur didn't catch it 
   -					// (e.g. if the keyboard was dismissed via a gesture that didn't blur immediately)
   -					if (editorView) {
   -						const pos = editorView.state.selection.main.head;
   -						editorPane.style.opacity = '0';
   -						document.body.classList.remove('mobile-keyboard-open');
   -						
   -						requestAnimationFrame(() => requestAnimationFrame(() => {
   -							const lineBlock = editorView.lineBlockAt(pos);
   -							const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2);
   -							editorView.scrollDOM.scrollTop = Math.max(0, targetScroll);
   -							editorPane.style.opacity = '';
   -						}));
   -					} else {
   -						document.body.classList.remove('mobile-keyboard-open');
   -					}
   -				}
   -			}
   -		}
   -
   -		if (window.visualViewport) {
   -			window.visualViewport.addEventListener('resize', handleViewportChange);
   -			window.visualViewport.addEventListener('scroll', updateViewportVariables);
   -		} else {
   -			window.addEventListener('resize', handleViewportChange);
   -		}
   -
   -		// Initialize when page loads
   -		initializeCodeMirror();
   -
   -		// Register service worker
   -		if ('serviceWorker' in navigator) {
   -			window.addEventListener('load', () => {
   -				navigator.serviceWorker.register('./sw.js')
   -					.then(registration => {
   -						console.log('SW registered: ', registration);
   -					})
   -					.catch(registrationError => {
   -						console.log('SW registration failed: ', registrationError);
   -					});
   -			});
   -		}
   -	</script>
32 +	<script type="module" src="main.js"></script>
33  </body>
   -</html>
34 +</html>
main.js
  1 +let updateTimer;
  2 +let editorView;
  3 +const preview = document.getElementById('preview');
  4 +
  5 +// Stock images loaded from manifest - can be referenced by bare filename
  6 +let stockImages = new Map(); // lowercase -> original filename
  7 +
  8 +// Load stock image list from manifest
  9 +const stockImagesReady = fetch('resource-manifest.json')
 10 +	.then(r => r.json())
 11 +	.then(manifest => {
 12 +		manifest.images.forEach(path => {
 13 +			const filename = path.split('/').pop();
 14 +			stockImages.set(filename.toLowerCase(), filename);
 15 +		});
 16 +	})
 17 +	.catch(() => console.log('Could not load resource manifest'));
 18 +
 19 +// Rewrite bare image filenames to use images/ prefix (case-insensitive, uses correct case)
 20 +function rewriteBareImageSrcs(html) {
 21 +	return html.replace(/(<img\s[^>]*\bsrc\s*=\s*["'])([^"'/:]+\.(gif|png|jpg|jpeg|svg|webp))(["'])/gi,
 22 +		(match, before, filename, ext, after) => {
 23 +			const original = stockImages.get(filename.toLowerCase());
 24 +			if (original) {
 25 +				return before + 'images/' + original + after;
 26 +			}
 27 +			return match;
 28 +		});
 29 +}
 30 +
 31 +// Expand <images> tags into gallery table
 32 +function expandImagesTag(html) {
 33 +	return html.replace(/<images\s*\/?>/gi, () => {
 34 +		const images = Array.from(stockImages.values()).sort();
 35 +		if (images.length === 0) return '<p>No images available</p>';
 36 +		const rows = images.map(filename =>
 37 +			`<tr><td>${filename}</td><td style="text-align:center;"><img src="images/${filename}"></td></tr>`
 38 +		).join('');
 39 +		return `<table border="1" cellpadding="8" cellspacing="0" style="width:100%;"><colgroup><col style="width:50%"><col style="width:50%"></colgroup>${rows}</table>`;
 40 +	});
 41 +}
 42 +const editorPane = document.querySelector('.editor-pane');
 43 +const previewPane = document.querySelector('.preview-pane');
 44 +const storageKey = 'html-lab-content';
 45 +let isFullscreen = false;
 46 +let showLineNumbers = false;
 47 +let enableLineWrapping = false;
 48 +let lineNumbersCompartment;
 49 +let lineWrappingCompartment;
 50 +
 51 +function toggleFullscreen() {
 52 +
 53 +	isFullscreen = !isFullscreen;
 54 +
 55 +	if (isFullscreen) {
 56 +		previewPane.classList.add('fullscreen');
 57 +		editorPane.classList.add('hidden');
 58 +	} else {
 59 +		previewPane.classList.remove('fullscreen');
 60 +		editorPane.classList.remove('hidden');
 61 +	}
 62 +
 63 +	// Update button title in iframe
 64 +	try {
 65 +		const toggleButton = preview.contentDocument.getElementById('fullscreenToggle');
 66 +		if (toggleButton) {
 67 +			toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen';
 68 +		}
 69 +	} catch (e) {
 70 +		// Ignore cross-origin errors
 71 +	}
 72 +}
 73 +
 74 +
 75 +// Listen for messages from iframe
 76 +window.addEventListener('message', function(event) {
 77 +	if (event.data === 'toggleFullscreen') {
 78 +		toggleFullscreen();
 79 +	}
 80 +});
 81 +
 82 +function extractTitleAndFavicon(htmlCode) {
 83 +	const parser = new DOMParser();
 84 +	const doc = parser.parseFromString(htmlCode, 'text/html');
 85 +
 86 +	// Extract title
 87 +	const titleElement = doc.querySelector('title');
 88 +	const title = titleElement ? titleElement.textContent.trim() : null;
 89 +
 90 +	// Extract favicon
 91 +	const faviconSelectors = [
 92 +		'link[rel="icon"]',
 93 +		'link[rel="shortcut icon"]',
 94 +		'link[rel="apple-touch-icon"]',
 95 +		'link[rel="mask-icon"]'
 96 +	];
 97 +
 98 +	let favicon = null;
 99 +	for (const selector of faviconSelectors) {
100 +		const faviconElement = doc.querySelector(selector);
101 +		if (faviconElement && faviconElement.href) {
102 +			favicon = faviconElement.href;
103 +			break;
104 +		}
105 +	}
106 +
107 +	return { title, favicon };
108 +}
109 +
110 +function updateMainPageTitleAndFavicon(title, favicon) {
111 +	// Update title
112 +	if (title) {
113 +		document.title = title;
114 +	} else {
115 +		document.title = 'Web Workshop';
116 +	}
117 +
118 +	// Update favicon
119 +	let faviconLink = document.querySelector('link[rel="icon"]');
120 +	if (!faviconLink) {
121 +		faviconLink = document.createElement('link');
122 +		faviconLink.rel = 'icon';
123 +		document.head.appendChild(faviconLink);
124 +	}
125 +
126 +	if (favicon) {
127 +		faviconLink.href = favicon;
128 +	} else {
129 +		faviconLink.href = 'resources/rollerskate.png';
130 +	}
131 +}
132 +
133 +function updatePreview() {
134 +	// Skip preview updates while mobile keyboard is open and editor is focused
135 +	// This prevents keyboard layer resets on Android
136 +	if (isMobileDevice() && isEditorFocused && document.body.classList.contains('mobile-keyboard-open')) {
137 +		return;
138 +	}
139 +
140 +	const code = editorView.state.doc.toString();
141 +
142 +	// Extract and update title and favicon from user's HTML
143 +	const { title, favicon } = extractTitleAndFavicon(code);
144 +	updateMainPageTitleAndFavicon(title, favicon);
145 +
146 +	// Store scroll position before updating
147 +	let scrollX = 0, scrollY = 0;
148 +	try {
149 +		if (preview.contentWindow?.scrollX !== undefined) {
150 +			scrollX = preview.contentWindow.scrollX;
151 +			scrollY = preview.contentWindow.scrollY;
152 +		}
153 +	} catch (e) {
154 +		// Ignore cross-origin errors
155 +	}
156 +
157 +	// Use srcdoc to create a completely fresh document context
158 +	// Expand <images> tag and rewrite bare stock image filenames
159 +	const processedCode = expandImagesTag(rewriteBareImageSrcs(code.trim())) || '<!DOCTYPE html><html><head></head><body></body></html>';
160 +	preview.srcdoc = processedCode;
161 +
162 +	// Add our functionality after the iframe loads
163 +	const onLoad = () => {
164 +		try {
165 +			const doc = preview.contentDocument;
166 +			if (!doc) return;
167 +
168 +			// Add CSS for overscroll and button
169 +			const style = doc.createElement('style');
170 +			style.textContent = '* { overscroll-behavior: none !important; } .iframe-fullscreen-toggle { position: fixed; top: 5px; right: 5px; z-index: 10000; background: rgba(0, 0, 0, 0.2); color: white; border: none; border-radius: 4px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.2s; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -webkit-tap-highlight-color: transparent; outline: none; user-select: none; } .iframe-fullscreen-toggle svg { filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3)); opacity: 0.8; transition: opacity 0.2s; } @media (hover: hover) and (pointer: fine) { .iframe-fullscreen-toggle:hover { background: rgba(0, 0, 0, 0.35); } .iframe-fullscreen-toggle:hover svg { opacity: 1; } }';
171 +			doc.head.appendChild(style);
172 +
173 +			// Create fullscreen button
174 +			const existingButton = doc.getElementById('fullscreenToggle');
175 +			if (existingButton) existingButton.remove();
176 +
177 +			const button = doc.createElement('button');
178 +			button.id = 'fullscreenToggle';
179 +			button.className = 'iframe-fullscreen-toggle';
180 +			button.title = 'Toggle fullscreen';
181 +
182 +			// Use inline SVG to avoid being affected by user's img styles
183 +			button.innerHTML = '<svg width="20" height="20" viewBox="0 0 14 14" fill="white"><path d="M 7,14 H 5 v 5 h 5 V 17 H 7 Z M 5,10 H 7 V 7 h 3 V 5 H 5 Z m 12,7 h -3 v 2 h 5 V 14 H 17 Z M 14,5 v 2 h 3 v 3 h 2 V 5 Z" transform="translate(-5,-5)"/></svg>';
184 +
185 +			button.addEventListener('click', function() {
186 +				parent.postMessage('toggleFullscreen', '*');
187 +			});
188 +
189 +			if (doc.body) {
190 +				doc.body.appendChild(button);
191 +			}
192 +
193 +			// Restore scroll position
194 +			setTimeout(() => {
195 +				try {
196 +					preview.contentWindow?.scrollTo(scrollX, scrollY);
197 +				} catch (e) {
198 +					// Ignore cross-origin errors
199 +				}
200 +			}, 10);
201 +		} catch (e) {
202 +			// Ignore cross-origin errors
203 +		}
204 +
205 +		preview.removeEventListener('load', onLoad);
206 +	};
207 +	preview.addEventListener('load', onLoad);
208 +}
209 +
210 +function saveToStorage() {
211 +	try {
212 +		localStorage.setItem(storageKey, editorView.state.doc.toString());
213 +	} catch (e) {
214 +		console.warn('Could not save to localStorage:', e);
215 +	}
216 +}
217 +
218 +function loadFromStorage() {
219 +	try {
220 +		return localStorage.getItem(storageKey) || '';
221 +	} catch (e) {
222 +		console.warn('Could not load from localStorage:', e);
223 +		return '';
224 +	}
225 +}
226 +
227 +function loadEditorSettings() {
228 +	try {
229 +		showLineNumbers = localStorage.getItem('editor-line-numbers') === 'true';
230 +		enableLineWrapping = localStorage.getItem('editor-line-wrapping') !== 'false';
231 +	} catch (e) {
232 +		console.warn('Could not load editor settings from localStorage:', e);
233 +		showLineNumbers = false;
234 +		enableLineWrapping = true;
235 +	}
236 +}
237 +
238 +function saveEditorSetting(key, value) {
239 +	try {
240 +		localStorage.setItem(key, value.toString());
241 +	} catch (e) {
242 +		console.warn('Could not save editor setting to localStorage:', e);
243 +	}
244 +}
245 +
246 +function toggleLineNumbers() {
247 +	const {lineNumbers} = window.CodeMirror;
248 +	showLineNumbers = !showLineNumbers;
249 +	saveEditorSetting('editor-line-numbers', showLineNumbers);
250 +
251 +	editorView.dispatch({
252 +		effects: lineNumbersCompartment.reconfigure(showLineNumbers ? lineNumbers() : [])
253 +	});
254 +}
255 +
256 +function createLineWrappingExtension() {
257 +	const {EditorView, Decoration} = window.CodeMirror;
258 +
259 +	return [
260 +		EditorView.lineWrapping,
261 +		EditorView.decorations.of((view) => {
262 +			const decorations = [];
263 +
264 +			for (let {from, to} of view.visibleRanges) {
265 +				for (let pos = from; pos <= to;) {
266 +					const line = view.state.doc.lineAt(pos);
267 +					const lineText = line.text;
268 +
269 +					// Calculate indentation level (count leading whitespace)
270 +					let indentChars = 0;
271 +					for (let i = 0; i < lineText.length; i++) {
272 +						if (lineText[i] === '\t') {
273 +							indentChars += 2; // Convert tab to 2 spaces for calculation
274 +						} else if (lineText[i] === ' ') {
275 +							indentChars += 1;
276 +						} else {
277 +							break;
278 +						}
279 +					}
280 +
281 +					// Apply hanging indent if line has indentation
282 +					if (indentChars > 0) {
283 +						const indentDecoration = Decoration.line({
284 +							attributes: {
285 +								style: `text-indent: -${indentChars}ch; padding-left: calc(${indentChars}ch + 6px);`
286 +							}
287 +						});
288 +						decorations.push(indentDecoration.range(line.from));
289 +					}
290 +
291 +					pos = line.to + 1;
292 +				}
293 +			}
294 +
295 +			return decorations.length > 0 ? Decoration.set(decorations) : Decoration.none;
296 +		}),
297 +	];
298 +}
299 +
300 +function toggleLineWrapping() {
301 +	enableLineWrapping = !enableLineWrapping;
302 +	saveEditorSetting('editor-line-wrapping', enableLineWrapping);
303 +
304 +	const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : [];
305 +
306 +	editorView.dispatch({
307 +		effects: lineWrappingCompartment.reconfigure(lineWrappingExtension)
308 +	});
309 +}
310 +
311 +// File operations
312 +window.saveFile = function() {
313 +	const blob = new Blob([editorView.state.doc.toString()], { type: 'text/html' });
314 +	const url = URL.createObjectURL(blob);
315 +	const a = document.createElement('a');
316 +	a.href = url;
317 +	a.download = 'index.html';
318 +	a.click();
319 +	URL.revokeObjectURL(url);
320 +};
321 +
322 +window.loadFile = function() {
323 +	const input = document.createElement('input');
324 +	input.type = 'file';
325 +	input.accept = '.html,.htm';
326 +	input.onchange = function(event) {
327 +		const file = event.target.files[0];
328 +		if (!file) return;
329 +
330 +		const reader = new FileReader();
331 +		reader.onload = function(e) {
332 +			editorView.dispatch({
333 +				changes: { from: 0, to: editorView.state.doc.length, insert: e.target.result }
334 +			});
335 +			saveToStorage();
336 +			updatePreview();
337 +		};
338 +		reader.readAsText(file);
339 +	};
340 +	input.click();
341 +};
342 +
343 +// Wait for CodeMirror to be available
344 +function initializeCodeMirror() {
345 +	if (!window.CodeMirror) {
346 +		setTimeout(initializeCodeMirror, 100);
347 +		return;
348 +	}
349 +
350 +	const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, undoDepth, redoDepth, history, historyKeymap, closeBrackets, closeBracketsKeymap, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers, Decoration, DecorationSet} = window.CodeMirror;
351 +
352 +	// Load saved content and editor settings
353 +	const savedContent = loadFromStorage();
354 +	loadEditorSettings();
355 +
356 +	// Create compartments for dynamic extensions
357 +	lineNumbersCompartment = new Compartment();
358 +	lineWrappingCompartment = new Compartment();
359 +
360 +	const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : [];
361 +
362 +	// Create CodeMirror editor
363 +	editorView = new EditorView({
364 +		state: EditorState.create({
365 +			doc: savedContent,
366 +			extensions: [
367 +				history(),
368 +				search(),
369 +				closeBrackets(),
370 +				keymap.of([
371 +					{key: "Mod-z", run: undo},
372 +					{key: "Mod-y", run: redo},
373 +					{key: "Mod-Shift-z", run: redo},
374 +					{key: "Mod-o", run: () => { window.loadFile(); return true; }},
375 +					{key: "Mod-s", run: () => { window.saveFile(); return true; }},
376 +					{key: "F1", run: () => { toggleLineNumbers(); return true; }},
377 +					{key: "F2", run: () => { toggleLineWrapping(); return true; }},
378 +					indentWithTab,
379 +					...searchKeymap.filter(binding => binding.key !== "Mod-f"),
380 +					...defaultKeymap
381 +				]),
382 +				html(),
383 +				githubDark,
384 +				indentUnit.of("\t"),
385 +				placeholder("Build something with HTML..."),
386 +				EditorView.updateListener.of((update) => {
387 +					if (update.docChanged) {
388 +						clearTimeout(updateTimer);
389 +						updateTimer = setTimeout(updatePreview, 600);
390 +						saveToStorage();
391 +					}
392 +				}),
393 +				// Disable text correction and autocomplete
394 +				EditorView.contentAttributes.of({
395 +					'autocomplete': 'off',
396 +					'autocorrect': 'off',
397 +					'autocapitalize': 'off',
398 +					'spellcheck': 'false'
399 +				}),
400 +				lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []),
401 +				lineWrappingCompartment.of(initialLineWrappingExtension)
402 +			]
403 +		}),
404 +		parent: document.getElementById('editor')
405 +	});
406 +
407 +	// Initial render (wait for stock images manifest to load first)
408 +	stockImagesReady.then(() => updatePreview());
409 +
410 +	// Track editor focus and handle keyboard dismissal
411 +	editorView.contentDOM.addEventListener('focus', () => { isEditorFocused = true; });
412 +
413 +	editorView.contentDOM.addEventListener('blur', () => {
414 +		isEditorFocused = false;
415 +
416 +		// If we are on mobile and the keyboard mode is active,
417 +		// exit immediately. Do not wait for visualViewport resize.
418 +		if (isMobileDevice() && document.body.classList.contains('mobile-keyboard-open')) {
419 +
420 +			// 1. Hide editor immediately to prevent the "stutter/jump" visual
421 +			editorPane.style.opacity = '0';
422 +
423 +			// 2. Remove the class to trigger layout engine (100% -> 50% height)
424 +			document.body.classList.remove('mobile-keyboard-open');
425 +
426 +			// 3. Wait for layout to settle, then restore scroll and opacity
427 +			requestAnimationFrame(() => requestAnimationFrame(() => {
428 +				if (editorView) {
429 +					// Scroll cursor into view in the new 50% layout
430 +					const pos = editorView.state.selection.main.head;
431 +					const lineBlock = editorView.lineBlockAt(pos);
432 +					const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2);
433 +					editorView.scrollDOM.scrollTop = Math.max(0, targetScroll);
434 +				}
435 +				// Reveal the editor
436 +				editorPane.style.opacity = '';
437 +
438 +				// Update preview with any changes made while keyboard was open
439 +				updatePreview();
440 +			}));
441 +		}
442 +	});
443 +
444 +	// Focus the editor
445 +	editorView.focus();
446 +
447 +	// Global keydown handler for Cmd+F toggle
448 +	document.addEventListener('keydown', function(e) {
449 +		if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
450 +			e.preventDefault();
451 +			closeSearchPanel(editorView) || openSearchPanel(editorView);
452 +		}
453 +	});
454 +}
455 +
456 +// Mobile keyboard detection
457 +function isMobileDevice() {
458 +	return window.matchMedia("(pointer: coarse), (pointer: none)").matches;
459 +}
460 +
461 +let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
462 +let isEditorFocused = false;
463 +
464 +function updateViewportVariables() {
465 +	const vv = window.visualViewport;
466 +	if (vv) {
467 +		document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`);
468 +		document.documentElement.style.setProperty('--visual-viewport-offset-top', `${vv.offsetTop}px`);
469 +	}
470 +}
471 +
472 +function handleViewportChange() {
473 +	if (!isMobileDevice()) return;
474 +
475 +	const currentHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
476 +	const heightDifference = initialViewportHeight - currentHeight;
477 +	const isKeyboardOpen = heightDifference > 150;
478 +
479 +	updateViewportVariables();
480 +
481 +	if (isKeyboardOpen && isEditorFocused) {
482 +		document.body.classList.add('mobile-keyboard-open');
483 +	} else if (!isKeyboardOpen) {
484 +		// Check if the class is still there (it might have been removed by blur already)
485 +		const wasKeyboardOpen = document.body.classList.contains('mobile-keyboard-open');
486 +
487 +		if (wasKeyboardOpen) {
488 +			// This is a fallback in case blur didn't catch it
489 +			// (e.g. if the keyboard was dismissed via a gesture that didn't blur immediately)
490 +			if (editorView) {
491 +				const pos = editorView.state.selection.main.head;
492 +				editorPane.style.opacity = '0';
493 +				document.body.classList.remove('mobile-keyboard-open');
494 +
495 +				requestAnimationFrame(() => requestAnimationFrame(() => {
496 +					const lineBlock = editorView.lineBlockAt(pos);
497 +					const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2);
498 +					editorView.scrollDOM.scrollTop = Math.max(0, targetScroll);
499 +					editorPane.style.opacity = '';
500 +				}));
501 +			} else {
502 +				document.body.classList.remove('mobile-keyboard-open');
503 +			}
504 +		}
505 +	}
506 +}
507 +
508 +if (window.visualViewport) {
509 +	window.visualViewport.addEventListener('resize', handleViewportChange);
510 +	window.visualViewport.addEventListener('scroll', updateViewportVariables);
511 +} else {
512 +	window.addEventListener('resize', handleViewportChange);
513 +}
514 +
515 +// Initialize when page loads
516 +initializeCodeMirror();
517 +
518 +// Register service worker
519 +if ('serviceWorker' in navigator) {
520 +	window.addEventListener('load', () => {
521 +		navigator.serviceWorker.register('./sw.js')
522 +			.then(registration => {
523 +				console.log('SW registered: ', registration);
524 +			})
525 +			.catch(registrationError => {
526 +				console.log('SW registration failed: ', registrationError);
527 +			});
528 +	});
529 +}
styles.css
  1 +:root {
  2 +	--text-color: #f8f8f2;
  3 +	--editor-bg: #16181b;
  4 +	scrollbar-color: grey var(--editor-bg);
  5 +}
  6 +
  7 +* {
  8 +	margin: 0;
  9 +	padding: 0;
 10 +	box-sizing: border-box;
 11 +}
 12 +
 13 +html, body {
 14 +	overscroll-behavior: none;
 15 +	background-color: var(--editor-bg);
 16 +}
 17 +
 18 +html {
 19 +	height: 100%;
 20 +}
 21 +
 22 +body {
 23 +	font-family: monospace;
 24 +	background: var(--bg-color);
 25 +	color: var(--text-color);
 26 +	height: 100%;
 27 +	display: flex;
 28 +	overflow: hidden;
 29 +}
 30 +
 31 +.editor-pane {
 32 +	width: 50%;
 33 +	height: 100%;
 34 +	position: relative;
 35 +	transition: opacity 0.1s ease-out; /* Added transition for smoother opacity hack */
 36 +}
 37 +
 38 +.preview-pane {
 39 +	width: 50%;
 40 +	height: 100%;
 41 +	position: relative;
 42 +}
 43 +
 44 +.preview-pane.fullscreen {
 45 +	position: fixed;
 46 +	top: 0;
 47 +	left: 0;
 48 +	width: 100vw;
 49 +	height: 100vh;
 50 +	z-index: 9999;
 51 +	background: white;
 52 +}
 53 +
 54 +/* Hide editor when preview is fullscreen */
 55 +.editor-pane.hidden {
 56 +	display: none;
 57 +}
 58 +
 59 +#editor {
 60 +	width: 100%;
 61 +	height: 100%;
 62 +	font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
 63 +	font-size: 21px;
 64 +}
 65 +
 66 +/* CodeMirror styling */
 67 +.cm-editor {
 68 +	width: 100%;
 69 +	height: 100%;
 70 +	background: var(--editor-bg);
 71 +	color: var(--text-color);
 72 +}
 73 +
 74 +.cm-placeholder {
 75 +	color: #666;
 76 +	opacity: 0.6;
 77 +}
 78 +
 79 +.cm-content {
 80 +	padding: 15px;
 81 +	font-size: 21px;
 82 +	line-height: 1.6;
 83 +}
 84 +
 85 +.cm-gutters {
 86 +	font-size: 21px;
 87 +	line-height: 1.6;
 88 +	user-select: none;
 89 +}
 90 +
 91 +.cm-lineNumbers .cm-gutterElement {
 92 +	line-height: 1.6;
 93 +	font-size: 21px;
 94 +}
 95 +
 96 +.ͼ1 .cm-lineNumbers .cm-gutterElement {
 97 +	padding-right: 0;
 98 +	padding-left: 8px;
 99 +}
100 +
101 +.cm-focused {
102 +	outline: none;
103 +}
104 +
105 +/* Force tab size to 2 spaces - override theme defaults */
106 +.cm-editor .cm-content,
107 +.cm-editor .cm-line,
108 +.cm-editor {
109 +	tab-size: 2 !important;
110 +	-moz-tab-size: 2 !important;
111 +}
112 +
113 +/* Disable overscroll on all CodeMirror elements */
114 +.cm-editor, .cm-content, .cm-scroller, .cm-scrollElement {
115 +	overscroll-behavior: none !important;
116 +}
117 +
118 +/* Simplify search panel - hide all buttons, labels, and replace input */
119 +.cm-search button {
120 +	display: none !important;
121 +}
122 +
123 +.cm-search {
124 +	background-color: #16181b;
125 +}
126 +
127 +.cm-search label {
128 +	display: none !important;
129 +}
130 +
131 +.cm-search input[name="replace"] {
132 +	display: none !important;
133 +}
134 +
135 +/* Style search input to match editor text size */
136 +.cm-search input[name="search"] {
137 +	font-size: 21px !important;
138 +	width: 100% !important;
139 +	border-radius: 50px !important;
140 +	padding-left: 0.8em !important;
141 +	padding-right: 0.8em !important;
142 +	padding-bottom: 7px !important;
143 +}
144 +
145 +.cm-panels-bottom {
146 +	border-radius: 0px !important;
147 +}
148 +
149 +.cm-panels-bottom {
150 +	border-top: none !important;
151 +
152 +}
153 +
154 +#preview {
155 +	width: 100%;
156 +	height: 100%;
157 +	border: none;
158 +	background: white;
159 +	overflow: auto;
160 +}
161 +
162 +/* Vertical layout for narrow screens (height > width) */
163 +@media (max-aspect-ratio: 1/1) {
164 +	body {
165 +		flex-direction: column;
166 +	}
167 +
168 +	.editor-pane {
169 +		width: 100%;
170 +		height: 50%;
171 +		order: 2;
172 +	}
173 +
174 +	.preview-pane {
175 +		width: 100%;
176 +		height: 50%;
177 +		order: 1;
178 +		padding-top: env(safe-area-inset-top);
179 +	}
180 +}
181 +
182 +/* Mobile: fullscreen editor when keyboard is open */
183 +@media (pointer: coarse), (pointer: none) {
184 +	body.mobile-keyboard-open .preview-pane {
185 +		display: none;
186 +	}
187 +
188 +	body.mobile-keyboard-open .editor-pane {
189 +		position: fixed;
190 +		top: calc(var(--visual-viewport-offset-top, 0px));
191 +		left: 0;
192 +		width: 100%;
193 +		height: var(--visual-viewport-height, 100%);
194 +		max-height: var(--visual-viewport-height, 100%);
195 +		order: 1;
196 +		overscroll-behavior: none;
197 +		overflow: hidden;
198 +	}
199 +
200 +	body.mobile-keyboard-open .editor-pane #editor,
201 +	body.mobile-keyboard-open .editor-pane .cm-editor {
202 +		height: 100% !important;
203 +		max-height: 100% !important;
204 +	}
205 +}