@acms-subir/core 0.1.0 → 0.2.0

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli.mjs +63 -9
  3. package/integration.mjs +11 -0
  4. package/package.json +10 -9
  5. package/src/actions/index.ts +184 -6
  6. package/src/components/DesignManager.tsx +53 -17
  7. package/src/components/LinksManager.tsx +110 -48
  8. package/src/components/NavManager.tsx +83 -37
  9. package/src/components/editor/PostEditor.tsx +228 -55
  10. package/src/components/editor/extensions/cards.tsx +17 -1
  11. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  12. package/src/components/editor/extensions/infoCards.tsx +16 -1
  13. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  14. package/src/components/editor/extensions/slash.tsx +70 -8
  15. package/src/components/editor/extensions/toc.tsx +89 -0
  16. package/src/components/editor/extensions/toggle.tsx +70 -0
  17. package/src/layouts/Site.astro +10 -1
  18. package/src/lib/analytics.ts +121 -0
  19. package/src/lib/designPreview.ts +14 -0
  20. package/src/lib/drafts.ts +77 -0
  21. package/src/lib/dynamicBlocks.ts +43 -3
  22. package/src/lib/repo/migrations.ts +37 -0
  23. package/src/lib/repo/sql.ts +127 -10
  24. package/src/lib/repo/types.ts +21 -1
  25. package/src/lib/schema.ts +53 -0
  26. package/src/pages/[...slug].astro +3 -1
  27. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  28. package/src/pages/admin/c/[collection]/index.astro +1 -0
  29. package/src/pages/admin/design.astro +6 -1
  30. package/src/pages/admin/index.astro +5 -2
  31. package/src/pages/admin/pages/[id].astro +10 -2
  32. package/src/pages/admin/pages/index.astro +1 -0
  33. package/src/pages/admin/settings.astro +148 -0
  34. package/src/pages/blog/[slug].astro +4 -1
  35. package/src/pages/index.astro +4 -1
  36. package/src/pages/links.astro +10 -4
  37. package/src/pages/preview/[type]/[id].astro +9 -4
  38. package/src/styles/admin.css +4 -0
  39. package/src/styles/cards.css +23 -0
  40. package/src/styles/editor.css +53 -1
  41. package/src/styles/site.css +3 -0
@@ -5,6 +5,8 @@ import { createPreviewToken } from '../../../lib/preview';
5
5
  import { resolveDesign } from '../../../lib/theme';
6
6
  import { env } from '../../../lib/env';
7
7
  import { BRAND } from '../../../lib/brand';
8
+ import { mergePageDraft } from '../../../lib/drafts';
9
+ import { localeHomeUrl } from '../../../lib/i18n';
8
10
  import PostEditor from '../../../components/editor/PostEditor';
9
11
  import '../../../styles/admin.css';
10
12
  import '../../../styles/editor.css';
@@ -14,9 +16,14 @@ const id = Astro.params.id!;
14
16
  const page = await repo.getPage(id);
15
17
  if (!page) return new Response(null, { status: 404 });
16
18
 
19
+ // The editor hydrates from the pending draft when one exists (live pages
20
+ // stage their edits until Update); the live URL stays on the live slug.
21
+ const editable = mergePageDraft(page);
22
+ const livePath = page.isHome ? localeHomeUrl(page.locale) : `/${page.slug}`;
23
+
17
24
  // Canonical content is the TipTap doc (as HTML). Legacy widget-block pages
18
25
  // are converted on first open and become rich content on next save.
19
- const initialHTML = page.bodyHtml || blocksToHtml(page.blocks);
26
+ const initialHTML = editable.bodyHtml || blocksToHtml(editable.blocks);
20
27
  const previewPath = `/preview/page/${page.id}`;
21
28
  const sharePath = `${previewPath}?token=${createPreviewToken('page', page.id)}`;
22
29
  const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
@@ -38,11 +45,12 @@ const templates = Object.keys(resolveDesign(settings).theme.pageTemplates);
38
45
  <PostEditor
39
46
  client:only="react"
40
47
  kind="page"
41
- page={page}
48
+ page={editable}
42
49
  initialHTML={initialHTML}
43
50
  unsplashEnabled={unsplashEnabled}
44
51
  previewPath={previewPath}
