@acms-subir/core 0.3.51 → 0.3.53

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 (55) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cli/migrations.generated.mjs +16 -1
  3. package/docs/calendar.md +24 -10
  4. package/docs/mobile.md +1 -1
  5. package/integration.mjs +5 -0
  6. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  7. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  8. package/package.json +1 -1
  9. package/src/components/ThemeSections.astro +2 -2
  10. package/src/components/display/ScreensManager.tsx +481 -0
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/layouts/Admin.astro +2 -0
  22. package/src/lib/backup/content.ts +10 -1
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/display/board.ts +4 -3
  25. package/src/lib/display/screens.ts +111 -0
  26. package/src/lib/display/store.ts +113 -0
  27. package/src/lib/display/words.ts +16 -6
  28. package/src/lib/dynamicBlocks.ts +4 -2
  29. package/src/lib/forms/fields.ts +14 -9
  30. package/src/lib/forms/localize.ts +68 -0
  31. package/src/lib/forms/render.ts +8 -2
  32. package/src/lib/forms/schema.ts +31 -2
  33. package/src/lib/forms/service.ts +9 -1
  34. package/src/lib/forms/validate.ts +16 -4
  35. package/src/lib/forms/words.ts +32 -0
  36. package/src/lib/mobile/forms.ts +45 -13
  37. package/src/lib/mobile/openapi.ts +3 -3
  38. package/src/lib/mobile/rsvp.ts +4 -2
  39. package/src/lib/mobile/schemas.ts +3 -1
  40. package/src/lib/repo/migrations.ts +3 -0
  41. package/src/lib/repo/sql.ts +5 -0
  42. package/src/lib/repo/types.ts +5 -0
  43. package/src/pages/[...slug].astro +1 -1
  44. package/src/pages/admin/calendar/screens/index.astro +24 -0
  45. package/src/pages/admin/calendar/screens/preview.astro +36 -0
  46. package/src/pages/admin/forms/[id].astro +6 -0
  47. package/src/pages/api/calendar/screens.ts +30 -0
  48. package/src/pages/blog/[slug].astro +1 -1
  49. package/src/pages/display/s/[key].astro +48 -0
  50. package/src/pages/events/[slug].astro +9 -3
  51. package/src/pages/forms/[id].astro +22 -4
  52. package/src/pages/index.astro +1 -1
  53. package/src/styles/display.css +2 -1
  54. package/src/styles/forms.css +8 -0
  55. package/src/styles/screens-admin.css +53 -0
@@ -0,0 +1,30 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { ApiError, apiError, json, readJson } from '../../../lib/api';
4
+ import { requirePermission } from '../../../lib/permissions/access';
5
+ import { CALENDAR } from '../../../lib/calendar/config';
6
+ import { siteOrigin } from '../../../lib/seo';
7
+ import { ScreenAction, screenAction, screensState } from '../../../lib/display/screens';
8
+
9
+ // Admin → Calendar → Screens: saved TV screens, for people who manage rooms
10
+ // and calendars (a screen's link can show private calendars).
11
+ export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
12
+ try {
13
+ if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar is off on this site.');
14
+ const repo = await getRepo(locals);
15
+ const access = await requirePermission(request, cookies, repo, 'schedule.manage');
16
+ return json(await screensState(repo, siteOrigin(await repo.getSettings(), url), { bookings: access.can('schedule.bookings') }));
17
+ } catch (e) { return apiError(e); }
18
+ };
19
+ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
20
+ try {
21
+ if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar is off on this site.');
22
+ const repo = await getRepo(locals);
23
+ const access = await requirePermission(request, cookies, repo, 'schedule.manage');
24
+ // a screen's key unlocks private calendars: 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 bookings = access.can('schedule.bookings');
27
+ const result = await screenAction(repo, ScreenAction.parse(await readJson(request, 20_000)), { bookings });
28
+ return json({ ...result, state: await screensState(repo, siteOrigin(await repo.getSettings(), url), { bookings }) });
29
+ } catch (e) { return apiError(e); }
30
+ };
@@ -63,7 +63,7 @@ const po = design.post;
63
63
  const PostTemplate = (post.template ? design.theme.postTemplates[post.template] : null) as any;
64
64
  const tagSlug = (name: string) =>
65
65
  tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
