freemd 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="es">
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -8,8 +8,8 @@
8
8
  href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Crect%20width='100'%20height='100'%20rx='22'%20fill='%23282c34'/%3E%3Ctext%20x='50'%20y='70'%20font-size='54'%20font-family='monospace'%20font-weight='700'%20text-anchor='middle'%20fill='%2398c379'%3EM%3C/text%3E%3C/svg%3E"
9
9
  />
10
10
  <title>freemd</title>
11
- <script type="module" crossorigin src="/assets/index-CiAQinMf.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-C0w_6LCS.css">
11
+ <script type="module" crossorigin src="/assets/index-D77myrtE.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C0VJG4kI.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freemd",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local markdown notes editor in your browser and export. Run with npx freemd",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -99,17 +99,34 @@ function mergeSegments(segments) {
99
99
  return merged;
100
100
  }
101
101
 
102
- // Devuelve segmentos coloreados del código; sin lenguaje reconocido, texto plano.
102
+ // Subset para auto-detección cuando el fence no declara lenguaje. Se excluyen
103
+ // lenguajes "genéricos" (css, markdown, ini, diff, toml): sobre texto plano
104
+ // producen demasiados falsos positivos.
105
+ const AUTO_SUBSET = [
106
+ 'javascript', 'typescript', 'python', 'json', 'xml', 'bash', 'sql',
107
+ 'java', 'go', 'rust', 'c', 'cpp', 'csharp', 'yaml', 'php', 'ruby'
108
+ ];
109
+
110
+ // Umbral mínimo de relevancia para confiar en la auto-detección: el código
111
+ // real puntúa 3+, el texto plano se queda en 0-2.
112
+ const AUTO_MIN_RELEVANCE = 3;
113
+
114
+ // Devuelve segmentos coloreados del código. Con lenguaje declarado se respeta;
115
+ // sin lenguaje se intenta auto-detección; sin coincidencias fiables, texto plano.
103
116
  export function highlightSegments(code, info) {
104
117
  const language = String(info ?? '').trim().split(/\s+/)[0]?.toLowerCase() ?? '';
105
- if (!language || !hljs.getLanguage(language)) {
106
- return [{ text: code, color: null }];
107
- }
108
118
  try {
109
- return mergeSegments(parseHljsHtml(hljs.highlight(code, { language, ignoreIllegals: true }).value));
110
- } catch {
111
- return [{ text: code, color: null }];
112
- }
119
+ if (language && hljs.getLanguage(language)) {
120
+ return mergeSegments(parseHljsHtml(hljs.highlight(code, { language, ignoreIllegals: true }).value));
121
+ }
122
+ if (!language) {
123
+ const result = hljs.highlightAuto(code, AUTO_SUBSET);
124
+ if (result.language && result.relevance >= AUTO_MIN_RELEVANCE) {
125
+ return mergeSegments(parseHljsHtml(result.value));
126
+ }
127
+ }
128
+ } catch {}
129
+ return [{ text: code, color: null }];
113
130
  }
114
131
 
115
132
  // Parte segmentos en líneas conservando el color de cada tramo.
@@ -119,7 +119,7 @@ function pushImages(out, images, state) {
119
119
  new Paragraph({
120
120
  spacing: { before: 120, after: 160 },
121
121
  children: [
122
- new TextRun({ text: `[imagen no soportada: ${alt}]`, italics: true, color: '808080' })
122
+ new TextRun({ text: `[unsupported image: ${alt}]`, italics: true, color: '808080' })
123
123
  ]
124
124
  })
125
125
  );
@@ -365,7 +365,7 @@ class PdfRenderer {
365
365
  .font('Helvetica-Oblique')
366
366
  .fontSize(10)
367
367
  .fillColor(COLORS.muted)
368
- .text(`[imagen no soportada: ${alt}]`, { align: 'center' });
368
+ .text(`[unsupported image: ${alt}]`, { align: 'center' });
369
369
  doc.moveDown(0.5);
370
370
  return;
371
371
  }
package/server/index.js CHANGED
@@ -27,7 +27,7 @@ const isNoteName = (name) => name.endsWith('-md');
27
27
  function safeJoin(root, rel = '') {
28
28
  const abs = path.resolve(root, rel);
29
29
  if (abs !== root && !abs.startsWith(root + path.sep)) {
30
- const error = new Error('Ruta no permitida');
30
+ const error = new Error('Path not allowed');
31
31
  error.status = 400;
32
32
  throw error;
33
33
  }
@@ -138,10 +138,10 @@ export function createApp(root) {
138
138
  }
139
139
  } catch {}
