@rathnasgala2/template 3.0.1 → 3.1.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.
package/README.md CHANGED
@@ -551,7 +551,7 @@ npm run format # apply Prettier
551
551
  npm run format:check # CI check
552
552
  npm run lint # ESLint flat config
553
553
  npm run typecheck # tsc --checkJs --noEmit against types/index.d.ts
554
- npm run architecture # dependency-cruiser: no src/modules/, no circular deps
554
+ npm run architecture # dependency-cruiser: core never imports src/modules, no circular deps
555
555
  npm run duplication # jscpd, 3% / 50 tokens
556
556
  npm run contracts:generate # (re)write contracts/*.jcs from their reviewed source modules
557
557
  npm run contracts:check # fail if committed contracts drifted
@@ -569,23 +569,25 @@ npm run audit # npm audit --audit-level=high
569
569
  ## Package layout
570
570
 
571
571
  ```text
572
- src/core/ # the only source root
572
+ src/core/ # the renderer
573
+ src/modules/interactions/browser/ # the reader-interactions browser script (emitted as data)
573
574
  contracts/render-policy.jcs # normalized-body policy identity (real content)
574
575
  contracts/theme-styling-contract.jcs # published styling catalog (real and closed)
575
576
  ```
576
577
 
577
- No `src/modules/` tree, module import edge, registration stub, module
578
- configuration, module output or module runtime is ever admitted beside
579
- `src/core/`. `test/module-tree-absence.test.js` and `.dependency-cruiser.cjs`'s
580
- `only-src-core-is-a-source-root` rule both enforce this; `interactions`,
581
- `whitelabel`, `newsletter` and `prism` have no module directory, package,
582
- configuration schema, stub, output, runtime code, network target or
583
- compatibility promise in this MVP.
578
+ `src/core/` and `src/modules/interactions/` are the only source roots, and
579
+ `src/core` never imports from `src/modules`: the interactions browser script is
580
+ read from disk and emitted as `assets/gala-interactions-v1.js` when
581
+ `build-input.modules.interactions` is present.
582
+ `test/module-tree-absence.test.js`, `.dependency-cruiser.cjs` and the ESLint
583
+ `no-restricted-imports` rule enforce this. `whitelabel`, `newsletter` and
584
+ `prism` have no module directory, package, configuration schema, stub, output or
585
+ runtime code in this MVP.
584
586
 
585
587
  ## Consuming `@rathnasgala2/schemas`
586
588
 
587
- This repository consumes `@rathnasgala2/schemas@3.0.0` from the public npm
588
- registry, declared as `"@rathnasgala2/schemas": "3.0.0"` (exact pin, no range)
589
+ This repository consumes `@rathnasgala2/schemas@3.1.0` from the public npm
590
+ registry, declared as `"@rathnasgala2/schemas": "3.1.0"` (exact pin, no range)
589
591
  so `package-lock.json` records the resolved version and integrity hash. The
590
592
  LOCAL-1/LOCAL-39 local-tarball convention
591
593
  (`file:../../local-packages/rathnasgala2-schemas-*.tgz`) is retired for this