66
- const html = highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo));
66
+ const html = highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo, locale));
67
67
  const fmt = (ts: number | null) => formatDate(ts, settings);
68
68
  const words = (post.bodyHtml || post.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
69
69
  const readMins = Math.max(1, Math.round(words / 220));
@@ -0,0 +1,48 @@
1
+ ---
2
+ import DisplayPage from '../../../components/display/DisplayPage.astro';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { CALENDAR } from '../../../lib/calendar/config';
5
+ import { I18N, localePath, requestLocale } from '../../../lib/i18n';
6
+ import { siteOrigin } from '../../../lib/seo';
7
+ import { siteLang } from '../../../lib/dates';
8
+ import { defaultDisplaySettings, readDisplaySettings } from '../../../lib/display/settings';
9
+ import { loadDisplayBoard } from '../../../lib/display/board';
10
+ import { displayHeaders } from '../../../lib/display/http';
11
+ import { MISS_WINDOW, openScreen } from '../../../lib/display/screens';
12
+
13
+ // A saved screen (Admin → Calendar → Screens): its long random key is the
14
+ // whole address. With "Show private calendars and room bookings" on, it shows
15
+ // the private calendars it names and its rooms' bookings, with titles, rooms,
16
+ // hosts and the name on a booking; never emails, phone numbers or notes.
17
+ // The board's 5-minute refresh fetches this same address, so a rotated key
18
+ // stops the TV at once and every fetch counts as the screen's check-in.
19
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
20
+ const repo = await getRepo(Astro.locals);
21
+ let address = 'unknown';
22
+ try { address = Astro.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
23
+ const opened = await openScreen(repo, Astro.params.key ?? '', address);
24
+ if (opened.status === 'limited') {
25
+ return new Response('Too many wrong screen links from this network. Try again in a few minutes.', {
26
+ status: 429,
27
+ headers: { 'Retry-After': String(MISS_WINDOW / 1000), 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'Content-Type': 'text/plain; charset=utf-8' },
28
+ });
29
+ }
30
+ const site = await repo.getSettings();
31
+ // the address's language prefix (/es/display/s/…) picks the worker's scope
32
+ const pathLang = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
33
+ const prefix = I18N.enabled && pathLang && pathLang !== I18N.defaultLocale ? `/${pathLang}` : '';
34
+ const screen = opened.status === 'ok' ? opened.screen : null;
35
+ const settings = screen ? readDisplaySettings(new URLSearchParams(screen.params)) : defaultDisplaySettings();
36
+ const lang = screen?.locale || (prefix ? pathLang : '') || siteLang(site);
37
+ const board = screen
38
+ ? await loadDisplayBoard(repo, site, {
39
+ settings, privateDetails: screen.privateDetails, lang,
40
+ phoneLink: siteOrigin(site, Astro.url) + localePath(CALENDAR.base, lang),
41
+ })
42
+ : null;
43
+ if (screen) await repo.screens.checkIn(screen.id);
44
+ else Astro.response.status = 404;
45
+ displayHeaders(Astro.response.headers, { shared: false });
46
+ ---
47
+
48
+ <DisplayPage site={site} board={board} settings={settings} title={screen?.name || site.siteTitle} lang={lang} live sw={`${prefix}/display/sw.js`} />
@@ -3,6 +3,8 @@ import { ApiError } from '../../lib/api';
3
3
  import { starter } from '../../lib/forms/schema';
4
4
  import { formVersion } from '../../lib/forms/service';
5
5
  import { renderFields, hiddenFields } from '../../lib/forms/render';
6
+ import { localizeForm } from '../../lib/forms/localize';
7
+ import { formStrings } from '../../lib/forms/words';
6
8
  import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
7
9
  import { submitEventForm, prepareLegacyEventForm } from '../../lib/forms/events';
8
10
  import { I18N, localePath, requestLocale } from '../../lib/i18n';
@@ -74,22 +76,26 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
74
76
  }
75
77
  }
76
78
  const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
79
+ // the questions in the page's language (main-language words where the form has none)
80
+ const fieldsHtml = renderFields(localizeForm(definition, isExtraLocale(locale) ? locale : ''), formValues, 'event-registration', formStrings((I18N.enabled ? locale : '') || siteLang(settings)));
77
81
  const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
78
82
  // the event's RSVP deadline for this date (null: open until it ends)
79
83
  const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