140
140
  }
141
- if (!stat) return res.status(404).json({ error: 'El archivo no existe' });
141
+ if (!stat) return res.status(404).json({ error: 'File not found' });
142
142
  }
143
143
  if (!stat.isFile()) {
144
- return res.status(400).json({ error: 'No es un archivo' });
144
+ return res.status(400).json({ error: 'Not a file' });
145
145
  }
146
146
  const ext = path.extname(abs).toLowerCase();
147
147
  const { name, dir } = fileLabels(rel);
@@ -169,7 +169,7 @@ export function createApp(root) {
169
169
  await fsp.writeFile(abs, String(req.body?.content ?? ''), 'utf8');
170
170
  } catch (error) {
171
171
  if (error.code === 'ENOENT') {
172
- return res.status(404).json({ error: 'El archivo ya no existe' });
172
+ return res.status(404).json({ error: 'The file no longer exists' });
173
173
  }
174
174
  throw error;
175
175
  }
@@ -178,12 +178,12 @@ export function createApp(root) {
178
178
 
179
179
  app.post('/api/file', json, wrap(async (req, res) => {
180
180
  const name = slug(req.body?.name);
181
- if (!name) return res.status(400).json({ error: 'Nombre inválido' });
181
+ if (!name) return res.status(400).json({ error: 'Invalid name' });
182
182
  const parentRel = String(req.body?.parent ?? '');
183
183
  const parentAbs = safeJoin(root, parentRel);
184
184
  const rel = parentRel ? `${parentRel}/${name}.md` : `${name}.md`;
185
185
  const abs = path.join(parentAbs, `${name}.md`);
186
- if (fs.existsSync(abs)) return res.status(409).json({ error: 'Ya existe un archivo con ese nombre' });
186
+ if (fs.existsSync(abs)) return res.status(409).json({ error: 'A file with that name already exists' });
187
187
  await fsp.writeFile(abs, String(req.body?.content ?? ''), 'utf8');
188
188
  res.status(201).json({ ok: true, path: rel });
189
189
  }));
@@ -191,12 +191,12 @@ export function createApp(root) {
191
191
  app.post('/api/note', json, wrap(async (req, res) => {
192
192
  const raw = String(req.body?.name ?? '').trim();
193
193
  let base = slug(raw);
194
- if (!base) return res.status(400).json({ error: 'Nombre inválido' });
194
+ if (!base) return res.status(400).json({ error: 'Invalid name' });
195
195
  base = base.replace(/-md$/, '');
196
- if (!base) return res.status(400).json({ error: 'Nombre inválido' });
196
+ if (!base) return res.status(400).json({ error: 'Invalid name' });
197
197
  const folder = `${base}-md`;
198
198
  const abs = path.join(root, folder);
199
- if (fs.existsSync(abs)) return res.status(409).json({ error: 'Ya existe una nota con ese nombre' });
199
+ if (fs.existsSync(abs)) return res.status(409).json({ error: 'A note with that name already exists' });
200
200
  await fsp.mkdir(abs, { recursive: true });
201
201
  const title = raw.replace(/-md$/i, '').trim() || base;
202
202
  await fsp.writeFile(path.join(abs, 'index.md'), `# ${title}\n\n`, 'utf8');
@@ -205,11 +205,11 @@ export function createApp(root) {
205
205
 
206
206
  app.post('/api/folder', json, wrap(async (req, res) => {
207
207
  const name = slug(req.body?.name);
208
- if (!name) return res.status(400).json({ error: 'Nombre inválido' });
208
+ if (!name) return res.status(400).json({ error: 'Invalid name' });
209
209
  const parentRel = String(req.body?.parent ?? '');
210
210
  const parentAbs = safeJoin(root, parentRel);
211
211
  const abs = path.join(parentAbs, name);
212
- if (fs.existsSync(abs)) return res.status(409).json({ error: 'Ya existe una carpeta con ese nombre' });
212
+ if (fs.existsSync(abs)) return res.status(409).json({ error: 'A folder with that name already exists' });
213
213
  await fsp.mkdir(abs);
214
214
  // Una carpeta terminada en -md es una nota: le creamos su index.md
215
215
  if (name.endsWith('-md')) {
@@ -224,7 +224,7 @@ export function createApp(root) {
224
224
  const dirRel = String(req.query.dir ?? '');
225
225
  const dirAbs = safeJoin(root, dirRel);
226
226
  const stat = await fsp.stat(dirAbs);
227
- if (!stat.isDirectory()) return res.status(400).json({ error: 'No es una carpeta' });
227
+ if (!stat.isDirectory()) return res.status(400).json({ error: 'Not a folder' });
228
228
  const entries = await fsp.readdir(dirAbs, { withFileTypes: true });
229
229
  const files = [];
230
230
  for (const entry of entries) {
@@ -249,7 +249,7 @@ export function createApp(root) {
249
249
  const dirAbs = safeJoin(root, dirRel);
250
250
  const rawName = String(req.query.name ?? req.headers['x-filename'] ?? 'file');
251
251
  const name = path.basename(rawName).replace(/[^\p{L}\p{N}._ -]/gu, '_');
252
- if (!req.body?.length) return res.status(400).json({ error: 'Sin contenido' });
252
+ if (!req.body?.length) return res.status(400).json({ error: 'No content' });
253
253
  await fsp.mkdir(dirAbs, { recursive: true });
254
254
  await fsp.writeFile(path.join(dirAbs, name), req.body);
255
255
  const rel = dirRel ? `${dirRel}/${name}` : name;
@@ -258,9 +258,9 @@ export function createApp(root) {
258
258
 
259
259
  app.delete('/api/entry', wrap(async (req, res) => {
260
260
  const rel = String(req.query.path ?? '');
261
- if (!rel) return res.status(400).json({ error: 'Falta la ruta' });
261
+ if (!rel) return res.status(400).json({ error: 'Missing path' });
262
262
  const abs = safeJoin(root, rel);
263
- if (abs === root) return res.status(400).json({ error: 'No se puede borrar la carpeta raíz' });
263
+ if (abs === root) return res.status(400).json({ error: 'Cannot delete the root folder' });
264
264
  await fsp.rm(abs, { recursive: true });
265
265
  res.json({ ok: true });
266
266
  }));
@@ -268,10 +268,10 @@ export function createApp(root) {
268
268
  app.patch('/api/entry', json, wrap(async (req, res) => {
269
269
  const rel = String(req.body?.path ?? '').split('\\').join('/');
270
270
  const rawName = String(req.body?.name ?? '');
271
- if (!rel) return res.status(400).json({ error: 'Falta la ruta' });
271
+ if (!rel) return res.status(400).json({ error: 'Missing path' });
272
272
  const abs = safeJoin(root, rel);
273
273
  const stat = await fsp.stat(abs).catch(() => null);
274
- if (!stat) return res.status(404).json({ error: 'El elemento no existe' });
274
+ if (!stat) return res.status(404).json({ error: 'Item not found' });
275
275
  const parentRel = path.posix.dirname(rel);
276
276
  const parentAbs = safeJoin(root, parentRel === '.' ? '' : parentRel);
277
277
  const currentName = path.basename(abs);
@@ -280,13 +280,13 @@ export function createApp(root) {
280
280
  if (stat.isDirectory()) {
281
281
  // notas (carpetas *-md): se admite que el usuario escriba el nombre con .md o -md
282
282
  let base = slug(rawName.replace(/\.md$/i, '').replace(/-md$/i, ''));
283
- if (!base) return res.status(400).json({ error: 'Nombre inválido' });
283
+ if (!base) return res.status(400).json({ error: 'Invalid name' });
284
284
  if (currentName.endsWith('-md')) base = `${base}-md`;
285
285
  newName = base;
286
286
  } else {
287
287
  const ext = path.extname(rel);
288
288
  const base = slug(rawName.replace(/\.[^.]+$/, ''));
289
- if (!base) return res.status(400).json({ error: 'Nombre inválido' });
289
+ if (!base) return res.status(400).json({ error: 'Invalid name' });
290
290
  newName = `${base}${ext}`;
291
291
  }
292
292
 
@@ -295,7 +295,7 @@ export function createApp(root) {
295
295
  }
296
296
  const target = path.join(parentAbs, newName);
297
297
  if (fs.existsSync(target)) {
298
- return res.status(409).json({ error: 'Ya existe un elemento con ese nombre' });
298
+ return res.status(409).json({ error: 'An item with that name already exists' });
299
299
  }
300
300
  await fsp.rename(abs, target);
301
301
  const newRel = parentRel === '.' ? newName : `${parentRel}/${newName}`;
@@ -306,16 +306,16 @@ export function createApp(root) {
306
306
  const rel = String(req.body?.path ?? '').split('\\').join('/');
307
307
  const format = String(req.body?.format ?? 'pdf');
308
308
  if (format !== 'pdf' && format !== 'docx') {
309
- return res.status(400).json({ error: 'Formato no soportado' });
309
+ return res.status(400).json({ error: 'Unsupported format' });
310
310
  }
311
311
  const abs = safeJoin(root, rel);
312
312
  let stat;
313
313
  try {
314
314
  stat = await fsp.stat(abs);
315
315
  } catch {
316
- return res.status(404).json({ error: 'El archivo no existe' });
316
+ return res.status(404).json({ error: 'File not found' });
317
317
  }
318
- if (!stat.isFile()) return res.status(400).json({ error: 'No es un archivo' });
318
+ if (!stat.isFile()) return res.status(400).json({ error: 'Not a file' });
319
319
  const source = await fsp.readFile(abs, 'utf8');
320
320
  const dirName = path.posix.dirname(rel);
321
321
  const baseRel = dirName === '.' ? '' : dirName;
@@ -343,7 +343,7 @@ export function createApp(root) {
343
343
  if (req.method !== 'GET' || req.path.startsWith('/api')) return next();
344
344
  const index = path.join(DIST_DIR, 'index.html');
345
345
  if (!fs.existsSync(index)) {
346
- return res.status(500).send('Falta la build del frontend. Ejecuta: pnpm build');
346
+ return res.status(500).send('Frontend build missing. Run: pnpm build');
347
347
  }
348
348
  res.sendFile(index);
349
349
  });
@@ -1 +0,0 @@
1
- pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-keyword,.hljs-formula{color:#c678dd}.hljs-section,.hljs-name,.hljs-selector-tag,.hljs-deletion,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-string,.hljs-regexp,.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string{color:#98c379}.hljs-attr,.hljs-variable,.hljs-template-variable,.hljs-type,.hljs-selector-class,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-number{color:#d19a66}.hljs-symbol,.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-title.class_,.hljs-class .hljs-title{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}:root{--bg: #282c34;--bg-darker: #21252b;--bg-darkest: #1b1e24;--bg-panel: #2c313a;--bg-hover: #333842;--border: #3a3f4b;--border-dark: #181a1f;--fg: #abb2bf;--fg-bright: #d7dae0;--fg-dim: #5c6370;--red: #e06c75;--orange: #d19a66;--yellow: #e5c07b;--green: #98c379;--cyan: #56b6c2;--blue: #61afef;--purple: #c678dd;--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, "Liberation Mono", monospace}*{box-sizing:border-box}html,body,#app{height:100%;margin:0}body{background:var(--bg);color:var(--fg);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px;-webkit-font-smoothing:antialiased}::selection{background:#61afef47}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:5px;border:2px solid transparent;background-clip:content-box}::-webkit-scrollbar-thumb:hover{background:var(--fg-dim);border:2px solid transparent;background-clip:content-box}button{font-family:inherit}.app{display:flex;height:100%;overflow:hidden}.main{flex:1;display:flex;flex-direction:column;min-width:0}.sidebar{width:280px;min-width:280px;display:flex;flex-direction:column;background:var(--bg-darker);border-right:1px solid var(--border-dark)}.side-head{display:flex;align-items:center;justify-content:space-between;height:48px;padding:0 10px 0 16px;border-bottom:1px solid var(--border-dark);flex-shrink:0}.brand{font-size:15px;font-weight:600;letter-spacing:.4px;color:var(--fg-bright)}.brand-md{color:var(--green)}.head-actions{display:flex;gap:2px}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:none;border-radius:6px;background:none;color:var(--fg-dim);cursor:pointer}.icon-btn:hover:not(:disabled){color:var(--fg-bright);background:var(--bg-panel)}.icon-btn:disabled{opacity:.35;cursor:default}.new-item{padding:10px;border-bottom:1px solid var(--border-dark)}.new-item input{width:100%;padding:7px 10px;font-size:13px;color:var(--fg-bright);background:var(--bg-darkest);border:1px solid var(--blue);border-radius:6px;outline:none}.new-item input::placeholder{color:var(--fg-dim)}.tree{flex:1;overflow-y:auto;padding:8px 6px}.tree-loading,.tree-empty{padding:16px 12px;font-size:12.5px;line-height:1.6;color:var(--fg-dim)}.item{display:flex;align-items:center;border-radius:6px}.item .row{flex:1;display:flex;align-items:center;gap:7px;min-width:0;padding:5px 8px;border:none;border-radius:6px;background:none;color:var(--fg);font-size:13px;text-align:left;cursor:pointer}.item .row:hover{background:var(--bg-panel)}.item.active .row{background:var(--bg-panel);color:var(--fg-bright)}.item .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chev{display:inline-flex;align-items:center;justify-content:center;width:11px;flex-shrink:0;color:var(--fg-dim)}.chev svg{transition:transform .15s ease}.chev.open svg{transform:rotate(90deg)}.icon{display:inline-flex;flex-shrink:0}.t-note{color:var(--purple)}.t-folder{color:var(--blue)}.t-md{color:var(--cyan)}.t-image{color:var(--orange)}.t-file{color:var(--fg-dim)}.item .del{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;margin-right:4px;padding:0;border:none;border-radius:6px;background:none;color:var(--fg-dim);cursor:pointer;visibility:hidden;flex-shrink:0}.rename-item{padding:2px 6px 2px 0}.rename-item input{width:100%;padding:4px 8px;font-size:13px;color:var(--fg-bright);background:var(--bg-darkest);border:1px solid var(--blue);border-radius:6px;outline:none}.item:hover .del{visibility:visible}.item .del:hover{color:var(--red);background:var(--bg-panel)}.side-foot{display:flex;flex-direction:column;gap:8px;padding:12px;border-top:1px solid var(--border-dark);flex-shrink:0}.upload-btn{display:flex;align-items:center;justify-content:center;gap:8px;padding:8px;font-size:12.5px;color:var(--fg);background:var(--bg-panel);border:1px solid var(--border);border-radius:8px;cursor:pointer}.upload-btn:hover:not(:disabled){color:var(--blue);border-color:var(--blue)}.upload-btn:disabled{opacity:.4;cursor:default}.root-hint{font-size:11px;font-family:var(--mono);color:var(--fg-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toolbar{display:flex;align-items:center;gap:12px;height:48px;padding:0 16px;background:var(--bg-darker);border-bottom:1px solid var(--border-dark);flex-shrink:0}.meta{display:flex;align-items:center;gap:9px;min-width:0}.meta .name{font-size:13px;font-weight:500;color:var(--fg-bright);white-space:nowrap}.meta .crumb{font-size:12px;color:var(--fg-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.unsaved{width:7px;height:7px;border-radius:50%;background:var(--yellow);flex-shrink:0}.spacer{flex:1}.count{font-size:12px;color:var(--fg-dim);white-space:nowrap}.seg{display:flex;padding:2px;background:var(--bg-darkest);border:1px solid var(--border);border-radius:8px}.seg button{padding:5px 12px;font-size:12px;border:none;border-radius:6px;background:transparent;color:var(--fg-dim);cursor:pointer}.seg button.active{background:var(--bg-panel);color:var(--fg-bright)}.save-btn{display:flex;align-items:center;gap:7px;padding:6px 12px;font-size:12.5px;color:var(--blue);background:var(--bg-panel);border:1px solid var(--border);border-radius:8px;cursor:pointer}.save-btn:hover:not(:disabled){border-color:var(--blue)}.save-btn:disabled{color:var(--fg-dim);opacity:.6;cursor:default}.export-wrap{position:relative}.export-chev{display:inline-flex;color:var(--fg-dim)}.export-chev svg{transition:transform .15s ease}.export-chev.open svg{transform:rotate(180deg)}.menu-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:44;padding:0;border:none;background:transparent;cursor:default}.export-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:45;display:flex;flex-direction:column;min-width:150px;padding:4px;background:var(--bg-darker);border:1px solid var(--border);border-radius:8px;box-shadow:0 10px 32px #00000073}.export-menu button{display:flex;align-items:center;gap:8px;padding:8px 10px;border:none;border-radius:6px;background:none;color:var(--fg);font-size:12.5px;text-align:left;cursor:pointer}.export-menu button:hover{background:var(--bg-panel);color:var(--fg-bright)}.editor-body{flex:1;display:flex;min-height:0}.autosaved{font-size:11.5px;color:var(--green);white-space:nowrap}.panel-btn.active{color:var(--blue);background:var(--bg-panel)}.resources{width:240px;flex-shrink:0;display:flex;flex-direction:column;background:var(--bg-darker);border-left:1px solid var(--border-dark);overflow:hidden}.res-head{display:flex;align-items:center;justify-content:space-between;height:40px;padding:0 6px 0 14px;font-size:11px;font-weight:600;letter-spacing:.8px;text-transform:uppercase;color:var(--fg-dim);border-bottom:1px solid var(--border-dark);flex-shrink:0}.res-actions{display:flex;gap:2px}.res-empty{padding:14px;font-size:12px;line-height:1.6;color:var(--fg-dim)}.res-scroll{flex:1;overflow-y:auto}.res-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;padding:10px}.res-item{position:relative;aspect-ratio:1;padding:0;border:1px solid var(--border);border-radius:6px;background:var(--bg-panel);overflow:hidden;cursor:pointer}.res-item:hover{border-color:var(--blue)}.res-remove{position:absolute;top:4px;right:4px;display:flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:none;border-radius:4px;background:#181a1fd9;color:var(--red);cursor:pointer;opacity:0}.res-item:hover .res-remove,.res-remove:focus-visible{opacity:1}.res-remove:hover{background:var(--bg-darkest)}.res-item img{display:block;width:100%;height:100%;object-fit:cover}.res-file{display:flex;align-items:center;justify-content:center;height:100%;color:var(--fg-dim)}.res-name{position:absolute;left:4px;right:4px;bottom:4px;padding:2px 6px;font-size:10px;text-align:left;color:#fff;background:#181a1fcc;border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}textarea.code{flex:1;width:100%;padding:24px 32px;border:none;outline:none;resize:none;background:var(--bg);color:var(--fg);font-family:var(--mono);font-size:14px;line-height:1.75;caret-color:var(--blue)}textarea.code.half{flex:1 1 50%;width:auto;min-width:0;padding:20px 24px;border-right:1px solid var(--border-dark)}.preview-wrap.half{flex:1 1 50%;min-width:0}.preview-wrap.half .preview{padding:24px 24px 60px}.preview-wrap{flex:1;overflow-y:auto}.preview{max-width:760px;margin:0 auto;padding:36px 48px 80px;font-size:15px;line-height:1.75}.preview h1,.preview h2,.preview h3,.preview h4{color:var(--fg-bright);line-height:1.3;margin:1.6em 0 .6em}.preview h1,.preview h2{padding-bottom:.3em;border-bottom:1px solid var(--border)}.preview h1{margin-top:.4em}.preview p{margin:.8em 0}.preview a{color:var(--blue);text-decoration:none}.preview a:hover{text-decoration:underline}.preview code{padding:2px 6px;border-radius:4px;background:#3a3f4b;color:var(--yellow);font-family:var(--mono);font-size:.88em}.preview pre{padding:16px;overflow-x:auto;background:var(--bg-darker);border:1px solid var(--border);border-radius:8px;line-height:1.6}.preview pre code{padding:0;background:none;color:inherit;font-size:13px}.preview blockquote{margin:1em 0;padding:2px 16px;color:var(--fg-dim);border-left:3px solid var(--fg-dim)}.preview ul,.preview ol{padding-left:1.6em}.preview li{margin:.3em 0}.preview img{max-width:100%;border-radius:6px}.preview hr{margin:1.6em 0;border:none;border-top:1px solid var(--border)}.preview table{border-collapse:collapse;margin:1em 0;width:100%}.preview th,.preview td{padding:7px 12px;border:1px solid var(--border)}.preview th{background:var(--bg-panel);color:var(--fg-bright)}.media-view{flex:1;display:flex;align-items:center;justify-content:center;overflow:auto;padding:32px}.media-view img{max-width:100%;max-height:100%;border-radius:8px;box-shadow:0 8px 40px #0006}.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--fg-dim)}.primary-btn{display:flex;align-items:center;gap:8px;margin-top:8px;padding:9px 18px;font-size:13px;font-weight:600;color:var(--bg-darkest);background:var(--green);border:none;border-radius:8px;cursor:pointer}.primary-btn:hover{filter:brightness(1.1)}.logo{margin:0;font-size:30px;font-weight:600;letter-spacing:1px;color:#3e4451}.toast{position:fixed;bottom:24px;left:50%;transform:translate(-50%);padding:10px 16px;font-size:13px;color:var(--fg-bright);background:var(--bg-darkest);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 30px #00000059;z-index:100;animation:rise .2s ease}.toast.error{color:var(--red);border-color:var(--red)}@keyframes rise{0%{opacity:0;transform:translate(-50%) translateY(8px)}to{opacity:1;transform:translate(-50%) translateY(0)}}