@acms-subir/core 0.3.32 → 0.3.33

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 (53) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/cli/main.mjs +1 -1
  3. package/cli/migrations.generated.mjs +35 -2
  4. package/docs/mobile.md +2 -0
  5. package/integration.mjs +44 -0
  6. package/mobile-template/client/AGENTS.md +1 -0
  7. package/mobile-template/recipes/announcements/files/app/announcements/[id].tsx +43 -0
  8. package/mobile-template/recipes/announcements/files/app/announcements/index.tsx +155 -0
  9. package/mobile-template/recipes/announcements/files/components/AnnouncementBanners.tsx +47 -0
  10. package/mobile-template/recipes/announcements/files/components/AnnouncementIcon.tsx +21 -0
  11. package/mobile-template/recipes/announcements/recipe.json +19 -0
  12. package/package.json +1 -1
  13. package/src/components/Announcements.tsx +806 -0
  14. package/src/components/MobileApp.tsx +1 -1
  15. package/src/layouts/Admin.astro +4 -0
  16. package/src/layouts/Site.astro +42 -2
  17. package/src/lib/agent-discovery.ts +6 -2
  18. package/src/lib/agent.ts +5 -0
  19. package/src/lib/announcements/agent.ts +49 -0
  20. package/src/lib/announcements/api.ts +222 -0
  21. package/src/lib/announcements/config.ts +8 -0
  22. package/src/lib/announcements/migration.ts +21 -0
  23. package/src/lib/announcements/pages.ts +54 -0
  24. package/src/lib/announcements/service.ts +132 -0
  25. package/src/lib/announcements/store.ts +88 -0
  26. package/src/lib/announcements/types.ts +78 -0
  27. package/src/lib/announcements/words.ts +55 -0
  28. package/src/lib/backup/content.ts +5 -1
  29. package/src/lib/mobile/openapi.ts +3 -0
  30. package/src/lib/mobile/schemas.ts +9 -2
  31. package/src/lib/mobile/site.ts +3 -0
  32. package/src/lib/push/api.ts +9 -6
  33. package/src/lib/push/service.ts +8 -1
  34. package/src/lib/push/store.ts +20 -7
  35. package/src/lib/push/types.ts +4 -2
  36. package/src/lib/repo/migrations.ts +4 -0
  37. package/src/lib/repo/sql.ts +6 -0
  38. package/src/lib/repo/types.ts +5 -0
  39. package/src/lib/scheduler.ts +3 -0
  40. package/src/lib/schema.ts +1 -0
  41. package/src/pages/admin/announcements.astro +18 -0
  42. package/src/pages/admin/integrations.astro +2 -1
  43. package/src/pages/admin/settings.astro +2 -0
  44. package/src/pages/announcements/[id].astro +35 -0
  45. package/src/pages/announcements/index.astro +61 -0
  46. package/src/pages/api/announcements.ts +32 -0
  47. package/src/pages/api/v1/announcements/[id].ts +7 -0
  48. package/src/pages/api/v1/announcements/index.ts +8 -0
  49. package/src/pages/api/webhooks/docs.ts +2 -0
  50. package/src/styles/announcements-site.css +32 -0
  51. package/src/styles/announcements.css +210 -0
  52. package/src/styles/site.css +5 -0
  53. package/src/virtual.d.ts +1 -0
@@ -8,6 +8,7 @@ import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/mi
8
8
  import { PLACES_MIGRATION } from '../maps/migration';
9
9
  import { ROLES_MIGRATION } from '../permissions/migration';
10
10
  import { SYNC_MIGRATION } from '../sync/migration';
11
+ import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
11
12
  // Versioned schema migrations shared by the two SQLite-dialect adapters
12
13
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
13
14
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -676,6 +677,8 @@ export const MIGRATIONS: Migration[] = [
676
677
  `DROP TRIGGER content_version_settings_update`,
677
678
  ...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal']),
678
679
  ] },
680
+ // announcements show on public pages, the banner and the app API
681
+ { version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers('announcements', ANNOUNCEMENT_VISIBLE_COLUMNS)] },
679
682
  ];
680
683
 
681
684
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -683,6 +686,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
683
686
  ...CONTENT_VERSION_COLUMNS,
684
687
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
685
688
  events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
689
+ announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
686
690
  };
687
691
 
688
692
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -5,6 +5,7 @@ import { FormStore, formInsert, submissionInsert } from '../forms/store';
5
5
  import { GuestStore } from '../mobile/guest-store';
6
6
  import { LiveStore } from '../live/store';
7
7
  import { PushStore } from '../push/store';
8
+ import { AnnouncementStore, announcementInsert } from '../announcements/store';
8
9
  import { EditorStore } from '../editor/store';
9
10
  import { PermissionStore } from '../permissions/store';
10
11
  import { BackupStore } from '../backup/store';
@@ -276,6 +277,7 @@ export class SqlRepository implements ContentRepository {
276
277
  readonly guests: GuestStore;
277
278
  readonly live: LiveStore;
278
279
  readonly push: PushStore;
280
+ readonly announcements: AnnouncementStore;
279
281
  readonly editor: EditorStore;
280
282
  readonly backups: BackupStore;
281
283
  readonly permissions: PermissionStore;
@@ -287,6 +289,7 @@ export class SqlRepository implements ContentRepository {
287
289
  this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
288
290
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
289
291
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
292
+ this.announcements = new AnnouncementStore(async () => { await this.ready; return this.driver; });
290
293
  this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
291
294
  this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
292
295
  this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
@@ -1823,6 +1826,9 @@ export class SqlRepository implements ContentRepository {
1823
1826
  JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
1824
1827
  ],
1825
1828
  })),
