Skip to content

Instantly share code, notes, and snippets.

@semanticentity
Created October 10, 2025 21:34
Show Gist options
  • Select an option

  • Save semanticentity/db53d439c5b21fcaa02adc332f60638a to your computer and use it in GitHub Desktop.

Select an option

Save semanticentity/db53d439c5b21fcaa02adc332f60638a to your computer and use it in GitHub Desktop.
WireframeLang Editor v0.0.2
<!-- WIZZY PANEL -->
<div id="wizzy">
<button data-sn="--- ">New section</button>
<button data-sn=" | ">Column “|”</button>
<button data-sn="--- 50/50 ">2-col 50/50</button>
<button data-sn="--- 33/34/33 ">3-col thirds</button>
<button data-sn="▣ ">CTA ▣</button>
<button data-sn="▸ ">Sub-head ▸</button>
<button data-sn="• ">Bullet •</button>
<button data-sn="[[progress 50]]">Progress</button>
<button data-sn="[[img 400x300 kitten]]">Kitten 🐱</button>
<button id="exportJSON">💾 JSON</button>
<button id="importJSON">📂 Import</button>
<input type="file" id="importFile" accept=".json" style="display:none;">
<button id="savePNG">💾 PNG</button>
<button id="saveSVG">➰ SVG</button>
<button id="share">🔗 Share</button>
<button id="preview">↻ Preview (WIP)</button>
<button id="pasteJSON">📋 Paste JSON</button>
</div>
<!-- WIZZY PANEL (add after the last <button>) -->
<select id="tpl" style="background: linear-gradient(to bottom, #313639 0%, #1c1c1c 33%, #1d1e22 66%, #101010 99%); border: 0; border-bottom: 1px solid #000; color: #fff; border-radius: 0;">
<option value="">— Templates —</option>
<option value="hero">Hero section</option>
<option value="markdowndemo">Markdown Demo</option>
<option value="helpModal">Help Modal</option>
<option value="featureCallouts">Feature Callouts (2-up)</option>
<option value="imageGallery">Image Gallery (4-up)</option>
<option value="pricing">Pricing table (3-up)</option>
<option value="comparison">Feature Comparison</option>
<option value="steps">Steps to Start</option>
<option value="nav">Top Navigation</option>
<option value="features">Feature Highlights (cards)</option>
<option value="team">Meet the Team (cards)</option>
<option value="cards">Cards Grid</option>
<option value="heroWorkflow">Hero – Workflow</option>
<option value="value50">50/50 Value Props</option>
<option value="progress3">Three-Step Progress</option>
<option value="featuresCards">Feature Bullets</option>
<option value="highlight">Product Highlight</option>
<option value="useCases">Use-Case Columns</option>
<option value="integrations">Integrations Row</option>
<option value="pricingTable">Pricing 3-up</option>
<option value="kittenDiv">Kitten Divider</option>
</select>
<main id="editor">
<textarea id="src" spellcheck="false" style="border-radius: 0;">
## WireframeLang supports coments
## Both inside and outside sections
--- WireframeLang Documentation
[[img 860x300 kitten]]
▸ Overview
• WireframeLang (WFL) lets you create quick visual layouts using plain text.
• Every section starts with three dashes `---`.
• Common UI elements are easy to write and fast to preview.
--- Example: Overview
# Welcome to WireframeLang
• Fast prototyping
• Plain-text layouts
• Instant live preview
▣ Start building now
--- Sections
▸ Section Basics
• `--- Section Title`
• `--- 50/50 Title` → Two equal columns.
• `--- 33/33/33 Title` → Three equal columns.
• `--- cards3 Title` → Card layout with 3 cards.
• Use `|wide|` inside sections to make a full-width row.
--- 50/50 50/50 Example
Left side content | Right side content
--- 33/33/33 Example
One third | One third | One third
--- cards3 Example
h3: Card A
h3: Card B
h3: Card C
|wide| Spanning text below the cards. Full width content.
--- Text & Markdown
▸ Headings
# H1 Example
• `# Heading` → H1
H2: H2 Example
• `h2 Heading` → H2
▸ ▸ Subhead Example
• `▸ Subhead` → H3
H3: H3 Example
• `H3: Manual Heading` → H3
▸ Text Formatting
• `**bold**` → Bold text
• `*italic*` → Italic text
• `~~strike~~` → Strikethrough
• `` `inline code` `` → Inline code snippet
▸ Lists
• `• Bullet point`
• `1. Numbered item`
▸ Links
• `[Link Text](https://example.com)`
• `https://example.com` (auto-link)
▸ Blockquotes (styling tk)
• `> Quoted text here`
--- Layouts and Grids
▸ Grids
• Use `|` to split content into columns inside 50/50 or 33/33/33 layouts.
▸ Wide Rows
• Insert `|wide|` before a line to make it span all columns/cards.
--- 50/50 50/50 Split
Left column text | Right column text
|wide| Full width note across both columns.
--- Components
▸ Buttons and CTAs
• `▣ Call to Action` → Big Button
• `[[button "Small Button"]]`
▸ Progress Bars
• `[[progress 50]]` → 50% filled bar
▸ Images
• `[[img 400x300 kitten]]`
• `[[img 400x300 unsplash:nature]]`
• `[[img 400x300 bw]]`
• `[[img 400x300 blur]]`
▸ Modals
• `[[modal ModalID Open Modal]]` → Button to open a modal.
• Define modal with:
`--- modal "ModalID"`
`---- header`
`Modal Title`
`---- body`
`Modal Content`
`---- footer`
`Modal Footer`
--- Example: Components
▣ Big Call to Action
[[button "Secondary Button"]]
[[progress 60]]
[[img 400x200 kitten]]
[[modal DemoModal Open Modal Example]]
--- modal "DemoModal"
---- header
Demo Modal Title
---- body
Welcome to the demo modal!
• Supports bullets
• Inline elements
▣ Close me
---- footer
Footer content
--- Forms
▸ Form Blocks
• `[[form]]` ... `[[/form]]` to group fields.
▸ Fields
• `[[input email "Your e-mail"]]`
• `[[password pass "Password"]]`
• `[[textarea message "Your message"]]`
• `[[select country USA|Canada|UK]]`
• `[[checkbox tos "I agree to terms"]]`
• `[[submit "Send"]]`
• `[[upload file "Upload file"]]`
• `[[color favoriteColor "Pick a color"]]`
• `[[range volume "Volume"]]`
▸ Form Layout
• Use `[[grid]]` and `[[/grid]]` with `|` to split form fields.
--- Example: Forms
[[form]]
[[grid]]
[[input email "Your email"]] | [[password pass "Password"]]
[[select country USA|Canada|UK]]
[[checkbox tos "Accept terms"]]
[[submit "Submit Form"]]
[[/grid]]
[[/form]]
--- Escaping Special Characters
▸ Escaping Syntax
• `\▸` → literal ▸
• `\▣` → literal ▣
• `\•` → literal •
• `\>` → literal >
--- Example: Escaping Characters
\▸ Not a heading
\▣ Not a button
\• Not a bullet
\> Not a blockquote
--- Advanced Features
▸ Image sources:
• `kitten` → Cute cats
• `unsplash:topic` → Unsplash topics
• `bw` → Black and white
• `blur` → Blurred random
▸ JSON Import/Export
• Backup and restore wireframes.
▸ PNG/SVG Export
• Snapshot your layout.
▸ Share Links
• URL encodes and shares your layout easily.
--- cards2 Example: Advanced Features
|wide| [[progress 25]]
[[img 400x300 blur]]
[[img 400x300 kitten]]
|wide| [[progress 50]]
[[img 400x300 unsplash:nature]]
[[img 400x300 bw]]
|wide| [[progress 100]]
--- Templates Built In
▸ Templates Provided
• Hero Sections
• Pricing Tables
• Feature Lists
• Galleries
• Steps to Start
• Navigation Bars
▸ Access
• Use the Templates dropdown inside the editor toolbar.
--- Example: Template Demos
--- Hero Section Example
# Amazing Headline
▸ Subheading that sells.
[[img 800x400 kitten]]
▣ Get Started Now
--- Help Section
▸ Need Assistance?
Click the button below to open the help modal.
[[modal HelpModal Open Help Dialog]]
--- modal "HelpModal"
---- header
Help & Documentation
---- body
Welcome to the help section!
**Basic Syntax:**
Use `---` to start new sections.
Use `# H1` or `H1:` for headings.
Use `•` for bullets, `1.` for numbered lists.
**Layouts:**
`--- 50/50 Title` for grids.
`--- cards3 Title` for card layouts.
Use `|wide|` to break out of columns/cards.
**Components:**
`[[img WxH label]]`
`[[button "Text"]]`
`[[progress N]]`
`[[form]] ... [[/form]]`
Check the **Templates** dropdown for more examples!
---- footer
Need more help? Visit our [Docs Site](https://example.com) (link placeholder).
[[button "Got it!"]]
--- cards2 Feature Callouts
[[img 500x200 bw]]
▸ Instant Preview
• See changes live as you type. No refresh needed.
▣ Try the Demo
[[img 500x200 blur]]
▸ Flexible Layouts
• Mix grids, cards, and flow layouts seamlessly.
▣ Get Started
--- cards4 Image Gallery
[[img 250x150 kitten]]
[[img 250x151 kitten]]
[[img 250x149 kitten]]
[[img 250x152 kitten]]
[[img 250x150 unsplash:api]]
[[img 250x151 unsplash:api]]
[[img 250x149 unsplash:api]]
[[img 250x152 unsplash:api]]
|wide| [[img 1000x250 kitten]]
--- 33/33/33 Pricing
▸ Free | ▸ $49/mo | ▸ Enterprise
• Basic | • Up to 5 seats | • Everything
▣ Sign up | ▣ Buy now | ▣ Contact
--- 33/33/33 Feature Comparison
▸ Features | ▸ Basic | ▸ Pro
• Unlimited Projects | • ✅ | • ✅
• Team Access | • ❌ | • ✅
• Analytics | • ❌ | • ✅
▣ Upgrade | ▣ — | ▣ Get Pro
--- 33/33/33 Steps to Start
▸ Type your idea | ▸ Hit preview | ▸ Share or export
[[img 300x200 kitten]]
[[img 300x200 bw]]
[[img 300x200 blur]]
▣ Start | ▣ Preview | ▣ Share
--- cards3 Top Nav
▣ Features
▣ Pricing
▣ FAQ
--- cards3 Feature Highlights
▸ Blazing Fast
• Optimized render engine
[[img 100x100 blur]]
▸ Dead Simple
• Just type and ship
[[img 100x100 bw]]
▸ Extensible
• Add your own snippets
[[img 100x100 unsplash:gears]]
--- cards3 Meet the Team
[[img 200x200 unsplash:person]]
▸ Alex – Product
• Loves clean UIs
[[img 200x200 unsplash:person]]
▸ Sam – Design
• Fonts are feelings
[[img 200x200 unsplash:person]]
▸ Riley – Engineering
• Renders fast af
--- cards3 Cards
[[img 400x200 bw]]
▸ Post Title
• Lorem ipsum dolor sit amet
▣ Read
[[img 400x200 bw]]
▸ Post Title
• Lorem ipsum dolor sit amet
▣ Read
[[img 400x200 bw]]
▸ Post Title
• Lorem ipsum dolor sit amet
▣ Read
--- Hero Section
[[img 800x300 kitten]]
H2: Welcome to Your New Workflow
▸ Simplify. Automate. Grow.
• Launch faster with zero-code blocks
• Tailored layouts, fully editable
• Real-time preview built-in
H3: Why wait?
▣ Get Started Now
--- 50/50 Value Props
▸ Key Benefits | ▸ Why Choose Us?
• Built for teams | • Real-time collaboration
• Inline editing | • Scales with you
• Export options | • Dedicated support
--- Three-Step Progress
H3: How It Works
[[progress 33]]
▸ Step 1: Choose a layout
• Pick from grids, cards, or flow.
[[progress 66]]
▸ Step 2: Add your content
• Use simple text or shortcodes.
[[progress 100]]
▸ Step 3: Launch! ✨
• Preview, export, or share.
--- cards3 Features You’ll Love
▸ Fast setup
• Get started in minutes with intuitive syntax.
[[button "Try Now"]]
▸ Responsive Design
• Layouts adapt beautifully to any screen size.
[[button "Learn More"]]
▸ Component Library
• Use built-in components or add your own.
[[button "See Docs"]]
|wide| Power tools meet simplicity. Build beautiful layouts without the fuss.
--- 33/67 Product Highlight
▸ Spotlight On Speed
• Render complex layouts instantly.
• Optimized for performance.
• Handles large projects smoothly.
| [[img 500x250 unsplash:workspace]]
--- 33/33/33 Use Cases
H3: For Designers | H3: For Developers | H3: For Marketers
• Rapid prototyping | • Snippet injection | • Landing pages fast
• Wireframe mode | • JSON export/import | • Analytics-ready
• Style consistency | • Easy integration | • Campaign mockups
▣ Explore Design | ▣ Code Examples | ▣ Marketing Guide
--- cards4 Integrations
[[img 80x80 unsplash:api]]
Zapier
[[img 80x80 unsplash:api]]
Webhooks
[[img 80x80 unsplash:api]]
WordPress
[[img 80x80 unsplash:api]]
Notion
--- 33/33/33 Pricing Tiers
H3: Starter | H3: Pro | H3: Team
▸ Free Forever | ▸ $19 / month | ▸ $99 / month
• 3 Projects | • Unlimited Projects | • Everything in Pro
• Basic Components | • Advanced Components | • Priority Support
• Community Support | • Email Support | • Shared Workspaces
▣ Free trial | ▣ Pro Plan | ▣ Get Demo
--- Placekitten Divider 😻
[[img 700x500 kitten]]
--- The End
▣ You are ready to wireframe!
</textarea>
<div id="stage"></div>
<!-- /modals/modal.html (or inside <body>) -->
<div id="modalOverlay" class="modal-overlay" style="display:none;">
<div id="modalBox" class="modal-box">
<div id="modalHeader" class="modal-header"></div>
<div id="modalBody" class="modal-body"></div>
<div id="modalFooter" class="modal-footer"></div>
</div>
</div>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=PT+Mono&display=swap');
</style>
/* ===== grab elements ===== */
const src = document.getElementById("src");
const stage = document.getElementById("stage");
const ta = src;
const pv = stage;
let modals = {}; // 🛠 modal registry
let __currentLine = 0;
window.addEventListener("DOMContentLoaded", render);
src.addEventListener("input", render);
/* ---------- UNIVERSAL INLINE FORMATTER ------------------- */
/* --- tiny vanilla-Markdown helper ------------------------ */
function mdInline(s) {
// (Keep existing mdInline function as is)
return (
s
// code ⇒ <code>
.replace(/`([^`]+)`/g, (_, c) => `<code>${escapeHTML(c)}</code>`)
// bold ⇒ <strong>
.replace(
/\*\*([^*]+)\*\*/g,
(_, c) => `<strong>${escapeHTML(c)}</strong>`
)
// italics ⇒ <em>
.replace(
/(^|[^*])\*([^*]+)\*(?!\*)/g,
(_, lead, c) => `${lead}<em>${escapeHTML(c)}</em>`
)
// strike ⇒ <del>
.replace(/~~([^~]+)~~/g, (_, c) => `<del>${escapeHTML(c)}</del>`)
// Markdown links ⇒ <a>
.replace(
/\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g,
(_, txt, url) =>
`<a href="${escapeHTML(
url
)}" target="_blank" rel="noopener noreferrer">${escapeHTML(txt)}</a>`
)
// bare URLs ⇒ <a> (skip ones inside attributes by requiring whitespace or > before)
.replace(
/(^|[\s>])(https?:\/\/[^\s<]+)/g,
(_, lead, url) =>
`${lead}<a href="${escapeHTML(
url
)}" target="_blank" rel="noopener noreferrer">${escapeHTML(url)}</a>`
)
);
}
/* ── HTML helpers for form wrapper ───────────────────────── */
// (Keep all existing HTML helper functions: fieldWrap, fieldHTML, etc.)
const fieldWrap = (lab, id, ctrl) =>
`<div class="field"><label for="${id}">${lab}</label>${ctrl}</div>`;
const fieldHTML = (type, n, ph) =>
fieldWrap(
capitalize(n),
`field-${n}`,
`<input type="${type}" id="field-${n}" name="${n}" placeholder="${ph}">`
);
const textareaHTML = (n, ph) =>
fieldWrap(
capitalize(n),
`field-${n}`,
`<textarea id="field-${n}" name="${n}" placeholder="${ph}"></textarea>`
);
const fileFieldHTML = (n, l) =>
fieldWrap(l, `field-${n}`, `<input type="file" id="field-${n}" name="${n}">`);
const colorFieldHTML = (n, l) =>
fieldWrap(
l,
`field-${n}`,
`<input type="color" id="field-${n}" name="${n}">`
);
const rangeFieldHTML = (n, l) =>
fieldWrap(
l,
`field-${n}`,
`<input type="range" id="field-${n}" name="${n}">`
);
const checkboxHTML = (n, l) =>
`<div class="field"><label><input type="checkbox" name="${n}"> ${l}</label></div>`;
function selectHTML(n, r) {
const id = `field-${n}`;
const opts = r
.split("|")
.map((o) => `<option>${escapeHTML(o.trim())}</option>`)
.join("");
return fieldWrap(
capitalize(n),
id,
`<select id="${id}" name="${n}">${opts}</select>`
);
}
const buttonHTML = (txt, t = "button") =>
`<div class="field"><button type="${t}">${escapeHTML(txt)}</button></div>`;
// ---------- MAIN FORMATTER FUNCTION (WITHOUT LINE NUMBERS) --------
function fmt(raw) {
// (Keep the existing fmt function logic exactly as it was)
const t = raw.trim();
if (!t) return " ";
// --------- Heading markdown
const h = t.match(/^(#{1,6})\s+(.*)$/);
if (h) return `<h${h[1].length}>${mdInline(h[2])}</h${h[1].length}>`;
// --------- Horizontal rule
if (/^\s*([*_]\s*){3,}$/.test(t)) return "<hr>";
// --------- Modal Button
const modalBtn = t.match(/^\[\[modal\s+(\S+)\s+(.+?)\]\]$/i);
if (modalBtn) {
const [, id, label] = modalBtn;
return `<a href="#" class="btn" data-open-modal="${escapeHTML(
id
)}">${escapeHTML(label)}</a>`;
}
// --------- A11Y Forms
const patterns = [
[
/^\[\[input\s+(\w+)\s+"([^"]+)"\]\]$/i,
(m) => fieldHTML("text", m[1], m[2])
],
[
/^\[\[number\s+(\w+)\s+"([^"]+)"\]\]$/i,
(m) => fieldHTML("number", m[1], m[2])
],
[
/^\[\[password\s+(\w+)\s+"([^"]+)"\]\]$/i,
(m) => fieldHTML("password", m[1], m[2])
],
[
/^\[\[textarea\s+(\w+)\s+"([^"]+)"\]\]$/i,
(m) => textareaHTML(m[1], m[2])
],
[/^\[\[upload\s+(\w+)\s+"([^"]+)"\]\]$/i, (m) => fileFieldHTML(m[1], m[2])],
[/^\[\[color\s+(\w+)\s+"([^"]+)"\]\]$/i, (m) => colorFieldHTML(m[1], m[2])],
[/^\[\[range\s+(\w+)\s+"([^"]+)"\]\]$/i, (m) => rangeFieldHTML(m[1], m[2])],
[/^\[\[select\s+(\w+)\s+(.+?)\]\]$/i, (m) => selectHTML(m[1], m[2])],
[
/^\[\[checkbox\s+(\w+)\s+"([^"]+)"\]\]$/i,
(m) => checkboxHTML(m[1], m[2])
],
[/^\[\[submit\s+"([^"]+)"\]\]$/i, (m) => buttonHTML(m[1], "submit")],
[/^\[\[button\s+"([^"]+)"\]\]$/i, (m) => buttonHTML(m[1])]
];
for (const [re, fn] of patterns) {
const m = t.match(re);
if (m) return fn(m);
}
// --------- Image Markdown
const mdImg = t.match(/^!\[([^\]]*)]\((https?:\/\/[^\s)]+)\)$/);
if (mdImg) {
return `<img crossorigin="anonymous" src="${mdImg[2]}" alt="${escapeHTML(
mdImg[1]
)}" style="display:block;max-width:100%;border:var(--stroke) solid var(--ink);border-radius:var(--r);">`;
}
// --------- Progress Bar
const progress = t.match(/^\[\[progress\s+(\d+)\]\]$/i);
if (progress) {
const pct = Math.max(0, Math.min(100, +progress[1]));
return `<div class="bar"><span style="width:${pct}%"></span></div>`;
}
// --------- Img Shortcode
const img = t.match(/^\[\[img\s+(\d+)x(\d+)(?:\s+([^\]]+))?\]\]$/i);
if (img) {
const [, w, h, labelRaw = ""] = img;
const label = labelRaw.trim().toLowerCase();
let srcImg = "";
if (label.startsWith("unsplash:")) {
const topic = encodeURIComponent(label.split(":")[1] || "random");
const seed = Math.random().toString(36).slice(2);
srcImg = `https://source.unsplash.com/random/${w}x${h}?${topic}&sig=${seed}`;
} else if (label === "bw") {
srcImg = `https://picsum.photos/${w}/${h}?grayscale`;
} else if (label === "blur") {
srcImg = `https://picsum.photos/${w}/${h}?grayscale&blur=3`;
} else if (label === "kitten") {
srcImg = `https://cataas.com/cat?width=${w}&height=${h}`;
} else {
srcImg = `https://placehold.co/${w}x${h}?text=%20`;
}
let fallback = "";
if (label.startsWith("unsplash:")) {
fallback = ` onerror="this.src='https://picsum.photos/${w}/${h}?random&${Date.now()}'"`;
} else if (label === "kitten") {
fallback = ` onerror="this.src='https://loremflickr.com/${w}/${h}/kitten'"`;
}
return `<img src="${srcImg}"${fallback} alt="${escapeHTML(
label
)}" width="${w}" height="${h}" style="display:block;max-width:100%;border:var(--stroke) solid var(--ink);border-radius:var(--r);">`;
}
// --------- Headings, Lists, CTA Buttons
if (t.startsWith(">"))
return `<blockquote>${mdInline(t.slice(1).trim())}</blockquote>`;
if (t.startsWith("▸")) return `<h3>${mdInline(t.slice(1).trim())}</h3>`;
if (t.startsWith("•"))
return `<ul><li>${mdInline(t.slice(1).trim())}</li></ul>`;
if (t.startsWith("▣"))
return `<a href="#" class="btn">${mdInline(t.slice(1).trim())}</a>`;
if (/^H[1-6]:/i.test(t)) {
const lvl = t[1];
return `<h${lvl}>${mdInline(t.slice(3).trim())}</h${lvl}>`;
}
// --------- Escaped
if (/^(\\[>▸▣•])/.test(t)) return `<p>${mdInline(t.slice(1))}</p>`;
// --- Default: Paragraph ---
return `<p>${mdInline(t)}</p>`;
}
// -------------- fmtLoc: FORMATTER WRAPPER (adds line numbers) -----
// This function wraps fmt() and adds the data-line span
function fmtLoc(raw, ln = __currentLine) {
return addLoc(fmt(raw), ln); // Call the original formatter, then wrap
}
/* util: wrap html with data-line */
// This helper adds the span itself
function addLoc(html, lineNo) {
// Avoid wrapping empty/whitespace-only results
if (!html || !html.trim() || html === " ") return html;
return `<span class="tap-target" data-line="${lineNo}">${html}</span>`;
}
/* === Form-block renderer === */
// Modify renderFormBlock to use fmtLoc
function renderFormBlock(lines, startLine) {
// Pass startLine if known, though fmtLoc uses global __currentLine
const groups = [];
let buf = [];
const flush = () => {
if (buf.length) {
groups.push(buf);
buf = [];
}
};
lines.forEach((l) => {
if (l === "") {
flush();
return;
}
buf.push(l);
});
flush();
const rows = groups
.map((g, groupIndex) => {
// Call fmtLoc for each item in the form group
// We *could* try to calculate the exact line, but relying on __currentLine
// being set correctly by parseBody before this runs is simpler for now.
const cells = g
.map((cellContent, cellIndex) => fmtLoc(cellContent))
.join("");
// If a group has multiple items, wrap in form-row
return g.length > 1 ? `<div class="form-row">${cells}</div>` : cells;
})
.join("\n");
// Add data-line to the form itself, pointing to the [[form]] tag line
// Note: This requires parseBody to pass the startLine of the form block
const formTagLine =
startLine !== undefined ? ` data-line="${startLine}"` : "";
return `<form class="form"${formTagLine}>\n${rows}\n</form>`;
}
/* === Grid-block renderer === */
function renderGridBlock(lines, startLine, nCols = null) {
const rows = lines.map((line, idx) => {
if (!line.trim()) return "";
const parts = smartSplitPipes(line);
const cells = parts
.map(part => `<div>${fmtLoc(part, startLine + idx)}</div>`)
.join("");
return `<div class="grid-row">${cells}</div>`;
}).join("");
// nCols → CSS variable (falls back to "auto-fit" when null)
const style = nCols ? ` style="--cols:${nCols}"` : "";
return `<div class="grid"${style} data-line="${startLine}">${rows}</div>`;
}
/* === parseBody: processes one section’s raw text === */
function parseBody(text, startLine = 0){
const out = [];
/* 1 — STATE */
let inForm = false, formStartLine = -1, buf = [];
let inGrid = false, gridStartLine = -1, gridBuf = [];
let gridCols = null; // <-- lives for the whole parseBody call!
/* 2 — helpers */
const flushForm = () => {
if (buf.length){
out.push(renderFormBlock(buf, formStartLine));
buf.length = 0;
formStartLine = -1;
}
};
const flushGrid = () => {
if (gridBuf.length){
out.push(renderGridBlock(gridBuf, gridStartLine, gridCols));
gridBuf.length = 0;
gridStartLine = -1;
gridCols = null; // reset for the next grid
}
};
/* 3 — iterate lines */
text.split(/\r?\n/).forEach((line, idx) => {
const ln = startLine + idx;
__currentLine = ln;
const t = line.trim();
/* --- GRID tags ----------------------------------- */
const open = t.match(/^\[\[grid(?:\s+(\d+))?\]\]$/i);
if (open){
flushGrid();
inGrid = true;
gridStartLine = ln;
gridCols = open[1] ? +open[1] : null; // null → auto-fit
return;
}
if (/^\[\[\/grid]]$/i.test(t)){
inGrid = false;
flushGrid();
return;
}
if (inGrid){
gridBuf.push(line);
return;
}
/* --- FORM tags ----------------------------------- */
if (t === '[[form]]'){
flushForm();
inForm = true;
formStartLine = ln;
return;
}
if (t === '[[/form]]'){
inForm = false;
flushForm();
return;
}
if (inForm){
buf.push(line);
return;
}
/* --- normal content ------------------------------ */
if (t) out.push(fmtLoc(line, ln));
});
flushForm();
flushGrid();
return out.join('\n');
}
// Small utility functions:
/* util: capitalize first letter */
function capitalize(word) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
// addLoc is now defined near fmtLoc
/* ---------- PARSER (Main Structure) --------------------------------------- */
// Modify parse to track line numbers and store structured body items
function parse(txt) {
const lines = txt.split(/\r?\n/);
const sections = [];
let cur = null,
inCode = false,
codeBuf = [],
codeStartLine = -1; // Track start line of code block
// modal scanning states (Keep existing modal logic)
let inModal = false;
let modalID = "";
let modalParts = { header: [], body: [], footer: [] };
let modalSection = "body";
let modalStartLine = -1; // Track modal definition start
const pushCur = () => {
if (cur) sections.push(cur);
};
const pushCode = () => {
if (codeBuf.length) {
// Store code block with its content and start line
cur.body.push({
type: "code",
content: codeBuf.join("\n"),
ln: codeStartLine
});
codeBuf = [];
codeStartLine = -1;
}
};
lines.forEach((raw, i) => {
// 'i' is the absolute line number (0-based)
const lineNo = i; // Use 'lineNo' for clarity
if (raw.startsWith("##")) return; // comment, skip
// --- Handle Modal Definitions ---
const modalMatch = raw.match(/^---\s*modal\s+"([^"]+)"/i);
if (modalMatch) {
// If ending a previous modal implicitly, store it first
if (inModal) {
modals[modalID] = { ...modalParts, ln: modalStartLine }; // Store with line number
}
inModal = true;
modalID = modalMatch[1];
modalParts = { header: [], body: [], footer: [] };
modalSection = "body";
modalStartLine = lineNo; // Remember where this modal definition started
return; // Stop parsing regular sections while in modal
}
if (inModal) {
if (/^----\s*header/i.test(raw)) {
modalSection = "header";
return;
}
if (/^----\s*body/i.test(raw)) {
modalSection = "body";
return;
}
if (/^----\s*footer/i.test(raw)) {
modalSection = "footer";
return;
}
// Check for end of modal (which is also start of a new normal section)
if (/^---(?!-)/.test(raw)) {
modals[modalID] = { ...modalParts, ln: modalStartLine }; // Store completed modal with line#
inModal = false;
modalID = "";
// DO NOT return; let this line be processed as a new section marker below
} else {
// Still inside modal definition, add line to current part
modalParts[modalSection].push(raw);
return; // Skip adding to normal section body
}
}
// --- End Modal Handling ---
// --- Handle Fenced Code Blocks ---
if (raw.trim().startsWith("```")) {
if (!cur) {
// Code block before the first '---' section marker
cur = { widths: "", title: "Untitled", body: [], ln: 0 }; // Assign default section at line 0
}
if (inCode) {
// Ending code block
pushCode(); // pushCode now stores type:'code' and line number
inCode = false;
} else {
// Starting code block
pushCode(); // Push any previous code block first (safety)
inCode = true;
codeStartLine = lineNo; // Remember code block start line
codeBuf = []; // Clear buffer for new block
}
return; // Don't process ``` line further
}
if (inCode) {
codeBuf.push(raw); // Add line to code buffer
return; // Don't process as regular line
}
// --- End Code Block Handling ---
// --- Handle New Section Markers ---
if (/^---/.test(raw)) {
pushCode(); // Finalize any code block before starting new section
pushCur(); // Push the completed previous section
const match = raw
.replace(/^---+/, "")
.trim()
.match(/^((?:\d+\*\d+|\d+(?:\/\d+)*|cards\d+)?)\s*(.*)$/i);
let widths = match?.[1] || "";
let title = match?.[2] || "Untitled";
if (/\*/.test(widths)) {
const [w, n] = widths.split("*").map(Number);
if (!isNaN(w) && !isNaN(n)) widths = Array(n).fill(w).join("/");
// title = title.replace(/^\*\d+\s*/, ""); // Keep title as is
}
// Create new section object, storing its definition line number
cur = { widths, title, body: [], ln: lineNo };
} else if (cur) {
// --- Regular line within a section ---
// Store line content with its absolute line number
cur.body.push({ type: "line", content: raw, ln: lineNo });
} else {
// Line appears before the first '---' marker and isn't code/modal
// Treat it as part of an implicit first section
cur = { widths: "", title: "Untitled", body: [], ln: 0 };
cur.body.push({ type: "line", content: raw, ln: lineNo });
}
}); // End lines.forEach
// After processing all lines:
pushCode(); // Push any trailing code block
// Handle trailing modal if file ends within modal definition
if (inModal) {
modals[modalID] = { ...modalParts, ln: modalStartLine };
}
pushCur(); // Push the last section
// Map sections using the modified toHTML
return sections;
}
// turn structured sections into one HTML string
const sectionsToHTML = (arr) => arr.map(toHTML).join("\n");
/* ---------- SECTION → HTML -------------------------------- */
// Modify toHTML to work with structured body items and pass line numbers
function toHTML({ widths, title, body, ln: sectionLine }) {
// Receive section's definition line
const inner = [];
// Add section title with its line number
const titleHTML = title
? `<h2 class="tap-target" data-line="${sectionLine}">${escapeHTML(
title
)}</h2>`
: "";
const colSpec = widths && /^\d+(?:\/\d+)+$/.test(widths) ? widths : null;
const isCards = widths && /^cards\d+$/i.test(widths);
// Helper to render a body item (line or code)
const renderBodyItem = (item) => {
if (item.type === "code") {
// Wrap pre/code with span using the code block's starting line number
return `<span class="tap-target code-block" data-line="${
item.ln
}"><pre><code>${escapeHTML(item.content)}</code></pre></span>`;
} else if (item.type === "line") {
// Use parseBody for line content, passing its absolute line number
// parseBody itself will call fmtLoc internally to add spans to formatted elements
return parseBody(item.content, item.ln);
}
return ""; // Should not happen
};
/* ===== Grid layout ===== */
if (colSpec) {
const nCols = colSpec.split("/").length;
const rows = [];
let currentRowItems = []; // Store items for the current row
let currentRowHTML = []; // Store HTML for cells in current row
const flushRow = () => {
if (currentRowHTML.length === 0) return;
// Pad row if needed
while (currentRowHTML.length < nCols) {
currentRowHTML.push("<div> </div>"); // Empty cell
}
rows.push(currentRowHTML.join(""));
currentRowItems = [];
currentRowHTML = [];
};
body.forEach((item) => {
if (item.type === "code") {
flushRow(); // Finish previous row
rows.push(`<div class="wide">${renderBodyItem(item)}</div>`); // Render code block wide
} else if (item.type === "line") {
const trimmed = item.content.trim();
if (!trimmed) return; // Skip empty lines
if (trimmed.startsWith("|wide|")) {
flushRow(); // Finish previous row
const wideContent = trimmed.replace("|wide|", "").trim();
// Render wide content using parseBody with the original line number
rows.push(
`<div class="wide">${parseBody(wideContent, item.ln)}</div>`
);
} else if (trimmed.includes("|") && !trimmed.startsWith("[[")) {
// Avoid splitting form tags like [[select | Option A | Option B]]
flushRow(); // Finish previous row
const cells = smartSplitPipes(trimmed); // Use existing smartSplitPipes
while (cells.length < nCols) cells.push("");
// Render each cell using parseBody with the original line number
// This ensures elements inside cells get correct line numbers
rows.push(
cells.map((c) => `<div>${parseBody(c, item.ln)}</div>`).join("")
);
} else {
// Regular item, add to current row buffer
currentRowItems.push(item);
// Render the item and add to HTML buffer
currentRowHTML.push(`<div>${renderBodyItem(item)}</div>`);
if (currentRowHTML.length === nCols) {
flushRow(); // Flush if row is full
}
}
}
});
flushRow(); // Flush any remaining items in the last row
const gridStyle = `grid-template-columns:${colSpec
.split("/")
.map((p) => (p === "*" ? "1fr" : p + "%")) // Support '*' for fr units maybe? Or just %
.join(" ")};`;
inner.push(
`<div class="cols" style="${gridStyle}">${rows.join("\n")}</div>`
);
} else if (isCards) {
/* ===== Card layout ===== */
const nCols = +widths.match(/\d+/)[0] || 1; // Default to 1 if number missing
const openGrid = () =>
inner.push(
`<div class="cards cards${nCols}" style="grid-template-columns:repeat(${nCols},1fr);">`
);
const closeGrid = () => inner.push(`</div>`);
openGrid();
let cardBuffer = []; // Buffer for lines within a single card
let cardStartLine = -1; // Track the line number of the first item in the card
const flushCard = () => {
if (cardBuffer.length === 0) return;
// Combine buffered lines and render using parseBody with the card's start line
const cardContent = cardBuffer.map((item) => item.content).join("\n");
// Use the start line of the *first* item in the buffer for the card's div
inner.push(
`<div class="tap-target card-container" data-line="${cardStartLine}">${parseBody(
cardContent,
cardStartLine
)}</div>`
);
cardBuffer = [];
cardStartLine = -1;
};
body.forEach((item) => {
if (item.type === "code") {
flushCard(); // Finish previous card
inner.push(`<div class="wide">${renderBodyItem(item)}</div>`); // Render code block wide
} else if (item.type === "line") {
const raw = item.content.trim();
if (raw.startsWith("|wide|")) {
flushCard(); // Finish previous card
closeGrid(); // Close current cards grid
const wideContent = raw.replace("|wide|", "").trim();
inner.push(
`<div class="wide">${parseBody(wideContent, item.ln)}</div>`
); // Render wide content
openGrid(); // Start a new cards grid
} else if (raw === "" && cardBuffer.length > 0) {
// Empty line signifies end of a card
flushCard();
} else if (raw !== "") {
// Non-empty line, add to card buffer
if (cardBuffer.length === 0) {
cardStartLine = item.ln; // Mark start line of this card
}
cardBuffer.push(item);
}
}
});
flushCard(); // Flush the last card
closeGrid(); // Close the final grid
} else {
/* ===== Single-column / flow layout ===== */
const allLines = body.map(b => b.content).join('\n');
inner.push(parseBody(allLines, body[0]?.ln ?? sectionLine));
}
// Wrap the entire section content
// Add sectionLine to the section tag itself
return `<section data-line="${sectionLine}">${titleHTML}\n${inner.join(
"\n"
)}</section>`;
}
/* ---------- UTIL ----------------------------------------- */
function escapeHTML(s){
if(!s) return '';
return s.replace(/[&<>]/g, c => ({
'&':'&amp;',
'<':'&lt;',
'>':'&gt;'
}[c]));
}
function smartSplitPipes(s) {
// (Keep existing smartSplitPipes)
const parts = [];
let cur = "",
depth = 0; // Track [[ ]] nesting
for (let i = 0; i < s.length; i++) {
const ch = s[i];
const nextCh = s[i + 1];
if (ch === "[" && nextCh === "[") {
depth++;
cur += ch; // Add the character
} else if (ch === "]" && nextCh === "]") {
// Check depth before decrementing to handle unbalanced brackets gracefully
if (depth > 0) depth--;
cur += ch; // Add the character
} else if (ch === "|" && depth === 0) {
// Split only if not inside [[ ]]
parts.push(cur.trim());
cur = "";
} else {
cur += ch; // Add other characters
}
}
if (cur) parts.push(cur.trim()); // Add the last part
return parts;
}
/* ---------- RENDER --------------------------------------- */
// Modify render to handle modals correctly alongside parsing
function render() {
const text = src.value; // Use full value to get correct line numbers
modals = {}; // Clear previous modal definitions
// scanForModals is now integrated into the main parse function
// 1. Parse the text. This will:
// - Populate the `modals` object.
// - Build sections with structured body items ({type, content, ln}).
// - Call `toHTML` which uses `parseBody`/`fmtLoc` to add `data-line`.
const structured = parse(text); // array of sections
const renderedSectionsHTML = sectionsToHTML(structured);
// 2. Generate hidden modal HTML (if any modals were found during parse)
const hiddenModalsHTML = generateModalsHTML(); // Uses the populated `modals` object
// 3. Update the stage
stage.innerHTML = renderedSectionsHTML + "\n" + hiddenModalsHTML;
}
/* ---------- WIZZY PANEL INSERTS -------------------------- */
// (Keep existing Wizzy panel logic)
document.getElementById("wizzy").addEventListener("click", (e) => {
const b = e.target.closest("button[data-sn]");
if (!b) return;
const [st, en] = [src.selectionStart, src.selectionEnd];
const snippet = b.dataset.sn;
// Insert snippet and move cursor to end of insertion
src.setRangeText(snippet, st, en, "end");
src.focus();
render(); // Re-render after insertion
});
/* ---------- TEMPLATE PICKER ------------------------------ */
// (Keep existing Template Picker logic)
document.getElementById("tpl").addEventListener("change", (e) => {
const id = e.target.value;
if (!id || !templates[id]) return; // Added check for template existence
const snippet = templates[id];
const [st, en] = [src.selectionStart, src.selectionEnd];
const textToInsert =
(st > 0 && src.value[st - 1] !== "\n" ? "\n" : "") + snippet + "\n"; // Add newlines defensively
src.setRangeText(textToInsert, st, en, "end");
src.focus();
render();
e.target.value = ""; // Reset select
});
/* ---------- TEMPLATE PICKER OPTIONS ---------------------- */
// (Keep existing templates object)
const templates = {
hero: `--- Hero Section
H2: Big punchy headline
▸ Supporting sub-copy
[[img 800x300 kitten]]
▣ Get Started
`,
pricing: `--- 33/33/33 Pricing
▸ Free | ▸ $49/mo | ▸ Enterprise
• Basic | • Up to 5 seats | • Everything
▣ Sign up | ▣ Buy now | ▣ Contact sales
`,
comparison: `--- 33/33/33 Feature Comparison
▸ Features | ▸ Basic | ▸ Pro
• Unlimited Projects | • ✅ | • ✅
• Team Access | • ❌ | • ✅
• Analytics | • ❌ | • ✅
▣ Upgrade | ▣ — | ▣ Get Pro
`,
steps: `--- 33/33/33 Steps to Start
▸ Type your idea | ▸ Hit preview | ▸ Share or export
[[img 300x200 kitten]]
[[img 300x200 bw]]
[[img 300x200 blur]]
▣ Start typing | ▣ Preview | ▣ Share
`,
nav: `--- 25/25/25/25 Top Nav
▣ Hero | ▣ Features | ▣ Pricing | ▣ FAQ
`,
features: `--- cards3 Feature Highlights
▸ Blazing Fast
• Optimized render engine
[[img 100x100 blur]]
▸ Dead Simple
• Just type and ship
[[img 100x100 bw]]
▸ Extensible
• Add your own snippets
[[img 100x100 unsplash:gears]]
`,
team: `--- cards3 Meet the Team
[[img 200x200 unsplash:person]]
▸ Alex – Product
• Loves clean UIs
[[img 200x200 unsplash:person]]
▸ Sam – Design
• Fonts are feelings
[[img 200x200 unsplash:person]]
▸ Riley – Engineering
• Renders fast af
`,
cards: `--- cards3 Cards
[[img 400x200 bw]]
▸ Post Title
• Lorem ipsum dolor sit amet
▣ Read
[[img 400x200 bw]]
▸ Post Title
• Lorem ipsum dolor sit amet
▣ Read
[[img 400x200 bw]]
▸ Post Title
• Lorem ipsum dolor sit amet
▣ Read
`,
heroWorkflow: `--- Hero Section
[[img 800x300 kitten]]
H2: Welcome to Your New Workflow
▸ Simplify. Automate. Grow.
• Launch faster with zero-code blocks
• Tailored layouts, fully editable
• Real-time preview built-in
H3: Why wait?
▣ Get Started Now
`,
value50: `--- 50/50 Value Props
▸ Key Benefits | ▸ Why Choose Us?
• Built for teams | • Real-time collaboration
• Inline editing | • Scales with you
• Export options | • Dedicated support
`,
progress3: `--- Three-Step Progress
H3: How It Works
[[progress 33]]
▸ Step 1: Choose a layout
• Pick from grids, cards, or flow.
[[progress 66]]
▸ Step 2: Add your content
• Use simple text or shortcodes.
[[progress 100]]
▸ Step 3: Launch! ✨
• Preview, export, or share.
`,
featuresCards: `--- cards3 Features You’ll Love
▸ Fast setup
• Get started in minutes with intuitive syntax.
[[button "Try Now"]]
▸ Responsive Design
• Layouts adapt beautifully to any screen size.
[[button "Learn More"]]
▸ Component Library
• Use built-in components or add your own.
[[button "See Docs"]]
|wide| Power tools meet simplicity. Build beautiful layouts without the fuss.
`,
highlight: `--- 33/67 Product Highlight
▸ Spotlight On Speed
• Render complex layouts instantly.
• Optimized for performance.
• Handles large projects smoothly.
| [[img 500x250 unsplash:workspace]]
`,
useCases: `--- 33/33/33 Use Cases
H3: For Designers | H3: For Developers | H3: For Marketers
• Rapid prototyping | • Snippet injection | • Landing pages fast
• Wireframe mode | • JSON export/import | • Analytics-ready
• Style consistency | • Easy integration | • Campaign mockups
▣ Explore Design | ▣ Code Examples | ▣ Marketing Guide
`,
integrations: `--- cards4 Integrations
[[img 80x80 unsplash:api]]
Zapier
[[img 80x80 unsplash:api]]
Webhooks
[[img 80x80 unsplash:api]]
WordPress
[[img 80x80 unsplash:api]]
Notion
`,
pricingTable: `--- 33/33/33 Pricing Tiers
H3: Starter | H3: Pro | H3: Team
▸ Free Forever | ▸ $19 / month | ▸ $99 / month
• 3 Projects | • Unlimited Projects | • Everything in Pro
• Basic Components | • Advanced Components | • Priority Support
• Community Support | • Email Support | • Shared Workspaces
▣ Sign Up Free | ▣ Start Pro Trial | ▣ Talk to Sales
`,
kittenDiv: `--- Placekitten Divider 😻
[[img 700x500 kitten]]
`,
featureCallouts: `--- cards2 Feature Callouts
[[img 350x200 bw]]
▸ Instant Preview
• See changes live as you type. No refresh needed.
▣ Try the Demo
[[img 350x200 blur]]
▸ Flexible Layouts
• Mix grids, cards, and flow layouts seamlessly.
▣ Get Started
`,
imageGallery: `--- cards4 Image Gallery
[[img 250x150 kitten]]
[[img 250x151 kitten]]
[[img 250x149 kitten]]
[[img 250x152 kitten]]
[[img 250x150 unsplash:api]]
[[img 250x151 unsplash:api]]
[[img 250x149 unsplash:api]]
[[img 250x152 unsplash:api]]
|wide| [[img 1000x250 kitten]]
`,
markdowndemo: `--- Markdown Demo
# H1 hash heading
## H2 hash
### H3 hash
#### H4 hash
##### H5 hash
###### H6 hash
H1: Labeled H1
H2: Labeled H2
H3: Labeled H3
H4: Labeled H4
H5: Labeled H5
H6: Labeled H6
▸ H3 triangle heading
--- 33/33/33 Inline Goodies
**bold text** | *italic text* | ~~strikethrough~~
\`inline code\` | [Markdown Link](https://example.com) | Plain URL: https://example.com
--- Single-Column Extras
Horizontal rule below:
***
> Blockquote: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam in dui mauris.
Ordered List:
1. First item
2. Second item
3. Third item
Unordered List:
• Bullet A
• Bullet B
• Nested Bullet B.1 (Note: nested lists not directly supported by simple '•', use indentation in source)
• Bullet C
Progress Bar:
[[progress 75]]
Standard Markdown Image:
![Picsum Alt Text](https://picsum.photos/400/200)
Shortcode Image:
[[img 300x150 unsplash:nature]]
Fenced Code Block:
\`\`\`javascript
// Example code
function greet(name) {
console.log(\`Hello, \${name}!\`);
}
greet('Wireframe User');
\`\`\`
Escaped characters: \\> \\▸ \\▣ \\• will render literally.
> This is > not a blockquote start.
`,
helpModal: `--- Help Section
▸ Need Assistance?
Click the button below to open the help modal.
[[modal HelpModal Open Help Dialog]]
--- modal "HelpModal"
---- header
Help & Documentation
---- body
Welcome to the help section!
**Basic Syntax:**
Use \`---\` to start new sections.
Use \`# H1\` or \`H1:\` for headings.
Use \`•\` for bullets, \`1.\` for numbered lists.
**Layouts:**
\`--- 50/50 Title\` for grids.
\`--- cards3 Title\` for card layouts.
Use \`|wide|\` to break out of columns/cards.
**Components:**
\`[[img WxH label]]\`
\`[[button "Text"]]\`
\`[[progress N]]\`
\`[[form]] ... [[/form]]\`
Check the **Templates** dropdown for more examples!
---- footer
Need more help? Visit our [Docs Site](https://example.com) (link placeholder).
[[button "Got it!"]]
`
};
/* ---------- WIREFRAME TO JSON / JSON TO WIREFRAME ------------ */
// Use the modified parser's structure for export
function wireframeToJSON(txt) {
// Use the main `parse` function, which now returns structured data
// including line numbers if needed (though typically not needed for JSON export)
const sections = parse(txt); // `parse` handles comments, modals, etc.
// Simplify the output for JSON: remove line numbers and structure for clarity
return sections.map((section) => ({
widths: section.widths,
title: section.title,
body: section.body
.map((item) => {
if (item.type === "code") {
return { type: "code", content: item.content };
} else if (item.type === "line") {
return { type: "line", content: item.content };
}
return null; // Should not happen
})
.filter(Boolean) // Remove nulls if any
}));
}
// (Keep existing JSON export button listener)
document.getElementById("exportJSON").addEventListener("click", () => {
// Generate JSON using the raw text from the editor
const json = wireframeToJSON(src.value);
const blob = new Blob([JSON.stringify(json, null, 2)], {
type: "application/json"
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "wireframe.json";
a.click();
URL.revokeObjectURL(a.href); // Clean up
});
// (Keep existing JSON import listeners)
document.getElementById("importJSON").addEventListener("click", () => {
document.getElementById("importFile").click();
});
document.getElementById("importFile").addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const imported = JSON.parse(event.target.result);
// Validate imported structure minimally
if (!Array.isArray(imported)) {
throw new Error("JSON root must be an array of sections.");
}
const newDSL = jsonToWireframeLang(imported);
src.value = newDSL;
render();
e.target.value = null; // Reset file input
} catch (err) {
alert("Failed to import JSON: " + err);
e.target.value = null; // Reset file input
}
};
reader.onerror = () => {
alert("Failed to read the file.");
e.target.value = null; // Reset file input
};
reader.readAsText(file);
});
// Converts imported JSON structure -> DSL Text
// (Keep existing jsonToWireframeLang function)
function jsonToWireframeLang(json) {
const out = [];
json.forEach((section) => {
// Ensure section is an object and has expected properties
if (typeof section !== "object" || section === null) return;
const title = section.title || "Untitled";
const widths = section.widths ? ` ${section.widths}` : "";
out.push(`---${widths} ${title}`);
if (Array.isArray(section.body)) {
section.body.forEach((item) => {
if (typeof item !== "object" || item === null) return;
if (item.type === "line") {
// Ensure content is a string, default to empty if missing/not string
out.push(typeof item.content === "string" ? item.content : "");
} else if (item.type === "code") {
out.push("```");
// Ensure content is a string, default to empty if missing/not string
out.push(typeof item.content === "string" ? item.content : "");
out.push("```");
}
});
}
out.push(""); // Add a blank line between sections for readability
});
return out.join("\n").trim(); // Trim trailing newlines
}
/* ===== Modal Framework (Generic open/close) ===== */
// (Keep existing openModal, closeModal functions)
function openModal(options) {
const overlay = document.getElementById("modalOverlay");
const box = document.getElementById("modalBox");
const header = document.getElementById("modalHeader");
const body = document.getElementById("modalBody");
const footer = document.getElementById("modalFooter");
// Ensure elements exist
if (!overlay || !box || !header || !body || !footer) {
console.error("Modal HTML elements not found!");
return;
}
// Clear previous content and listeners carefully
header.innerHTML = ""; // Clear previous header
body.innerHTML = ""; // Clear previous body
footer.innerHTML = ""; // Clear previous footer
// Set Title and Add Close Button
const titleSpan = document.createElement("span");
titleSpan.innerHTML = options.title || "Modal"; // Use innerHTML to allow formatting from modal definition
header.appendChild(titleSpan);
const closeBtn = document.createElement("button");
closeBtn.id = "modalCloseBtn"; // Ensure unique ID if needed, or use class
closeBtn.innerHTML = "✖";
closeBtn.style.float = "right";
closeBtn.style.background = "none";
closeBtn.style.border = "none";
closeBtn.style.fontSize = "1.2em";
closeBtn.style.cursor = "pointer";
closeBtn.onclick = closeModal; // Attach listener directly
header.appendChild(closeBtn);
// Set Body Content
if (options.bodyHTML) {
body.innerHTML = options.bodyHTML; // Assume safe HTML (e.g., from parsed markdown)
} else if (options.bodyText) {
const p = document.createElement("p");
p.textContent = options.bodyText;
body.appendChild(p);
}
// Set Footer Buttons (or raw HTML if provided)
if (options.footerHTML) {
footer.innerHTML = options.footerHTML;
// Manually wire buttons if needed, e.g., based on classes or IDs in footerHTML
footer.querySelectorAll("button").forEach((b) => {
// Example: Close modal if a button has class="modal-close"
if (b.classList.contains("modal-close")) {
b.addEventListener("click", closeModal);
}
// Add other specific button logic here if parsing footerHTML
});
// Wire up any potential form submission logic if footer contains a form
} else if (Array.isArray(options.buttons)) {
options.buttons.forEach((btnConfig) => {
const b = document.createElement("button");
b.innerHTML = btnConfig.label || "OK"; // Use innerHTML for labels
if (btnConfig.className) b.className = btnConfig.className;
b.addEventListener("click", () => {
if (btnConfig.onClick) btnConfig.onClick();
if (!btnConfig.noClose) closeModal(); // Close unless specified otherwise
});
footer.appendChild(b);
});
}
overlay.style.display = "flex"; // Show the modal
box.focus(); // Focus the modal box for accessibility
}
function closeModal() {
const overlay = document.getElementById("modalOverlay");
if (overlay) {
overlay.style.display = "none";
}
}
// ESC close support (Keep existing listener)
document.addEventListener("keydown", (e) => {
const overlay = document.getElementById("modalOverlay");
// Close only if modal is visible and Escape key is pressed
if (e.key === "Escape" && overlay && overlay.style.display === "flex") {
closeModal();
}
});
// (Keep existing Paste JSON logic and listener)
document.getElementById("pasteJSON").addEventListener("click", () => {
openModal({
title: "Paste Wireframe JSON",
bodyHTML: `<p>Paste the exported JSON content below:</p><textarea id="pasteArea" style="width: 95%; height: 150px; margin-top: 5px;"></textarea>`,
buttons: [
{
label: "Load JSON",
onClick: () => {
const txt = document.getElementById("pasteArea")?.value.trim();
if (!txt) {
alert("Paste area is empty.");
return;
}
try {
const imported = JSON.parse(txt);
if (!Array.isArray(imported)) {
throw new Error("JSON root must be an array of sections.");
}
const newDSL = jsonToWireframeLang(imported);
src.value = newDSL;
render(); // Re-render with imported content
// Modal will close via default button behavior
} catch (err) {
alert("Invalid JSON format: " + err);
// Keep modal open on error
}
},
noClose: false // Close modal on successful load
},
{
label: "Cancel",
noClose: false // Close modal on cancel
}
]
});
// Focus the textarea after the modal is likely rendered
setTimeout(() => document.getElementById("pasteArea")?.focus(), 50);
});
/* ---------- MODAL EXTENSION (Definition Handling) ------------ */
// scanForModals is now integrated into the main `parse` function.
// generateModalsHTML uses the `modals` object populated by `parse`
function generateModalsHTML() {
const all = [];
for (const id in modals) {
const modalData = modals[id];
// Render markdown content from modal parts
// Use parseBody starting at line 0 for content processing within the modal parts
// Pass the modal definition line number (modalData.ln) for the outer container
const headerHTML = parseBody(modalData.header.join("\n"), modalData.ln); // Use modal start line for context
const bodyHTML = parseBody(
modalData.body.join("\n"),
modalData.ln + modalData.header.length + 1
); // Estimate body start line
const footerHTML = parseBody(
modalData.footer.join("\n"),
modalData.ln + modalData.header.length + modalData.body.length + 2
); // Estimate footer start line
all.push(
// Add data-line to the hidden container itself
`<div class="hidden-modal" id="modal-${id}" style="display:none;" data-definition-line="${modalData.ln}">
<div class="modal-header-content">${headerHTML}</div>
<div class="modal-body-content">${bodyHTML}</div>
<div class="modal-footer-content">${footerHTML}</div>
</div>`
);
}
return all.join("\n");
}
// wireModals connects buttons to the generic openModal
function wireModals() {
// Remove previous listeners to avoid duplication if render is called multiple times
// A more robust approach might involve a different listener management strategy
stage.querySelectorAll("[data-open-modal]").forEach((btn) => {
// Clone and replace to remove old listeners simply
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
// Add listener to the new button
newBtn.addEventListener("click", (e) => {
e.preventDefault();
const id = newBtn.dataset.openModal;
const modalDefinition = document.getElementById("modal-" + id);
if (modalDefinition) {
// Extract content from the hidden definition divs
const titleHTML =
modalDefinition.querySelector(".modal-header-content")?.innerHTML ||
id; // Fallback title
const bodyHTML =
modalDefinition.querySelector(".modal-body-content")?.innerHTML || "";
const footerHTML =
modalDefinition.querySelector(".modal-footer-content")?.innerHTML ||
""; // Get raw footer HTML
openModal({
// Pass the extracted HTML to the generic modal display function
title: titleHTML,
bodyHTML: bodyHTML,
footerHTML: footerHTML // Pass footer HTML directly
// The generic openModal will handle rendering this HTML
// and attaching basic close listeners if needed based on content structure
});
} else {
console.warn(`Modal definition not found for ID: ${id}`);
// Optionally open a fallback error modal
openModal({
title: "Error",
bodyText: `Modal content for "${id}" could not be found.`
});
}
});
});
}
/* ---------- DOUBLE-CLICK TO EDIT ------------------------- */
// colour of the active-line band ↓ (yellow-ish with 40 % alpha)
const HIGHLIGHT_COLOR = "rgba(255, 216, 107, .4)";
stage.addEventListener("dblclick", (e) => {
const hit = e.target.closest("[data-line]");
if (!hit) return;
const line = +hit.dataset.line; // Get the 0-based line number
// Calculate character position to jump to
// Split by '\n' which works for both \n and \r\n endings
const lines = src.value.split("\n");
let pos = 0;
for (let i = 0; i < line; i++) {
// Add length of the line + 1 for the newline character itself
pos += (lines[i] || "").length + 1;
}
src.focus();
src.setSelectionRange(pos, pos); // Set cursor position
// Scroll the textarea to make the line visible (roughly center it)
const totalLines = lines.length;
const lineHeight = src.scrollHeight / totalLines;
// Calculate target scroll position (line number * line height)
// Subtract half the client height to center, but clamp to bounds
const targetScrollTop = line * lineHeight - src.clientHeight / 3;
src.scrollTop = Math.max(
0,
Math.min(targetScrollTop, src.scrollHeight - src.clientHeight)
);
});
/* ---------- INITIAL RENDER ------------------------------ */
// Initial Render on Load
render();
/* ---------- fake caret position ---------- */
function paintCaret() {
const mirror = document.createElement("div");
mirror.style.cssText = `
position:absolute; display:inline; left:-9999px;
white-space:pre-wrap; pointer-events:none;
font:${getComputedStyle(ta).font};`;
mirror.textContent = ta.value.slice(0, ta.selectionStart);
// ⬇︎ wrap the marker in an element we can measure
const mark = document.createElement("span");
mark.textContent = "\u200b"; // zero-width char
mirror.appendChild(mark);
document.body.appendChild(mirror);
const { left, top } = mark.getBoundingClientRect(); // ← works now
document.body.removeChild(mirror);
const taRect = ta.getBoundingClientRect();
ta.style.setProperty("--caret-x", `${left - taRect.left + ta.scrollLeft}px`);
ta.style.setProperty("--caret-y", `${top - taRect.top + ta.scrollTop}px`);
}
// Update the fake caret position
["input", "keyup", "click", "scroll", "focus"].forEach((ev) =>
ta.addEventListener(ev, paintCaret)
);
paintCaret(); // initial position
// === Double-click preview → jump to source ===
stage.addEventListener("dblclick", (e) => {
const hit = e.target.closest("[data-line]");
if (!hit) return;
const line = +hit.dataset.line; // Get 0-based line number
const lines = src.value.split("\n");
let pos = 0;
for (let i = 0; i < line; i++) {
pos += (lines[i] || "").length + 1; // +1 for newline
}
src.focus();
src.setSelectionRange(pos, pos);
// (Optional scroll adjustment to center the cursor roughly)
const totalLines = lines.length;
const lineHeight = src.scrollHeight / totalLines;
const targetScrollTop = line * lineHeight - src.clientHeight / 3;
src.scrollTop = Math.max(
0,
Math.min(targetScrollTop, src.scrollHeight - src.clientHeight)
);
});
// one listener handles every modal button
stage.addEventListener('click', e => {
const trigger = e.target.closest('[data-open-modal]');
if (!trigger) return;
e.preventDefault();
openModalFromId(trigger.dataset.openModal);
});
function openModalFromId(id){
const def = document.getElementById('modal-' + id);
if (!def){
openModal({title:'Error', bodyText:'Modal "' + id + '" not found.'});
return;
}
const titleHTML = def.querySelector('.modal-header-content')?.innerHTML || id;
const bodyHTML = def.querySelector('.modal-body-content')?.innerHTML || '';
const footerHTML = def.querySelector('.modal-footer-content')?.innerHTML || '';
openModal({title:titleHTML, bodyHTML:bodyHTML, footerHTML:footerHTML});
}
/* ---------- PNG EXPORT (clone trick) --------------------- */
// replace with any CORS proxy you trust
function proxied(u){
return 'https://api.allorigins.win/raw?url=' + encodeURIComponent(u);
}
function savePNG(){
const ghost = stage.cloneNode(true);
Object.assign(ghost.style,{
position:'absolute',
left:'-9999px',
top:0,
overflow:'visible',
height:'auto'
});
// swap Cataas images for proxy copies inside the clone
ghost.querySelectorAll('img').forEach(img=>{
if(img.src.includes('cataas.com')) img.src = proxied(img.src);
});
document.body.appendChild(ghost);
html2canvas(ghost,{
backgroundColor:'#fafafa',
useCORS:true
}).then(cv=>{
document.body.removeChild(ghost);
const a = document.createElement('a');
a.download='wireframe.png';
a.href=cv.toDataURL('image/png');
a.click();
});
}
document.getElementById('savePNG').addEventListener('click',savePNG);
window.addEventListener('keydown',e=>{
if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='s'){e.preventDefault();savePNG();}
});
/* ---------- SHARING -------------------------------------- */
function applyHash(){
if (location.hash.startsWith('#wf=')){
try { src.value = atob(location.hash.slice(4)); }
catch { /* bad hash → ignore */ }
render();
}
}
window.addEventListener('hashchange', applyHash);
applyHash(); // run on first load
document.getElementById('share').addEventListener('click',()=>{
location.hash = '#wf=' + btoa(unescape(encodeURIComponent(src.value)));
navigator.clipboard.writeText(location.href)
.then(()=>alert('URL copied — share away!'));
});
/* ========= GLOBAL THEME ========= */
@import url("https://fonts.googleapis.com/css2?family=Balsamiq+Sans:wght@400;700&display=swap");
:root {
--ink: #222;
--stroke: 3px;
--r: 7px;
--font: "Balsamiq Sans", cursive;
--accent: #ffd86b;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
*::selection {
background: #ffd86b;
}
#editor #src::selection {
color: #ffd86b;
background: #313639;
}
/* ========= LAYOUT ========= */
/* 0 - reset */
/* 1 — page skeleton */
body {
margin: 0;
font-family: "Balsamiq Sans", cursive;
display: flex;
flex-direction: column; /* column until we switch in media-query */
height: 100vh;
max-width: 1200px;
margin: 0 auto;
background: #e0e0e0;
}
button {
cursor: pointer;
}
/* 2 — sticky toolbar */
#wizzy {
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 5;
background: #fff;
padding: 0.4rem 0.6rem;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
#wizzy button {
font: inherit;
cursor: pointer;
border: var(--stroke) solid var(--ink);
border-radius: var(--r);
padding: 0.25rem 0.7rem;
background: #ffd86b;
box-shadow: 2px 2px 0 var(--ink);
}
#wizzy button:active {
transform: translate(2px, 2px);
box-shadow: none;
}
/* 3 — editor / preview container */
main#editor {
position: relative;
flex: 1; /* fills remaining height */
display: flex;
flex-direction: column; /* default → stacked */
min-height: 0; /* allow children to shrink inside flexbox */
}
/* 4 — textarea + stage styles (work for both orientations) */
#src {
padding: 1rem;
font-family: "PT Mono", monospace;
border: none;
resize: none;
outline: none;
overflow: auto;
color: #111;
background: #dbdbdb;
background-size: 3px 3px;
font-size: 0.9rem;
white-space: pre-wrap;
line-height: 1.5;
}
#stage {
overflow: auto;
padding: 2rem;
background: #fafafa;
flex: 1;
}
/* 5 — RESPONSIVE BREAKPOINTS
-------------------------------------------------
• <48rem : stacked (both occupy full width)
• 48–80rem: side-by-side 40 % / 60 %
• ≥80rem : side-by-side 30 % / 70 %
------------------------------------------------- */
@media (min-width: 48rem) {
/* ≈ 768 px */
main#editor {
flex-direction: row;
}
#src {
width: 40%;
height: auto;
border: none;
border-left: var(--stroke) solid var(--ink);
}
#stage {
flex: 1;
}
}
@media (min-width: 80rem) {
/* ≈ 1280 px */
#src {
width: 30%;
}
#stage {
flex: 1;
}
}
/* ===== “sketch” content styles ===== */
section {
border: var(--stroke) solid var(--ink);
border-radius: var(--r);
padding: 2rem;
margin-bottom: 2rem;
background: #fff;
}
h2 {
text-align: center;
margin: 0 0 0.7em;
font-size: 1.75rem;
}
h3 {
margin: 1.2em 0 0.4em;
font-size: 1.25rem;
}
p {
margin: 0.5em 0;
}
ul {
list-style: none;
padding-left: 0.8rem;
margin: 0.8em 0;
}
ul li::before {
content: "• ";
}
ol {
list-style: decimal;
padding-left: 1.8rem;
margin: 0.8em 0;
}
ol li {
margin-left: 0.2rem;
}
.btn {
display: inline-block;
padding: 0.5em 1.2em;
background: #ffd86b;
border: var(--stroke) solid var(--ink);
border-radius: var(--r);
font-weight: 700;
box-shadow: 3px 3px 0 var(--ink);
text-decoration: none;
color: inherit;
cursor: pointer;
transition: 0.2s;
}
.btn:hover,
.btn:active {
transform: translate(3px, 3px);
box-shadow: none;
transition: 0.15s;
}
/* injected helpers */
.cols {
display: grid;
gap: 1rem;
}
.cols > div > *:not(:last-child) {
margin-bottom: 0.6rem;
}
.cards {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
container-type: inline-size;
}
.cards > .wide {
grid-column: 1/-1; /* span all columns, no matter how many */
}
.cols > div {
display: flex; /* default simple */
flex-direction: column;
}
.grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.grid-row{
display:grid;
gap:1rem;
grid-template-columns:
repeat(var(--cols,auto-fit),minmax(100px,1fr));
}
/* but if a div inside cols or cards wants to be a mini-grid */
.cols > div.grid,
.cards > div.grid {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
}
@container (min-width: 600px) {
.cards {
grid-template-columns: repeat(2, 1fr);
}
}
@container (min-width: 900px) {
.cards {
grid-template-columns: repeat(3, 1fr);
}
}
@container (min-width: 1200px) {
.cards {
grid-template-columns: repeat(4, 1fr);
}
}
.placeholder {
border: var(--stroke) dashed var(--ink);
border-radius: var(--r);
aspect-ratio: 4/3;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
/* Progress bar */
.bar {
background: #eee;
border: var(--stroke) solid var(--ink);
border-radius: 6px;
height: 14px;
overflow: hidden;
margin: 0.6rem 0;
}
.bar span {
display: block;
height: 100%;
background: #ffd86b;
}
img {
max-width: 100%;
height: auto;
object-fit: contain;
margin: 1rem 0;
}
pre {
background: #f5f5f5;
border: var(--stroke) solid var(--ink);
padding: 1rem;
border-radius: var(--r);
overflow: auto;
font-family: "PT Mono", monospace;
font-size: 0.85rem;
line-height: 1.4;
}
/* PATH: /src/css/forms.css */
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.form-row > .field {
flex: 1;
min-width: 200px;
}
/* === BASE FIELD WRAPPERS === */
.field {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
gap: 0.5rem;
}
.field label {
display: block;
font-weight: 700;
font-size: 0.9rem;
margin-bottom: 0.3rem;
}
/* === GROUPING FIELDS SIDE BY SIDE === */
.field-group {
display: flex;
flex-direction: row;
gap: 1rem;
flex-wrap: wrap;
}
.field-group .field {
flex: 1;
min-width: 200px;
}
/* === FORM ELEMENT BASE STYLES === */
input, select, textarea, button {
font-family: "PT Mono", monospace;
font-size: 0.95rem;
font: inherit;
padding: 0.5rem 0.8rem;
border: var(--stroke) solid var(--ink);
border-radius: var(--r);
background: #fff;
box-sizing: border-box;
box-shadow: 2px 2px 0 var(--ink);
transition: all 0.2s ease;
}
/* === INPUT SPECIFIC === */
.field input,
.field select,
.field textarea {
background: #f5f5f5;
}
/* === CHECKBOX & RADIO TWEAKS === */
input[type="checkbox"],
input[type="radio"] {
width: auto;
margin-right: 0.5rem;
box-shadow: none;
}
/* === FOCUS & HOVER STATES === */
input:hover, select:hover, textarea:hover, button:hover {
border-color: #333;
}
input:focus, select:focus, textarea:focus, button:focus {
outline: none;
border-color: dodgerblue;
box-shadow: 0 0 0 2px rgba(30,144,255,0.3);
}
/* === SUBMIT BUTTON === */
button[type="submit"] {
background: dodgerblue;
color: #fff;
border: none;
}
button[type="submit"]:hover {
background: #1e90ff;
}
/* === OTHER BUTTONS === */
button:not([type="submit"]) {
background: #eee;
}
button:not([type="submit"]):hover {
background: #ddd;
}
/* === RANGE SLIDER CUSTOM === */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: #ccc;
height: 6px;
border-radius: 3px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: dodgerblue;
border-radius: 50%;
border: 2px solid #000;
cursor: pointer;
transition: background 0.2s ease;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: dodgerblue;
border-radius: 50%;
border: 2px solid #000;
cursor: pointer;
}
/* === FILE UPLOAD FIELD === */
input[type="file"] {
background: #fff;
}
/* === COLOR PICKER SPECIFIC === */
input[type="color"] {
width: 3rem;
height: 3rem;
padding: 0;
background: none;
border: none;
cursor: pointer;
box-shadow: 0 0 0 2px var(--ink);
border-radius: 0.5rem;
}
/* Make the color swatch obvious */
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 0.5rem;
}
/* Firefox */
input[type="color"]::-moz-color-swatch {
border: none;
border-radius: 0.5rem;
}
/* ===== Generic Modal System ===== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(30, 30, 30, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-box {
background: #fff;
border: var(--stroke) solid var(--ink);
border-radius: var(--r);
box-shadow: 6px 6px 0 var(--ink);
max-width: 90vw;
max-height: 80vh;
width: 400px;
overflow-y: auto;
font-family: "Balsamiq Sans", cursive;
padding: 1rem;
}
.modal-header {
font-size: 1.5rem;
margin-bottom: 1rem;
text-align: center;
}
.modal-body textarea {
width: 100%;
height: 200px;
padding: 0.8rem;
font-family: "PT Mono", monospace;
font-size: 0.9rem;
border: var(--stroke) solid var(--ink);
border-radius: var(--r);
background: #dbdbdb;
resize: vertical;
box-sizing: border-box;
margin-bottom: 1rem;
}
.modal-footer {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.modal-footer button {
font: inherit;
background: #ffd86b;
border: var(--stroke) solid var(--ink);
border-radius: var(--r);
box-shadow: 3px 3px 0 var(--ink);
padding: 0.4rem 1rem;
cursor: pointer;
}
.modal-footer button:active {
transform: translate(2px, 2px);
box-shadow: none;
}
#modalCloseBtn {
background: none;
border: none;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
margin-left: 0.5rem;
color: var(--ink);
}
#modalCloseBtn:hover {
color: darkred;
}
#src{
caret-color:#ffd86b;
color: #96b38a;
background: #1d1e22;
padding: 1rem;
letter-spacing: 0.1rem;
font-family: "PT Mono", monospace;
font-size: 0.8rem;
line-height: 1.2rem;
}
textarea#src {
min-height: 120px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment