@pramen/cms-editor 0.0.16 → 0.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/app.tsx CHANGED
@@ -64,21 +64,21 @@ function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfig
64
64
  <span className="brand">
65
65
  pramen <span className="dim">· cms</span>
66
66
  </span>
67
- <nav className="tabs" style={{ margin: 0 }}>
68
- <button className={view === "pages" ? "on" : "ghost"} onClick={() => { setView("pages"); setCurrent(null); }}>
69
- Pages
70
- </button>
71
- <button className={view === "media" ? "on" : "ghost"} onClick={() => setView("media")}>
72
- Media
73
- </button>
74
- </nav>
75
67
  {view === "pages" && current ? (
76
68
  <span className="crumb">
77
69
  <a onClick={() => setCurrent(null)}>Pages</a> / <b>{current.title}</b>
78
70
  </span>
79
71
  ) : null}
80
72
  <span className="grow" />
81
- <span className="muted">{cfg.tenant}</span>
73
+ <nav className="tabs nav" style={{ margin: 0 }}>
74
+ <button className={view === "pages" ? "on" : ""} onClick={() => { setView("pages"); setCurrent(null); }}>
75
+ Pages
76
+ </button>
77
+ <button className={view === "media" ? "on" : ""} onClick={() => setView("media")}>
78
+ Media
79
+ </button>
80
+ </nav>
81
+ <span className="muted" style={{ marginLeft: 12 }}>{cfg.tenant}</span>
82
82
  <button className="ghost sm" onClick={onReconfigure}>
83
83
  sign out
84
84
  </button>
@@ -98,26 +98,36 @@ function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfig
98
98
  function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
99
99
  const [creating, setCreating] = useState(false);