1829
+ // announcements (a backup made before them leaves the current ones). Restored
1830
+ // rows never push again here: the push ledger keeps announcement:<id>
1831
+ ...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
1826
1832
  ...(content.forms ?? []).map(formInsert),
1827
1833
  ...(content.formSubmissions ?? []).map(submissionInsert),
1828
1834
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
@@ -8,6 +8,8 @@ import type { LiveStore } from '../live/store';
8
8
  import type { BookingRequest } from '../backup/content';
9
9
  import type { EmailStore } from '../email/store';
10
10
  import type { PushStore } from '../push/store';
11
+ import type { AnnouncementStore } from '../announcements/store';
12
+ import type { Announcement } from '../announcements/types';
11
13
  import type { EditorStore } from '../editor/store';
12
14
  import type { PermissionStore } from '../permissions/store';
13
15
  export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
@@ -62,6 +64,7 @@ export interface ContentRepository {
62
64
  readonly guests: GuestStore;
63
65
  readonly live: LiveStore;
64
66
  readonly push: PushStore;
67
+ readonly announcements: AnnouncementStore;
65
68
  readonly editor: EditorStore;
66
69
  readonly permissions: PermissionStore;
67
70
  // Bounded public catalog; fetch limit + 1 IDs without loading every body.
@@ -319,6 +322,8 @@ export interface ContentRepository {
319
322
  places?: Place[];
320
323
  forms?: SavedForm[];
321
324
  formSubmissions?: FormSubmission[];
325
+ // added with announcements; a backup without them leaves the current ones
326
+ announcements?: Announcement[];
322
327
  }, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
323
328
 
324
329
  // imports (lib/import/apply.ts drives these). writeImported is the
@@ -4,6 +4,7 @@ import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
5
  import { decideOverdue, sendDueReminders } from './booking/service';
6
6
  import { runPushJobs } from './push/service';
7
+ import { runAnnouncementJobs } from './announcements/service';
7
8
  import { GUEST_IDLE_MS } from './mobile/guest-store';
8
9
  import { pushHeartbeat, pushUrlFor } from './monitoring';
9
10
  import { syncCachePurge } from './cache/purge';
@@ -22,6 +23,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
22
23
  approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
23
24
  email: () => dispatchEmail(repo, locals, 10),
24
25
  live: () => syncDueLive(repo, locals),
26
+ // scheduled announcements go up (with their push) and ended ones come down
27
+ announcements: () => runAnnouncementJobs(repo, { locals, now: startedAt }),
25
28
  // queued first-publish notices, starting-soon reminders, receipts, retention
26
29
  push: () => runPushJobs(repo, { locals, now: startedAt }),
27
30
  // expired guest sign-in codes and app sessions unused for 90 days
package/src/lib/schema.ts CHANGED
@@ -710,6 +710,7 @@ export const WEBHOOK_EVENTS = [
710
710
  'design.published', 'media.created', 'media.updated', 'media.deleted', 'site.restored',
711
711
  'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
712
712
  'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
713
+ 'announcement.published',
713
714
  ] as const;
714
715
  export type WebhookEventType = (typeof WEBHOOK_EVENTS)[number];
715
716
 
@@ -0,0 +1,18 @@
1
+ ---
2
+ import Admin from '../../layouts/Admin.astro';
3
+ import Announcements from '../../components/Announcements';
4
+ import { getRepo } from '../../lib/repo';
5
+ import { forbiddenUnless } from '../../lib/permissions/access';
6
+ const repo = await getRepo(Astro.locals);
7
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'services.manage');
8
+ if (denied) return denied;
9
+ ---
10
+ <Admin title="Announcements" section="announcements">
11
+ <div class="page-head">
12
+ <div>
13
+ <h1>Announcements</h1>
14
+ <p class="sub">Write it once: it goes on the announcements list, as a banner and, if you choose, as a push to the app.</p>
15
+ </div>
16
+ </div>
17
+ <Announcements client:only="react" />
18
+ </Admin>
@@ -5,6 +5,7 @@ import { getRepo } from '../../lib/repo';
5
5
  import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
6
6
  import { accessFor } from '../../lib/permissions/access';
7
7
  import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
8
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
8
9
  import { issueApiKey } from '../../lib/api-keys';
9
10
  import { sessionRole } from '../../lib/api';
10
11
  import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