80
84
  // waitlist: a full date offers "Join the waitlist" (the same form, posting waitlist=1)
81
85
  const rsvp = event.rsvp.enabled
82
- ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues,
86
+ ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml, values: coreValues,
83
87
  open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt, waitlist: waitlistOn(event) }
84
88
  : null;
85
89
  const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
86
- const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
90
+ const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo, locale));
87
91
  const upcoming = (await upcomingEvents(repo, settings, now, { limit: 4 })).filter((u) => u.event.id !== event.id).slice(0, 3);
88
92
  const pageUrl = origin + eventUrl(event.slug);
89
93
  const jsonLd = eventJsonLd(event, occurrence, settings, origin, pageUrl);
90
94
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
95
  const Template = resolveDesign(settings).theme.EventTemplate as any;
92
- const props = { event, occurrence, settings, calendars, html, upcoming, origin, preview: event.status !== 'published', rsvp };
96
+ // templates read the form's title, introduction and button from the event: in the page's language too
97
+ const shownEvent = event.rsvp.form && isExtraLocale(locale) ? { ...event, rsvp: { ...event.rsvp, form: localizeForm(event.rsvp.form, locale) } } : event;
98
+ const props = { event: shownEvent, occurrence, settings, calendars, html, upcoming, origin, preview: event.status !== 'published', rsvp };
93
99
  ---
94
100
 
95
101
  <Site
@@ -5,12 +5,24 @@ import { renderForm } from '../../lib/forms/render';
5
5
  import { collectResponse } from '../../lib/forms/service';
6
6
  import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
7
7
  import { saveResponse } from '../../lib/forms/alerts';
8
+ import { localizeForm } from '../../lib/forms/localize';
9
+ import { formStrings, thanksText } from '../../lib/forms/words';
8
10
  import { ApiError } from '../../lib/api';
11
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
12
+ import { isExtraLocale } from '../../lib/translations';
13
+ import { siteLang } from '../../lib/dates';
9
14
  const repo = await getRepo(Astro.locals);
10
15
  const form = await repo.forms.get(Astro.params.id || '');
11
16
  if (!form || form.status !== 'published' || form.kind !== 'form' || form.purpose !== 'general') return new Response(null, { status: 404 });
12
17
  Astro.response.headers.set('Cache-Control', 'private, no-store');
13
18
  const embed = Astro.url.searchParams.has('embed');
19
+ // /es/forms/<id>: the form's Spanish words (main-language ones where it has
20
+ // none) and the page's own words in Spanish; answers are stored the same
21
+ const locale = requestLocale(Astro.url, Astro.locals);
22
+ const formLocale = isExtraLocale(locale) ? locale : '';
23
+ const lang = (I18N.enabled ? locale : '') || siteLang(await repo.getSettings());
24
+ const shown = localizeForm(form.definition, formLocale);
25
+ const path = localePath(`/forms/${encodeURIComponent(form.id)}`, locale);
14
26
  let error = '', success = false, values: Record<string,string> = {};
15
27
  let requestId: string = crypto.randomUUID();
16
28
  if (Astro.request.method === 'POST') {
@@ -18,11 +30,17 @@ if (Astro.request.method === 'POST') {
18
30
  const data = await readForm(Astro.request);
19
31
  requestId = await submissionGuard(repo, data, Astro.clientAddress, `form:${form.id}`);
20
32
  values = readAnswers(data, form.definition);
21
- const response = await collectResponse(form.definition, values, String(data.get('formVersion') || ''));
22
- await saveResponse(repo, form, response, requestId, { via: 'web', locals: Astro.locals });
33
+ // a resubmit of a saved response (refresh, double click) thanks them again,
34
+ // also after the form changed since; the same id with other answers is a 409
35
+ const prior = await repo.forms.receipt(form.id, requestId);
36
+ if (!prior || JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition)) {
37
+ const response = await collectResponse(form.definition, values, prior ? undefined : String(data.get('formVersion') || ''));
38
+ await saveResponse(repo, form, response, requestId, { via: 'web', locals: Astro.locals });
39
+ }
23
40
  success = true;
24
41
  } catch (e) { error = e instanceof ApiError ? e.message : 'Could not save your response. Please try again.'; Astro.response.status = e instanceof ApiError ? e.status : 500; }
25
42
  }