100
100
  return (
101
- <div style={{ padding: 20, maxWidth: 780, margin: "0 auto" }}>
102
- <div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
103
- <h2 style={{ margin: 0, flex: 1 }}>Pages</h2>
104
- <button className="primary" onClick={() => setCreating(true)}>
105
- + New page
106
- </button>
101
+ <>
102
+ <div className="hero">
103
+ <h1 className="hero-h">
104
+ <span className="lead">Pages</span>
105
+ <span className="em">{pages.length === 0 ? "None yet" : pages.length === 1 ? "1 page total" : `${pages.length} pages total`}</span>
106
+ </h1>
107
+ <div className="cta">
108
+ <span className="cta-text">
109
+ Let&apos;s <span className="em">create</span> something
110
+ </span>
111
+ <button className="primary" onClick={() => setCreating(true)}>
112
+ + New page
113
+ </button>
114
+ </div>
107
115
  </div>
108
- <div className="list">
109
- {pages.map((p) => (
110
- <div className="row" key={p.id} onClick={() => onOpen(p)}>
111
- <span className="grow">{p.title}</span>
112
- <span className="muted">/{p.slug}</span>
113
- <span className="muted">{p.locale}</span>
114
- <span className={`pill ${p.status}`}>{p.status}</span>
115
- </div>
116
- ))}
117
- {pages.length === 0 ? <p className="muted">No pages yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
116
+ <div className="list-wrap">
117
+ <div className="list">
118
+ {pages.map((p) => (
119
+ <div className="row" key={p.id} onClick={() => onOpen(p)}>
120
+ <span className="grow">{p.title}</span>
121
+ <span className="muted">/{p.slug}</span>
122
+ <span className="muted">{p.locale}</span>
123
+ <span className={`pill ${p.status}`}>{p.status}</span>
124
+ </div>
125
+ ))}
126
+ {pages.length === 0 ? <p className="muted">No pages yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
127
+ </div>
118
128
  </div>
119
129
  {creating ? <CreatePage api={api} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
120
- </div>
130
+ </>
121
131
  );
122
132
  }
123
133
 
@@ -140,7 +150,9 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
140
150
  return (
141
151
  <div className="scrim" onClick={onClose}>
142
152
  <div className="modal" onClick={(e) => e.stopPropagation()}>
143
- <h2>New page</h2>
153
+ <h2>
154
+ Create a <span className="dim">new page</span> and define the essentials<span className="dim">.</span>
155
+ </h2>
144
156
  <label className="field">
145
157
  <span className="lbl">Content type</span>
146
158
  <select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
@@ -334,10 +346,9 @@ function BlockInspector({ api, block, blockType, onClose, onSaved, onError }: {
334
346
  };
335
347
  return (
336
348
  <div>
337
- <div style={{ display: "flex", alignItems: "center" }}>
338
- <b className="btype" style={{ flex: 1, color: "var(--spring)", fontFamily: "var(--mono)" }}>
339
- {block.block_type}
340
- </b>
349
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
350
+ <span className="btype">{block.block_type}</span>
351
+ <span style={{ flex: 1 }} />
341
352
  <button className="ghost sm" onClick={onClose}>
342
353
  done
343
354
  </button>
@@ -537,14 +548,23 @@ function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void
537
548
  };
538
549
 
539
550
  return (
540
- <div className="media-lib">
541
- <div className="media-toolbar">
542
- <h2 style={{ margin: 0, flex: 1 }}>Media</h2>
543
- <label className={`btn primary${busy ? " disabled" : ""}`}>
544
- {busy ? "Uploading…" : "+ Upload"}
545
- <input type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
546
- </label>
551
+ <>
552
+ <div className="hero">
553
+ <h1 className="hero-h">
554
+ <span className="lead">Media</span>
555
+ <span className="em">{media.length === 0 ? "None yet" : media.length === 1 ? "1 file" : `${media.length}${hasMore ? "+" : ""} files`}</span>
556
+ </h1>
557
+ <div className="cta">
558
+ <span className="cta-text">
559
+ Let&apos;s <span className="em">upload</span> something
560
+ </span>
561
+ <label className={`btn primary${busy ? " disabled" : ""}`}>
562
+ {busy ? "Uploading…" : "+ Upload"}
563
+ <input type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
564
+ </label>
565
+ </div>
547
566
  </div>
567
+ <div className="media-lib">
548
568
  {media.length === 0 ? (
549
569
  <p className="muted">No media yet. Upload images to use them in blocks and SEO.</p>
550
570
  ) : (
@@ -574,7 +594,8 @@ function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void
574
594
  onError={onError}
575
595
  />
576
596
  ) : null}
577
- </div>
597
+ </div>
598
+ </>
578
599
  );
579
600
  }
580
601
 
@@ -610,7 +631,9 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
610
631
  return (
611
632
  <div className="scrim" onClick={onClose}>
612
633
  <div className="modal media-detail" onClick={(e) => e.stopPropagation()}>
613
- <h2>{media.file.filename ?? "Media"}</h2>
634
+ <h2>
635
+ <span className="dim">Media</span> {media.file.filename ?? ""}
636
+ </h2>
614
637
  {isImage(media) ? <img src={url} alt={media.alt ?? ""} /> : <div className="ext lg">{ext(media)}</div>}
615
638
  <label className="field">
616
639
  <span className="lbl">Alt text (for accessibility &amp; SEO)</span>
package/src/fields.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  // Schema-driven field forms: one input per FieldDefinition type, recursively composed for
2
2
  // group/repeater. Media fields open a picker (upload + choose from the library).
3
3
 
4
- import { useEffect, useState } from "react";
4
+ import { useEffect, useRef, useState } from "react";
5
5
  import type { Api } from "./api";
6
6
  import type { FieldDefinition, Media } from "./types";
7
7
 
@@ -32,13 +32,21 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
32
32
  </label>
33
33
  );
34
34
  case "textarea":
35
- case "richtext":
36
35
  return (
37
36
  <label className="field">
38
37
  {label}
39
38
  <textarea value={typeof value === "string" ? value : value == null ? "" : JSON.stringify(value)} onChange={(e) => onChange(e.target.value)} />
40
39
  </label>
41
40
  );
41
+ case "richtext":
42
+ // A rich-text value is an HTML string (round-trips with the site's set:html
43
+ // renderers). A legacy object value isn't editable here — fall back to raw text.
44
+ return (
45
+ <div className="field">
46
+ {label}
47
+ <RichText value={typeof value === "string" ? value : ""} onChange={onChange as (v: string) => void} />
48
+ </div>
49
+ );
42
50
  case "number":
43
51
  return (
44
52
  <label className="field">
@@ -98,6 +106,98 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
98
106
  }
99
107
  }
100
108
 
109
+ // --- rich text (WYSIWYG) --------------------------------------------------------------
110
+ // A dependency-free contentEditable editor. Emits an HTML string, so it round-trips with
111
+ // existing rich_text content and every set:html renderer — no value migration, no bundled
112
+ // ProseMirror. TipTap is the upgrade path if tables/embeds are ever needed.
113
+
114
+ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?: string) => void) => void }> = [
115
+ { label: "B", title: "Bold", run: (x) => x("bold") },
116
+ { label: "I", title: "Italic", run: (x) => x("italic") },
117
+ { label: "H2", title: "Heading", run: (x) => x("formatBlock", "H2") },
118
+ { label: "H3", title: "Subheading", run: (x) => x("formatBlock", "H3") },
119
+ { label: "¶", title: "Paragraph", run: (x) => x("formatBlock", "P") },
120
+ { label: "• List", title: "Bulleted list", run: (x) => x("insertUnorderedList") },
121
+ { label: "1. List", title: "Numbered list", run: (x) => x("insertOrderedList") },
122
+ { label: "Link", title: "Add link", run: (x) => {
123
+ const raw = window.prompt("Link URL (https://, mailto:, /path)", "https://");
124
+ if (!raw) return;
125
+ const url = safeLinkUrl(raw);
126
+ if (!url) { window.alert("Only http(s), mailto, tel, or relative (/, #) links are allowed."); return; }
127
+ x("createLink", url);
128
+ } },
129
+ { label: "Unlink", title: "Remove link", run: (x) => x("unlink") },
130
+ { label: "Clear", title: "Clear formatting", run: (x) => x("removeFormat") },
131
+ ];
132
+
133
+ /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
134
+ * The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:` (they
135
+ * don't match), so a bare script URL never reaches execCommand. */
136
+ function safeLinkUrl(raw: string): string | null {
137
+ const url = raw.trim();
138
+ return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null;
139
+ }
140
+
141
+ /** NOTE: this is cosmetic paste-cleaning, NOT a security boundary. It cannot be relied
142
+ * on for XSS defense — an editor-role caller can POST any `richtext` value straight to
143
+ * the RPC handler, never touching this editor. Stored-XSS is prevented on the SERVER by
144
+ * sanitizeRichText() in @pramen/cms on write (which also covers raw writes / imports).
145
+ * Here we just tidy obviously-unwanted markup so the editor doesn't render junk. */
146
+ function scrubHtml(html: string): string {
147
+ return html
148
+ .replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, "")
149
+ .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
150
+ .replace(/(href|src)\s*=\s*("javascript:[^"]*"|'javascript:[^']*')/gi, '$1="#"');
151
+ }
152
+
153
+ export function RichText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
154
+ const ref = useRef<HTMLDivElement>(null);
155
+ const last = useRef<string>("");
156
+
157
+ // Sync only EXTERNAL changes (e.g. switching blocks) into the DOM — never on our own
158
+ // keystrokes, or the caret jumps to the start on every character.
159
+ useEffect(() => {
160
+ const el = ref.current;
161
+ if (el && value !== last.current) {
162
+ // Scrub on load too — content may have entered the store via raw writes or imports,
163
+ // not just this editor. contentEditable requires innerHTML; scrubHtml strips
164
+ // scripts / inline handlers / javascript: URLs first.
165
+ el.innerHTML = scrubHtml(value || "");
166
+ last.current = value || "";
167
+ }
168
+ }, [value]);
169
+
170
+ const emit = () => {
171
+ const html = scrubHtml(ref.current?.innerHTML ?? "");
172
+ last.current = html;
173
+ onChange(html);
174
+ };
175
+ const exec = (command: string, arg?: string) => {
176
+ ref.current?.focus();
177
+ document.execCommand(command, false, arg);
178
+ emit();
179
+ };
180
+ // Paste as plain text — avoids importing Word/Docs style-junk into the HTML.
181
+ const onPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
182
+ e.preventDefault();
183
+ document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
184
+ };
185
+
186
+ return (
187
+ <div className="rt">
188
+ <div className="rt-toolbar">
189
+ {RT_TOOLS.map((t) => (
190
+ // preventDefault on mousedown keeps the editor's selection while the button is clicked.
191
+ <button key={t.label} type="button" className="sm ghost" title={t.title} onMouseDown={(e) => e.preventDefault()} onClick={() => t.run(exec)}>
192
+ {t.label}
193
+ </button>
194
+ ))}
195
+ </div>
196
+ <div ref={ref} className="rt-editor" contentEditable suppressContentEditableWarning data-placeholder="Write…" onInput={emit} onBlur={emit} onPaste={onPaste} />
197
+ </div>
198
+ );
199
+ }
200
+
101
201
  function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: React.ReactNode }) {
102
202
  const items = Array.isArray(value) ? value : [];
103
203
  const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
@@ -195,7 +295,9 @@ export function MediaPicker({ api, onClose, onPick }: { api: Api; onClose: () =>
195
295
  return (
196
296
  <div className="scrim" onClick={onClose}>
197
297
  <div className="modal" onClick={(e) => e.stopPropagation()}>
198
- <h2>Media library</h2>
298
+ <h2>
299
+ Choose <span className="dim">a file</span> from the library
300
+ </h2>
199
301
  {err ? <div className="banner err">{err}</div> : null}
200
302
  <label className="field">
201
303
  <span className="lbl">Upload a new file</span>
package/src/styles.ts CHANGED
@@ -1,12 +1,18 @@
1
- // Inline stylesheet for the CMS editor. Borders-only depth, spring-teal accent — the
2
- // same design language as @pramen/admin, adapted for an editor (canvas, palette, forms).
1
+ // Inline stylesheet for the CMS editor. Cream light theme, mint accent, serif-italic
2
+ // display headings the "graphic standard" visual language, adapted for an editor
3
+ // (canvas, palette, forms). Same class names as before; only paint changed and a
4
+ // handful of new hero / tile / cta classes added.
3
5
 
4
6
  export const css = `