@@ -1 +1 @@
1
- {"contentSecurityPolicy":"default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; media-src 'self'; manifest-src 'self'; worker-src 'none'","contentSecurityPolicyHeaderOnlyDirectives":["frame-ancestors 'none'"],"contentSecurityPolicyMeta":"default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; media-src 'self'; manifest-src 'self'; worker-src 'none'","highlighter":{"grammars":["bash","css","diff","java","javascript","json","jsx","markdown","markup","python","sql","tsx","typescript","yaml"],"package":"@11ty/eleventy-plugin-syntaxhighlight","tokenClasses":["atrule","attr-equals","attr-name","attr-value","bold","boolean","builtin","cdata","char","class-name","comment","constant","content","coord","deleted","deleted-sign","doctype","entity","for-or-select","function","function-variable","important","inserted","inserted-sign","interpolation","interpolation-punctuation","italic","key","keyword","namespace","null","number","operator","prefix","prolog","property","punctuation","regex","rule","selector","shebang","string","symbol","tag","template-punctuation","template-string","title","token","url","variable"],"version":"5.0.2"},"markdownIt":{"options":{"html":false,"linkify":false,"typographer":false},"package":"markdown-it","version":"14.3.1"},"name":"gala-render-policy","sanitizer":{"allowedAttributes":{"a":["href","rel"],"code":["class"],"h1":["id"],"h2":["id"],"h3":["id"],"h4":["id"],"h5":["id"],"h6":["id"],"img":["src","alt"],"ol":["start"],"pre":["class"],"span":["class"]},"allowedTags":["p","br","hr","h1","h2","h3","h4","h5","h6","ul","ol","li","blockquote","pre","code","strong","em","b","i","u","s","del","ins","sub","sup","mark","small","wbr","a","img","table","thead","tbody","tr","th","td","dl","dt","dd","figure","figcaption","span"],"allowedUrlSchemes":["https","http","mailto"],"package":"sanitize-html","version":"2.17.7"},"version":"2.0.0"}
1
+ {"contentSecurityPolicy":"default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self' https://api.galascribe.com; media-src 'self'; manifest-src 'self'; worker-src 'none'","contentSecurityPolicyHeaderOnlyDirectives":["frame-ancestors 'none'"],"contentSecurityPolicyMeta":"default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self' https://api.galascribe.com; media-src 'self'; manifest-src 'self'; worker-src 'none'","highlighter":{"grammars":["bash","css","diff","java","javascript","json","jsx","markdown","markup","python","sql","tsx","typescript","yaml"],"package":"@11ty/eleventy-plugin-syntaxhighlight","tokenClasses":["atrule","attr-equals","attr-name","attr-value","bold","boolean","builtin","cdata","char","class-name","comment","constant","content","coord","deleted","deleted-sign","doctype","entity","for-or-select","function","function-variable","important","inserted","inserted-sign","interpolation","interpolation-punctuation","italic","key","keyword","namespace","null","number","operator","prefix","prolog","property","punctuation","regex","rule","selector","shebang","string","symbol","tag","template-punctuation","template-string","title","token","url","variable"],"version":"5.0.2"},"markdownIt":{"options":{"html":false,"linkify":false,"typographer":false},"package":"markdown-it","version":"14.3.1"},"name":"gala-render-policy","sanitizer":{"allowedAttributes":{"a":["href","rel"],"code":["class"],"h1":["id"],"h2":["id"],"h3":["id"],"h4":["id"],"h5":["id"],"h6":["id"],"img":["src","alt"],"ol":["start"],"pre":["class"],"span":["class"]},"allowedTags":["p","br","hr","h1","h2","h3","h4","h5","h6","ul","ol","li","blockquote","pre","code","strong","em","b","i","u","s","del","ins","sub","sup","mark","small","wbr","a","img","table","thead","tbody","tr","th","td","dl","dt","dd","figure","figcaption","span"],"allowedUrlSchemes":["https","http","mailto"],"package":"sanitize-html","version":"2.17.7"},"version":"2.0.0"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala2/template",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Deterministic Eleventy-backed static publication renderer for Galascribe portable repositories.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,6 +22,7 @@
22
22
  "types": "./types/index.d.ts",
23
23
  "files": [
24
24
  "src/core/",
25
+ "src/modules/",
25
26
  "contracts/",
26
27
  "types/",
27
28
  "LICENSE",
@@ -52,7 +53,7 @@
52
53
  "format": "prettier --write .",
53
54
  "format:check": "prettier --check .",
54
55
  "lint": "eslint .",
55
- "typecheck": "tsc --project tsconfig.json",
56
+ "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.modules.json",
56
57
  "architecture": "depcruise --validate .dependency-cruiser.cjs src contracts eslint.config.js",
57
58
  "duplication": "jscpd src scripts test eslint.config.js",
58
59
  "contracts:generate": "node scripts/generate-contracts.mjs",
@@ -71,7 +72,7 @@
71
72
  "dependencies": {
72
73
  "@11ty/eleventy": "3.1.6",
73
74
  "@11ty/eleventy-plugin-syntaxhighlight": "5.0.2",
74
- "@rathnasgala2/schemas": "3.0.0",
75
+ "@rathnasgala2/schemas": "3.1.0",
75
76
  "jpeg-js": "0.4.4",
76
77
  "markdown-it": "14.3.1",
77
78
  "sanitize-html": "2.17.7"
package/src/core/index.js CHANGED
@@ -83,8 +83,16 @@ import {
83
83
  assertPolicyConformantHtml,
84
84
  assertRenderPolicyIdentity,
85
85
  computeRenderPolicyIdentity,
86
+ contentSecurityPolicyMetaTag,
86
87
  } from './internal/content-security.js';
87
88
  import { renderPagesWithEleventy } from './internal/eleventy-render.js';
89
+ import {
90
+ INTERACTIONS_SCRIPT_MEDIA_TYPE,
91
+ INTERACTIONS_SCRIPT_PATH,
92
+ interactionsActive,
93
+ loadInteractionsScript,
94
+ validateInteractionsModule,
95
+ } from './internal/interactions.js';
88
96
  import {
89
97
  ATOM_FEED_ROUTE,
90
98
  RSS_FEED_ROUTE,
@@ -241,8 +249,9 @@ function assertOptions(options) {
241
249
  * Verify every `renderableBody.renderPolicy` in the publication profile,
242
250
  * footer card and every content record byte-equals the currently published
243
251
  * render-policy identity, and that no module or placement value leaked
244
- * through (belt-and-suspenders on top of the `build-input:2.0.0` schema's
245
- * own `modules: {}` / `placements: []` closure). Fails closed before any
252
+ * through (belt-and-suspenders on top of the `build-input` schema's module
253
+ * closure: only a valid `interactions` module is admitted, `placements` stays
254
+ * empty). Fails closed before any
246
255
  * directory is created or Eleventy ever runs: an absent policy file, wrong
247
256
  * path/name/version/digest, mixed identity between records, or body
248
257
  * produced under a different policy rejects before build.
@@ -252,14 +261,20 @@ function assertOptions(options) {
252
261
  * @returns {Promise<void>} resolves once every reference is verified
253
262
  */
254
263
  async function assertRenderPolicyCompliance(validatedInput) {
255
- if (
256
- Object.keys(validatedInput.modules).length !== 0 ||
257
- validatedInput.placements.length !== 0
258
- ) {
264
+ const moduleKeys = Object.keys(validatedInput.modules);
265
+ if (moduleKeys.some((key) => key !== 'interactions')) {
259
266
  throw new RenderPolicyViolationError(
260
- 'build-input.modules and build-input.placements must both be empty in S2',
267
+ 'build-input.modules may only carry interactions',
261
268
  );
262
269
  }
270
+ if (validatedInput.placements.length !== 0) {
271
+ throw new RenderPolicyViolationError(
272
+ 'build-input.placements must be empty',
273
+ );
274
+ }
275
+ if (validatedInput.modules.interactions !== undefined) {
276
+ validateInteractionsModule(validatedInput.modules.interactions);
277
+ }
263
278
 
264
279
  const identity = await computeRenderPolicyIdentity();
265
280
 
@@ -343,6 +358,17 @@ export async function renderPublication(buildInput, options) {
343
358
  validatedInput.baseUrl,
344
359
  validatedInput.basePath,
345
360
  );
361
+ const interactionsModule = validatedInput.modules.interactions;
362
+ const interactionsOn = interactionsActive(interactionsModule);
363
+ // One CSP per build: the production policy, plus (local stack only) the
364
+ // interactions module's differing API origin in `connect-src`.
365
+ const cspMetaTag = contentSecurityPolicyMetaTag(
366
+ interactionsModule?.apiOrigin,
367
+ );
368
+ const interactionsScriptHref = joinBasePathAndRoute(
369
+ publicBasePath,
370
+ `/${INTERACTIONS_SCRIPT_PATH}`,
371
+ );
346
372
  const urlPrefix = derivePublicBasePath(validatedInput.baseUrl, '/');
347
373
  const homeRoute = joinPublicRoute(publicBasePath, '/');
348
374
 
@@ -500,7 +526,10 @@ export async function renderPublication(buildInput, options) {
500
526
  const generatedPages = buildGeneratedPages(validatedInput, {
501
527
  mediaAssets,
502
528
  mediaDimensions,
529
+ interactions: interactionsOn ? interactionsModule : undefined,
503
530
  });
531
+ /** @type {Set<string>} output paths of the interaction-bearing routes */
532
+ const interactionBearingPaths = new Set();
504
533
  generatedPages.forEach((page, index) => {
505
534
  const joinedRoute = joinBasePathAndRoute(
506
535
  validatedInput.basePath,
@@ -520,10 +549,12 @@ export async function renderPublication(buildInput, options) {
520
549
  basePath: publicBasePath,
521
550
  });
522
551
  const ogImage = ogImageInfo?.url;
552
+ const pagePermalink = projectRouteToFilePath(joinedRoute, routeProfile);
553
+ if (page.interactionBearing) interactionBearingPaths.add(pagePermalink);
523
554
  contentPages.push({
524
555
  virtualPath: `pages/${page.kind}-${index}.html`,
525
556
  content: bodyHtml,
526
- permalink: projectRouteToFilePath(joinedRoute, routeProfile),
557
+ permalink: pagePermalink,
527
558
  layout: 'skeleton.njk',
528
559
  data: {
529
560
  title: page.documentTitle ?? page.title,
@@ -552,6 +583,10 @@ export async function renderPublication(buildInput, options) {
552
583
  atomFeedUrl: feeds.atomSelfUrl,
553
584
  rssFeedUrl: feeds.rssSelfUrl,
554
585
  appearanceScriptHref: appearanceScriptHrefValue,
586
+ interactionsScriptHref: page.interactionBearing
587
+ ? interactionsScriptHref
588
+ : undefined,
589
+ cspMetaTag,
555
590
  baseStylesheetHref: baseStylesheetHrefValue,
556
591
  themeStylesheetLinksHtml,
557
592
  },
@@ -623,6 +658,7 @@ export async function renderPublication(buildInput, options) {
623
658
  atomFeedUrl: feeds.atomSelfUrl,
624
659
  rssFeedUrl: feeds.rssSelfUrl,
625
660
  appearanceScriptHref: appearanceScriptHrefValue,
661
+ cspMetaTag,
626
662
  baseStylesheetHref: baseStylesheetHrefValue,
627
663
  themeStylesheetLinksHtml,
628
664
  },
@@ -767,7 +803,7 @@ export async function renderPublication(buildInput, options) {
767
803
  byteLength: String(bytes.byteLength),
768
804
  sha256: digestBytes(bytes),
769
805
  routeClass: special?.routeClass ?? 'html',
770
- interactionBearing: false,
806
+ interactionBearing: interactionBearingPaths.has(filePath),
771
807
  });
772
808
  }
773
809
  routes.sort((a, b) => compareUtf8Bytes(a.path, b.path));
@@ -897,10 +933,39 @@ export async function renderPublication(buildInput, options) {
897
933
  immutable: false,
898
934
  };
899
935
 
936
+ // The reader-interactions browser script, only when the module is on. Its
937
+ // bytes are the template package's own file, so the asset row is a pure
938
+ // function of the template version.
939
+ /** @type {import('../../types/index.d.ts').ManifestAssetEntry[]} */
940
+ const interactionsAssets = [];
941
+ if (interactionsOn) {
942
+ const interactionsScriptBytes = await loadInteractionsScript();
943
+ const joinedInteractionsScriptPath = projectFixedAssetPath(
944
+ validatedInput.basePath,
945
+ `/${INTERACTIONS_SCRIPT_PATH}`,
946
+ );
947
+ const interactionsScriptDestination = path.join(
948
+ outputDirectory,
949
+ joinedInteractionsScriptPath,
950
+ );
951
+ await mkdir(path.dirname(interactionsScriptDestination), {
952
+ recursive: true,
953
+ });
954
+ await writeFile(interactionsScriptDestination, interactionsScriptBytes);
955
+ interactionsAssets.push({
956
+ path: joinedInteractionsScriptPath,
957
+ mediaType: INTERACTIONS_SCRIPT_MEDIA_TYPE,
958
+ byteLength: String(interactionsScriptBytes.byteLength),
959
+ sha256: digestBytes(interactionsScriptBytes),
960
+ immutable: false,
961
+ });
962
+ }
963
+
900
964
  const assets = [
901
965
  ...joinedMediaAssets,
902
966
  appearanceScriptAsset,
903
967
  baseStylesheetAsset,
968
+ ...interactionsAssets,
904
969
  ...(themeAssetsResult ? themeAssetsResult.assets : []),
905
970
  ].sort((a, b) => compareUtf8Bytes(a.path, b.path));
906
971
 
@@ -654,6 +654,83 @@ export const GALA_BASE_COMPONENT_CSS = `
654
654
  [aria-selected="true"] > .g-search-hit > .g-icon { opacity: 1; color: var(--gala-color-accent); }
655
655
  .g-search-empty { padding: var(--gala-space-6) var(--gala-space-4); color: var(--gala-color-text-muted); font-family: var(--gala-font-ui); text-align: center; }
656
656
 
657
+ /* reader interactions: server-rendered section and the classes the browser script creates */
658
+ .g-interactions { display: grid; gap: var(--gala-space-4); min-width: 0; padding: var(--gala-space-6); border-radius: var(--gala-radius-large); background: var(--gala-color-surface); border: var(--gala-border-card); font-family: var(--gala-font-ui); }
659
+ .g-interactions__title { font-family: var(--gala-font-display); font-weight: var(--gala-weight-title); font-size: 1.25rem; letter-spacing: var(--gala-tracking-title); text-transform: var(--gala-title-transform); line-height: 1.2; }
660
+ .g-interactions__notice, .g-interactions__status { font-size: .875rem; color: var(--gala-color-text-muted); overflow-wrap: anywhere; }
661
+ .g-interactions__status:empty { display: none; }
662
+ .g-interactions__signout { display: inline; padding: 0; border: 0; background: none; font: inherit; color: var(--gala-color-link); text-decoration: underline; text-underline-offset: var(--gala-link-offset); cursor: pointer; }
663
+ .g-interactions__signout:hover { color: var(--gala-color-text); }
664
+ .g-reactions { display: flex; flex-wrap: wrap; gap: var(--gala-space-2); }
665
+ .g-reaction {
666
+ display: inline-flex; align-items: center; gap: .5em; min-height: 2.75rem; max-width: 100%; padding: .45rem .95rem;
667
+ border-radius: var(--gala-radius-pill); border: var(--gala-border-width) solid var(--gala-color-border); background: var(--gala-color-canvas); color: var(--gala-color-text-muted);
668
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; line-height: 1.2; cursor: pointer;
669
+ transition: transform var(--gala-duration-fast) var(--gala-ease-standard), border-color var(--gala-duration-fast) var(--gala-ease-standard), color var(--gala-duration-fast) var(--gala-ease-standard), background-color var(--gala-duration-fast) var(--gala-ease-standard);
670
+ }
671
+ .g-reaction:hover:not(:disabled) { border-color: var(--gala-color-text); color: var(--gala-color-text); }
672
+ .g-reaction:active:not(:disabled) { transform: scale(.96); }
673
+ .g-reaction:disabled { opacity: .55; cursor: default; }
674
+ .g-reaction__icon { display: inline-grid; place-items: center; font-size: 1.1rem; line-height: 1; }
675
+ .g-reaction__icon .g-icon { width: 1.15rem; height: 1.15rem; }
676
+ .g-reaction__label { overflow-wrap: anywhere; }
677
+ .g-reaction__count { font-variant-numeric: tabular-nums; font-size: .8rem; color: var(--gala-color-text-faint); padding-left: var(--gala-space-2); border-left: var(--gala-border-width) solid var(--gala-color-border); }
678
+ .g-reaction__count:empty { display: none; }
679
+ .g-reaction[aria-pressed="true"], .g-reaction--active { border-color: var(--gala-color-accent); background: var(--gala-paint-chip); color: var(--gala-color-chip-text); }
680
+ .g-reaction[aria-pressed="true"] .g-icon, .g-reaction--active .g-icon { fill: currentColor; color: var(--gala-color-accent); }
681
+ .g-comments { display: grid; gap: var(--gala-space-4); min-width: 0; padding-top: var(--gala-space-4); border-top: var(--gala-border-width) solid var(--gala-color-border); }
682
+ .g-comments__title { font-family: var(--gala-font-display); font-weight: var(--gala-weight-title); font-size: 1.05rem; }
683
+ .g-comments__count { font-family: var(--gala-font-ui); font-weight: var(--gala-weight-normal); font-size: .875rem; color: var(--gala-color-text-faint); font-variant-numeric: tabular-nums; }
684
+ .g-comments__more {
685
+ justify-self: start; min-height: 2.75rem; padding: .5rem 1.1rem; border-radius: var(--gala-radius-pill); border: var(--gala-border-width) solid var(--gala-color-border); background: transparent; color: var(--gala-color-text);
686
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; cursor: pointer;
687
+ transition: border-color var(--gala-duration-fast) var(--gala-ease-standard), transform var(--gala-duration-fast) var(--gala-ease-standard);
688
+ }
689
+ .g-comments__more:hover { border-color: var(--gala-color-text); }
690
+ .g-comments__more:active { transform: scale(.97); }
691
+ .g-composer { display: grid; gap: var(--gala-space-3); min-width: 0; }
692
+ .g-composer label { font-size: .875rem; font-weight: var(--gala-weight-ui); color: var(--gala-color-text-muted); }
693
+ .g-composer__input, .g-report select, .g-report textarea {
694
+ width: 100%; max-width: 100%; min-height: 2.75rem; padding: .7rem .9rem; border-radius: var(--gala-radius-medium); border: var(--gala-border-width) solid var(--gala-color-border);
695
+ background: var(--gala-color-canvas); color: var(--gala-color-text); font-family: var(--gala-font-body); font-size: 1rem; line-height: 1.5;
696
+ }
697
+ .g-composer__input, .g-report textarea { min-height: 6rem; resize: vertical; }
698
+ .g-composer__input:hover, .g-report select:hover, .g-report textarea:hover { border-color: var(--gala-color-text-muted); }
699
+ .g-composer__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--gala-space-3); justify-content: space-between; font-size: .875rem; color: var(--gala-color-text-muted); }
700
+ .g-composer__submit, .g-composer__signin, .g-report button[type="submit"] {
701
+ display: inline-flex; align-items: center; justify-content: center; gap: .55em; min-height: 2.75rem; padding: .6rem 1.2rem; border-radius: var(--gala-radius-pill);
702
+ background: var(--gala-paint-button); color: var(--gala-color-btn-text); border: var(--gala-border-button); box-shadow: var(--gala-shadow-button);
703
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9375rem; cursor: pointer;
704
+ transition: transform var(--gala-duration-fast) var(--gala-ease-standard), filter var(--gala-duration-base) var(--gala-ease-standard);
705
+ }
706
+ .g-composer__submit:hover:not(:disabled), .g-composer__signin:hover, .g-report button[type="submit"]:hover { filter: brightness(1.07) saturate(1.05); }
707
+ .g-composer__submit:active:not(:disabled), .g-composer__signin:active { transform: scale(.97); }
708
+ .g-composer__submit:disabled { opacity: .55; cursor: default; box-shadow: none; }
709
+ .g-composer__cancel, .g-report button[type="button"] {
710
+ min-height: 2.75rem; padding: .6rem 1rem; border-radius: var(--gala-radius-pill); border: var(--gala-border-width) solid var(--gala-color-border); background: transparent; color: var(--gala-color-text);
711
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; cursor: pointer;
712
+ }
713
+ .g-composer__cancel:hover, .g-report button[type="button"]:hover { border-color: var(--gala-color-text); }
714
+ .g-comment-list { display: grid; gap: var(--gala-space-4); min-width: 0; }
715
+ .g-comment { display: grid; gap: var(--gala-space-2); min-width: 0; }
716
+ .g-comment__head { display: flex; align-items: center; gap: var(--gala-space-3); min-width: 0; }
717
+ .g-comment__avatar { display: inline-grid; place-items: center; width: 2.25rem; height: 2.25rem; flex: none; border-radius: var(--gala-radius-avatar); background: var(--gala-paint-button); color: var(--gala-color-btn-text); box-shadow: var(--gala-shadow-avatar-ring); font-family: var(--gala-font-display); font-weight: var(--gala-weight-display); font-size: .8rem; text-transform: uppercase; }
718
+ .g-comment__author { font-weight: var(--gala-weight-ui); font-size: .9375rem; overflow-wrap: anywhere; min-width: 0; }
719
+ .g-comment__time { font-size: .8125rem; color: var(--gala-color-text-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
720
+ .g-comment__body { margin: 0; font-family: var(--gala-font-body); font-size: 1rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
721
+ .g-comment__body a { color: var(--gala-color-link); text-decoration: underline; text-decoration-color: var(--gala-color-link-underline); text-underline-offset: var(--gala-link-offset); }
722
+ .g-comment__body a:hover { text-decoration-color: var(--gala-color-link-underline-hover); }
723
+ .g-comment__actions { display: flex; flex-wrap: wrap; gap: var(--gala-space-1) var(--gala-space-3); }
724
+ .g-comment__action { min-height: 2.5rem; padding: 0 .5rem; margin-left: -.5rem; border: 0; border-radius: var(--gala-radius-small); background: transparent; color: var(--gala-color-text-muted); font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .8125rem; cursor: pointer; transition: color var(--gala-duration-fast) var(--gala-ease-standard), background-color var(--gala-duration-fast) var(--gala-ease-standard); }
725
+ .g-comment__action:hover { color: var(--gala-color-text); background: var(--gala-color-surface-raised); }
726
+ .g-comment__replies { display: grid; gap: var(--gala-space-4); min-width: 0; margin-top: var(--gala-space-2); padding-left: var(--gala-space-4); border-left: var(--gala-border-width) solid var(--gala-color-border); }
727
+ .g-comment--tombstone > .g-comment__body { font-style: italic; color: var(--gala-color-text-faint); }
728
+ .g-comment--mine > .g-comment__head .g-comment__avatar { box-shadow: 0 0 0 2px var(--gala-color-accent); }
729
+ .g-report { width: min(28rem, calc(100% - 2rem)); max-width: 100%; padding: var(--gala-space-6); border-radius: var(--gala-radius-large); border: var(--gala-border-card); background: var(--gala-color-surface); color: var(--gala-color-text); box-shadow: var(--gala-shadow-dialog); font-family: var(--gala-font-ui); }
730
+ .g-report::backdrop { background: var(--gala-color-overlay); }
731
+ .g-report form { display: grid; gap: var(--gala-space-3); }
732
+ .g-report menu { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--gala-space-3); margin: 0; padding: 0; }
733
+
657
734
  /* toast */
658
735
  .g-toast {
659
736
  position: fixed; left: 50%; bottom: var(--gala-space-6); z-index: 60; max-width: calc(100% - 2rem); padding: .7rem 1.1rem;
@@ -709,6 +786,10 @@ export const GALA_BASE_COMPONENT_CSS = `
709
786
  .g-article-cover img { aspect-ratio: 16 / 10; }
710
787
  .g-footer-grid { grid-template-columns: minmax(0, 1fr); }
711
788
  .g-author-card { flex-direction: column; }
789
+ .g-interactions { padding: var(--gala-space-4); }
790
+ .g-comment__replies { padding-left: var(--gala-space-3); }
791
+ .g-composer__actions { flex-direction: column; align-items: stretch; }
792
+ .g-composer__submit, .g-composer__signin { width: 100%; }
712
793
  .g-topic-head { align-items: flex-start; }
713
794
  }
714
795
 
@@ -38,7 +38,6 @@ import {
38
38
  APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE,
39
39
  COLOR_SCHEME_META_CONTENT,
40
40
  } from './appearance/contract.js';
41
- import { contentSecurityPolicyMetaTag } from './content-security.js';
42
41
  import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
43
42
 
44
43
  /**
@@ -133,9 +132,12 @@ const SKELETON_LAYOUT_SOURCE = [
133
132
  '<head>',
134
133
  '<meta charset="utf-8">',
135
134
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
136
- contentSecurityPolicyMetaTag(),
135
+ '{{ cspMetaTag | safe }}',
137
136
  `<meta name="color-scheme" content="${COLOR_SCHEME_META_CONTENT}">`,
138
- '<script src="{{ appearanceScriptHref }}"></script>',
137
+ // The interactions script (article pages, module on) is deferred and sits
138
+ // on the appearance script's line, so a page without it is byte-identical
139
+ // to before.
140
+ '<script src="{{ appearanceScriptHref }}"></script>{% if interactionsScriptHref %}<script src="{{ interactionsScriptHref }}" defer></script>{% endif %}',
139
141
  '<title>{{ title }}</title>',
140
142
  '{% if description %}<meta name="description" content="{{ description }}">{% endif %}',
141
143
  '{% if robotsContent %}<meta name="robots" content="{{ robotsContent }}">{% endif %}',
@@ -35,6 +35,8 @@ export const ICONS = Object.freeze({
35
35
  '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 16l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/>',
36
36
  list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
37
37
  layers: '<path d="m12 3 9 5-9 5-9-5z"/><path d="m3 13 9 5 9-5"/>',
38
+ heart:
39
+ '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7z"/>',
38
40
  pen: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/>',
39
41
  });
40
42