45
52
  sharePath={sharePath}
53
+ livePath={livePath}
46
54
  templates={templates}
47
55
  />
48
56
  </body>
@@ -70,6 +70,7 @@ const fmt = (ts: number) => new Date(ts).toLocaleDateString('en-US', { month: 's
70
70
  </td>
71
71
  <td>
72
72
  <span class={`pill ${p.status}`}>{p.status}</span>
73
+ {p.draft && <span class="pill edited" title="Has pending edits — press Update in the editor to push them live">edited</span>}
73
74
  {p.isHome && <span class="pill home">home</span>}
74
75
  {I18N.enabled && <span class="pill lang">{(p.locale || I18N.defaultLocale).toUpperCase()}</span>}
75
76
  </td>
@@ -4,6 +4,7 @@ import { getRepo } from '../../lib/repo';
4
4
  import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
5
5
  import { invalidateRedirectCache } from '../../middleware';
6
6
  import { CONTENT } from '../../lib/content';
7
+ import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
7
8
 
8
9
  const repo = await getRepo(Astro.locals);
9
10
 
@@ -77,6 +78,57 @@ if (Astro.request.method === 'POST') {
77
78
  });
78
79
  flash = 'Code injection saved.';
79
80
  }
81
+ } else if (action === 'analytics') {
82
+ if (!isOwner) {
83
+ flashError = 'Only owners can edit analytics.';
84
+ } else {
85
+ // replay sample rate is edited as a percent, stored as a fraction
86
+ const replayPct = Number(String(form.get('umamiReplaySampleRate') ?? '15').trim());
87
+ const replayDuration = Number(String(form.get('umamiReplayMaxDuration') ?? '300000').trim());
88
+ const umami = {
89
+ enabled: form.get('umamiEnabled') === 'on',
90
+ websiteId: String(form.get('umamiWebsiteId') ?? '').trim(),
91
+ scriptUrl: String(form.get('umamiScriptUrl') ?? '').trim() || 'https://cloud.umami.is/script.js',
92
+ replay: {
93
+ enabled: form.get('umamiReplayEnabled') === 'on',
94
+ sampleRate: Math.round(replayPct) / 100,
95
+ maskLevel: (form.get('umamiReplayMaskLevel') === 'strict' ? 'strict' : 'moderate') as 'strict' | 'moderate',
96
+ maxDuration: replayDuration,
97
+ },
98
+ };
99
+ const metaPixel = {
100
+ enabled: form.get('metaEnabled') === 'on',
101
+ pixelId: String(form.get('metaPixelId') ?? '').trim(),
102
+ };
103
+ const tiktokPixel = {
104
+ enabled: form.get('tiktokEnabled') === 'on',
105
+ pixelId: String(form.get('tiktokPixelId') ?? '').trim(),
106
+ };
107
+ // reject bad values even while disabled — a stored ID must always be
108
+ // renderable, since these end up in set:html on every public page
109
+ if (umami.websiteId && !UMAMI_ID_RE.test(umami.websiteId)) {
110
+ flashError = 'Umami website ID should be a UUID — copy it from your Umami dashboard (Settings → Websites → Edit).';
111
+ } else if (!normalizeScriptUrl(umami.scriptUrl)) {
112
+ flashError = 'Umami script URL must be an https:// URL, e.g. https://cloud.umami.is/script.js';
113
+ } else if (!Number.isFinite(replayPct) || replayPct < 1 || replayPct > 100) {
114
+ flashError = 'Replay sample rate must be between 1 and 100 percent.';
115
+ } else if (!Number.isInteger(replayDuration) || replayDuration <= 0) {
116
+ flashError = 'Replay max duration is invalid.';
117
+ } else if (metaPixel.pixelId && !META_PIXEL_ID_RE.test(metaPixel.pixelId)) {
118
+ flashError = 'Meta Pixel ID should be numbers only — find it in Meta Events Manager.';
119
+ } else if (tiktokPixel.pixelId && !TIKTOK_PIXEL_ID_RE.test(tiktokPixel.pixelId)) {
120
+ flashError = 'TikTok Pixel ID should be letters and numbers only — find it in TikTok Events Manager.';
121
+ } else if (umami.enabled && !umami.websiteId) {
122
+ flashError = 'Enter the Umami website ID to enable it.';
123
+ } else if (metaPixel.enabled && !metaPixel.pixelId) {
124
+ flashError = 'Enter the Meta Pixel ID to enable it.';
125
+ } else if (tiktokPixel.enabled && !tiktokPixel.pixelId) {
126
+ flashError = 'Enter the TikTok Pixel ID to enable it.';
127
+ } else {
128
+ await repo.updateSettings({ analytics: { umami, metaPixel, tiktokPixel } });
129
+ flash = 'Analytics saved.';
130
+ }
131
+ }
80
132
  } else if (action === 'announcement') {
81
133
  // link must be a relative path or http(s) URL — never javascript: etc.
82
134
  const rawLink = String(form.get('annLink') ?? '').trim();
@@ -314,6 +366,102 @@ const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOri
314
366
  </div>
315
367
  )}