5
7
  :root {
6
- --bg:#0e1116; --surface:#141921; --surface-2:#181e28; --raise:#1d2531;
7
- --border:#232c3a; --border-2:#2e3a4d; --ink:#e6edf3; --ink-2:#aab7c7; --ink-3:#6b7a8d;
8
- --spring:#3fd6c0; --spring-dim:#1c8d80; --spring-bg:#0f2723; --amber:#e0a458;
9
- --danger:#e0606a; --danger-bg:#2a161a; --radius:7px; --radius-sm:5px;
8
+ --bg:#f7f3ea; --surface:#efe9dc; --surface-2:#ffffff; --raise:#efe9dc;
9
+ --border:#e5dfd0; --border-2:#d9d1bd;
10
+ --ink:#111111; --ink-2:#6b6a63; --ink-3:#a4a29a;
11
+ --spring:#7ed4a6; --spring-dim:#52b380; --spring-bg:#a3e2c0; --spring-ink:#0f3a25;
12
+ --amber:#b8863b; --amber-bg:#f1e6cf;
13
+ --danger:#b04141; --danger-bg:#f5e2e2;
14
+ --radius:14px; --radius-sm:10px; --radius-pill:999px;
15
+ --serif:"Iowan Old Style","Palatino",Georgia,"Times New Roman",serif;
10
16
  --mono:ui-monospace,"SF Mono","JetBrains Mono","Menlo",monospace;
11
17
  --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
12
18
  }