26
- const html = success ? '' : await renderForm(form.id, form.definition, values, error, requestId, embed);
43
+ const w = formStrings(lang);
44
+ const html = success ? '' : await renderForm(form.id, form.definition, values, error, requestId, embed, { locale: formLocale, lang, action: path });
27
45
  ---
28
- <Site title={form.definition.title || form.name} noindex bare={embed}>{success ? <section class="pc-simple-form"><h1>{form.definition.title || form.name}</h1><p role="status">{form.definition.confirmation || 'Thank you. Your response has been received.'}</p><a href={Astro.url.pathname + (embed ? '?embed=1' : '')}>Send another response</a></section> : <div set:html={html}/>}</Site>
46
+ <Site title={shown.title || form.name} noindex bare={embed}>{success ? <section class="pc-simple-form"><h1>{shown.title || form.name}</h1><p role="status">{thanksText(shown.confirmation, w)}</p><a href={path + (embed ? '?embed=1' : '')}>{w.sendAnother}</a></section> : <div set:html={html}/>}</Site>
@@ -46,7 +46,7 @@ const [posts, tags] = HomeTemplate
46
46
  : [[], []];
47
47
  // expand dynamic blocks (post lists) in the page body for BOTH paths — a theme
48
48
  // template injects the body as its hero, so it must receive expanded HTML too
49
- const homeHtml = home?.bodyHtml ? await expandDynamicBlocks(home.bodyHtml, repo) : '';
49
+ const homeHtml = home?.bodyHtml ? await expandDynamicBlocks(home.bodyHtml, repo, locale) : '';
50
50
  const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
51
51
  ---
52
52
 
