diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b43c81d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Report something that is broken or behaving unexpectedly +title: "[Bug] " +labels: bug +assignees: "" +--- + +## Summary + +Describe the problem clearly. + +## Steps To Reproduce + +1. Go to +2. Click +3. Observe + +## Expected Behavior + +Describe what you expected to happen. + +## Actual Behavior + +Describe what actually happened. + +## Environment + +- OS: +- Browser: +- Python version: + +## Notes + +Add screenshots, sample files, or extra context if helpful. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e31cd78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Contribution Guide + url: https://github.com/oywino/python_xml_editor/blob/main/CONTRIBUTING.md + about: Read the project workflow and contribution notes before opening large changes. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..aa51a73 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an improvement or new capability +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +## Summary + +Describe the feature or improvement. + +## Problem + +What problem would this solve for users or maintainers? + +## Proposed Change + +Describe the preferred behavior or workflow. + +## Alternatives Considered + +List any alternatives you already considered. + +## Notes + +Add screenshots, examples, or links if helpful. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..657b90b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Summary + +- describe the change + +## Testing + +- describe how you tested it + +## UI Notes + +- add screenshots or short notes if the interface changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dec9b23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: + - main + - chore/** + - feature/** + - fix/** + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Compile Python launcher + run: python -m py_compile app.py + + - name: Verify required app files exist + run: | + test -f app.js + test -f index.html + test -f style.css + + - name: Verify browser entrypoint references assets + run: | + grep -q 'style.css' index.html + grep -q 'app.js' index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7ed01c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.log +.DS_Store +Thumbs.db +.codex-gh/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4936c2f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +## Workflow + +1. Branch from `main`. +2. Keep changes focused and easy to review. +3. Test locally before opening a pull request. +4. Describe user-facing behavior changes clearly in the PR. + +Suggested branch prefixes: + +- `feature/` for new functionality +- `fix/` for bug fixes +- `chore/` for maintenance and repo setup + +## Pull Request Checklist + +- explain what changed +- explain why it changed +- note how it was tested +- include screenshots for UI changes when useful +- mention any parsing or export behavior changes explicitly + +## Local Run + +```bash +python app.py +``` + +If needed on Windows: + +```bash +py app.py +``` + +## Project Expectations + +- prefer simple, readable changes over heavy abstractions +- keep the static app easy to run without extra dependencies +- document changes that affect file formats, parsing, or export behavior + +## Issues + +Use the GitHub issue templates for: + +- bug reports +- feature requests + +For larger changes, open an issue first so the implementation plan is easy to discuss. diff --git a/README.md b/README.md index e7d0361..8399e0a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,86 @@ -# python_xml_editor -This app is essentially a browser-based XML prompt editor with a very small Python launcher in front of it. +# Python XML Editor + +`python_xml_editor` is a local browser-based XML prompt editor with a tiny Python launcher. + +It is designed for editing prompt documents that contain: + +- a free-form preamble, such as a Markdown heading or note +- an XML body that can be edited visually instead of by hand + +The app runs entirely locally. Python serves the static files, and the browser handles parsing, editing, preview, and export. + +## Features + +- open `.md`, `.txt`, and `.xml` files +- edit a prompt preamble separately from the XML structure +- rename tags and edit attributes inline +- add root, child, and sibling elements +- reorder nodes with buttons or drag-and-drop +- edit text nodes directly +- preview raw XML output +- export either AI-ready XML text or full editor format + +## Project Structure + +- `app.py`: local launcher that serves the app and opens the browser +- `index.html`: single HTML mount point +- `app.js`: main application logic, parsing, tree editing, rendering, and export +- `style.css`: application styling +- `CONTRIBUTING.md`: contribution workflow and expectations +- `docs/ARCHITECTURE.md`: architecture and state model notes + +## Requirements + +- Python 3.10 or newer is recommended +- no third-party Python dependencies are required + +## Run Locally + +From the repository root: + +```bash +python app.py +``` + +If your machine uses the Windows launcher instead of `python`, you can also use: + +```bash +py app.py +``` + +The launcher will: + +1. find a free local port +2. serve the repository directory over HTTP +3. open `index.html` in your default browser + +Stop the server with `Ctrl+C`. + +## Development Workflow + +1. branch from `main` +2. make and test your changes locally +3. open a pull request with a clear summary +4. include screenshots for UI changes when useful + +This repository includes: + +- a pull request template in `.github/pull_request_template.md` +- issue templates for bugs and feature requests +- a basic GitHub Actions workflow for pull requests and pushes + +## Architecture + +The application has two parts: + +1. a Python wrapper in `app.py` that starts a local HTTP server +2. a plain JavaScript single-page app in `app.js` that parses mixed preamble + XML text into a tree, renders it visually, and serializes it back out for export + +The editor keeps all state in memory in the browser session and does not currently save automatically. + +More detail is available in `docs/ARCHITECTURE.md`. + +## Notes + +- the XML parsing logic is custom and currently optimized for simple prompt-style XML +- the app is intentionally lightweight and has no front-end framework or backend service diff --git a/app.js b/app.js new file mode 100644 index 0000000..3e55298 --- /dev/null +++ b/app.js @@ -0,0 +1,1144 @@ +(() => { + const SAMPLE_DOC = `# My AI Prompt + + + + + My Amazing Project + + + Describe your project goal here. What are you building? + + + + + Always provide step-by-step reasoning. + Ask for clarification when requirements are unclear. + Never speculate beyond the provided context. + + + + Please respond in a structured, clear manner. + +`; + + let idCounter = 0; + + const state = { + doc: null, + showExport: false, + exportMode: 'ai', + copied: false, + showRaw: false, + preambleEditing: false, + preambleVal: '', + showHelp: false, + collapsed: {}, + dragNodeId: null, + }; + + function generateId() { + idCounter += 1; + return `node_${idCounter}_${Math.random().toString(36).slice(2, 7)}`; + } + + function parseAttributes(attrStr) { + const attrs = {}; + const regex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + let m; + while ((m = regex.exec(attrStr)) !== null) { + attrs[m[1]] = m[2] ?? m[3] ?? ''; + } + return attrs; + } + + function tokenize(xml) { + const tokens = []; + let i = 0; + while (i < xml.length) { + if (xml[i] === '<') { + const end = xml.indexOf('>', i); + if (end === -1) { + tokens.push({ type: 'text', text: xml.slice(i) }); + break; + } + const tagContent = xml.slice(i + 1, end); + + if (tagContent.startsWith('!--')) { + const commentEnd = xml.indexOf('-->', i); + if (commentEnd === -1) { + i = xml.length; + } else { + i = commentEnd + 3; + } + continue; + } + + if (tagContent.startsWith('/')) { + tokens.push({ type: 'close', tag: tagContent.slice(1).trim() }); + i = end + 1; + } else if (tagContent.endsWith('/')) { + const inner = tagContent.slice(0, -1).trim(); + const spaceIdx = inner.search(/\s/); + const tag = spaceIdx === -1 ? inner : inner.slice(0, spaceIdx); + const attrStr = spaceIdx === -1 ? '' : inner.slice(spaceIdx); + tokens.push({ type: 'selfclose', tag, attributes: parseAttributes(attrStr) }); + i = end + 1; + } else { + const spaceIdx = tagContent.search(/\s/); + const tag = spaceIdx === -1 ? tagContent : tagContent.slice(0, spaceIdx); + const attrStr = spaceIdx === -1 ? '' : tagContent.slice(spaceIdx); + tokens.push({ type: 'open', tag, attributes: parseAttributes(attrStr) }); + i = end + 1; + } + } else { + const nextTag = xml.indexOf('<', i); + const text = nextTag === -1 ? xml.slice(i) : xml.slice(i, nextTag); + if (text.trim()) tokens.push({ type: 'text', text }); + i = nextTag === -1 ? xml.length : nextTag; + } + } + return tokens; + } + + function buildTree(tokens) { + const stack = []; + const roots = []; + for (const token of tokens) { + if (token.type === 'open') { + const node = { + id: generateId(), + type: 'element', + tag: token.tag, + attributes: token.attributes || {}, + children: [], + }; + if (stack.length > 0) { + node.parent = stack[stack.length - 1].id; + stack[stack.length - 1].children.push(node); + } else { + roots.push(node); + } + stack.push(node); + } else if (token.type === 'close') { + if (stack.length > 0) stack.pop(); + } else if (token.type === 'selfclose') { + const node = { + id: generateId(), + type: 'element', + tag: token.tag, + attributes: token.attributes || {}, + children: [], + }; + if (stack.length > 0) { + node.parent = stack[stack.length - 1].id; + stack[stack.length - 1].children.push(node); + } else { + roots.push(node); + } + } else if (token.type === 'text') { + const textNode = { + id: generateId(), + type: 'text', + text: token.text.trim(), + children: [], + }; + if (stack.length > 0) { + textNode.parent = stack[stack.length - 1].id; + stack[stack.length - 1].children.push(textNode); + } else { + roots.push(textNode); + } + } + } + return roots; + } + + function parseDocument(input) { + const lines = input.split('\n'); + let preamble = ''; + let xmlStart = -1; + for (let i = 0; i < lines.length; i += 1) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('<') && !trimmed.startsWith(' ` ${k}="${v}"`).join('') + : ''; + if (node.children.length === 0) { + result += `${pad}<${node.tag}${attrs} />\n`; + } else { + const hasOnlyText = node.children.length === 1 && node.children[0].type === 'text'; + if (hasOnlyText) { + const text = (node.children[0].text || '').trim(); + result += `${pad}<${node.tag}${attrs}>${text}\n`; + } else { + result += `${pad}<${node.tag}${attrs}>\n`; + result += serializeToXml(node.children, indent + 1); + result += `${pad}\n`; + } + } + } + } + return result; + } + + function serializeFlatXml(nodes) { + let result = ''; + for (const node of nodes) { + if (node.type === 'text') { + const text = (node.text || '').trim(); + if (text) result += `${text}\n`; + } else { + const attrs = node.attributes + ? Object.entries(node.attributes) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ` ${k}="${v}"`).join('') + : ''; + if (node.children.length === 0) { + result += `<${node.tag}${attrs} />\n`; + } else { + result += `<${node.tag}${attrs}>\n`; + result += serializeFlatXml(node.children); + result += `\n`; + } + } + } + return result; + } + + function serializeDocument(doc) { + let result = ''; + if (doc.preamble) result += `${doc.preamble}\n\n`; + result += serializeToXml(doc.root, 0); + return result.replace(/\n+$/, ''); + } + + function serializeDocumentFlat(doc) { + return serializeFlatXml(doc.root).replace(/\n+$/, ''); + } + + function serializeDocumentForAI(doc) { + return serializeDocumentFlat(doc); + } + + function removeNodeById(nodes, id) { + return nodes.filter((n) => n.id !== id).map((n) => ({ ...n, children: removeNodeById(n.children, id) })); + } + + function updateNodeById(nodes, id, updater) { + return nodes.map((n) => { + if (n.id === id) return updater(n); + return { ...n, children: updateNodeById(n.children, id, updater) }; + }); + } + + function createElementNode(tag, parentId) { + return { + id: generateId(), + type: 'element', + tag, + attributes: {}, + children: [{ id: generateId(), type: 'text', text: '', children: [], parent: undefined }], + parent: parentId, + }; + } + + function findNodeById(nodes, id) { + for (const node of nodes) { + if (node.id === id) return node; + const found = findNodeById(node.children, id); + if (found) return found; + } + return null; + } + + function getNodeChildrenSiblings(nodes, nodeId) { + function find(list, parentId) { + for (const n of list) { + if (n.id === nodeId) return { siblings: list, parentId }; + const found = find(n.children, n.id); + if (found) return found; + } + return null; + } + return find(nodes, null) || { siblings: nodes, parentId: null }; + } + + function siblingElementTags(siblings, excludeId) { + return new Set( + siblings + .filter((n) => n.type === 'element' && n.id !== excludeId && n.tag) + .map((n) => n.tag) + ); + } + + function uniqueTagName(base, usedNames) { + if (!usedNames.has(base)) return base; + let i = 2; + while (usedNames.has(`${base}_${i}`)) i += 1; + return `${base}_${i}`; + } + + function isDescendant(node, maybeAncestorId) { + if (!node || !maybeAncestorId) return false; + if (node.id === maybeAncestorId) return true; + return node.children.some((child) => isDescendant(child, maybeAncestorId)); + } + + function insertNodeAt(nodes, node, parentId, index) { + if (parentId === null) { + const arr = [...nodes]; + arr.splice(index, 0, node); + return arr; + } + return nodes.map((n) => { + if (n.id === parentId) { + const children = [...n.children]; + children.splice(index, 0, { ...node, parent: parentId }); + return { ...n, children }; + } + return { ...n, children: insertNodeAt(n.children, node, parentId, index) }; + }); + } + + function moveNode(nodes, nodeId, target) { + const entry = findNodeById(nodes, nodeId); + if (!entry) return nodes; + const targetParent = target.parentId ? findNodeById(nodes, target.parentId) : null; + if (targetParent && isDescendant(entry, target.parentId)) return nodes; + const movedNode = JSON.parse(JSON.stringify(entry)); + movedNode.parent = target.parentId || undefined; + let result = removeNodeById(nodes, nodeId); + result = insertNodeAt(result, movedNode, target.parentId, target.index); + return result; + } + + function setDoc(doc) { + state.doc = doc; + state.preambleVal = doc.preamble; + render(); + } + + function updateRoot(root) { + state.doc = { ...state.doc, root }; + render(); + } + + function getExportContent() { + return state.exportMode === 'ai' ? serializeDocumentForAI(state.doc) : serializeDocument(state.doc); + } + + function escapeHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function icon(text) { + const span = document.createElement('span'); + span.textContent = text; + return span; + } + + function btn(label, opts = {}) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = opts.className || 'btn'; + b.textContent = label; + if (opts.title) b.title = opts.title; + if (opts.onClick) b.addEventListener('click', opts.onClick); + return b; + } + + function renderTagEditor(node, nodes, container) { + const wrap = document.createElement('div'); + wrap.style.display = 'flex'; + wrap.style.flexDirection = 'column'; + wrap.style.gap = '4px'; + + const input = document.createElement('input'); + input.className = 'inline-input'; + input.value = node.tag || ''; + wrap.appendChild(input); + + const errorLine = document.createElement('div'); + errorLine.className = 'error-line hidden'; + wrap.appendChild(errorLine); + + function isDuplicate(name) { + const { siblings } = getNodeChildrenSiblings(nodes, node.id); + return siblingElementTags(siblings, node.id).has(name); + } + + function showError(message) { + input.classList.add('error'); + errorLine.textContent = `โš  ${message}`; + errorLine.classList.remove('hidden'); + container.closest('.node-card')?.classList.add('error'); + } + + function clearError() { + input.classList.remove('error'); + errorLine.classList.add('hidden'); + container.closest('.node-card')?.classList.remove('error'); + } + + function save() { + const name = input.value.trim(); + if (!name) { + render(); + return; + } + if (isDuplicate(name)) { + showError(`"${name}" already exists among siblings.`); + return; + } + clearError(); + updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, tag: name }))); + } + + input.addEventListener('input', () => { + const val = input.value.trim(); + if (val && isDuplicate(val)) showError(`"${val}" already exists among siblings.`); + else clearError(); + }); + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') save(); + if (e.key === 'Escape') render(); + }); + + container.replaceChildren(wrap); + input.focus(); + input.select(); + } + + function renderAttrEditor(node, nodes, container) { + const input = document.createElement('input'); + input.className = 'attr-input'; + input.value = node.attributes + ? Object.entries(node.attributes).map(([k, v]) => (v ? `${k}="${v}"` : k)).join(' ') + : ''; + + function save() { + const attrs = {}; + const regex = /(\w+)(?:\s*=\s*"([^"]*)")?/g; + let m; + while ((m = regex.exec(input.value)) !== null) { + attrs[m[1]] = m[2] ?? ''; + } + updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, attributes: attrs }))); + } + + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') save(); + if (e.key === 'Escape') render(); + }); + + container.replaceChildren(input); + input.focus(); + input.select(); + } + + function renderTextNode(node, depth, nodes) { + const row = document.createElement('div'); + row.className = `node-row node-indent-${Math.min(depth, 10)}`; + + const wrap = document.createElement('div'); + wrap.className = 'text-row'; + + const iconEl = document.createElement('div'); + iconEl.className = 'text-icon'; + iconEl.textContent = 'โ–ค'; + wrap.appendChild(iconEl); + + const textarea = document.createElement('textarea'); + textarea.className = 'text-node-textarea'; + textarea.placeholder = 'Text content...'; + textarea.value = (node.text || '').trim(); + textarea.rows = Math.max(1, textarea.value ? textarea.value.split('\n').length : 1); + textarea.addEventListener('input', () => { + textarea.rows = Math.max(1, textarea.value ? textarea.value.split('\n').length : 1); + state.doc.root = updateNodeById(nodes, node.id, (n) => ({ ...n, text: textarea.value })); + }); + wrap.appendChild(textarea); + + const actions = document.createElement('div'); + actions.className = 'text-actions'; + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'action-btn red'; + del.title = 'Delete text node'; + del.textContent = '๐Ÿ—‘'; + del.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id))); + actions.appendChild(del); + wrap.appendChild(actions); + + row.appendChild(wrap); + return row; + } + + function renderElementNode(node, depth, nodes) { + const row = document.createElement('div'); + row.className = `node-row node-indent-${Math.min(depth, 10)}`; + + const card = document.createElement('div'); + card.className = 'node-card'; + card.draggable = true; + card.addEventListener('dragstart', (e) => { + state.dragNodeId = node.id; + card.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', node.id); + }); + card.addEventListener('dragend', () => { + state.dragNodeId = null; + card.classList.remove('dragging'); + document.querySelectorAll('.drag-over').forEach((el) => el.classList.remove('drag-over')); + }); + card.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!state.dragNodeId || state.dragNodeId === node.id) return; + card.classList.add('drag-over'); + }); + card.addEventListener('dragleave', () => card.classList.remove('drag-over')); + card.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + card.classList.remove('drag-over'); + const dragged = e.dataTransfer.getData('text/plain'); + if (dragged && dragged !== node.id) { + updateRoot(moveNode(nodes, dragged, { parentId: node.id, index: node.children.length })); + } + }); + + const grip = document.createElement('div'); + grip.className = 'grip'; + grip.textContent = 'โ‹ฎโ‹ฎ'; + card.appendChild(grip); + + const expand = document.createElement('button'); + expand.type = 'button'; + expand.className = 'expand-btn'; + const hasChildren = node.children.length > 0; + expand.textContent = hasChildren ? (state.collapsed[node.id] ? 'โ–ธ' : 'โ–พ') : ''; + expand.title = state.collapsed[node.id] ? 'Expand' : 'Collapse'; + expand.addEventListener('click', () => { + state.collapsed[node.id] = !state.collapsed[node.id]; + render(); + }); + card.appendChild(expand); + + const main = document.createElement('div'); + main.className = 'node-main'; + + const tagSymbol = document.createElement('span'); + tagSymbol.className = 'tag-symbol'; + tagSymbol.textContent = '๐Ÿท'; + main.appendChild(tagSymbol); + + const tagWrap = document.createElement('div'); + const tagBtn = document.createElement('button'); + tagBtn.type = 'button'; + tagBtn.className = 'tag-pill'; + tagBtn.textContent = `<${node.tag}>`; + tagBtn.addEventListener('click', () => renderTagEditor(node, nodes, tagWrap)); + tagWrap.appendChild(tagBtn); + main.appendChild(tagWrap); + + const attrWrap = document.createElement('div'); + const attrDisplay = node.attributes + ? Object.entries(node.attributes) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => (v ? `${k}="${v}"` : k)) + .join(' ') + : ''; + + if (attrDisplay) { + const attrBtn = document.createElement('button'); + attrBtn.type = 'button'; + attrBtn.className = 'attr-pill'; + attrBtn.textContent = attrDisplay; + attrBtn.addEventListener('click', () => renderAttrEditor(node, nodes, attrWrap)); + attrWrap.appendChild(attrBtn); + } else { + const attrBtn = document.createElement('button'); + attrBtn.type = 'button'; + attrBtn.className = 'attr-pill attr-placeholder'; + attrBtn.textContent = '+ attr'; + attrBtn.addEventListener('click', () => renderAttrEditor(node, nodes, attrWrap)); + attrWrap.appendChild(attrBtn); + } + main.appendChild(attrWrap); + card.appendChild(main); + + const actions = document.createElement('div'); + actions.className = 'node-actions'; + + const moveUpBtn = document.createElement('button'); + moveUpBtn.type = 'button'; + moveUpBtn.className = 'action-btn'; + moveUpBtn.title = 'Move up'; + moveUpBtn.textContent = 'โ–ฒ'; + moveUpBtn.addEventListener('click', () => { + const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id); + const idx = siblings.findIndex((n) => n.id === node.id); + if (idx <= 0) return; + const arr = [...siblings]; + [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]; + if (parentId === null) updateRoot(arr); + else updateRoot(updateNodeById(nodes, parentId, (n) => ({ ...n, children: arr }))); + }); + actions.appendChild(moveUpBtn); + + const moveDownBtn = document.createElement('button'); + moveDownBtn.type = 'button'; + moveDownBtn.className = 'action-btn'; + moveDownBtn.title = 'Move down'; + moveDownBtn.textContent = 'โ–ผ'; + moveDownBtn.addEventListener('click', () => { + const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id); + const idx = siblings.findIndex((n) => n.id === node.id); + if (idx >= siblings.length - 1) return; + const arr = [...siblings]; + [arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]; + if (parentId === null) updateRoot(arr); + else updateRoot(updateNodeById(nodes, parentId, (n) => ({ ...n, children: arr }))); + }); + actions.appendChild(moveDownBtn); + + const addChildBtn = document.createElement('button'); + addChildBtn.type = 'button'; + addChildBtn.className = 'action-btn green'; + addChildBtn.title = 'Add child element'; + addChildBtn.textContent = '+'; + addChildBtn.addEventListener('click', () => { + const existing = new Set(node.children.filter((c) => c.type === 'element').map((c) => c.tag)); + const name = uniqueTagName('new_tag', existing); + const child = createElementNode(name, node.id); + state.collapsed[node.id] = false; + updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, children: [...n.children, child] }))); + }); + actions.appendChild(addChildBtn); + + const addSiblingBtn = document.createElement('button'); + addSiblingBtn.type = 'button'; + addSiblingBtn.className = 'action-btn blue'; + addSiblingBtn.title = 'Add sibling after'; + addSiblingBtn.textContent = '+โ†“'; + addSiblingBtn.addEventListener('click', () => { + const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id); + const idx = siblings.findIndex((n) => n.id === node.id); + const used = siblingElementTags(siblings, ''); + const name = uniqueTagName('new_tag', used); + const newNode = createElementNode(name, parentId || undefined); + const newSiblings = [...siblings]; + newSiblings.splice(idx + 1, 0, newNode); + if (parentId === null) updateRoot(newSiblings); + else updateRoot(updateNodeById(nodes, parentId, (n) => ({ ...n, children: newSiblings }))); + }); + actions.appendChild(addSiblingBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'action-btn red'; + deleteBtn.title = 'Delete'; + deleteBtn.textContent = '๐Ÿ—‘'; + deleteBtn.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id))); + actions.appendChild(deleteBtn); + + card.appendChild(actions); + row.appendChild(card); + + const isCollapsed = !!state.collapsed[node.id]; + if (!isCollapsed && hasChildren) { + const childrenWrap = document.createElement('div'); + childrenWrap.className = 'children-wrap'; + for (const child of node.children) { + childrenWrap.appendChild(renderNode(child, 0, nodes)); + } + row.appendChild(childrenWrap); + + const closing = document.createElement('div'); + closing.className = 'node-card'; + closing.style.marginTop = '4px'; + const grip2 = document.createElement('div'); + grip2.className = 'grip'; + grip2.textContent = 'โ‹ฎโ‹ฎ'; + closing.appendChild(grip2); + const spacer = document.createElement('div'); + spacer.style.width = '28px'; + spacer.style.marginLeft = '4px'; + closing.appendChild(spacer); + const main2 = document.createElement('div'); + main2.className = 'node-main'; + const tagSymbol2 = document.createElement('span'); + tagSymbol2.className = 'tag-symbol'; + tagSymbol2.textContent = '๐Ÿท'; + main2.appendChild(tagSymbol2); + const closingTag = document.createElement('span'); + closingTag.className = 'tag-pill-closing'; + closingTag.textContent = ``; + main2.appendChild(closingTag); + closing.appendChild(main2); + row.appendChild(closing); + } + + return row; + } + + function renderNode(node, depth, nodes) { + if (node.type === 'text') return renderTextNode(node, depth, nodes); + return renderElementNode(node, depth, nodes); + } + + function downloadText(filename, content) { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + function renderHeader(root) { + const topbar = document.createElement('header'); + topbar.className = 'topbar'; + const inner = document.createElement('div'); + inner.className = 'topbar-inner'; + + const brand = document.createElement('div'); + brand.className = 'brand'; + const iconBox = document.createElement('div'); + iconBox.className = 'brand-icon'; + iconBox.textContent = ''; + brand.appendChild(iconBox); + const title = document.createElement('div'); + title.className = 'brand-title'; + title.textContent = 'XML Prompt Editor'; + brand.appendChild(title); + inner.appendChild(brand); + + const spacer = document.createElement('div'); + spacer.className = 'spacer'; + inner.appendChild(spacer); + + const toolbar = document.createElement('div'); + toolbar.className = 'toolbar'; + + const newBtn = btn('๏ผ‹ New', { + className: 'btn', + onClick: () => { + if (!window.confirm('Start a new document? Unsaved changes will be lost.')) return; + setDoc(parseDocument('# New Prompt\n\n\n \n')); + state.showRaw = false; + state.preambleEditing = false; + }, + }); + toolbar.appendChild(newBtn); + + const openBtn = btn('โคด Open File', { className: 'btn' }); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.md,.txt,.xml'; + input.className = 'hidden'; + input.addEventListener('change', (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = String(ev.target?.result || ''); + setDoc(parseDocument(text)); + state.showRaw = false; + state.preambleEditing = false; + }; + reader.readAsText(file); + input.value = ''; + }); + openBtn.addEventListener('click', () => input.click()); + toolbar.appendChild(openBtn); + toolbar.appendChild(input); + + const rawBtn = btn(state.showRaw ? '๐Ÿ‘ Visual' : '๐Ÿ“„ Raw', { + className: `btn ${state.showRaw ? 'btn-toggle-active' : ''}`, + onClick: () => { + state.showRaw = !state.showRaw; + render(); + }, + }); + toolbar.appendChild(rawBtn); + + const helpBtn = btn('?', { + className: 'btn btn-icon', + title: 'Help', + onClick: () => { + state.showHelp = !state.showHelp; + render(); + }, + }); + toolbar.appendChild(helpBtn); + + const exportBtn = btn('โค“ Export', { + className: 'btn btn-primary', + onClick: () => { + state.showExport = true; + state.copied = false; + render(); + }, + }); + toolbar.appendChild(exportBtn); + + inner.appendChild(toolbar); + topbar.appendChild(inner); + root.appendChild(topbar); + } + + function renderHelp(root) { + if (!state.showHelp) return; + const help = document.createElement('div'); + help.className = 'help-panel'; + const inner = document.createElement('div'); + inner.className = 'help-inner'; + const grid = document.createElement('div'); + grid.className = 'help-grid'; + const items = [ + ['Click tag name', 'rename it'], + ['Click attr text', 'edit attributes'], + ['Drag grip', 'move elements into another node'], + ['โ–ฒโ–ผ arrows', 'reorder siblings'], + ['+ button', 'add child element'], + ['+โ†“ button', 'add sibling below'], + ['Trash icon', 'delete element'], + ['Text area', 'edit text content'], + ]; + for (const [strong, rest] of items) { + const div = document.createElement('div'); + div.innerHTML = `${escapeHtml(strong)} โ€” ${escapeHtml(rest)}`; + grid.appendChild(div); + } + inner.appendChild(grid); + help.appendChild(inner); + root.appendChild(help); + } + + function renderPreambleCard(container) { + if (state.doc.preamble || state.preambleEditing) { + const card = document.createElement('div'); + card.className = 'card'; + const header = document.createElement('div'); + header.className = 'card-header'; + const title = document.createElement('div'); + title.className = 'card-title'; + title.textContent = 'PREAMBLE (MARKDOWN HEADER)'; + header.appendChild(title); + if (!state.preambleEditing) { + const edit = document.createElement('button'); + edit.type = 'button'; + edit.className = 'edit-link'; + edit.textContent = 'Edit'; + edit.addEventListener('click', () => { + state.preambleEditing = true; + render(); + }); + header.appendChild(edit); + } + card.appendChild(header); + + if (state.preambleEditing) { + const body = document.createElement('div'); + body.className = 'card-body'; + const textarea = document.createElement('textarea'); + textarea.className = 'preamble-textarea'; + textarea.value = state.preambleVal; + textarea.addEventListener('input', () => { + state.preambleVal = textarea.value; + }); + body.appendChild(textarea); + const actions = document.createElement('div'); + actions.className = 'action-row'; + const save = document.createElement('button'); + save.type = 'button'; + save.className = 'mini-btn mini-btn-primary'; + save.textContent = 'Save'; + save.addEventListener('click', () => { + state.doc = { ...state.doc, preamble: state.preambleVal }; + state.preambleEditing = false; + render(); + }); + actions.appendChild(save); + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'mini-btn mini-btn-muted'; + cancel.textContent = 'Cancel'; + cancel.addEventListener('click', () => { + state.preambleVal = state.doc.preamble; + state.preambleEditing = false; + render(); + }); + actions.appendChild(cancel); + body.appendChild(actions); + card.appendChild(body); + } else { + const display = document.createElement('div'); + display.className = 'preamble-display'; + display.textContent = state.doc.preamble || 'No preamble'; + if (!state.doc.preamble) display.classList.add('muted-empty'); + display.addEventListener('click', () => { + state.preambleEditing = true; + render(); + }); + card.appendChild(display); + } + container.appendChild(card); + } else { + const add = document.createElement('button'); + add.type = 'button'; + add.className = 'add-preamble'; + add.textContent = '+ Add preamble (e.g. Markdown title or comment)'; + add.addEventListener('click', () => { + state.preambleEditing = true; + render(); + }); + container.appendChild(add); + } + } + + function renderStructureCard(container) { + const card = document.createElement('div'); + card.className = 'card'; + const header = document.createElement('div'); + header.className = 'card-header'; + const iconEl = document.createElement('span'); + iconEl.textContent = ''; + iconEl.style.color = '#94a3b8'; + iconEl.style.fontSize = '13px'; + header.appendChild(iconEl); + const title = document.createElement('div'); + title.className = 'card-title'; + title.textContent = 'XML Structure'; + header.appendChild(title); + const subtle = document.createElement('div'); + subtle.className = 'card-subtle'; + subtle.textContent = `${state.doc.root.length} root element${state.doc.root.length !== 1 ? 's' : ''}`; + header.appendChild(subtle); + card.appendChild(header); + + const body = document.createElement('div'); + body.className = 'structure-body'; + const list = document.createElement('div'); + list.className = 'node-list'; + for (const node of state.doc.root) { + list.appendChild(renderNode(node, 0, state.doc.root)); + } + body.appendChild(list); + + const addWrap = document.createElement('div'); + addWrap.className = 'add-root-wrap'; + const addRoot = document.createElement('button'); + addRoot.type = 'button'; + addRoot.className = 'add-root-btn'; + addRoot.textContent = '+ Add root element'; + addRoot.addEventListener('click', () => { + const used = new Set(state.doc.root.filter((n) => n.type === 'element').map((n) => n.tag)); + const name = uniqueTagName('new_tag', used); + updateRoot([...state.doc.root, createElementNode(name)]); + }); + addWrap.appendChild(addRoot); + body.appendChild(addWrap); + card.appendChild(body); + container.appendChild(card); + } + + function renderRawCard(container) { + const card = document.createElement('div'); + card.className = 'card'; + const header = document.createElement('div'); + header.className = 'card-header'; + const iconEl = document.createElement('span'); + iconEl.textContent = '๐Ÿ“„'; + iconEl.style.fontSize = '13px'; + header.appendChild(iconEl); + const title = document.createElement('div'); + title.className = 'card-title'; + title.textContent = 'Raw text preview'; + header.appendChild(title); + const subtle = document.createElement('div'); + subtle.className = 'card-subtle'; + subtle.textContent = 'Read-only โ€” switch to Visual to edit'; + header.appendChild(subtle); + card.appendChild(header); + + const pre = document.createElement('pre'); + pre.className = 'raw-pre'; + pre.textContent = serializeDocumentFlat(state.doc); + card.appendChild(pre); + container.appendChild(card); + } + + function renderExportModal(root) { + if (!state.showExport) return; + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) { + state.showExport = false; + render(); + } + }); + + const modal = document.createElement('div'); + modal.className = 'modal'; + + const header = document.createElement('div'); + header.className = 'modal-header'; + const left = document.createElement('div'); + const title = document.createElement('div'); + title.className = 'modal-title'; + title.textContent = 'Export Prompt'; + left.appendChild(title); + const subtitle = document.createElement('div'); + subtitle.className = 'modal-subtitle'; + subtitle.textContent = 'Choose export format below'; + left.appendChild(subtitle); + header.appendChild(left); + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'close-btn'; + close.textContent = 'ร—'; + close.addEventListener('click', () => { + state.showExport = false; + render(); + }); + header.appendChild(close); + modal.appendChild(header); + + const tabs = document.createElement('div'); + tabs.className = 'modal-tabs'; + const aiTab = document.createElement('button'); + aiTab.type = 'button'; + aiTab.className = `tab-btn ${state.exportMode === 'ai' ? 'active' : ''}`; + aiTab.textContent = 'AI-Ready (clean text)'; + aiTab.addEventListener('click', () => { + state.exportMode = 'ai'; + render(); + }); + const editorTab = document.createElement('button'); + editorTab.type = 'button'; + editorTab.className = `tab-btn ${state.exportMode === 'editor' ? 'active' : ''}`; + editorTab.textContent = 'Editor Format (full)'; + editorTab.addEventListener('click', () => { + state.exportMode = 'editor'; + render(); + }); + tabs.appendChild(aiTab); + tabs.appendChild(editorTab); + modal.appendChild(tabs); + + const note = document.createElement('div'); + note.className = 'modal-note'; + note.textContent = state.exportMode === 'ai' + ? 'Clean XML output ready to paste into your AI prompt. No visual markers.' + : 'Full format including preamble โ€” use this to reload the file for editing.'; + modal.appendChild(note); + + const preWrap = document.createElement('div'); + preWrap.className = 'modal-pre-wrap'; + const pre = document.createElement('pre'); + pre.className = 'modal-pre'; + pre.textContent = getExportContent(); + preWrap.appendChild(pre); + modal.appendChild(preWrap); + + const actions = document.createElement('div'); + actions.className = 'modal-actions'; + const copy = document.createElement('button'); + copy.type = 'button'; + copy.className = 'copy-btn'; + copy.textContent = state.copied ? 'Copied!' : 'Copy to Clipboard'; + copy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(getExportContent()); + state.copied = true; + render(); + setTimeout(() => { + state.copied = false; + render(); + }, 1800); + } catch { + window.alert('Copy failed in this browser.'); + } + }); + actions.appendChild(copy); + + const download = document.createElement('button'); + download.type = 'button'; + download.className = 'download-btn'; + download.textContent = 'Download'; + download.addEventListener('click', () => { + const ext = state.exportMode === 'ai' ? 'txt' : 'md'; + const filename = `prompt_${state.exportMode === 'ai' ? 'ai_ready' : 'editor'}.${ext}`; + downloadText(filename, getExportContent()); + }); + actions.appendChild(download); + + modal.appendChild(actions); + backdrop.appendChild(modal); + root.appendChild(backdrop); + } + + function render() { + const app = document.getElementById('app'); + app.replaceChildren(); + + const shell = document.createElement('div'); + shell.className = 'app-shell'; + renderHeader(shell); + renderHelp(shell); + + const main = document.createElement('main'); + const mainInner = document.createElement('div'); + mainInner.className = 'main-inner'; + if (state.showRaw) { + renderRawCard(mainInner); + } else { + renderPreambleCard(mainInner); + renderStructureCard(mainInner); + } + main.appendChild(mainInner); + shell.appendChild(main); + renderExportModal(shell); + app.appendChild(shell); + } + + function init() { + state.doc = parseDocument(SAMPLE_DOC); + state.preambleVal = state.doc.preamble; + render(); + } + + window.addEventListener('DOMContentLoaded', init); +})(); diff --git a/app.py b/app.py new file mode 100644 index 0000000..655642f --- /dev/null +++ b/app.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import contextlib +import http.server +import os +import socket +import threading +import time +import webbrowser +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +HOST = "127.0.0.1" + + +def find_free_port(host: str = HOST) -> int: + with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + sock.bind((host, 0)) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return int(sock.getsockname()[1]) + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(BASE_DIR), **kwargs) + + def log_message(self, format: str, *args) -> None: + return + + +def main() -> None: + port = find_free_port() + server = http.server.ThreadingHTTPServer((HOST, port), QuietHandler) + url = f"http://{HOST}:{port}/index.html" + + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + print("XML Prompt Editor") + print(f"Serving: {url}") + print("Press Ctrl+C to stop.") + + try: + time.sleep(0.35) + webbrowser.open(url) + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b6fd63e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,94 @@ +# Architecture + +## Overview + +The application has two layers: + +1. `app.py` launches a local HTTP server and opens the browser. +2. `app.js` implements the editor as a plain JavaScript single-page app. + +There is no backend API, database, or framework runtime. All editing happens in memory in the browser. + +## Runtime Flow + +1. `app.py` finds a free local port. +2. It serves the repository directory with `http.server`. +3. It opens `index.html` in the default browser. +4. The browser loads `app.js` and initializes the editor with a sample document. + +## Front-End Structure + +The JavaScript is organized as a single file with a few logical layers: + +- parsing helpers that split preamble text from XML and build a node tree +- serialization helpers that convert the node tree back into text +- tree update helpers for add, remove, rename, reorder, and move operations +- rendering functions that rebuild the UI from current state + +The app uses a full re-render approach. After significant state changes, it calls `render()` and reconstructs the visible interface. + +## State Model + +Global state lives in a single `state` object in `app.js`. + +UI state includes: + +- current export modal visibility and export mode +- raw view toggle +- help panel toggle +- copied-to-clipboard status +- drag-and-drop state +- collapsed tree state +- preamble editing state + +Document state includes: + +- `doc.preamble`: free-form text before the XML block +- `doc.root`: array of root nodes + +## Node Model + +The XML tree uses two node shapes. + +Element nodes contain: + +- `id` +- `type: "element"` +- `tag` +- `attributes` +- `children` +- optional `parent` + +Text nodes contain: + +- `id` +- `type: "text"` +- `text` +- `children` +- optional `parent` + +## Parsing Model + +The editor accepts documents with a free-form preamble followed by XML. + +`parseDocument()`: + +1. scans line-by-line until it finds the start of XML +2. treats everything before that as preamble +3. tokenizes the XML text +4. builds a nested tree structure from the tokens + +The parser is custom and lightweight. It is built for prompt-style XML rather than strict general-purpose XML compatibility. + +## Export Model + +The app supports two export modes: + +- AI-ready export: clean XML without the preamble +- editor export: preamble plus XML, suitable for reopening in the editor + +## Maintenance Notes + +- there is currently no persistent storage layer +- there are no third-party package managers or dependencies +- most future changes will happen in `app.js`, with `style.css` for UI styling and `app.py` only for launcher behavior diff --git a/gh-codex.ps1 b/gh-codex.ps1 new file mode 100644 index 0000000..c4722ac --- /dev/null +++ b/gh-codex.ps1 @@ -0,0 +1,15 @@ +$ghPath = "C:\Program Files\GitHub CLI\gh.exe" +$ghConfigDir = Join-Path $PSScriptRoot ".codex-gh" + +if (-not (Test-Path $ghPath)) { + Write-Error "GitHub CLI not found at $ghPath" + exit 1 +} + +if (-not (Test-Path $ghConfigDir)) { + New-Item -ItemType Directory -Path $ghConfigDir | Out-Null +} + +$env:GH_CONFIG_DIR = $ghConfigDir +& $ghPath @args +exit $LASTEXITCODE diff --git a/index.html b/index.html new file mode 100644 index 0000000..01fffed --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + XML Prompt Editor + + + +
+ + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..c02c8fb --- /dev/null +++ b/style.css @@ -0,0 +1,781 @@ +:root { + --bg: #f8fafc; + --card: #ffffff; + --border: #e2e8f0; + --border-soft: #f1f5f9; + --text: #0f172a; + --muted: #64748b; + --muted-2: #94a3b8; + --blue: #2563eb; + --blue-50: #eff6ff; + --blue-100: #dbeafe; + --blue-200: #bfdbfe; + --green-50: #f0fdf4; + --green-600: #16a34a; + --red-50: #fef2f2; + --red-300: #fca5a5; + --red-600: #dc2626; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.05); + --shadow-lg: 0 20px 45px rgba(15, 23, 42, 0.18); + --radius: 16px; + --mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace; + --sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--sans); +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.hidden { + display: none !important; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + position: sticky; + top: 0; + z-index: 40; + background: rgba(255,255,255,0.96); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} + +.topbar-inner, +.main-inner, +.help-inner { + max-width: 980px; + margin: 0 auto; + padding-left: 16px; + padding-right: 16px; +} + +.topbar-inner { + display: flex; + align-items: center; + gap: 12px; + padding-top: 12px; + padding-bottom: 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; +} + +.brand-icon { + width: 28px; + height: 28px; + border-radius: 10px; + background: var(--blue); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; + font-size: 13px; +} + +.brand-title { + font-size: 14px; + font-weight: 600; + color: #1e293b; +} + +.spacer { + flex: 1; +} + +.toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.btn { + border: 0; + background: transparent; + color: #475569; + font-size: 14px; + line-height: 1; + border-radius: 12px; + padding: 9px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + transition: background .15s ease, color .15s ease, box-shadow .15s ease, border-color .15s ease; +} + +.btn:hover { + background: #f1f5f9; +} + +.btn-primary { + background: var(--blue); + color: #fff; + box-shadow: var(--shadow); + padding-left: 16px; + padding-right: 16px; +} + +.btn-primary:hover { + background: #1d4ed8; +} + +.btn-toggle-active { + background: #e2e8f0; + color: #1e293b; +} + +.btn-icon { + padding: 9px; + color: var(--muted-2); +} + +.btn-icon:hover { + color: var(--muted); +} + +.help-panel { + background: #eff6ff; + border-bottom: 1px solid #dbeafe; +} + +.help-inner { + padding-top: 12px; + padding-bottom: 12px; +} + +.help-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px 18px; + color: #1e40af; + font-size: 13px; +} + +.help-grid strong { + font-weight: 600; +} + +.main-inner { + width: 100%; + padding-top: 24px; + padding-bottom: 24px; +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.card + .card, +.card + .add-preamble, +.add-preamble + .card { + margin-top: 16px; +} + +.card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-soft); + background: #f8fafc; +} + +.card-title { + font-size: 13px; + font-weight: 600; + color: #475569; +} + +.card-subtle { + margin-left: auto; + font-size: 12px; + color: var(--muted-2); +} + +.card-body { + padding: 16px; +} + +.edit-link { + border: 0; + background: transparent; + color: #3b82f6; + font-size: 12px; + margin-left: auto; + padding: 0; +} + +.edit-link:hover { + color: #1d4ed8; +} + +.add-preamble { + width: 100%; + text-align: left; + border: 1px dashed var(--border); + background: transparent; + color: var(--muted-2); + border-radius: 16px; + padding: 12px 16px; + font-size: 14px; + transition: all .15s ease; +} + +.add-preamble:hover { + border-color: #cbd5e1; + background: white; + color: var(--muted); +} + +.preamble-display { + padding: 14px 16px; + color: var(--muted); + font-size: 14px; + font-family: var(--mono); + white-space: pre-wrap; + cursor: pointer; +} + +.preamble-display:hover { + background: #f8fafc; +} + +.preamble-textarea, +.raw-pre, +.text-node-textarea, +.inline-input, +.attr-input { + font-family: var(--mono); +} + +.preamble-textarea { + width: 100%; + min-height: 84px; + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 12px; + font-size: 14px; + color: #334155; + resize: vertical; + outline: none; +} + +.preamble-textarea:focus, +.inline-input:focus, +.attr-input:focus, +.text-node-textarea:focus { + border-color: #93c5fd; + box-shadow: 0 0 0 3px rgba(59,130,246,.12); +} + +.action-row { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.mini-btn { + border: 0; + border-radius: 10px; + padding: 8px 12px; + font-size: 12px; +} + +.mini-btn-primary { + background: var(--blue); + color: #fff; +} + +.mini-btn-primary:hover { background: #1d4ed8; } +.mini-btn-muted { background: #f1f5f9; color: #475569; } +.mini-btn-muted:hover { background: #e2e8f0; } + +.structure-body { + padding: 20px; +} + +.node-list { + min-height: 40px; +} + +.node-row { + margin-bottom: 4px; +} + +.node-card { + display: flex; + align-items: center; + gap: 4px; + background: #fff; + border: 1px solid var(--border); + border-radius: 12px; + transition: border-color .15s ease, box-shadow .15s ease, opacity .15s ease, background .15s ease; +} + +.node-card:hover { + border-color: #93c5fd; + box-shadow: var(--shadow); +} + +.node-card.error { + border-color: var(--red-300); +} + +.dragging { + opacity: .45; +} + +.drag-over { + border-color: #60a5fa !important; + box-shadow: 0 0 0 3px rgba(59,130,246,.12); +} + +.grip { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + align-self: stretch; + border-right: 1px solid var(--border-soft); + color: #cbd5e1; + user-select: none; + cursor: grab; +} + +.expand-btn, +.icon-btn, +.action-btn { + border: 0; + background: transparent; + color: var(--muted-2); +} + +.expand-btn { + width: 28px; + height: 28px; + border-radius: 8px; + margin-left: 4px; +} + +.expand-btn:hover, +.action-btn:hover, +.icon-btn:hover { + background: #f8fafc; + color: var(--muted); +} + +.node-main { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 8px 8px 0; + flex-wrap: wrap; +} + +.tag-symbol { + color: #60a5fa; + font-size: 13px; +} + +.tag-pill, +.tag-pill-closing { + border: 1px solid #bfdbfe; + background: var(--blue-50); + color: #1d4ed8; + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + border-radius: 8px; + padding: 4px 8px; +} + +.tag-pill:hover { + background: #dbeafe; + border-color: #93c5fd; +} + +.inline-input { + width: 220px; + border: 1px solid #93c5fd; + background: var(--blue-50); + border-radius: 8px; + padding: 4px 8px; + font-size: 13px; + color: #1d4ed8; + font-weight: 600; +} + +.inline-input.error { + background: var(--red-50); + border-color: var(--red-300); + color: #991b1b; +} + +.attr-pill { + border: 0; + background: transparent; + color: var(--muted-2); + font-family: var(--mono); + font-size: 12px; + padding: 4px 6px; + border-radius: 8px; +} + +.attr-pill:hover { + background: #f8fafc; + color: var(--muted); +} + +.attr-placeholder { + color: #cbd5e1; + font-size: 12px; + padding: 4px 6px; + border-radius: 8px; +} + +.attr-placeholder:hover { + color: #94a3b8; +} + +.attr-input { + flex: 1 1 200px; + min-width: 180px; + border: 1px solid #cbd5e1; + background: #f8fafc; + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + color: #475569; +} + +.node-actions { + display: flex; + align-items: center; + gap: 2px; + padding-right: 8px; +} + +.action-btn { + width: 28px; + height: 28px; + border-radius: 8px; + font-size: 13px; +} + +.action-btn.green:hover { + background: var(--green-50); + color: var(--green-600); +} + +.action-btn.blue:hover { + background: var(--blue-50); + color: var(--blue); +} + +.action-btn.red:hover { + background: var(--red-50); + color: #ef4444; +} + +.error-line { + margin-left: 74px; + margin-top: 4px; + color: var(--red-600); + font-size: 12px; + font-weight: 600; +} + +.children-wrap { + margin-top: 4px; + margin-left: 24px; + padding-left: 12px; + border-left: 2px solid var(--blue-100); +} + +.text-row { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 4px; +} + +.text-icon { + color: #cbd5e1; + padding-top: 9px; + padding-left: 4px; + font-size: 12px; +} + +.text-node-textarea { + flex: 1; + min-height: 36px; + border: 1px solid transparent; + background: transparent; + color: #334155; + border-radius: 8px; + padding: 8px 10px; + resize: vertical; + font-size: 14px; + line-height: 1.55; +} + +.text-node-textarea:hover { + border-color: var(--border); +} + +.text-actions { + padding-top: 6px; +} + +.node-indent-1 { margin-left: 24px; } +.node-indent-2 { margin-left: 48px; } +.node-indent-3 { margin-left: 72px; } +.node-indent-4 { margin-left: 96px; } +.node-indent-5 { margin-left: 120px; } +.node-indent-6 { margin-left: 144px; } +.node-indent-7 { margin-left: 168px; } +.node-indent-8 { margin-left: 192px; } +.node-indent-9 { margin-left: 216px; } +.node-indent-10 { margin-left: 240px; } + +.add-root-wrap { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-soft); +} + +.add-root-btn { + width: 100%; + border: 2px dashed var(--border); + border-radius: 12px; + background: transparent; + color: var(--muted-2); + font-size: 14px; + padding: 12px 16px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all .15s ease; +} + +.add-root-btn:hover { + border-color: #93c5fd; + background: var(--blue-50); + color: #3b82f6; +} + +.raw-pre { + margin: 0; + padding: 20px; + font-size: 12px; + line-height: 1.7; + color: #334155; + white-space: pre-wrap; + overflow: auto; + max-height: 70vh; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 60; +} + +.modal { + width: min(960px, calc(100vw - 32px)); + max-height: 80vh; + display: flex; + flex-direction: column; + background: white; + border-radius: 22px; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + padding: 18px 24px 14px; + border-bottom: 1px solid var(--border-soft); +} + +.modal-title { + font-size: 22px; + font-weight: 600; + color: #1e293b; +} + +.modal-subtitle { + margin-top: 4px; + font-size: 14px; + color: var(--muted); +} + +.close-btn { + width: 38px; + height: 38px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--muted-2); + font-size: 20px; +} + +.close-btn:hover { background: #f1f5f9; color: var(--muted); } + +.modal-tabs { + display: flex; + gap: 8px; + padding: 16px 24px 0; +} + +.tab-btn { + border: 0; + border-radius: 12px; + background: #f1f5f9; + color: #475569; + padding: 10px 16px; + font-size: 14px; + font-weight: 500; +} + +.tab-btn.active { + background: var(--blue); + color: white; +} + +.modal-note { + padding: 10px 24px 12px; + font-size: 12px; + color: var(--muted-2); +} + +.modal-pre-wrap { + margin: 0 24px 16px; + border: 1px solid var(--border); + border-radius: 16px; + background: #f8fafc; + overflow: auto; + flex: 1; +} + +.modal-pre { + margin: 0; + padding: 16px; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + color: #334155; + line-height: 1.7; + font-family: var(--mono); +} + +.modal-actions { + display: flex; + gap: 10px; + padding: 0 24px 24px; +} + +.copy-btn, +.download-btn { + border: 0; + border-radius: 12px; + padding: 12px 16px; + font-size: 14px; + font-weight: 600; +} + +.copy-btn { + background: #f1f5f9; + color: #334155; +} + +.copy-btn:hover { background: #e2e8f0; } + +.download-btn { + background: var(--blue); + color: white; +} + +.download-btn:hover { background: #1d4ed8; } + +.muted-empty { + color: #cbd5e1; + font-style: italic; +} + +@media (max-width: 900px) { + .help-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 700px) { + .topbar-inner { + align-items: flex-start; + flex-direction: column; + } + + .spacer { + display: none; + } + + .toolbar { + width: 100%; + } + + .help-grid { + grid-template-columns: 1fr; + } + + .node-main { + min-width: 0; + } + + .inline-input, + .attr-input { + width: 100%; + flex-basis: 100%; + } +}