@@ -14,95 +20,156 @@ export const css = `
14
20
  html,body { margin:0; height:100%; }
15
21
  body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }
16
22
  #app { min-height:100%; }
17
- a { color:var(--spring); }
23
+ a { color:var(--ink); text-decoration:underline; text-underline-offset:2px; text-decoration-color:var(--ink-3); cursor:pointer; }
24
+ a:hover { text-decoration-color:var(--ink); }
18
25
 
19
- .bar { display:flex; align-items:center; gap:10px; padding:10px 16px; background:var(--surface); border-bottom:1px solid var(--border); position:sticky; top:0; z-index:10; }
26
+ /* --- top bar ------------------------------------------------------------ */
27
+ .bar { display:flex; align-items:center; gap:16px; padding:16px 28px; background:var(--bg); border-bottom:1px solid transparent; position:sticky; top:0; z-index:10; }
20
28
  .bar .grow { flex:1; }
21
- .brand { font-weight:700; letter-spacing:.02em; }
29
+ .brand { font-weight:700; font-size:15px; letter-spacing:.01em; }
22
30
  .brand .dim { color:var(--ink-3); font-weight:400; }
23
- .crumb { color:var(--ink-2); }
24
- .crumb b { color:var(--ink); }
31
+ .crumb { color:var(--ink-2); font-size:13px; }
32
+ .crumb b { color:var(--ink); font-weight:600; }
33
+ .crumb a { text-decoration:none; color:var(--ink-2); }
34
+ .crumb a:hover { color:var(--ink); }
25
35
 
26
- button, .btn { font:inherit; color:var(--ink); background:var(--raise); border:1px solid var(--border-2); border-radius:var(--radius-sm); padding:5px 11px; cursor:pointer; }
27
- button:hover { border-color:var(--spring-dim); }
28
- button.primary { background:var(--spring-bg); border-color:var(--spring-dim); color:var(--spring); }
36
+ /* --- buttons: pill-shaped, black-filled primary ------------------------- */
37
+ button, .btn { font:inherit; color:var(--ink); background:var(--surface); border:1px solid var(--border-2); border-radius:var(--radius-pill); padding:9px 20px; cursor:pointer; font-weight:500; transition:background .12s, border-color .12s, color .12s; }
38
+ button:hover { background:#e6dfcd; }
39
+ button.primary { background:#111111; border-color:#111111; color:#ffffff; }
40
+ button.primary:hover { background:#000000; }
29
41
  button.ghost { background:transparent; border-color:transparent; color:var(--ink-2); }
30
- button.ghost:hover { color:var(--ink); border-color:var(--border); }
42
+ button.ghost:hover { color:var(--ink); background:var(--surface); }
31
43
  button.danger { color:var(--danger); border-color:var(--danger); background:var(--danger-bg); }
32
- button:disabled { opacity:.5; cursor:not-allowed; }
33
- button.sm { padding:3px 7px; font-size:12px; }
34
-
35
- input, textarea, select { font:inherit; color:var(--ink); background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--radius-sm); padding:6px 9px; width:100%; }
36
- input:focus, textarea:focus, select:focus { outline:none; border-color:var(--spring-dim); }
37
- textarea { min-height:72px; font-family:var(--mono); font-size:13px; resize:vertical; }
38
- label.field { display:block; margin:10px 0; }
39
- label.field > .lbl { display:block; color:var(--ink-2); font-size:12px; margin-bottom:4px; }
40
- label.field > .lbl .req { color:var(--amber); }
44
+ button.danger:hover { background:#eeced0; }
45
+ button:disabled { opacity:.45; cursor:not-allowed; }
46
+ button.sm { padding:4px 11px; font-size:12px; font-weight:500; }
47
+
48
+ /* --- inputs ------------------------------------------------------------- */
49
+ input, textarea, select { font:inherit; color:var(--ink); background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--radius-sm); padding:10px 14px; width:100%; }
50
+ input:focus, textarea:focus, select:focus { outline:none; border-color:var(--ink); }
51
+ input::placeholder, textarea::placeholder { color:var(--ink-3); }
52
+ textarea { min-height:88px; font-family:var(--mono); font-size:13px; resize:vertical; }
53
+ label.field { display:block; margin:14px 0; }
54
+ label.field > .lbl { display:block; color:var(--ink); font-weight:600; font-size:13px; margin-bottom:6px; }
55
+ label.field > .lbl .req { color:var(--danger); font-weight:400; }
41
56
  .checkbox { display:flex; align-items:center; gap:8px; }
42
57
  .checkbox input { width:auto; }
43
58
 
44
- .layout { display:grid; grid-template-columns:230px 1fr 380px; min-height:calc(100vh - 47px); }
45
- .side { border-right:1px solid var(--border); background:var(--surface); padding:12px; overflow:auto; }
46
- .canvas { padding:18px 20px; overflow:auto; }
47
- .inspect { border-left:1px solid var(--border); background:var(--surface); padding:14px 16px; overflow:auto; }
48
-
49
- .sect { color:var(--ink-3); font-size:11px; text-transform:uppercase; letter-spacing:.08em; margin:14px 0 6px; }
50
- .list { display:flex; flex-direction:column; gap:4px; }
51
- .row { display:flex; align-items:center; gap:8px; padding:7px 9px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--surface-2); cursor:pointer; }
52
- .row:hover { border-color:var(--border-2); }
53
- .row.active { border-color:var(--spring-dim); background:var(--spring-bg); }
54
- .row .grow { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
55
- .pill { font-size:11px; padding:1px 7px; border-radius:20px; border:1px solid var(--border-2); color:var(--ink-2); }
56
- .pill.published { color:var(--spring); border-color:var(--spring-dim); }
57
- .pill.review { color:var(--amber); border-color:var(--amber); }
58
- .pill.rejected, .pill.archived { color:var(--danger); border-color:var(--danger); }
59
-
60
- .region { margin-bottom:18px; }
61
- .region > h3 { font-size:13px; color:var(--ink-2); margin:0 0 8px; display:flex; align-items:center; gap:8px; }
62
- .region > h3 .allow { font-size:11px; color:var(--ink-3); font-weight:400; }
63
- .block { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface-2); padding:10px 12px; margin-bottom:8px; }
64
- .block.selected { border-color:var(--spring-dim); }
65
- .block .bhead { display:flex; align-items:center; gap:8px; }
66
- .block .btype { font-family:var(--mono); font-size:12px; color:var(--spring); }
59
+ /* --- editor 3-col layout ------------------------------------------------ */
60
+ .layout { display:grid; grid-template-columns:260px 1fr 400px; min-height:calc(100vh - 68px); gap:20px; padding:8px 28px 28px; }
61
+ .side { background:var(--surface); border-radius:var(--radius); padding:18px; overflow:auto; }
62
+ .canvas { padding:6px 0; overflow:auto; }
63
+ .inspect { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; overflow:auto; }
64
+
65
+ .sect { color:var(--ink-3); font-family:var(--serif); font-style:italic; font-size:14px; letter-spacing:0; margin:18px 0 8px; }
66
+ .list { display:flex; flex-direction:column; gap:8px; }
67
+ .row { display:flex; align-items:center; gap:12px; padding:14px 18px; border:1px solid transparent; border-radius:var(--radius); background:var(--surface); cursor:pointer; transition:background .12s, border-color .12s; }
68
+ .row:hover { background:#e6dfcd; }
69
+ .row.active { border-color:var(--ink); background:var(--surface-2); }
70
+ .row .grow { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500; }
71
+
72
+ /* --- status pills ------------------------------------------------------- */
73
+ .pill { font-size:11px; padding:3px 10px; border-radius:var(--radius-pill); border:1px solid var(--border-2); color:var(--ink-2); background:var(--surface-2); font-weight:500; letter-spacing:.02em; }
74
+ .pill.published { color:var(--spring-ink); border-color:transparent; background:var(--spring); }
75
+ .pill.review, .pill.in_review { color:#4a3512; border-color:transparent; background:var(--amber-bg); }
76
+ .pill.rejected, .pill.archived { color:#5c1e1e; border-color:transparent; background:var(--danger-bg); }
77
+
78
+ /* --- editor regions / blocks ------------------------------------------- */
79
+ .region { margin-bottom:24px; }
80
+ .region > h3 { font-family:var(--serif); font-size:22px; color:var(--ink); margin:0 0 12px; display:flex; align-items:baseline; gap:12px; font-weight:400; }
81
+ .region > h3 .allow { font-family:var(--sans); font-size:11px; color:var(--ink-3); font-weight:400; }
82
+ .block { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface-2); padding:14px 16px; margin-bottom:10px; transition:border-color .12s; }
83
+ .block.selected { border-color:var(--ink); }
84
+ .block .bhead { display:flex; align-items:center; gap:10px; }
85
+ .block .btype { font-family:var(--mono); font-size:12px; color:var(--ink-2); background:var(--surface); padding:2px 8px; border-radius:var(--radius-pill); }
67
86
  .block .grow { flex:1; }
68
87
  .block .shared { font-size:11px; color:var(--amber); }
69
- .dropzone { border:1px dashed var(--border-2); border-radius:var(--radius); padding:10px; text-align:center; color:var(--ink-3); font-size:12px; }
70
- .dropzone.over { border-color:var(--spring); color:var(--spring); }
88
+ .dropzone { border:1px dashed var(--border-2); border-radius:var(--radius); padding:14px; text-align:center; color:var(--ink-3); font-size:13px; }
89
+ .dropzone.over { border-color:var(--ink); color:var(--ink); }
71
90
 
72
- .palette { display:flex; flex-wrap:wrap; gap:6px; }
73
- .palette button { font-size:12px; }
74
- .repeater-item, .group { border:1px solid var(--border); border-radius:var(--radius-sm); padding:8px 10px; margin:6px 0; background:var(--surface-2); }
75
- .repeater-item > .ih { display:flex; justify-content:flex-end; }
91
+ .palette { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
92
+ .palette button { font-size:12px; padding:6px 14px; }
93
+ .repeater-item, .group { border:1px solid var(--border); border-radius:var(--radius-sm); padding:12px 14px; margin:8px 0; background:var(--surface); }
94
+ .repeater-item > .ih { display:flex; justify-content:flex-end; gap:4px; }
76
95
 
77
- .media-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
78
- .media-cell { border:1px solid var(--border); border-radius:var(--radius-sm); overflow:hidden; cursor:pointer; background:var(--surface-2); }
96
+ /* --- media library ------------------------------------------------------ */
97
+ .media-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; }
98
+ .media-cell { border:1px solid var(--border); border-radius:var(--radius-sm); overflow:hidden; cursor:pointer; background:var(--surface-2); transition:border-color .12s; }
79
99
  .media-cell:hover { border-color:var(--border-2); }
80
- .media-cell.sel { border-color:var(--spring); }
81
- .media-cell img { width:100%; height:70px; object-fit:cover; display:block; }
82
- .media-cell .fn { font-size:10px; padding:3px 5px; color:var(--ink-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
83
- .media-cell .ext { height:70px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:12px; color:var(--ink-3); background:var(--raise); }
84
-
85
- .media-lib { padding:20px; max-width:1000px; margin:0 auto; }
86
- .media-lib .media-grid { grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); }
87
- .media-lib .media-cell img, .media-lib .media-cell .ext { height:110px; }
88
- .media-toolbar { display:flex; align-items:center; margin-bottom:14px; }
100
+ .media-cell.sel { border-color:var(--ink); }
101
+ .media-cell img { width:100%; height:80px; object-fit:cover; display:block; }
102
+ .media-cell .fn { font-size:11px; padding:6px 8px; color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
103
+ .media-cell .ext { height:80px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:12px; color:var(--ink-3); background:var(--surface); }
104
+
105
+ .media-lib { padding:0 28px 28px; max-width:1200px; margin:0 auto; }
106
+ .media-lib .media-grid { grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); }
107
+ .media-lib .media-cell img, .media-lib .media-cell .ext { height:130px; }
108
+ .media-toolbar { display:none; }
89
109
  label.btn { display:inline-flex; align-items:center; }
90
110
  .btn.disabled { opacity:.5; cursor:not-allowed; }
91
- .media-detail img { max-width:100%; max-height:340px; object-fit:contain; display:block; margin:0 auto 12px; background:var(--surface-2); border-radius:var(--radius-sm); }
92
- .media-detail .ext.lg { height:180px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); color:var(--ink-3); background:var(--surface-2); border-radius:var(--radius-sm); margin-bottom:12px; }
111
+ .media-detail img { max-width:100%; max-height:340px; object-fit:contain; display:block; margin:0 auto 14px; background:var(--surface); border-radius:var(--radius-sm); }
112
+ .media-detail .ext.lg { height:200px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); color:var(--ink-3); background:var(--surface); border-radius:var(--radius-sm); margin-bottom:14px; }
93
113
  .media-detail .kv a { word-break:break-all; }
94
114
 
95
- .scrim { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:50; }
96
- .modal { background:var(--surface); border:1px solid var(--border-2); border-radius:var(--radius); padding:18px; width:min(680px,92vw); max-height:86vh; overflow:auto; }
97
- .modal h2 { margin:0 0 12px; font-size:16px; }
98
- .banner { padding:8px 12px; border-radius:var(--radius-sm); margin:8px 0; font-size:13px; }
115
+ /* --- modals ------------------------------------------------------------- */
116
+ .scrim { position:fixed; inset:0; background:rgba(20,15,5,.28); display:flex; align-items:center; justify-content:center; z-index:50; padding:24px; }
117
+ .modal { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:32px 36px; width:min(680px,92vw); max-height:86vh; overflow:auto; box-shadow:0 24px 60px rgba(30,20,10,.12); }
118
+ .modal h2 { margin:0 0 20px; font-family:var(--serif); font-weight:400; font-size:28px; line-height:1.15; color:var(--ink); }
119
+ .modal h2 .dim { color:var(--ink-3); font-style:italic; }
120
+ .banner { padding:10px 14px; border-radius:var(--radius-sm); margin:8px 0; font-size:13px; }
99
121
  .banner.err { background:var(--danger-bg); border:1px solid var(--danger); color:var(--danger); }
100
- .banner.ok { background:var(--spring-bg); border:1px solid var(--spring-dim); color:var(--spring); }
122
+ .banner.ok { background:var(--spring); border:1px solid var(--spring-dim); color:var(--spring-ink); }
101
123
  .muted { color:var(--ink-3); }
102
- .tabs { display:flex; gap:4px; margin-bottom:10px; }
103
- .tabs button { border-radius:20px; font-size:12px; }
104
- .tabs button.on { color:var(--spring); border-color:var(--spring-dim); }
105
- .setup { max-width:440px; margin:12vh auto; padding:0 20px; }
106
- .setup h1 { font-size:20px; }
107
- .kv { display:grid; grid-template-columns:auto 1fr; gap:6px 10px; font-size:12px; color:var(--ink-2); }
124
+
125
+ /* --- tabs: pill nav (top bar) + pill sub-tabs (inspector) --------------- */
126
+ .tabs { display:flex; gap:4px; margin-bottom:12px; align-items:center; }
127
+ .tabs button { border-radius:var(--radius-pill); font-size:13px; padding:7px 16px; background:transparent; border-color:transparent; color:var(--ink); font-weight:500; }
128
+ .tabs button:hover { background:var(--surface); }
129
+ .tabs button.on { background:var(--surface); color:var(--ink); border-color:transparent; }
130
+ .tabs.nav { gap:2px; }
131
+ .tabs.nav button { font-size:14px; padding:8px 18px; }
132
+
133
+ .setup { max-width:520px; margin:14vh auto; padding:32px 40px; background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 24px 60px rgba(30,20,10,.08); }
134
+ .setup h1 { font-family:var(--serif); font-weight:400; font-size:32px; margin:0 0 16px; color:var(--ink); }
135
+ .setup h1 .dim { color:var(--ink-3); font-style:italic; }
136
+ .setup .primary { width:100%; margin-top:8px; }
137
+ .kv { display:grid; grid-template-columns:auto 1fr; gap:8px 14px; font-size:13px; color:var(--ink-2); }
138
+ .kv > span:nth-child(odd) { color:var(--ink-3); font-family:var(--serif); font-style:italic; }
139
+
140
+ /* --- hero band (list views): big serif split heading + mint CTA card ---- */
141
+ .hero { display:grid; grid-template-columns:1fr auto; gap:24px; align-items:center; padding:32px 28px 24px; max-width:1200px; margin:0 auto; }
142
+ .hero-h { font-family:var(--serif); font-weight:400; font-size:56px; line-height:1.05; letter-spacing:-.01em; margin:0; }
143
+ .hero-h .lead { color:var(--ink-3); font-style:italic; display:block; }
144
+ .hero-h .em { color:var(--ink); font-style:normal; display:block; }
145
+ .cta { display:flex; align-items:center; gap:18px; background:var(--spring); border-radius:var(--radius-pill); padding:12px 12px 12px 28px; min-width:360px; }
146
+ .cta .cta-text { font-family:var(--serif); font-style:italic; font-size:18px; color:var(--spring-ink); }
147
+ .cta .cta-text .em { font-style:normal; font-weight:600; }
148
+ .cta button.primary { flex-shrink:0; }
149
+ .list-wrap { max-width:1200px; margin:0 auto; padding:8px 28px 32px; }
150
+ .list-wrap .sect { margin-top:0; }
151
+
152
+ /* --- rich text (WYSIWYG) ------------------------------------------------ */
153
+ .rt { border:1px solid var(--border-2); border-radius:var(--radius-sm); overflow:hidden; background:var(--surface-2); }
154
+ .rt-toolbar { display:flex; flex-wrap:wrap; gap:3px; padding:6px 8px; border-bottom:1px solid var(--border); background:var(--surface); }
155
+ .rt-toolbar button { padding:3px 10px; font-size:12px; border-radius:var(--radius-sm); background:transparent; border-color:transparent; color:var(--ink-2); }
156
+ .rt-toolbar button:hover { background:var(--surface-2); color:var(--ink); }
157
+ .rt-editor { min-height:180px; padding:14px 16px; font-family:var(--sans); font-size:14px; line-height:1.65; color:var(--ink); outline:none; }
158
+ .rt-editor:empty:before { content:attr(data-placeholder); color:var(--ink-3); pointer-events:none; }
159
+ .rt-editor:focus { box-shadow:inset 0 0 0 2px var(--ink); border-radius:0 0 var(--radius-sm) var(--radius-sm); }
160
+ .rt-editor h2 { font-family:var(--serif); font-weight:400; font-size:24px; line-height:1.2; margin:.7em 0 .3em; }
161
+ .rt-editor h3 { font-family:var(--serif); font-weight:400; font-size:19px; line-height:1.25; margin:.7em 0 .3em; }
162
+ .rt-editor p { margin:.55em 0; }
163
+ .rt-editor ul, .rt-editor ol { margin:.55em 0; padding-left:1.5em; }
164
+ .rt-editor li { margin:.2em 0; }
165
+ .rt-editor a { color:var(--ink); text-decoration:underline; text-underline-offset:2px; }
166
+ .rt-editor:first-child { margin-top:0; }
167
+
168
+ @media (max-width:820px) {
169
+ .hero { grid-template-columns:1fr; padding:20px 20px 16px; }
170
+ .hero-h { font-size:40px; }
171
+ .cta { min-width:0; }
172
+ .layout { grid-template-columns:1fr; padding:8px 20px 20px; }
173
+ .list-wrap, .media-lib { padding-left:20px; padding-right:20px; }
174
+ }
108
175
  `;