@@ -243,7 +244,7 @@ MAPS_API_KEY=…`}</pre>
243
244
  <input type="hidden" name="op" value="hook" />
244
245
  <label class="field"><span>Endpoint URL</span><input type="url" class="mono" name="url" placeholder="https://hooks.zapier.com/…" required /></label>
245
246
  <div class="field"><span>Send these events</span>
246
- {WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
247
+ {WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.')) && (ANNOUNCEMENTS.enabled || !e.startsWith('announcement.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
247
248
  </div>
248
249
  <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add endpoint</button></div>
249
250
  <p class="hint modal-note">Deliveries are signed and retried 3 times over about an hour.</p>
@@ -4,6 +4,7 @@ import AiSettings from '../../components/AiSettings';
4
4
  import MonitoringSettings from '../../components/MonitoringSettings';
5
5
  import LegalSettings from '../../components/LegalSettings';
6
6
  import { LEGAL } from '../../lib/legal/config';
7
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
7
8
  import { getRepo } from '../../lib/repo';
8
9
  import { accessFor } from '../../lib/permissions/access';
9
10
  import type { Permission } from '../../lib/permissions/catalog';
@@ -381,6 +382,7 @@ const groups = [
381
382
  <label class="field"><span>Link (optional)</span><input type="text" name="annLink" value={settings.announcement.link} placeholder={`${CONTENT.posts.base}/announcement`} /></label>
382
383
  </div>
383
384
  <label class="check-row"><input type="checkbox" name="annDismissible" checked={settings.announcement.dismissible} /><span>Visitors can dismiss it <span class="hint">(remembered per browser until the text changes)</span></span></label>
385
+ {ANNOUNCEMENTS.enabled && <p class="hint">A pinned announcement (<a href="/admin/announcements">Announcements</a>) shows here instead while it’s up, in each language and on a schedule.</p>}
384
386
  </form>
385
387
  </section>
386
388
 
@@ -0,0 +1,35 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { requestLocale, localePath } from '../../lib/i18n';
5
+ import { siteLang } from '../../lib/dates';
6
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
7
+ import { isLive } from '../../lib/announcements/service';
8
+ import { announcementStrings } from '../../lib/announcements/words';
9
+ import { announcementView } from '../../lib/announcements/pages';
10
+ import '../../styles/announcements-site.css';
11
+
12
+ // One announcement while it's up (404 before it goes up and after it comes
13
+ // down). Pushes without a button open this page.
14
+ if (!ANNOUNCEMENTS.enabled || !ANNOUNCEMENTS.pages) return new Response(null, { status: 404, statusText: 'Not found' });
15
+ const repo = await getRepo(Astro.locals);
16
+ const id = Astro.params.id ?? '';
17
+ const now = Date.now();
18
+ const a = id.length <= 100 ? await repo.announcements.get(id) : null;
19
+ if (!a || !isLive(a, now) || !(a.listed || a.pinned)) return new Response(null, { status: 404, statusText: 'Not found' });
20
+ const settings = await repo.getSettings();
21
+ const locale = requestLocale(Astro.url, Astro.locals);
22
+ const lang = locale || siteLang(settings);
23
+ const t = announcementStrings(lang);
24
+ const v = announcementView(a, locale, lang, settings.timezone || 'UTC', now);
25
+ ---
26
+ <Site title={v.title} description={v.message}>
27
+ <article class="ann-page ann-detail" data-kind={v.kind}>
28
+ <p class="ann-back"><a href={localePath(ANNOUNCEMENTS.base, locale)}>← {t.back}</a></p>
29
+ <p class="ann-meta"><span class="ann-kind">{v.kindLabel}</span> · <time datetime={v.iso}>{v.date}</time>{v.until && <> · {v.until}</>}</p>
30
+ <h1>{v.title}</h1>
31
+ {v.message && <p class="ann-lead">{v.message}</p>}
32
+ {v.details.map((p) => <p>{p}</p>)}
33
+ {v.button && <p><a class="ann-button" href={v.button.href} rel={v.button.external ? 'noopener' : undefined}>{v.button.label}</a></p>}
34
+ </article>
35
+ </Site>
@@ -0,0 +1,61 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { requestLocale, localePath } from '../../lib/i18n';
5
+ import { siteLang } from '../../lib/dates';
6
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
7
+ import { ANNOUNCEMENT_KINDS, type AnnouncementKind } from '../../lib/announcements/types';
8
+ import { announcementStrings } from '../../lib/announcements/words';
9
+ import { announcementView, groupByWhen } from '../../lib/announcements/pages';
10
+ import '../../styles/announcements-site.css';
11
+
12
+ // The website's announcements list (acms({ announcements })): what's up
13
+ // right now, newest first, with a tab per kind. A site file at this path
14
+ // replaces it; GET /api/v1/announcements has the same data.
15
+ if (!ANNOUNCEMENTS.enabled || !ANNOUNCEMENTS.pages) return new Response(null, { status: 404, statusText: 'Not found' });
16
+ const repo = await getRepo(Astro.locals);
17
+ const settings = await repo.getSettings();
18
+ const locale = requestLocale(Astro.url, Astro.locals);
19
+ const lang = locale || siteLang(settings);
20
+ const t = announcementStrings(lang);
21
+ const tz = settings.timezone || 'UTC';
22
+ const now = Date.now();
23
+ const asked = Astro.url.searchParams.get('kind') ?? '';
24
+ const kind = (ANNOUNCEMENT_KINDS as readonly string[]).includes(asked) ? asked as AnnouncementKind : undefined;
25
+ const live = await repo.announcements.live(now, { limit: 200, offset: 0 });
26
+ // tabs only for kinds that have something up (urgent shows under All)
27
+ const kinds = ANNOUNCEMENT_KINDS.filter((k) => k !== 'urgent' && live.some((a) => a.kind === k));
28
+ const shown = live.filter((a) => !kind || a.kind === kind).map((a) => announcementView(a, locale, lang, tz, now));
29
+ const groups = groupByWhen(shown, lang, tz, now);
30
+ const base = localePath(ANNOUNCEMENTS.base, locale);
31
+ const tabs = kinds.length ? [{ href: base, label: t.all, current: !kind }, ...kinds.map((k) => ({ href: base + '?kind=' + k, label: t.kinds[k], current: kind === k }))] : [];
32
+ ---
33
+ <Site title={t.title} description={t.intro}>
34
+ <section class="ann-page">
35
+ <header class="ann-head">
36
+ <h1>{t.title}</h1>
37
+ <p>{t.intro}</p>
38
+ </header>
39
+ {tabs.length > 0 && (
40
+ <nav class="ann-tabs" aria-label={t.title}>
41
+ {tabs.map((tab) => <a href={tab.href} aria-current={tab.current ? 'page' : undefined}>{tab.label}</a>)}
42
+ </nav>
43
+ )}
44
+ {groups.length === 0 && <p class="ann-empty">{kind ? t.emptyKind : t.empty}</p>}
45
+ {groups.map((g) => (
46
+ <section class="ann-group" aria-labelledby={'ann-' + g.key}>
47
+ <h2 id={'ann-' + g.key}>{g.label}</h2>
48
+ <ul class="ann-list">
49
+ {g.items.map((a) => (
50
+ <li class="ann-item" data-kind={a.kind}>
51
+ <p class="ann-meta"><span class="ann-kind">{a.kindLabel}</span> · <time datetime={a.iso}>{a.date}</time></p>
52
+ <h3><a href={a.href}>{a.title}</a></h3>
53
+ {a.message && <p class="ann-message">{a.message}</p>}
54
+ {a.button && <a class="ann-action" href={a.button.href} rel={a.button.external ? 'noopener' : undefined}>{a.button.label} →</a>}
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ </section>
59
+ ))}
60
+ </section>
61
+ </Site>
@@ -0,0 +1,32 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+ import { ApiError, apiError, json, readJson } from '../../lib/api';
4
+ import { OWNER_ENV } from '../../lib/auth';
5
+ import { validateRequestSession } from '../../lib/auth/request';
6
+ import { requirePermission } from '../../lib/permissions/access';
7
+ import { waitUntilFrom } from '../../lib/webhooks';
8
+ import { AdminAnnouncementAction, adminAnnouncementAction, adminAnnouncementsState, requireAnnouncements } from '../../lib/announcements/api';
9
+
10
+ // Admin → Announcements: the people who can send pushes (Connected services).
11
+ export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
12
+ try {
13
+ requireAnnouncements();
14
+ const repo = await getRepo(locals);
15
+ await requirePermission(request, cookies, repo, 'services.manage');
16
+ return json(await adminAnnouncementsState(repo, url));
17
+ } catch (e) { return apiError(e); }
18
+ };
19
+ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
20
+ try {
21
+ requireAnnouncements();
22
+ const repo = await getRepo(locals);
23
+ await requirePermission(request, cookies, repo, 'services.manage');
24
+ // publishing can reach every installed app: only from this site's admin
25
+ if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
26
+ const input = AdminAnnouncementAction.parse(await readJson(request, 100_000));
27
+ const id = await validateRequestSession(request, cookies, repo);
28
+ const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
29
+ const result = await adminAnnouncementAction(repo, input, { url, author, locals, waitUntil: waitUntilFrom(locals), fallbackOrigin: url.origin });
30
+ return json({ ...result, state: input.action === 'count' ? undefined : await adminAnnouncementsState(repo, url) });
31
+ } catch (e) { return apiError(e); }
32
+ };
@@ -0,0 +1,7 @@
1
+ import { getAnnouncement } from '../../../../lib/announcements/api';
2
+ import { readRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const GET = readRoute(getAnnouncement);
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,8 @@
1
+ import { listAnnouncements } from '../../../../lib/announcements/api';
2
+ import { readRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ // announcements go up and come down with the clock: not timeless
6
+ export const GET = readRoute(listAnnouncements);
7
+ export const HEAD = GET;
8
+ export const ALL = () => methodNotAllowed();
@@ -23,6 +23,7 @@ design.published: a design draft is published, including its navigation and sect
23
23
  Restoring design history only stages a draft; publishing it emits this event.
24
24
  media.created/updated/deleted: a media record is added, its alt text changes, or it is removed.
25
25
  site.restored: a content backup is restored; resync instead of expecting individual record events.
26
+ announcement.published: an announcement goes up (acms({ announcements }); scheduled ones when their time comes).
26
27
  Existing event.* and booking.* payloads are described at /api/calendar/docs.
27
28
 
28
29
  ## Payloads
@@ -34,6 +35,7 @@ page.* data: { "page": { "id", "slug", "title", "status", "locale", "isHome" },
34
35
  design.published data: { "design": { "revision", "activeTheme", "navigationChanged", "sectionPageIds" } }
35
36
  media.* data: { "media": { "id", "key", "filename", "mime", "width", "height", "size", "alt" } }
36
37
  site.restored data: { "counts": { "posts", "pages", "media" } }
38
+ announcement.published data: { "announcement": { the GET /api/v1/announcements/<id> item in the main language, plus "status" } }
37
39
 
38
40
  Content notifications carry metadata, not document bodies or pending drafts.
39
41
  On publication/update, fetch current public content from /api/v1/content/<id>?kind=post (or kind=page).
@@ -0,0 +1,32 @@
1
+ /* Built-in /announcements pages: on the page background (no card), the
2
+ kind in words first, urgent marked by its label rather than colour alone. */
3
+ .ann-page { max-width: 720px; margin: 0 auto; padding: 40px 20px 72px; }
4
+ .ann-head h1, .ann-detail h1 { font-size: clamp(28px, 4vw, 36px); letter-spacing: -0.03em; margin: 0 0 6px; }
5
+ .ann-head p { color: var(--muted); margin: 0 0 24px; }
6
+ .ann-tabs { display: flex; flex-wrap: wrap; gap: 4px 18px; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
7
+ .ann-tabs a { color: var(--muted); text-decoration: none; padding: 10px 0; min-height: 44px; display: inline-flex; align-items: center; border-bottom: 2px solid transparent; margin-bottom: -1px; }
8
+ .ann-tabs a:hover { color: var(--ink); }
9
+ .ann-tabs a[aria-current='page'] { color: var(--ink); border-bottom-color: var(--ink); font-weight: 600; }
10
+ .ann-group h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 28px 0 4px; }
11
+ .ann-list { list-style: none; margin: 0; padding: 0; }
12
+ .ann-item { padding: 16px 0; border-bottom: 1px solid var(--line); }
13
+ .ann-item:last-child { border-bottom: 0; }
14
+ .ann-meta { font-size: 13px; color: var(--muted); margin: 0 0 4px; }
15
+ .ann-kind { font-weight: 600; color: var(--ink); }
16
+ [data-kind='urgent'] .ann-kind { text-transform: uppercase; letter-spacing: 0.04em; }
17
+ .ann-item h3 { font-size: 18px; margin: 0 0 4px; letter-spacing: -0.01em; }
18
+ .ann-item h3 a { color: inherit; text-decoration: none; }
19
+ .ann-item h3 a:hover { text-decoration: underline; text-underline-offset: 3px; }
20
+ .ann-message { margin: 0 0 6px; }
21
+ .ann-action { font-weight: 600; color: var(--accent-text, var(--accent)); text-decoration: none; display: inline-block; padding: 4px 0; }
22
+ .ann-action:hover { text-decoration: underline; text-underline-offset: 3px; }
23
+ .ann-empty { color: var(--muted); padding: 32px 0; }
24
+ .ann-back { margin: 0 0 20px; }
25
+ .ann-back a { color: var(--muted); text-decoration: none; }
26
+ .ann-back a:hover { color: var(--ink); }
27
+ .ann-lead { font-size: 18px; }
28
+ .ann-button {
29
+ display: inline-flex; align-items: center; min-height: 44px; padding: 0 20px; border-radius: var(--r-sm);
30
+ background: var(--btn); color: var(--btn-ink); text-decoration: none; font-weight: 600;
31
+ }
32
+ .ann-button:hover { opacity: 0.92; }
@@ -0,0 +1,210 @@
1
+ /* Admin → Announcements island. Scoped under .an; admin.css tokens only, except
2
+ the lock-screen and website-banner previews, which show a phone and the
3
+ public site (fixed device/site colours, like mobile-app.css). Kind colours
4
+ reuse the status tokens; red is for urgent only. No pills: statuses are
5
+ coloured text, controls 8–10px rects. */
6
+ .an { display: flex; flex-direction: column; gap: 16px; }
7
+ .an .card + .card { margin-top: 0; }
8
+ .an code { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; overflow-wrap: anywhere; }
9
+ .an [data-kind='event'] { --an-c: var(--blue); --an-bg: var(--blue-soft); }
10
+ .an [data-kind='registration'] { --an-c: var(--green); --an-bg: var(--green-soft); }
11
+ .an [data-kind='help'] { --an-c: var(--amber); --an-bg: var(--amber-soft); }
12
+ .an [data-kind='info'] { --an-c: var(--text-2); --an-bg: var(--sel); }
13
+ .an [data-kind='urgent'] { --an-c: var(--red); --an-bg: var(--red-soft); }
14
+
15
+ /* skeleton */
16
+ .an-skel { display: grid; gap: 12px; }
17
+ .an-skel i { display: block; height: 44px; border-radius: var(--r-lg); background: var(--panel-2); animation: an-pulse 1.2s ease-in-out infinite alternate; }
18
+ .an-skel i:nth-child(2) { height: 320px; }
19
+ .an-skel i:nth-child(3) { height: 20px; width: 50%; }
20
+ @keyframes an-pulse { to { opacity: 0.45; } }
21
+ @media (prefers-reduced-motion: reduce) { .an-skel i { animation: none; } }
22
+
23
+ /* flash (an always-present live region; no gap while empty) */
24
+ .an-flash .flash { margin: 0; }
25
+ .an > .an-flash:empty { margin-bottom: -16px; }
26
+ .an .flash.an-warn { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
27
+ .modal-body .an-flash .flash { margin-top: 12px; }
28
+
29
+ /* segmented controls */
30
+ .an-seg { max-width: 100%; }
31
+ .an-seg > button { font-size: 12.5px; padding: 6px 12px; min-height: 32px; }
32
+ .an-seg > button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
33
+ .an-seg > button:disabled { cursor: default; }
34
+ .an-seg > button:disabled:not([aria-selected='true']) { opacity: 0.5; }
35
+ .an-n { font-style: normal; font-weight: 500; color: var(--faint); font-variant-numeric: tabular-nums; margin-left: 3px; }
36
+
37
+ /* list */
38
+ .an-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
39
+ .an-list { overflow: hidden; }
40
+ .an-rows { list-style: none; margin: 0; padding: 0; }
41
+ .an-row { display: grid; grid-template-columns: 132px minmax(0, 1fr) auto; gap: 6px 16px; align-items: start; padding: 14px 20px; border-bottom: 1px solid var(--line); }
42
+ .an-row:last-child { border-bottom: 0; }
43
+ .an-row:hover { background: var(--hover); }
44
+ .an-kind { display: inline-flex; align-items: center; gap: 8px; min-width: 0; font-size: 12px; font-weight: 600; color: var(--an-c); }
45
+ .an-kind i { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto; border-radius: var(--r-sm); background: var(--an-bg); }
46
+ .an-kind svg { width: 14px; height: 14px; }
47
+ .an-row-main { min-width: 0; }
48
+ .an-row-title { display: block; font-size: 13.5px; font-weight: 600; color: var(--text); overflow-wrap: anywhere; }
49
+ .an-row-title.is-empty { color: var(--muted); font-weight: 500; }
50
+ .an-row-msg { margin: 1px 0 0; color: var(--text-2); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51
+ .an-row-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin-top: 6px; font-size: 12px; color: var(--muted); }
52
+ .an-where { display: inline-flex; gap: 4px; }
53
+ .an-chip { font-size: 11px; font-weight: 600; color: var(--text-2); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 0 6px; line-height: 18px; }
54
+ .an-tone { font-weight: 600; }
55
+ .an-tone[data-tone='ok'] { color: var(--green); }
56
+ .an-tone[data-tone='warn'] { color: var(--amber); }
57
+ .an-tone[data-tone='bad'] { color: var(--red); }
58
+ .an-row-actions { display: flex; gap: 4px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
59
+ .an-empty { padding: 40px 20px 44px; }
60
+ .an-empty p { margin: 0; }
61
+ .an-foot { margin: 0 2px; line-height: 1.5; display: flex; flex-wrap: wrap; gap: 4px 16px; }
62
+ .an-del { margin: 0; font-size: 13.5px; color: var(--text-2); overflow-wrap: anywhere; }
63
+
64
+ /* composer: head */
65
+ .an-head { display: flex; flex-direction: column; gap: 4px; }
66
+ .an-back { align-self: flex-start; border: 0; background: none; font: inherit; font-size: 13px; font-weight: 500; color: var(--muted); padding: 6px 8px; margin-left: -8px; min-height: 32px; border-radius: var(--r-sm); cursor: pointer; }
67
+ .an-back:hover { color: var(--text); background: var(--hover); }
68
+ .an-back:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
69
+ .an-head-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
70
+ .an-head h2 { font-size: 17px; margin: 0; letter-spacing: -0.01em; }
71
+ .an-head h2:focus { outline: none; }
72
+ .an-status { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
73
+
74
+ /* composer: form */
75
+ .an-compose { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 32px; align-items: start; }
76
+ .an-form { min-width: 0; }
77
+ .an-sec { padding: 18px 0; border-top: 1px solid var(--line); min-width: 0; }
78
+ .an-sec:first-child { border-top: 0; padding-top: 0; }
79
+ .an-sec:last-child { padding-bottom: 0; }
80
+ .an-fs { border: 0; padding: 0; margin: 0; min-width: 0; }
81
+ .an-legend { padding: 0; margin: 0 0 8px; font-size: 13.5px; font-weight: 600; color: var(--text); }
82
+ .an-opt-l { font-weight: 400; color: var(--muted); }
83
+ .an-sec-hint { margin: -6px 0 10px; }
84
+ .an-after { margin: 8px 0 0; line-height: 1.45; }
85
+ .an-ro { margin: 0; font-size: 13.5px; color: var(--text-2); }
86
+ .an-bad { color: var(--red); margin: -6px 0 12px; }
87
+ .an-linkhint { margin: -6px 0 0; }
88
+ .an-sec .field:last-child { margin-bottom: 0; }
89
+
90
+ /* kind: radio cards (selected = --sel fill, like the event editor's mode cards) */
91
+ .an-kinds { display: flex; flex-wrap: wrap; gap: 8px; }
92
+ .an-kind-opt { position: relative; flex: 1 1 104px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--r-md); background: var(--panel); font-size: 12.5px; font-weight: 600; color: var(--text-2); cursor: pointer; }
93
+ .an-kind-opt:hover { border-color: var(--faint); }
94
+ .an-kind-opt.on { background: var(--sel); border-color: var(--muted); color: var(--text); }
95
+ .an-kind-opt:has(input:focus-visible) { box-shadow: 0 0 0 3px var(--accent-ring); }
96
+ .an-kind-opt:has(input:disabled) { opacity: 0.5; cursor: not-allowed; }
97
+ .an-kind-opt input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }
98
+ .an-kind-opt i { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--r-sm); background: var(--an-bg); color: var(--an-c); }
99
+ .an-kind-opt svg { width: 15px; height: 15px; }
100
+ .an-kind-opt span { overflow-wrap: anywhere; }
101
+
102
+ /* words */
103
+ .an-langs { margin-bottom: 14px; }
104
+ .an-field { margin-bottom: 14px; }
105
+ .an-field-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 5px; }
106
+ .an-field-top label { font-size: 12px; color: var(--muted); font-weight: 500; }
107
+ .an-count { font-style: normal; color: var(--faint); font-size: 12px; font-variant-numeric: tabular-nums; flex: 0 0 auto; }
108
+ .an-count[data-near] { color: var(--amber); }
109
+ .an-field > small.hint { display: block; margin-top: 5px; }
110
+ .an-src { margin: 0 0 6px; padding: 7px 10px; border-radius: var(--r-sm); background: var(--panel-2); color: var(--text-2); font-size: 12.5px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; max-height: 7.5em; overflow: auto; }
111
+ .an-src span { display: block; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 1px; }
112
+
113
+ /* where */
114
+ .an-toggle { display: grid; grid-template-columns: 18px minmax(0, 1fr); column-gap: 10px; align-items: center; }
115
+ .an-toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; cursor: pointer; }
116
+ .an-toggle label { display: flex; align-items: center; min-height: 36px; font-size: 13.5px; color: var(--text); cursor: pointer; }
117
+ .an-toggle small { grid-column: 2; margin: -6px 0 6px; line-height: 1.45; }
118
+ .an-toggle:has(input:disabled) input, .an-toggle:has(input:disabled) label { cursor: default; }
119
+ .an-toggle:has(input:disabled) label { color: var(--muted); }
120
+
121
+ /* who gets the push (topic choices as in Mobile app) */
122
+ .an-topics { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; margin-top: 10px; }
123
+ .an-opt { display: flex; align-items: center; gap: 9px; padding: 9px 11px; min-height: 40px; border: 1px solid var(--line-strong); border-radius: var(--r-md); background: var(--panel); cursor: pointer; font-size: 13px; }
124
+ .an-opt:hover { background: var(--hover); }
125
+ .an-opt.on { background: var(--sel); color: var(--text); font-weight: 600; }
126
+ .an-opt:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
127
+ .an-opt input { accent-color: var(--accent); margin: 0; flex: 0 0 auto; }
128
+ .an-opt span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
129
+ .an-opt em { font-style: normal; font-weight: 500; color: var(--faint); font-size: 12px; font-variant-numeric: tabular-nums; }
130
+ .an-pace { margin: 12px 0 0; line-height: 1.45; }
131
+ .an-pace[data-tone='warn'] { color: var(--amber); }
132
+
133
+ /* when */
134
+ .an-when { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; }
135
+ .an-when input[type='datetime-local'] { display: block; margin-top: 8px; min-width: 0; max-width: 260px; }
136
+ :root[data-theme='dark'] .an input[type='datetime-local'] { color-scheme: dark; }
137
+
138
+ /* preview: lock screen (device colours) */
139
+ .an-preview { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 10px; position: sticky; top: 72px; min-width: 0; }
140
+ .an-preview-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; align-self: stretch; flex-wrap: wrap; }
141
+ .an-preview-lang { width: auto; max-width: 130px; padding: 4px 8px; font-size: 12.5px; }
142
+ .an-phone { width: 100%; max-width: 280px; min-height: 350px; border-radius: 42px 42px 0 0; padding: 9px 9px 0; background: #1d1c1a; box-shadow: inset 0 0 0 1px #34322e; display: flex; }
143
+ .an-phone.is-off { opacity: 0.6; }
144
+ .an-phone-screen { flex: 1; border-radius: 34px 34px 0 0; background: #33424b; color: #f7f5f2; display: flex; flex-direction: column; align-items: center; padding: 46px 11px 16px; overflow: hidden; min-width: 0; }
145
+ .an-clock { text-align: center; }
146
+ .an-clock small { display: block; font-size: 13px; font-weight: 500; opacity: 0.88; }
147
+ .an-clock b { display: block; font-size: 62px; line-height: 1.05; font-weight: 600; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
148
+ .an-note { margin-top: 30px; width: 100%; background: rgb(246 245 243 / 86%); color: #1c1b19; border-radius: 18px; padding: 10px 12px 11px; box-shadow: 0 6px 18px rgb(0 0 0 / 18%); }
149
+ .an-note-top { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: #5d5a55; margin-bottom: 5px; }
150
+ .an-appicon { width: 20px; height: 20px; border-radius: 5px; background: #fdfcfb; color: #3b3935; display: grid; place-items: center; overflow: hidden; flex: 0 0 auto; box-shadow: inset 0 0 0 1px rgb(0 0 0 / 8%); }
151
+ .an-appicon svg { width: 13px; height: 13px; }
152
+ .an-appicon img { width: 100%; height: 100%; object-fit: cover; }
153
+ .an-appname { font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
154
+ .an-now { margin-left: auto; flex: 0 0 auto; }
155
+ /* collapsed: one line of title, two of message; expanded: everything */
156
+ .an-note-title { font-size: 13.5px; line-height: 1.3; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
157
+ .an-note-body { margin: 1px 0 0; font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
158
+ .an-note.is-open .an-note-title, .an-note.is-open .an-note-body { display: block; -webkit-line-clamp: unset; overflow: visible; }
159
+ .an-cap { margin: 0; align-self: stretch; text-align: center; border-top: 1px solid var(--line); padding-top: 10px; }
160
+
161
+ /* preview: website banner (site colours) */
162
+ .an-site { align-self: stretch; }
163
+ .an-site-label { display: block; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-bottom: 6px; }
164
+ .an-site-bar { display: flex; align-items: center; gap: 8px; padding: 9px 10px 9px 11px; border-radius: 10px; background: #f3f1ed; color: #1c1b19; font-size: 12.5px; box-shadow: inset 0 0 0 1px rgb(0 0 0 / 7%); }
165
+ .an-site-bar i { display: grid; place-items: center; width: 18px; height: 18px; flex: 0 0 auto; color: #4c4841; }
166
+ .an-site-bar svg { width: 16px; height: 16px; }
167
+ .an-site-bar b { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
168
+ .an-site-btn { flex: 0 0 auto; max-width: 45%; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
169
+ .an-site-x { flex: 0 0 auto; opacity: 0.55; font-size: 11px; }
170
+ .an-site-bar[data-kind='event'] i { color: #2f5fb3; }
171
+ .an-site-bar[data-kind='registration'] i { color: #127442; }
172
+ .an-site-bar[data-kind='help'] i { color: #93660c; }
173
+ .an-site-bar[data-kind='urgent'] { background: #b42318; color: #fff; box-shadow: none; }
174
+ .an-site-bar[data-kind='urgent'] i { color: #fff; }
175
+
176
+ /* review dialog */
177
+ dialog.modal.an-review { width: min(540px, calc(100vw - 32px)); }
178
+ .an-checks { list-style: none; margin: 0; padding: 0; }
179
+ .an-checks li { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 13px; line-height: 1.45; color: var(--text-2); overflow-wrap: anywhere; }
180
+ .an-checks li:last-child { border-bottom: 0; }
181
+ .an-checks li[data-tone='bad'] { color: var(--red); }
182
+ .an-mark { font-weight: 700; text-align: center; }
183
+ .an-checks li[data-tone='ok'] .an-mark { color: var(--green); }
184
+ .an-checks li[data-tone='warn'] .an-mark { color: var(--amber); }
185
+ .an-checks li[data-tone='bad'] .an-mark { color: var(--red); }
186
+ .an-checks .btn { white-space: nowrap; }
187
+
188
+ @media (max-width: 1000px) {
189
+ .an-compose { grid-template-columns: 1fr; gap: 24px; }
190
+ .an-preview { position: static; border-top: 1px solid var(--line); padding-top: 18px; }
191
+ .an-phone { max-width: 260px; min-height: 300px; }
192
+ .an-clock b { font-size: 52px; }
193
+ }
194
+ @media (max-width: 767px) {
195
+ .an-bar .seg { display: flex; width: 100%; }
196
+ .an-bar .seg > button { flex: 1; padding: 6px 4px; min-height: 36px; }
197
+ .an-bar > .btn.primary { width: 100%; justify-content: center; min-height: 44px; }
198
+ .an-row { grid-template-columns: minmax(0, 1fr); padding: 14px 16px; }
199
+ .an-row-actions { justify-content: flex-start; }
200
+ .an-head-row .page-actions { width: 100%; }
201
+ .an-head-row .page-actions .btn { flex: 1 1 auto; justify-content: center; min-height: 44px; white-space: nowrap; }
202
+ .an-sec .seg { display: flex; }
203
+ .an-sec .seg > button { flex: 1; min-height: 36px; white-space: normal; }
204
+ .an-when { grid-template-columns: 1fr; }
205
+ .an-when input[type='datetime-local'] { max-width: none; }
206
+ .an-topics { grid-template-columns: 1fr; }
207
+ .an-toggle label { min-height: 44px; }
208
+ .an-checks li { grid-template-columns: 18px minmax(0, 1fr); }
209
+ .an-checks .btn { grid-column: 2; justify-self: start; }
210
+ }
@@ -535,12 +535,17 @@ li::marker { color: var(--muted); }
535
535
  background: var(--btn); color: var(--btn-ink);
536
536
  font-size: 14px; padding: 9px 44px; text-align: center; position: relative;
537
537
  }
538
+ /* hidden until its script checks it wasn't closed (display: flex would beat the attribute) */
539
+ .announcement-bar[hidden] { display: none; }
538
540
  .announcement-bar a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
539
541
  .announcement-x {
540
542
  position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
541
543
  background: none; border: 0; color: inherit; opacity: 0.7; cursor: pointer; font-size: 12px; padding: 6px; border-radius: 6px;
542
544
  }
543
545
  .announcement-x:hover { opacity: 1; background: color-mix(in srgb, currentColor 12%, transparent); }
546
+ /* pinned announcements: urgent says so in words, never by colour alone */
547
+ .announcement-bar .ann-urgent { font-weight: 700; letter-spacing: 0.01em; }
548
+ .announcement-bar .ann-cta { white-space: nowrap; }
544
549
 
545
550
  /* dynamic post-list blocks (expanded at request time) */
546
551
  .dyn-postlist { display: grid; gap: 24px; margin: 32px 0; }
package/src/virtual.d.ts CHANGED
@@ -23,6 +23,7 @@ declare module 'virtual:acms-config' {
23
23
  roles?: { key: string; label: string; summary: string; admin: boolean; home: string }[];
24
24
  permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];
25
25
  members?: { enabled: boolean; base: string; notify: string[] };
26
+ announcements?: { enabled: boolean; base: string; pages: boolean; topics: Partial<Record<'event' | 'registration' | 'help' | 'info', string>> };
26
27
  // resolved by integration.mjs (lib/calendar/config.ts documents the shape)
27
28
  calendar?: {
28
29
  enabled: boolean;