@@ -161,7 +161,8 @@ body { background: #000; }
161
161
  /* list: columns, then pages (the script turns them) */
162
162
  .display-list { height: 100%; overflow: hidden; }
163
163
  .display-list-cols { height: 100%; column-count: var(--dsp-list-cols, 3); column-gap: calc(3.5 * var(--dsp-u)); column-fill: auto; }
164
- .display-day { margin: 0 0 calc(2.2 * var(--dsp-u)); }
164
+ /* a day stays in one column when it fits, so a column never starts without its day */
165
+ .display-day { margin: 0 0 calc(2.2 * var(--dsp-u)); break-inside: avoid-column; }
165
166
  .display-day-title { margin: 0 0 calc(0.6 * var(--dsp-u)); font-size: calc(2.3 * var(--dsp-u)); font-weight: 700; color: var(--dsp-muted); break-after: avoid; }
166
167
  .display-day.is-today .display-day-title { color: var(--dsp-fg); }
167
168
  .display-row {
@@ -81,6 +81,7 @@
81
81
  .fb.is-preview .fb-canvas { border: 0; }
82
82
  .fb-wide .fb-canvas:has(> .fb-float) { padding-top: 48px; }
83
83
  .fb-float { position: absolute; top: 8px; right: 10px; z-index: 3; display: flex; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, var(--panel-2) 85%, transparent); }
84
+ .fb-float-l { right: auto; left: 10px; }
84
85
  .fb-paper {
85
86
  /* the public form's tokens, mapped onto the admin palette */
86
87
  --ink: var(--text); --line-2: var(--line-strong); --btn: var(--text); --btn-ink: var(--bg); --accent-text: var(--accent-deep);
@@ -195,6 +196,13 @@
195
196
  .fb-choice.dragging input { box-shadow: var(--shadow-pop); }
196
197
  .fb-choice input { min-height: 34px; }
197
198
  .fb-choice-add { margin: 2px 0 0; font-size: 12px; color: var(--muted); }
199
+ /* another language: the main language's words above each box, choices side by side */
200
+ .fb-src { display: block; padding: 6px 9px; 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; }
201
+ .fb-src > span { display: block; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 1px; }
202
+ .fb-tr-choices { display: grid; gap: 6px; }
203
+ .fb-tr-choice { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 8px; align-items: center; }
204
+ .fb-tr-choice > span { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
205
+ .fb-tr-choice input { min-height: 34px; }
198
206
 
199
207
  /* menus */
200
208
  .fb-more, .fb-q-more { position: relative; flex: none; }
@@ -0,0 +1,53 @@
1
+ /* Admin → Calendar → Screens (components/display/ScreensManager.tsx) */
2
+ .sc { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 16px; align-items: start; }
3
+ .sc .card + .card { margin-top: 0; }
4
+ @media (max-width: 1100px) { .sc { grid-template-columns: minmax(0, 1fr); } }
5
+ .sc-skel { color: var(--muted); }
6
+ .sc-list { overflow: hidden; }
7
+ .sc-list-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
8
+ .sc-list-actions { display: flex; gap: 6px; }
9
+ .sc-empty { padding: 26px 16px; }
10
+ .sc-rows { list-style: none; margin: 0; padding: 0; }
11
+ .sc-rows li + li { border-top: 1px solid var(--line); }
12
+ .sc-row { display: flex; flex-direction: column; gap: 3px; width: 100%; text-align: left; padding: 11px 14px; background: none; border: 0; font: inherit; color: var(--text); cursor: pointer; }
13
+ .sc-row:hover { background: var(--hover); }
14
+ .sc-row.on { background: var(--sel); }
15
+ .sc-row-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
16
+ .sc-row-sum { font-size: 12px; color: var(--muted); }
17
+ .sc-seen { font-size: 12px; color: var(--faint); }
18
+ .sc-seen.ok { color: var(--green); }
19
+ .sc-seen.stale { color: var(--amber); }
20
+ .pill.sc-private { background: var(--amber-soft); color: var(--amber); }
21
+ .sc-intro h2, .sc-h { font-size: 16px; margin: 0 0 8px; }
22
+ .sc-intro p { color: var(--text-2); font-size: 13.5px; line-height: 1.55; margin: 0 0 10px; }
23
+ .sc-steps { margin: 0 0 12px; padding-left: 18px; color: var(--text-2); font-size: 13.5px; line-height: 1.55; }
24
+ .sc-editor { padding: 16px 18px; }
25
+ .sc-editor-grid { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.25fr); gap: 20px; align-items: start; }
26
+ @media (max-width: 1380px) { .sc-editor-grid { grid-template-columns: minmax(0, 1fr); } .sc-side { order: -1; position: static; } }
27
+ .sc-side { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 12px; }
28
+ .sc-fs { border: 0; padding: 0; margin: 0 0 16px; min-width: 0; }
29
+ .sc-fs legend { font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); font-weight: 600; padding: 0; margin-bottom: 8px; }
30
+ .sc-fs > .hint { margin: 0 0 6px; }
31
+ .seg.sc-seg { flex-wrap: wrap; }
32
+ .seg.sc-seg > button { border: 0; background: none; font: inherit; font-size: 12.5px; font-weight: 600; padding: 5px 10px; border-radius: 7px; color: var(--muted); cursor: pointer; }
33
+ .sc-look { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
34
+ .sc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; }
35
+ .sc-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--line-strong); border-radius: var(--r-full); font-size: 12.5px; cursor: pointer; }
36
+ .sc-chip input { accent-color: var(--accent); margin: 0; }
37
+ .sc-checks { max-height: 200px; overflow: auto; }
38
+ .sc-checks .check-row { margin: 4px 0; align-items: center; }
39
+ .sc-off { opacity: 0.6; }
40
+ .sc-swatch { display: inline-block; width: 11px; height: 11px; border-radius: 3px; flex: none; }
41
+ .sc-order { display: inline-grid; place-items: center; min-width: 18px; height: 18px; border-radius: 9px; background: var(--accent-soft); color: var(--accent-deep); font-size: 11px; font-weight: 700; }
42
+ .sc-tags-label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; font-weight: 500; }
43
+ .sc-tag-list { border: 1px solid var(--line); border-radius: var(--r-md); padding: 4px 10px; max-height: 150px; }
44
+ .sc-inline { display: flex; gap: 6px; }
45
+ .sc-private { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px 12px 6px; }
46
+ .sc-private legend { padding-top: 2px; }
47
+ .sc-private .hint { margin: 0 0 6px 23px; }
48
+ .sc-preview { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: var(--r-md); background: #000; border: 1px solid var(--line); }
49
+ .sc-preview iframe { position: absolute; top: 0; left: 0; border: 0; transform-origin: 0 0; pointer-events: none; }
50
+ .sc-link .field { margin-bottom: 6px; }
51
+ .sc-link input[readonly] { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; }
52
+ .sc-link .form-actions { margin-top: 6px; }
53
+ .sc-save { margin-top: 0; padding-top: 12px; border-top: 1px solid var(--line); }