316
368
 
369
+ {isOwner && (
370
+ <div class="card card-pad">
371
+ <p class="card-title">Analytics</p>
372
+ <p style="margin:0">
373
+ Traffic and ad tracking on every public page. Only the ID is stored — the tracking code itself
374
+ is rendered by the CMS. The admin is never tracked. Mind visitor-consent rules (GDPR) if you
375
+ enable the ad pixels.
376
+ </p>
377
+ <form method="post">
378
+ <input type="hidden" name="action" value="analytics" />
379
+
380
+ <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
381
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
382
+ <input type="checkbox" name="umamiEnabled" checked={settings.analytics.umami.enabled} />
383
+ <span style="font-weight:600">Umami</span>
384
+ </label>
385
+ <div class="field-row">
386
+ <label class="field">
387
+ <span>Website ID</span>
388
+ <input type="text" name="umamiWebsiteId" value={settings.analytics.umami.websiteId} placeholder="94db1cb1-74f4-4a40-ad6c-962362670409" style="font-family:monospace;font-size:12.5px" />
389
+ </label>
390
+ <label class="field">
391
+ <span>Script URL</span>
392
+ <input type="text" name="umamiScriptUrl" value={settings.analytics.umami.scriptUrl} placeholder="https://cloud.umami.is/script.js" style="font-family:monospace;font-size:12.5px" />
393
+ </label>
394
+ </div>
395
+ <p class="hint" style="margin:0">
396
+ Privacy-friendly stats: pageviews, referrers, devices, countries, visit duration.
397
+ Umami Cloud by default — point Script URL at your own instance if self-hosting.
398
+ </p>
399
+ <div style="margin:10px 0 0 24px;padding:12px 14px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px">
400
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
401
+ <input type="checkbox" name="umamiReplayEnabled" checked={settings.analytics.umami.replay.enabled} />
402
+ <span style="font-weight:600">Session replay</span>
403
+ </label>
404
+ <div class="field-row">
405
+ <label class="field">
406
+ <span>Sample rate (%)</span>
407
+ <input type="number" name="umamiReplaySampleRate" min="1" max="100" step="1" value={Math.round(settings.analytics.umami.replay.sampleRate * 100)} />
408
+ </label>
409
+ <label class="field">
410
+ <span>Mask level</span>
411
+ <select name="umamiReplayMaskLevel">
412
+ <option value="moderate" selected={settings.analytics.umami.replay.maskLevel === 'moderate'}>moderate</option>
413
+ <option value="strict" selected={settings.analytics.umami.replay.maskLevel === 'strict'}>strict</option>
414
+ </select>
415
+ </label>
416
+ <label class="field">
417
+ <span>Max duration</span>
418
+ <select name="umamiReplayMaxDuration">
419
+ <option value="60000" selected={settings.analytics.umami.replay.maxDuration === 60000}>1 minute</option>
420
+ <option value="300000" selected={settings.analytics.umami.replay.maxDuration === 300000}>5 minutes</option>
421
+ <option value="600000" selected={settings.analytics.umami.replay.maxDuration === 600000}>10 minutes</option>
422
+ <option value="1800000" selected={settings.analytics.umami.replay.maxDuration === 1800000}>30 minutes</option>
423
+ </select>
424
+ </label>
425
+ </div>
426
+ <p class="hint" style="margin:0">
427
+ Records a sample of visitor sessions for playback in Umami (v3.1+, Behavior → Replays).
428
+ Umami keeps replays for 30 days. Inputs are masked — mind visitor-consent rules (GDPR) before enabling.
429
+ </p>
430
+ </div>
431
+ </div>
432
+
433
+ <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
434
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
435
+ <input type="checkbox" name="metaEnabled" checked={settings.analytics.metaPixel.enabled} />
436
+ <span style="font-weight:600">Meta Pixel</span>
437
+ </label>
438
+ <label class="field">
439
+ <span>Pixel ID</span>
440
+ <input type="text" name="metaPixelId" value={settings.analytics.metaPixel.pixelId} placeholder="724159876543210" style="font-family:monospace;font-size:12.5px;max-width:320px" />
441
+ </label>
442
+ <p class="hint" style="margin:0">Tracks page views for Facebook/Instagram ad attribution and retargeting audiences.</p>
443
+ </div>
444
+
445
+ <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
446
+ <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
447
+ <input type="checkbox" name="tiktokEnabled" checked={settings.analytics.tiktokPixel.enabled} />
448
+ <span style="font-weight:600">TikTok Pixel <span style="display:inline-block;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;padding:2px 8px;border-radius:999px;background:var(--amber-soft);color:var(--amber);vertical-align:1px">Experimental</span></span>
449
+ </label>
450
+ <label class="field">
451
+ <span>Pixel ID</span>
452
+ <input type="text" name="tiktokPixelId" value={settings.analytics.tiktokPixel.pixelId} placeholder="CABC123DEF456GHI789" style="font-family:monospace;font-size:12.5px;max-width:320px" />
453
+ </label>
454
+ <p class="hint" style="margin:0">
455
+ Untested integration — the snippet follows TikTok’s published base code, but verify events
456
+ in TikTok Events Manager before relying on it.
457
+ </p>
458
+ </div>
459
+
460
+ <div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
461
+ </form>
462
+ </div>
463
+ )}
464
+
317
465
  <div class="card card-pad">
318
466
  <p class="card-title">Announcement bar</p>
319
467
  <form method="post">
@@ -7,6 +7,7 @@ import { highlightCodeHtml } from '../../lib/highlightCode';
7
7
  import { verifySessionToken, SESSION_COOKIE } from '../../lib/auth';
8
8
  import { slugify, authorSlug, publicTags } from '../../lib/schema';
9
9
  import { resolveDesign } from '../../lib/theme';
10
+ import { effectiveSettings } from '../../lib/designPreview';
10
11
  import { siteOrigin } from '../../lib/seo';
11
12
  import { formatDate } from '../../lib/dates';
12
13
  import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
@@ -24,13 +25,15 @@ if (!post || (post.status !== 'published' && !isAdmin)) {
24
25
  return new Response(null, { status: 404, statusText: 'Not found' });
25
26
  }
26
27
  const authorIds = post.authorIds.length ? post.authorIds : post.authorId ? [post.authorId] : [];
27
- const [tags, allUsers, allPosts, settings, allMedia] = await Promise.all([
28
+ const [tags, allUsers, allPosts, rawSettings, allMedia] = await Promise.all([
28
29
  repo.listTags(),
29
30
  authorIds.length ? repo.listUsers() : Promise.resolve([]),
30
31
  repo.listPublishedPosts(col.key), // related reading stays within the collection
31
32
  repo.getSettings(),
32
33
  post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
33
34
  ]);
35
+ // ?designPreview=1 (admin-only) renders the staged Design-screen changes
36
+ const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
34
37
  // ordered author objects; the first is the primary (gets the author card)
35
38
  const authors = authorIds
36
39
  .map((id) => allUsers.find((u) => u.id === id))
@@ -3,6 +3,7 @@ import Site from '../layouts/Site.astro';
3
3
  import WidgetRenderer from '../components/WidgetRenderer.astro';
4
4
  import { getRepo } from '../lib/repo';
5
5
  import { resolveDesign } from '../lib/theme';
6
+ import { effectiveSettings } from '../lib/designPreview';
6
7
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
8
  import { I18N, localeFromPath } from '../lib/i18n';
8
9
 
@@ -11,10 +12,12 @@ const repo = await getRepo(Astro.locals);
11
12
  // resolved from the request path (injectRoute can't pass props)
12
13
  const locale = I18N.enabled ? localeFromPath(Astro.url.pathname) : I18N.defaultLocale;
13
14
  const wantsLocaleHome = I18N.enabled && locale !== I18N.defaultLocale;
14
- let [home, settings] = await Promise.all([
15
+ let [home, rawSettings] = await Promise.all([
15
16
  repo.getHomePage(wantsLocaleHome ? locale : undefined),
16
17
  repo.getSettings(),
17
18
  ]);
19
+ // ?designPreview=1 (admin-only) renders the staged Design-screen changes
20
+ const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
18
21
  // a locale home that isn't authored yet falls back to the default home —
19
22
  // /es must never 404 mid-setup
20
23
  if (wantsLocaleHome && !home) home = await repo.getHomePage();
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import Site from '../layouts/Site.astro';
3
3
  import { getRepo } from '../lib/repo';
4
+ import { cleanEventName } from '../lib/analytics';
4
5
 
5
6
  const repo = await getRepo(Astro.locals);
6
7
  const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
@@ -11,6 +12,11 @@ const profileBio = settings.linksBio || settings.tagline;
11
12
 
12
13
  const SPACER_PX: Record<string, number> = { s: 16, m: 36, l: 72 };
13
14
 
15
+ // every link button is auto-stamped with an analytics event so per-link click
16
+ // counts (sponsors!) show up in Umami alongside the first-party /go counter
17
+ const linkEvent = (label: string): string | undefined =>
18
+ cleanEventName(`Link: ${label}`) === 'Link:' ? undefined : cleanEventName(`Link: ${label}`);
19
+
14
20
  // icon column convention: '<svg…' inline markup, http/​/ image URL,
15
21
  // 'favicon' derive from the link's host, anything else = emoji/text
16
22
  function iconInfo(l: (typeof links)[number]): { type: 'svg' | 'img' | 'emoji' | 'none'; value: string } {
@@ -96,13 +102,13 @@ const visible = links.filter((l) => {
96
102
  case 'html':
97
103
  return <div class="link-html" set:html={l.html} />;
98
104
  case 'email':
99
- return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
105
+ return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
100
106
  case 'phone':
101
- return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
107
+ return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
102
108
  case 'vcard':
103
- return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
109
+ return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`} data-umami-event={linkEvent(l.label || 'Save contact')}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
104
110
  default:
105
- return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener">{icon}<span class="link-label">{l.label}</span></a>;
111
+ return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener" data-umami-event={linkEvent(l.label)}>{icon}<span class="link-label">{l.label}</span></a>;
106
112
  }
107
113
  })}
108
114
  {visible.length === 0 && <p>No links yet.</p>}
@@ -7,9 +7,12 @@ import { verifyPreviewToken } from '../../../lib/preview';
7
7
  import { expandDynamicBlocks } from '../../../lib/dynamicBlocks';
8
8
  import { highlightCodeHtml } from '../../../lib/highlightCode';
9
9
  import { verifySessionToken, SESSION_COOKIE } from '../../../lib/auth';
10
+ import { mergePageDraft, mergePostDraft } from '../../../lib/drafts';
10
11
 
11
12
  // Draft preview, any status. Reachable by a logged-in admin (the editor's
12
- // preview iframe) or by anyone holding a signed share token.
13
+ // preview iframe) or by anyone holding a signed share token. Live documents
14
+ // with pending edits render the PENDING version — exactly what Update will
15
+ // publish — while the public URL keeps serving the live one.
13
16
  const { type, id } = Astro.params;
14
17
  if (type !== 'post' && type !== 'page') return new Response(null, { status: 404 });
15
18
 
@@ -20,9 +23,11 @@ if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
20
23
  }
21
24
 
22
25
  const repo = await getRepo(Astro.locals);
23
- const post = type === 'post' ? await repo.getPost(id!) : null;
24
- const page = type === 'page' ? await repo.getPage(id!) : null;
25
- if (!post && !page) return new Response(null, { status: 404 });
26
+ const livePost = type === 'post' ? await repo.getPost(id!) : null;
27
+ const livePage = type === 'page' ? await repo.getPage(id!) : null;
28
+ if (!livePost && !livePage) return new Response(null, { status: 404 });
29
+ const post = livePost ? mergePostDraft(livePost) : null;
30
+ const page = livePage ? mergePageDraft(livePage) : null;
26
31
 
27
32
  const html = post ? highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo)) : '';
28
33
  const pageHtml = page?.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
@@ -140,6 +140,8 @@ table.list { width: 100%; border-collapse: collapse; }
140
140
  .pill.scheduled { background: #fde5d8; color: var(--accent-deep); }
141
141
  .pill.archived { background: rgb(102 109 124 / 12%); color: var(--muted); }
142
142
  .pill.home { background: #fde5d8; color: var(--accent-deep); }
143
+ /* live doc with staged edits awaiting Update */
144
+ .pill.edited { background: var(--amber-soft); color: var(--amber); margin-left: 4px; }
143
145
  .pill.lang { background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); }
144
146
 
145
147
  /* buttons & forms */
@@ -443,6 +445,8 @@ input.bare.mono { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12
443
445
  .lm-savestate { font-size: 11.5px; color: var(--faint); margin-left: auto; }
444
446
  .lm-savestate.saving { color: var(--muted); }
445
447
  .lm-savestate.error { color: var(--red); }
448
+ /* local edits awaiting an explicit "Save changes" */
449
+ .lm-savestate.dirty { color: var(--amber); font-weight: 600; }
446
450
  .lm-phone {
447
451
  border: 1px solid var(--line); border-radius: var(--r-xl); overflow: hidden;
448
452
  background: #fff; height: min(640px, calc(100vh - 140px)); box-shadow: var(--shadow-pop);
@@ -174,3 +174,26 @@ a.pc-info-card:hover { border-color: var(--info-badge); }
174
174
  .pc-info-row[data-icon='tag']::before { display: inline-block; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 4h7l9 9-7 7-9-9zM8.5 8.5h.01'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 4h7l9 9-7 7-9-9zM8.5 8.5h.01'/%3E%3C/svg%3E"); }
175
175
  .pc-info-row[data-icon='check']::before { display: inline-block; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5l5 5 11-11'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5l5 5 11-11'/%3E%3C/svg%3E"); }
176
176
  }
177
+
178
+ @layer base {
179
+ /* toggle — collapsible section, native <details> so no JS needed */
180
+ .pc-toggle { border: 1px solid var(--line, #e3e3e0); border-radius: 10px; padding: 12px 18px; margin: 1.6em 0; }
181
+ .pc-toggle > summary { font-weight: 600; cursor: pointer; list-style: none; display: flex; align-items: center; gap: 10px; }
182
+ .pc-toggle > summary::-webkit-details-marker { display: none; }
183
+ .pc-toggle > summary::before { content: "▸"; font-size: 0.8em; color: var(--muted, #777); transition: transform 0.15s ease; }
184
+ .pc-toggle[open] > summary::before { transform: rotate(90deg); }
185
+ .pc-toggle-body { margin-top: 10px; }
186
+ .pc-toggle-body > :last-child { margin-bottom: 0; }
187
+
188
+ /* table of contents — expanded at request time from the body's headings */
189
+ .pc-toc {
190
+ display: flex; flex-direction: column; gap: 2px;
191
+ border-left: 3px solid var(--accent, #6e7bff); background: rgb(0 0 0 / 3%);
192
+ border-radius: 0 10px 10px 0; padding: 14px 20px; margin: 1.6em 0;
193
+ }
194
+ .pc-toc-title { text-transform: uppercase; letter-spacing: 0.08em; font-size: 11.5px; font-weight: 700; color: var(--muted, #777); margin-bottom: 6px; }
195
+ .pc-toc-link { color: inherit; text-decoration: none; padding: 2px 0; font-size: 0.95em; }
196
+ a.pc-toc-link:hover { color: var(--accent, #6e7bff); }
197
+ .pc-toc-link[data-depth='1'] { padding-left: 16px; }
198
+ .pc-toc-link[data-depth='2'] { padding-left: 32px; }
199
+ }
@@ -11,6 +11,7 @@
11
11
  }
12
12
  .pe-topbar-left, .pe-topbar-right { display: flex; align-items: center; gap: 10px; }
13
13
  .pe-savestate { font-size: 12.5px; color: var(--faint); }
14
+ .pe-edited-dot { font-style: normal; font-weight: 600; color: var(--amber); }
14
15
  .pe-savestate.dirty { color: var(--muted); }
15
16
  .pe-savestate.saving { color: var(--muted); }
16
17
  .pe-savestate.error { color: var(--red); }
@@ -98,6 +99,7 @@
98
99
  /* prose */
99
100
  .pe-prose { outline: none; color: var(--text); font-size: 16.5px; line-height: 1.72; min-height: 320px; }
100
101
  .pe-prose p { margin: 0 0 0.9em; }
102
+ .pe-prose h1 { font-size: 31px; letter-spacing: -0.02em; margin: 1.3em 0 0.5em; }
101
103
  .pe-prose h2 { font-size: 25px; letter-spacing: -0.015em; margin: 1.4em 0 0.5em; }
102
104
  .pe-prose h3 { font-size: 19px; letter-spacing: -0.01em; margin: 1.3em 0 0.4em; }
103
105
  .pe-prose a { color: var(--accent-deep); text-decoration: underline; text-decoration-color: rgb(238 93 32 / 35%); }
@@ -106,6 +108,8 @@
106
108
  .pe-prose pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 14px 16px; overflow-x: auto; margin: 1.2em 0; }
107
109
  .pe-prose pre code { background: none; border: none; padding: 0; font-size: 13.5px; }
108
110
  .pe-prose hr { border: none; border-top: 1px solid var(--line); margin: 2em auto; width: 120px; }
111
+ .pe-prose mark { background: #fde58a; border-radius: 3px; padding: 0 2px; }
112
+ .pe-bubble mark { background: #fde58a; border-radius: 3px; padding: 0 4px; color: #1a1d25; }
109
113
 
110
114
  /* ---- code block (extensions/codeBlock.tsx) ---- */
111
115
  .pe-codeblock { position: relative; margin: 1.2em 0; }
@@ -246,6 +250,12 @@
246
250
  background: none; border: none; border-radius: var(--r-md); padding: 7px 10px; cursor: pointer; font-family: inherit;
247
251
  }
248
252
  .pe-slash-item.is-selected { background: var(--accent-soft); }
253
+ .pe-slash-del {
254
+ margin-left: auto; padding: 2px 7px; border-radius: 6px; color: var(--faint);
255
+ font-size: 11px; opacity: 0; transition: opacity 0.1s ease;
256
+ }
257
+ .pe-slash-item:hover .pe-slash-del, .pe-slash-item.is-selected .pe-slash-del { opacity: 1; }
258
+ .pe-slash-del:hover { color: var(--red); background: var(--red-soft); }
249
259
  .pe-slash-icon { width: 30px; height: 30px; flex: 0 0 auto; display: grid; place-items: center; background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-sm); color: var(--muted); }
250
260
  .pe-slash-icon svg { width: 16px; height: 16px; }
251
261
  .pe-slash-item.is-selected .pe-slash-icon { color: var(--accent-deep); }
@@ -482,14 +492,27 @@
482
492
  /* floating link editor (⌘K) */
483
493
  .pe-linkedit {
484
494
  position: sticky; top: 12px; z-index: 60;
485
- display: flex; gap: 8px; align-items: center;
486
495
  background: var(--panel, #fff); border: 1px solid var(--border, #d5d2ca);
487
496
  border-radius: 10px; padding: 8px 10px; margin: 0 auto 12px;
488
497
  max-width: 560px; box-shadow: 0 10px 30px rgb(0 0 0 / 14%);
489
498
  }
499
+ .pe-linkedit-row { display: flex; gap: 8px; align-items: center; }
490
500
  .pe-linkedit input {
491
501
  flex: 1; border: 0; outline: none; font-size: 14px; background: none; color: inherit;
492
502
  }
503
+ .pe-link-results {
504
+ border-top: 1px solid var(--line, #e3e5ec); margin-top: 8px; padding-top: 6px;
505
+ display: flex; flex-direction: column;
506
+ }
507
+ .pe-link-results button {
508
+ display: flex; align-items: baseline; gap: 8px; border: 0; background: none;
509
+ text-align: left; padding: 7px 8px; border-radius: 8px; cursor: pointer;
510
+ font: inherit; color: var(--text, #1a1d25);
511
+ }
512
+ .pe-link-results button.is-selected { background: var(--panel-2, #f4f5f8); }
513
+ .pe-link-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
514
+ .pe-link-url { font-size: 12px; color: var(--faint, #8f95a3); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
515
+ .pe-link-kind { font-size: 10px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--faint, #8f95a3); }
493
516
 
494
517
  /* shortcut cheat-sheet */
495
518
  .pe-wordcount { display: flex; align-items: center; gap: 10px; }
@@ -594,3 +617,32 @@
594
617
  .pe-infocard-icon svg { width: 14px; height: 14px; }
595
618
  .pe-infocard-foot { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
596
619
  .pe-infocard-href { flex: 1; }
620
+
621
+ /* toggle card (editor) — cards.css isn't loaded in the admin, so the box
622
+ look is restated here on the editor tokens */
623
+ .pe-toggle { border: 1px solid var(--line); border-radius: var(--r-md); padding: 12px 16px; margin: 1.2em 0; }
624
+ .pe-toggle.is-selected { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
625
+ .pe-toggle .pe-toggle-head { display: flex; align-items: center; gap: 8px; }
626
+ .pe-toggle-chevron {
627
+ border: 0; background: none; cursor: pointer; color: var(--muted, #666d7c);
628
+ font-size: 13px; padding: 2px 4px; transition: transform 0.15s ease;
629
+ }
630
+ .pe-toggle.is-open .pe-toggle-chevron { transform: rotate(90deg); }
631
+ .pe-toggle-summary { flex: 1; border: 0; outline: none; background: none; font: inherit; font-weight: 600; color: inherit; }
632
+ .pe-toggle .pc-toggle-body { margin-top: 10px; }
633
+
634
+ /* toc card (editor) */
635
+ .pe-toc .pc-toc {
636
+ display: flex; flex-direction: column; gap: 2px; margin: 0;
637
+ border-left: 3px solid var(--accent); background: var(--panel-2);
638
+ border-radius: 0 var(--r-md) var(--r-md) 0; padding: 14px 18px;
639
+ }
640
+ .pe-toc .pc-toc-link { padding: 2px 0; font-size: 14.5px; }
641
+ .pe-toc .pc-toc-link[data-depth='1'] { padding-left: 16px; }
642
+ .pe-toc .pc-toc-link[data-depth='2'] { padding-left: 32px; }
643
+ .pe-toc-title {
644
+ border: 0; outline: none; background: none; text-transform: uppercase;
645
+ letter-spacing: 0.08em; font-size: 11.5px; font-weight: 700;
646
+ color: var(--muted, #666d7c); margin-bottom: 6px; padding: 0;
647
+ }
648
+ .pe-toc-empty { color: var(--faint, #8f95a3); font-size: 13.5px; }
@@ -77,6 +77,7 @@ pre .hljs-strong { font-weight: 700; }
77
77
  code { font-family: ui-monospace, 'SF Mono', monospace; font-size: 0.9em; }
78
78
  p code { background: var(--soft); padding: 2px 5px; border-radius: 4px; }
79
79
  blockquote { margin: 16px 0; padding: 4px 18px; border-left: 3px solid var(--line); color: var(--muted); }
80
+ mark { background: #fde58a; border-radius: 3px; padding: 0 2px; }
80
81
  table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 15px; line-height: 1.5; }
81
82
  th, td { border: 1px solid var(--line); padding: 8px 12px; text-align: left; vertical-align: top; }
82
83
  th { background: var(--soft); font-weight: 600; }
@@ -233,6 +234,8 @@ a.tag:hover { background: var(--accent); color: #fff; }
233
234
  .post-kicker:empty { display: none; }
234
235
  .post-layout { margin-top: 8px; }
235
236
  .post-content > :first-child { margin-top: 0; }
237
+ /* in-body h1: below the post title's scale, above h2 */
238
+ .post-content h1 { font-size: 30px; margin: 1.2em 0 0.4em; }
236
239
 
237
240
  .related-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
238
241
  .related-card h3 { font-size: 18px; line-height: 1.3; margin: 4px 0 4px; }