@taprootio/site-authoring 0.31.0 → 0.32.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
@@ -972,6 +972,25 @@ targets are refused (`content.link_rel`, `content.link_target`). The editor's
972
972
  old default, `noopener noreferrer nofollow`, reads as no choice. See
973
973
  `taproot-site help page free-form`.
974
974
 
975
+ ### External links open in a new tab in 0.32.0
976
+
977
+ A link to another site opens in a new tab with nothing to set. On the next
978
+ render of a page, every absolute `http:` or `https:` address whose host is not
979
+ one of the site's own hosts publishes `target="_blank"` with `noopener
980
+ noreferrer` added after any `rel` tokens the link already carries. This covers
981
+ link marks in page text and tables, and component links: feature-grid,
982
+ image-row, card-grid, product-grid, hero-section, cta, image-banner and
983
+ social-links, plus the footer and header navigation. Each of these links also
984
+ says "(opens in a new tab)" to a screen reader, with no change on screen.
985
+ `mailto:`, `tel:`, relative paths, fragments,
986
+ queries and the site's own addresses stay in the same tab. A subdomain of the
987
+ site is another host, so it opens in a new tab; `www.` is ignored. To keep a
988
+ link mark in the same tab, write `target="_self"` after it in Markdown or set
989
+ `attrs.target` to `"_self"` in `.pm.json`. Component links have no setting for
990
+ this. Existing pages change when a publish renders the whole site, such as a
991
+ settings or navigation change; pages an incremental publish copies forward keep
992
+ their old links until then.
993
+
975
994
  ### What changed between releases
976
995
 
977
996
  Taproot accepts only the latest CLI, so an upgrade can be forced at any time.
@@ -524,6 +524,7 @@
524
524
  "viewportMin": 320
525
525
  },
526
526
  "defaultScheme": "system",
527
+ "blockSpacing": "standard",
527
528
  "lightLogoId": "a0000000-0000-4000-8000-0000000000a0",
528
529
  "darkLogoId": "a0000000-0000-4000-8000-0000000000a1",
529
530
  "lightCanvasImageId": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@taprootio/espalier": "6.8.0",
31
+ "@taprootio/espalier": "6.9.0",
32
32
  "mediabunny": "1.61.1"
33
33
  },
34
34
  "scripts": {
@@ -1,3 +1,4 @@
1
+ import { BLOCK_SPACINGS, DEFAULT_BLOCK_SPACING } from "./block-flow.js";
1
2
  import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
2
3
  import {
3
4
  SETTINGS_TYPE_BRAND,
@@ -26,6 +27,16 @@ export const APPEARANCE_SCALAR_FIELDS = Object.freeze([
26
27
  values: Object.freeze(["system", "light", "dark"]),
27
28
  default: "light",
28
29
  }),
30
+ // The names come from the synced block-flow rules, so a spacing the page
31
+ // has no scale for can never pass validation (TR01328).
32
+ Object.freeze({
33
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
34
+ name: "blockSpacing",
35
+ path: "taproot-styles.blockSpacing",
36
+ type: "enum",
37
+ values: Object.freeze(Object.keys(BLOCK_SPACINGS)),
38
+ default: DEFAULT_BLOCK_SPACING,
39
+ }),
29
40
  ...["lightLogoId", "darkLogoId", "lightCanvasImageId", "darkCanvasImageId"].map((name) =>
30
41
  Object.freeze({
31
42
  settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
@@ -0,0 +1,117 @@
1
+ // Generated from shared/block-flow.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The one owner of how page blocks space themselves (TR01328): the three
4
+ * site-wide spacing choices and the stylesheet that turns them into margins.
5
+ *
6
+ * Text used to get its space from a browser default and components got none.
7
+ * Now every direct child of a flow container takes one gap, a token that
8
+ * follows the site's text size and the chosen spacing. The published page
9
+ * shell inlines `BLOCK_FLOW_CSS` in its core section and the editor's page
10
+ * preview, which has no shell, applies the same string, so both agree.
11
+ *
12
+ * The generator and the site-authoring CLI cannot import a repo-root file, so
13
+ * `node scripts/sync-field-validation.mjs` writes their copies; edit only this
14
+ * file and re-run it. The editor imports it by relative path.
15
+ */
16
+ /** The scale each choice puts on the gap and on a heading's top margin. */
17
+ export const BLOCK_SPACINGS = {
18
+ compact: 0.75,
19
+ standard: 1,
20
+ roomy: 1.5,
21
+ };
22
+ /** What a site that never chose gets; it is also the value the page leaves unwritten. */
23
+ export const DEFAULT_BLOCK_SPACING = "standard";
24
+ // A flow container holds a page's blocks as direct children. This is the
25
+ // shell's older list with `.taproot-free-form` swapped for the section
26
+ // content: the free-form element's own children are sections and root bands,
27
+ // which must not gain a margin.
28
+ const FLOW_CONTAINERS = [
29
+ "[data-section-content]",
30
+ "[slot=\"body\"]",
31
+ "[slot=\"introduction\"]",
32
+ ".recipe-story",
33
+ ".taproot-legal__body",
34
+ ].join(", ");
35
+ // The end room needs only the text elements whose bottom margin is 0; the
36
+ // unpadded edge leaves text, lists and rules alone, as they are today.
37
+ const TEXT_AND_LISTS = "p, h1, h2, h3, h4, h5, h6, figure, blockquote, dl, ul, ol, pre, hr";
38
+ const TEXT_WITHOUT_END_MARGIN = "p, h1, h2, h3, h4, h5, h6, figure, blockquote, dl";
39
+ const SCALE_MAPPING = Object.entries(BLOCK_SPACINGS)
40
+ .map(([name, scale]) => `[data-block-spacing="${name}"] {\n --taproot-block-scale: ${scale};\n}`)
41
+ .join("\n");
42
+ /**
43
+ * The block flow rules, compact enough to inline in every page. No CSS comments
44
+ * ship, so each rule's reason is here:
45
+ *
46
+ * - The gap is a token, not em, so a heading and a paragraph resolve the same
47
+ * length and it stays fluid on phones. The 1em fallback holds the browser's
48
+ * text margin when the runtime never ran and set `--esp-type-normal`.
49
+ * - The block rule and its text reset have zero specificity (`:where()`), so any
50
+ * rule a component, a banner or an author writes for its own margin wins with
51
+ * no ordering fight. The reset repeats `esp-reset.css` because the page preview
52
+ * has no reset.
53
+ * - Components load lazily and an undefined element is inline, where a vertical
54
+ * margin does nothing, so the listed hosts match their `:host` from first paint.
55
+ * Add every new lazily loaded component host to that list.
56
+ * - A heading's top margin scales with the setting. At (0,1,0) it beats Espalier's
57
+ * root heading rule and loses to the banner resets, in any section order.
58
+ * - A spacer adds its height to the gap instead of a second margin.
59
+ * - The last text block of a padded section gets room, so a section closes as it
60
+ * opens. The descendant combinator reaches past a surface's `esp-box`.
61
+ * - An unpadded section's first and last component stay flush. Text, lists, rules
62
+ * and anything with a margin rule of its own keep what they have.
63
+ * - Banners stay flush with their neighbors. Those rules live here, not in the
64
+ * banner section, because the page preview has no shell.
65
+ */
66
+ export const BLOCK_FLOW_CSS = `
67
+ :where(${FLOW_CONTAINERS}) {
68
+ --taproot-block-gap: calc(var(--esp-type-normal, 1em) * var(--taproot-block-scale, 1));
69
+ }
70
+ ${SCALE_MAPPING}
71
+ :where(${FLOW_CONTAINERS}) > * {
72
+ margin-block: var(--taproot-block-gap);
73
+ }
74
+ :where(${FLOW_CONTAINERS}) > :where(p, h1, h2, h3, h4, figure, blockquote, dl) {
75
+ margin-block-end: 0;
76
+ }
77
+ taproot-hero,
78
+ taproot-cta,
79
+ taproot-testimonial,
80
+ taproot-feature-grid,
81
+ taproot-latest-posts,
82
+ taproot-card-grid,
83
+ taproot-product-grid {
84
+ display: block;
85
+ }
86
+ :is(${FLOW_CONTAINERS}) > :where(h1, h2, h3, h4, h5) {
87
+ margin-block-start: calc(var(--esp-size-small-to-normal, 0.83em) * var(--taproot-block-scale, 1));
88
+ }
89
+ :where(${FLOW_CONTAINERS}) > taproot-spacer {
90
+ margin-block: 0;
91
+ }
92
+ esp-section[data-content-padding="standard"] [data-section-content] > :is(${TEXT_WITHOUT_END_MARGIN}):last-child {
93
+ margin-block-end: var(--taproot-block-gap);
94
+ }
95
+ :where(esp-section[data-content-padding="none"] [data-section-content]) > :where(:first-child:not(${TEXT_AND_LISTS})) {
96
+ margin-block-start: 0;
97
+ }
98
+ :where(esp-section[data-content-padding="none"] [data-section-content]) > :where(:last-child:not(${TEXT_AND_LISTS})) {
99
+ margin-block-end: 0;
100
+ }
101
+ :is(${FLOW_CONTAINERS}) taproot-image-banner {
102
+ margin: 0;
103
+ }
104
+ :is(${FLOW_CONTAINERS}) esp-image[banner] {
105
+ margin: 0;
106
+ }
107
+ :is(${FLOW_CONTAINERS}) *:has(+ taproot-image-banner) {
108
+ margin-block-end: 0;
109
+ }
110
+ :is(${FLOW_CONTAINERS}) taproot-image-banner + * {
111
+ margin-block-start: 0;
112
+ }
113
+ [data-taproot-section][data-following-spacing="compact"] > [data-section-content] > :first-child,
114
+ [data-taproot-section][data-following-spacing="compact"] > esp-box > [data-section-content] > :first-child {
115
+ margin-block-start: 0;
116
+ }
117
+ `.trim();
@@ -1,5 +1,31 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.32.0",
5
+ "note": "Links to other sites open in a new tab on published pages with nothing to set; target=\"_self\" keeps a link mark in the same tab; page blocks get real default spacing with a site-wide setting.",
6
+ "behavior": [
7
+ "An absolute http or https link to a host other than the site's own now publishes target=\"_blank\" and rel with noopener noreferrer added, with nothing to set. This covers link marks in page text and tables, inline facts, and the links of feature-grid, image-row, card-grid, product-grid, hero-section, cta, image-banner and social-links components, and the footer. mailto and tel links, relative paths, fragments, queries and the site's own addresses (including www.) stay in the same tab; a subdomain counts as another host. Component links have no setting to keep them in the tab.",
8
+ "A link mark keeps its own choice: target=\"_blank\" opens a new tab for any link, and target=\"_self\" keeps even an external link in the same tab. In Markdown write {target=\"_self\"} after the link; in .pm.json set attrs.target to \"_self\". A mark with a null target, or with the editor's old default rel, follows the default. See help page free-form.",
9
+ "Header navigation follows the same rule: an item or linked group with an http or https address on another host opens in a new tab. mailto and tel items, page items and the site's own addresses stay in the same tab.",
10
+ "A link that opens a new tab now says \"(opens in a new tab)\" to a screen reader, with no change on screen. This covers component links, link marks, navigation and the footer.",
11
+ "Help for component links and the image-row and product-grid notes no longer say links open in the same tab.",
12
+ "Existing pages change at the site's next whole-site render, such as a settings or navigation change. Pages an incremental publish copies forward keep their old links until then.",
13
+ "Page blocks get real default spacing. Text, tables and every component sit one gap apart, where components used to have no space from the text before them; the last text block of a padded section gets the same gap as room before the section ends; a heading's top margin scales with it. Banners stay flush with their neighbors.",
14
+ "New required setting blockSpacing in settings/taproot-styles.json (Brand & style > Block spacing): compact, standard or roomy scales the gap to 0.75, 1 or 1.5 times, in both color schemes. Standard keeps text where it is today.",
15
+ "spacer blocks add their height to the new gap instead of replacing it, so review hand-placed spacers and remove the ones that only worked around tight spacing.",
16
+ "blockSpacing is required on theme push like every other appearance field. A workspace pulled before this version does not hold it: run pull again before theme push, or add \"blockSpacing\": \"standard\" to settings/taproot-styles.json.",
17
+ "A site shows the new spacing after its next whole-site render, such as a settings or navigation change. Pages an incremental publish copies forward keep their old spacing until then.",
18
+ "A section decoration can be sized by height. Set blockSizePercent (an integer from 10 to 200) for \"auto N%\" in place of inlineSizePercent; the two cannot be combined, and neither set keeps the width default of 40. See help page free-form.",
19
+ "The help reference version is 51."
20
+ ],
21
+ "help": [
22
+ "page",
23
+ "components",
24
+ "component",
25
+ "settings",
26
+ "appearance"
27
+ ]
28
+ },
3
29
  {
4
30
  "version": "0.31.0",
5
31
  "note": "Image defaults can be listed, read and changed safely through media images; metadata advice uses consistent title and description guidance.",
@@ -708,7 +708,13 @@ const COMPONENT_DEFINITIONS = Object.freeze({
708
708
  "Spacer",
709
709
  "Intentional vertical separation with an optional decorative divider.",
710
710
  { height: "medium", showDivider: false, dividerStyle: "solid", dividerWidth: 1 },
711
- ["Do not use spacing alone to communicate document structure; keep headings and landmarks semantic."],
711
+ [
712
+ "Do not use spacing alone to communicate document structure; keep headings and landmarks semantic.",
713
+ "Blocks already sit one gap apart, set by the site's blockSpacing (help settings). A spacer adds its height to "
714
+ + "that gap: after text the gap is the spacer's height plus one gap, and between two components it is one gap, "
715
+ + "the height, and one gap. Review spacers placed to work around tight spacing, and remove ones that are no "
716
+ + "longer needed.",
717
+ ],
712
718
  { height: "large", showDivider: true, dividerStyle: "solid", dividerWidth: 1 },
713
719
  ),
714
720
  "latest-posts": component(
@@ -911,7 +917,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
911
917
  "Every image needs alt text unless its descriptionMode is inherit, in which case the inherited description must resolve to non-blank text before deployment. A row of pictures carries no other text. For a linked image the alt is the link's name, so name the destination or action, such as \"Download on the App Store\"; for an unlinked image, describe the picture.",
912
918
  "In Markdown the alt comes from the media manifest entry the image path names; a blank alt is refused.",
913
919
  "Each image keeps its own shape at the chosen height and is never cropped or framed; an image wider than the row shrinks to fit. Width and height are required so the row can size the image; media upload records them.",
914
- "Links open in the same tab, as card-grid and feature-grid links do.",
920
+ "A link to another site opens in a new tab with nothing to set. The site's own addresses, relative paths, mailto: and tel: links open in the same tab.",
915
921
  ],
916
922
  {
917
923
  items: [
@@ -950,7 +956,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
950
956
  [
951
957
  "A button's visible text is its accessible name, and the product's title heads the same list item, so \"Buy now\" on every card reads with its product. Say what the button does, such as \"Buy now\" or \"Book a trial\".",
952
958
  PRODUCT_GRID_BUTTON_TEXT_NOTE,
953
- "buttonText and buttonUrl go together: text without an address and an address without text are both refused. Links open in the same tab, as card-grid and feature-grid links do.",
959
+ "buttonText and buttonUrl go together: text without an address and an address without text are both refused. A button to another site opens in a new tab with nothing to set. The site's own addresses, relative paths, mailto: and tel: links open in the same tab.",
954
960
  "price is free text (\"From $30\", \"Free\"); priceNote is shown beside it and refused without a price.",
955
961
  "An image is optional and cropped to 16:9. Its alt may be blank when the title already says what the picture shows; in Markdown the alt comes from the media manifest entry.",
956
962
  "highlighted draws a border of at least 2px (the borderWidth when that is larger) and the badge in the site's action color to mark one card, such as the most popular plan. The grid displays products only: no cart, payments or inventory.",
@@ -995,7 +1001,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
995
1001
  [
996
1002
  "A link's text is its accessible name: the label if set, else the platform's name. With showLabels false the text is visually hidden and read by screen readers only, so the name is the same either way.",
997
1003
  "Link only to accounts you control; each platform's brand rules apply to its mark. " + UNSUPPORTED_DOMAIN_NOTE,
998
- "platform other is any website and needs a label, such as \"Book online\". A named platform's link carries rel=\"me\"; a Website link carries no rel.",
1004
+ "platform other is any website and needs a label, such as \"Book online\". A link to another site opens in a new tab with nothing to set: a named platform's link publishes rel=\"me noopener noreferrer\", a Website link publishes rel=\"noopener noreferrer\", and a link to the site's own address stays in the same tab.",
999
1005
  ],
1000
1006
  {
1001
1007
  items: [
@@ -1071,7 +1077,7 @@ function referenceSchema(spec) {
1071
1077
  type: "string",
1072
1078
  format: "https-url",
1073
1079
  maxLength: SOCIAL_LINK_URL_MAX_LENGTH,
1074
- description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE}`,
1080
+ description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE} A link to another site opens in a new tab with nothing to set.`,
1075
1081
  };
1076
1082
  }
1077
1083
  return {
@@ -1079,7 +1085,7 @@ function referenceSchema(spec) {
1079
1085
  format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
1080
1086
  description: `${
1081
1087
  spec.allowEmpty === false ? "An" : "Empty, or an"
1082
- } explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms.`,
1088
+ } explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms. A web address on another site opens in a new tab with nothing to set; the site's own addresses, relative paths, fragments, queries, mailto and tel links do not.`,
1083
1089
  };
1084
1090
  case "css-length":
1085
1091
  return { type: "string", description: `Must be ${describe(spec)}.` };
@@ -0,0 +1,77 @@
1
+ // Generated from shared/external-link.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The one owner of which links open in a new tab (TR01327).
4
+ *
5
+ * A link opens in a new tab when its address is an absolute http or https URL
6
+ * whose host is not one of the site's own. Everything else (relative paths,
7
+ * fragments, mailto, tel, anything that does not parse) stays in the tab.
8
+ *
9
+ * It is pure and dependency-free. The generator, the editor and the
10
+ * site-authoring CLI cannot import this file across project roots, so
11
+ * `node scripts/sync-field-validation.mjs` writes their copies; edit only this
12
+ * file and re-run it.
13
+ */
14
+ /** A host as the rule compares it: lowercase, with one leading `www.` dropped. */
15
+ export function normalizeLinkHost(host) {
16
+ const lowered = (host ?? "").trim().toLowerCase();
17
+ return lowered.startsWith("www.") ? lowered.slice(4) : lowered;
18
+ }
19
+ /** The site's own hosts, normalized. Empty and missing hosts are left out. */
20
+ export function siteLinkHosts(hosts) {
21
+ const set = new Set();
22
+ for (const host of hosts) {
23
+ const normalized = normalizeLinkHost(host);
24
+ if (normalized)
25
+ set.add(normalized);
26
+ }
27
+ return set;
28
+ }
29
+ /** True for an absolute http or https URL to a host outside the set. */
30
+ export function isExternalLink(href, siteHosts) {
31
+ if (!href)
32
+ return false;
33
+ let parsed;
34
+ try {
35
+ parsed = new URL(href.trim());
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
41
+ return false;
42
+ return !siteHosts.has(normalizeLinkHost(parsed.hostname));
43
+ }
44
+ /** A rel with `noopener noreferrer` after its existing tokens, without duplicates. */
45
+ export function relWithNewTabTokens(rel) {
46
+ const tokens = new Set((rel ?? "").split(/\s+/u).filter(Boolean));
47
+ tokens.add("noopener");
48
+ tokens.add("noreferrer");
49
+ return [...tokens].join(" ");
50
+ }
51
+ /**
52
+ * The `target` and `rel` attributes for a link that opens in a new tab, as
53
+ * markup with a leading space. The values are tokens, so nothing needs escaping.
54
+ */
55
+ export function newTabAttributeMarkup(rel) {
56
+ return ` target="_blank" rel="${relWithNewTabTokens(rel)}"`;
57
+ }
58
+ /**
59
+ * What a screen reader hears after a link's name when it opens a new tab. These
60
+ * are the words Espalier appends to its own links, so a native anchor and an
61
+ * `esp-button` read alike. `generator/browser-tests/new-tab-cue.spec.ts` holds
62
+ * the two together.
63
+ */
64
+ export const NEW_TAB_CUE_TEXT = " (opens in a new tab)";
65
+ /**
66
+ * The cue for a native anchor, as its last child. Pagefind indexes raw HTML, so
67
+ * `data-pagefind-ignore` keeps the words out of search content and excerpts.
68
+ * Output without `NEW_TAB_CUE_CSS` would show the words, so only published pages
69
+ * may carry it.
70
+ */
71
+ export const NEW_TAB_CUE_HTML = `<span class="sr-only" data-pagefind-ignore>${NEW_TAB_CUE_TEXT}</span>`;
72
+ /** Hides the cue from sight only. The page shell and each shadow stylesheet with a cued anchor carry it. */
73
+ export const NEW_TAB_CUE_CSS = ".sr-only{position:absolute;width:0.0625rem;height:0.0625rem;padding:0;margin:-0.0625rem;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}";
74
+ /** Rendered HTML without its cues, for code that reads a link's text back. */
75
+ export function withoutNewTabCue(html) {
76
+ return html.split(NEW_TAB_CUE_HTML).join("");
77
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 15,
2
+ "version": 16,
3
3
 
4
4
  "imageDescription": {
5
5
  "fields": {
@@ -317,6 +317,12 @@
317
317
  "minimum": 10,
318
318
  "maximum": 100
319
319
  },
320
+ "blockSizePercent": {
321
+ "type": "integer",
322
+ "required": false,
323
+ "minimum": 10,
324
+ "maximum": 200
325
+ },
320
326
  "opacity": {
321
327
  "type": "number",
322
328
  "required": false,
@@ -469,8 +469,19 @@ export function normalizeFreeFormSectionDecoration(
469
469
  validateImageDescription(image, imagePath, "Section decoration image", report);
470
470
  }
471
471
 
472
+ // TR01329: width and height sizes are exclusive, so one conflict replaces both range checks.
473
+ const sizeConflict = Object.hasOwn(value, "inlineSizePercent") && Object.hasOwn(value, "blockSizePercent");
474
+ if (sizeConflict) {
475
+ report(
476
+ `${path}/blockSizePercent`,
477
+ CODES.attrInvalid,
478
+ "Section decoration blockSizePercent cannot be combined with inlineSizePercent. Use only one of the two sizes.",
479
+ );
480
+ }
481
+
472
482
  for (const [name, definition] of Object.entries(fields)) {
473
483
  if (name === "image" || !Object.hasOwn(value, name)) continue;
484
+ if (sizeConflict && (name === "inlineSizePercent" || name === "blockSizePercent")) continue;
474
485
  const fieldPath = `${path}/${name}`;
475
486
  const entry = value[name];
476
487
  if (definition.type === "enum") {
@@ -514,7 +525,13 @@ export function normalizeFreeFormSectionDecoration(
514
525
  };
515
526
  const normalized = { image: Object.freeze(normalizedImage) };
516
527
  for (const [name, definition] of Object.entries(fields)) {
517
- if (name !== "image") normalized[name] = value[name] ?? definition.default;
528
+ if (name === "image") continue;
529
+ // A field with no default stays absent. The width default applies only without a height size.
530
+ const fallback = name === "inlineSizePercent" && Object.hasOwn(value, "blockSizePercent")
531
+ ? undefined
532
+ : definition.default;
533
+ const resolved = value[name] ?? fallback;
534
+ if (resolved !== undefined) normalized[name] = resolved;
518
535
  }
519
536
  return finish(Object.freeze(normalized));
520
537
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
11
11
  import { imageDescriptionIssues } from "./image-description-validation.js";
12
+ import { isExternalLink, NEW_TAB_CUE_HTML, relWithNewTabTokens } from "./external-link.js";
12
13
  import {
13
14
  explicitImageMaxHeight,
14
15
  mediaOverrideStyleParts,
@@ -101,6 +102,12 @@ export interface RenderProseMirrorOptions {
101
102
  headingLevel?: (authored: number) => number;
102
103
  /** Claims the page's one LCP slot before rendering a section photo. */
103
104
  claimSectionBackgroundImage?: () => boolean;
105
+ /**
106
+ * The site's own hosts (TR01327). With it, external links open in a new tab
107
+ * unless the author chose otherwise; without it nothing is decided here, so
108
+ * previews and PDFs render as before.
109
+ */
110
+ siteHosts?: ReadonlySet<string>;
104
111
  }
105
112
 
106
113
  export function createEmptyProseMirrorDocument(): ProseMirrorDocument {
@@ -264,7 +271,7 @@ function renderNode(
264
271
  renderNodes(node.content ?? [], options, "nested"),
265
272
  );
266
273
  case "text":
267
- return renderTextNode(node);
274
+ return renderTextNode(node, options);
268
275
  case "hardBreak":
269
276
  return "<br>";
270
277
  case "heading":
@@ -345,15 +352,15 @@ function renderIntegrationPlacement(node: ProseMirrorNode, options: RenderProseM
345
352
  }, selected.html);
346
353
  }
347
354
 
348
- function renderTextNode(node: ProseMirrorNode): string {
355
+ function renderTextNode(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
349
356
  let rendered = escapeHtml(node.text ?? "");
350
357
  for (const mark of node.marks ?? []) {
351
- rendered = wrapMark(rendered, mark);
358
+ rendered = wrapMark(rendered, mark, options);
352
359
  }
353
360
  return rendered;
354
361
  }
355
362
 
356
- function wrapMark(html: string, mark: ProseMirrorMark): string {
363
+ function wrapMark(html: string, mark: ProseMirrorMark, options: RenderProseMirrorOptions): string {
357
364
  switch (mark.type) {
358
365
  case "bold":
359
366
  return `<strong>${html}</strong>`;
@@ -368,7 +375,7 @@ function wrapMark(html: string, mark: ProseMirrorMark): string {
368
375
  case "link": {
369
376
  const href = stringAttr(mark.attrs?.href);
370
377
  if (!href || !isSafeUrl(href)) return html;
371
- return renderElement("a", { href, ...linkRelAttributes(mark.attrs) }, html);
378
+ return renderLink(href, mark.attrs, html, options);
372
379
  }
373
380
  default:
374
381
  return html;
@@ -410,7 +417,7 @@ type NormalizedTable = {
410
417
  * omit one malformed table rather than reinterpret arbitrary blocks as cells.
411
418
  */
412
419
  function renderTable(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
413
- const table = normalizeTable(node);
420
+ const table = normalizeTable(node, options);
414
421
  if (!table) return "";
415
422
 
416
423
  const header = renderElement(
@@ -456,7 +463,7 @@ function renderTable(node: ProseMirrorNode, options: RenderProseMirrorOptions):
456
463
  );
457
464
  }
458
465
 
459
- function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
466
+ function normalizeTable(node: ProseMirrorNode, options: RenderProseMirrorOptions): NormalizedTable | undefined {
460
467
  const definition = freeFormSectionRegistry.table;
461
468
  if (!hasOnlySerializedKeys(node, ["type", "attrs", "content"])) return undefined;
462
469
  const attrs = node.attrs;
@@ -496,7 +503,7 @@ function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
496
503
  return undefined;
497
504
  }
498
505
 
499
- const headerCells = normalizeTableRow(headerRow, definition.nodeTypes.header, true);
506
+ const headerCells = normalizeTableRow(headerRow, definition.nodeTypes.header, true, options);
500
507
  if (!headerCells) return undefined;
501
508
  const normalizedDataRows: string[][] = [];
502
509
  for (const row of dataRows) {
@@ -508,7 +515,7 @@ function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
508
515
  || !Array.isArray(row.content)
509
516
  || row.content.length !== columnCount
510
517
  ) return undefined;
511
- const normalized = normalizeTableRow(row, definition.nodeTypes.cell, false);
518
+ const normalized = normalizeTableRow(row, definition.nodeTypes.cell, false, options);
512
519
  if (!normalized) return undefined;
513
520
  normalizedDataRows.push(normalized);
514
521
  }
@@ -524,6 +531,7 @@ function normalizeTableRow(
524
531
  row: ProseMirrorNode,
525
532
  expectedCellType: string,
526
533
  header: boolean,
534
+ options: RenderProseMirrorOptions,
527
535
  ): string[] | undefined {
528
536
  if (!Array.isArray(row.content)) return undefined;
529
537
  const cells: string[] = [];
@@ -534,7 +542,7 @@ function normalizeTableRow(
534
542
  || cell.type !== expectedCellType
535
543
  || !hasNoAttrs(cell)
536
544
  ) return undefined;
537
- const normalized = normalizeTableCell(cell);
545
+ const normalized = normalizeTableCell(cell, options);
538
546
  if (!normalized) return undefined;
539
547
  if (header && freeFormSectionRegistry.table.cells.headerNonWhitespace && normalized.text.trim() === "") {
540
548
  return undefined;
@@ -544,7 +552,10 @@ function normalizeTableRow(
544
552
  return cells;
545
553
  }
546
554
 
547
- function normalizeTableCell(cell: ProseMirrorNode): { html: string; text: string } | undefined {
555
+ function normalizeTableCell(
556
+ cell: ProseMirrorNode,
557
+ options: RenderProseMirrorOptions,
558
+ ): { html: string; text: string } | undefined {
548
559
  const paragraphs = cell.content;
549
560
  const paragraph = paragraphs?.[0];
550
561
  if (
@@ -574,7 +585,7 @@ function normalizeTableCell(cell: ProseMirrorNode): { html: string; text: string
574
585
  || inlineNode.text === ""
575
586
  ) return undefined;
576
587
  text += inlineNode.text;
577
- html.push(renderTextNode(inlineNode));
588
+ html.push(renderTextNode(inlineNode, options));
578
589
  } else if (inlineNode.type === "hardBreak") {
579
590
  if (
580
591
  !hasOnlySerializedKeys(inlineNode, ["type", "attrs", "marks"])
@@ -659,7 +670,7 @@ function renderInlineFacts(
659
670
  if (!facts) return "";
660
671
  const items = facts.map((fact) => {
661
672
  const value = fact.url
662
- ? renderElement("a", { href: fact.url }, escapeHtml(fact.value))
673
+ ? renderLink(fact.url, {}, escapeHtml(fact.value), options)
663
674
  : escapeHtml(fact.value);
664
675
  const label = fact.label === undefined
665
676
  ? ""
@@ -1030,7 +1041,7 @@ type NormalizedSectionDecoration = {
1030
1041
  anchor: string;
1031
1042
  inlineOffsetPercent: number;
1032
1043
  blockOffsetPercent: number;
1033
- inlineSizePercent: number;
1044
+ size: string;
1034
1045
  opacity: number;
1035
1046
  tintToken: string;
1036
1047
  };
@@ -1042,7 +1053,7 @@ function normalizedSectionDecorationStyle(value: unknown): string[] | undefined
1042
1053
  `--esp-section-decoration-image: url("${escapeCssString(decoration.imageUrl)}")`,
1043
1054
  `--esp-section-decoration-color: var(${decoration.tintToken})`,
1044
1055
  `--esp-section-decoration-position: ${sectionDecorationPosition(decoration)}`,
1045
- `--esp-section-decoration-size: ${decoration.inlineSizePercent}% auto`,
1056
+ `--esp-section-decoration-size: ${decoration.size}`,
1046
1057
  `--esp-section-decoration-opacity: ${decoration.opacity}`,
1047
1058
  ];
1048
1059
  }
@@ -1064,6 +1075,10 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
1064
1075
  const anchor = value.anchor ?? fields.anchor.default;
1065
1076
  const inlineOffsetPercent = value.inlineOffsetPercent ?? fields.inlineOffsetPercent.default;
1066
1077
  const blockOffsetPercent = value.blockOffsetPercent ?? fields.blockOffsetPercent.default;
1078
+ // TR01329: width and height sizes are exclusive. Read the raw keys, because
1079
+ // the width default is filled in below.
1080
+ if (value.inlineSizePercent !== undefined && value.blockSizePercent !== undefined) return undefined;
1081
+ const blockSizePercent = value.blockSizePercent;
1067
1082
  const inlineSizePercent = value.inlineSizePercent ?? fields.inlineSizePercent.default;
1068
1083
  const opacity = value.opacity ?? fields.opacity.default;
1069
1084
  const tint = value.tint ?? fields.tint.default;
@@ -1074,7 +1089,12 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
1074
1089
  if (!isBoundedNumber(blockOffsetPercent, fields.blockOffsetPercent.minimum, fields.blockOffsetPercent.maximum)) {
1075
1090
  return undefined;
1076
1091
  }
1077
- if (
1092
+ if (blockSizePercent !== undefined) {
1093
+ if (
1094
+ !isBoundedNumber(blockSizePercent, fields.blockSizePercent.minimum, fields.blockSizePercent.maximum)
1095
+ || !Number.isInteger(blockSizePercent)
1096
+ ) return undefined;
1097
+ } else if (
1078
1098
  !isBoundedNumber(inlineSizePercent, fields.inlineSizePercent.minimum, fields.inlineSizePercent.maximum)
1079
1099
  || !Number.isInteger(inlineSizePercent)
1080
1100
  ) return undefined;
@@ -1088,7 +1108,7 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
1088
1108
  anchor,
1089
1109
  inlineOffsetPercent,
1090
1110
  blockOffsetPercent,
1091
- inlineSizePercent,
1111
+ size: blockSizePercent !== undefined ? `auto ${blockSizePercent}%` : `${inlineSizePercent}% auto`,
1092
1112
  opacity,
1093
1113
  tintToken,
1094
1114
  };
@@ -1416,7 +1436,11 @@ export const LINK_REL_TOKENS = [
1416
1436
  "privacy-policy",
1417
1437
  "terms-of-service",
1418
1438
  ] as const;
1419
- /** The targets a link may carry; only `_blank` changes what renders. */
1439
+ /**
1440
+ * The targets a link may carry. `_blank` opens a new tab and `_self` keeps the
1441
+ * tab; with a site host set, any other value leaves the choice to the external
1442
+ * link rule (TR01327).
1443
+ */
1420
1444
  export const LINK_TARGETS = ["_blank", "_self"] as const;
1421
1445
  /**
1422
1446
  * The editor stored this rel, with `target="_blank"`, on every link before
@@ -1445,18 +1469,42 @@ export function chosenLinkRelTokens(rel: unknown): string[] | undefined {
1445
1469
  /**
1446
1470
  * The `rel` and `target` a link publishes with: the author's allowed tokens in
1447
1471
  * their order, and noopener and noreferrer whenever it opens a new tab.
1448
- * Anything else stored is ignored.
1472
+ * `_blank` opens a new tab and `_self` keeps the tab. Any other target, or a
1473
+ * mark with the old editor rel (whose target was never a choice), follows the
1474
+ * external link rule when a host set is supplied, and otherwise stays in the
1475
+ * tab. Anything else stored is ignored.
1449
1476
  */
1450
- export function linkRelAttributes(attrs: Record<string, unknown> | undefined): { rel?: string; target?: string } {
1477
+ export function linkRelAttributes(
1478
+ attrs: Record<string, unknown> | undefined,
1479
+ href?: string,
1480
+ siteHosts?: ReadonlySet<string>,
1481
+ ): { rel?: string; target?: string } {
1451
1482
  const stored = chosenLinkRelTokens(attrs?.rel);
1452
- if (!stored) return {};
1453
- const newTab = stringAttr(attrs?.target)?.trim().toLowerCase() === "_blank";
1483
+ const target = stringAttr(attrs?.target)?.trim().toLowerCase();
1484
+ const byRule = () => siteHosts !== undefined && isExternalLink(href, siteHosts);
1485
+ const newTab = stored && (target === "_blank" || target === "_self")
1486
+ ? target === "_blank"
1487
+ : byRule();
1454
1488
  const allowed: readonly string[] = LINK_REL_TOKENS;
1455
- const rel = new Set([
1456
- ...stored.filter((token) => allowed.includes(token)),
1457
- ...(newTab ? ["noopener", "noreferrer"] : []),
1458
- ]);
1459
- return { ...(rel.size ? { rel: [...rel].join(" ") } : {}), ...(newTab ? { target: "_blank" } : {}) };
1489
+ const kept = (stored ?? []).filter((token) => allowed.includes(token));
1490
+ const rel = newTab ? relWithNewTabTokens(kept.join(" ")) : [...new Set(kept)].join(" ");
1491
+ return { ...(rel ? { rel } : {}), ...(newTab ? { target: "_blank" } : {}) };
1492
+ }
1493
+
1494
+ /**
1495
+ * An anchor with the attributes `linkRelAttributes` chooses. Only a publish
1496
+ * passes a host set, and only then does a new-tab link carry its hidden cue,
1497
+ * because the editor preview and legal PDFs have no rule to hide it (TR01327).
1498
+ */
1499
+ function renderLink(
1500
+ href: string,
1501
+ attrs: Record<string, unknown> | undefined,
1502
+ html: string,
1503
+ options: RenderProseMirrorOptions,
1504
+ ): string {
1505
+ const linkAttributes = linkRelAttributes(attrs, href, options.siteHosts);
1506
+ const cue = options.siteHosts !== undefined && linkAttributes.target === "_blank" ? NEW_TAB_CUE_HTML : "";
1507
+ return renderElement("a", { href, ...linkAttributes }, `${html}${cue}`);
1460
1508
  }
1461
1509
 
1462
1510
  function isSafeUrl(url: string): boolean {
@@ -520,6 +520,14 @@ export function getAppearanceReference() {
520
520
  + "so full-bleed designs usually pair headerWidth 'wide' with headerLayout 'centered-menu', which keeps the "
521
521
  + "brand left, the buttons right, and the navigation centered. validate prints a hint, never a failure, when a "
522
522
  + "root-band page ships with a contained header.",
523
+ blockSpacingContract:
524
+ "Blocks on a page space themselves: text, tables and components all sit one gap apart, and the gap follows the "
525
+ + "site's text size. blockSpacing scales it for the whole site in both color schemes: 'standard' keeps the gap "
526
+ + "equal to the text spacing, 'compact' is three quarters of it and 'roomy' is one and a half times it. "
527
+ + "A heading's top margin scales the same way. The last text block in a padded section gets the gap as extra "
528
+ + "room before the section ends, and an image banner stays flush with its neighbors. A spacer block adds its "
529
+ + "own height to the gap, so review spacers placed by hand. A site shows the change after its next whole-site "
530
+ + "render.",
523
531
  menuContract:
524
532
  "navDrawerStyle 'full-screen' (the published default) opens the mobile menu over the whole viewport with the "
525
533
  + "brand centered above large centered items and navDrawerTransition choosing how it appears (fade, slide-down, "
@@ -841,6 +849,7 @@ export function formatPresentationReference(reference) {
841
849
  + "\nLogo contrast: " + reference.logoContrastContract
842
850
  + "\nFavicon: " + reference.faviconContract
843
851
  + "\nHeader width: " + reference.headerWidthContract
852
+ + "\nBlock spacing: " + reference.blockSpacingContract
844
853
  + "\nMobile menu and menu font: " + reference.menuContract
845
854
  + "\nFooter: " + reference.footerBoundary
846
855
  + "\nFooter content guard: " + reference.footerContentGuard + "\n\nAtomic change set (one transaction):\n"
@@ -95,7 +95,7 @@ import {
95
95
 
96
96
  export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
97
97
 
98
- export const REFERENCE_VERSION = 50;
98
+ export const REFERENCE_VERSION = 51;
99
99
 
100
100
  // The date-limit examples `help forms` prints; tests run them through the form validator.
101
101
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -695,7 +695,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
695
695
  "PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
696
696
  "EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
697
697
  "externalUrl is an absolute credential-free http/https URL, or a mailto:/tel: contact URL such as "
698
- + "tel:+15555550123, published exactly as authored.",
698
+ + "tel:+15555550123, published exactly as authored. An http or https address on another host opens in a new tab, "
699
+ + "and the link says so to a screen reader.",
699
700
  ]),
700
701
  example: Object.freeze({
701
702
  siteId: "<site-uuid>",
@@ -1409,6 +1410,8 @@ function decorationReference() {
1409
1410
  }\n## Rooted in warmth\n\nMove with confidence.\n:::`,
1410
1411
  maskGuidance:
1411
1412
  "Use a transparent PNG or WebP whose alpha channel contains only the mark. The active section context supplies the tint in both schemes.",
1413
+ sizeGuidance:
1414
+ "Set inlineSizePercent to size the art by the section's width, or blockSizePercent to size it by the section's height. The two cannot be combined.",
1412
1415
  opaqueWarning:
1413
1416
  "An opaque rectangle uses its whole alpha channel and will render as a tinted rectangle, not as a cut-out mark.",
1414
1417
  });
@@ -1453,7 +1456,7 @@ function inlineFactsReference() {
1453
1456
  + "mix labelled and standalone facts. An empty string is rejected, so omitting a label stays explicit. "
1454
1457
  + "Unicode spaces, tabs, and LF/CRLF line breaks are allowed; other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal <br> tags are rejected. The label follows the value in reading order and is outside its link.",
1455
1458
  urlPolicy:
1456
- "Optional safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL. Empty/whitespace-only values, other schemes, backslashes, and ASCII controls are rejected; tel: remains a native link. Contact URLs require a body and refuse tel:// and mailto:// authority forms or control characters, consistently with navigation and footer links.",
1459
+ "Optional safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL. Empty/whitespace-only values, other schemes, backslashes, and ASCII controls are rejected; tel: remains a native link. Contact URLs require a body and refuse tel:// and mailto:// authority forms or control characters, consistently with navigation and footer links. An absolute http(s) address on another site opens in a new tab with nothing to set; the site's own addresses and all other values keep the same tab.",
1457
1460
  markdown: Object.freeze({
1458
1461
  fence: INLINE_FACTS_DEFINITION.markdown.fence,
1459
1462
  body: INLINE_FACTS_DEFINITION.markdown.body,
@@ -1819,10 +1822,10 @@ const FREE_FORM_REFERENCE = Object.freeze({
1819
1822
  + "choice is per link, internal links included.",
1820
1823
  markdownRule:
1821
1824
  'Write {rel="…" target="…"} directly after the link\'s closing parenthesis: either attribute alone or both, '
1822
- + 'each double-quoted once. rel is space-separated; target="_blank" opens a new tab.',
1825
+ + 'each double-quoted once. rel is space-separated; an external web address opens in a new tab on its own, target="_blank" opens a new tab for any link, and target="_self" keeps a link in the same tab.',
1823
1826
  pmJson:
1824
1827
  'In .pm.json, a link mark carries attrs.rel (a space-separated string, or null) and attrs.target ("_blank" '
1825
- + 'opens a new tab and publishes with noopener noreferrer added, "_self" or null keeps the same tab).',
1828
+ + 'opens a new tab and publishes with noopener noreferrer added, "_self" keeps the same tab, and null leaves it to the default: an absolute http(s) link to another site opens in a new tab, and any other link keeps the same tab).',
1826
1829
  refusals:
1827
1830
  `Any other rel token, opener included, is refused (${CONTENT_ERROR_CODES.linkRel}); so is another target `
1828
1831
  + `(${CONTENT_ERROR_CODES.linkTarget}). The editor's old default, "noopener noreferrer nofollow", reads as no choice.`,
@@ -2039,9 +2042,15 @@ function formatRegistryField(field, indent = " ") {
2039
2042
  : `; ${field.minScalars} through ${field.maxScalars} Unicode scalar values${
2040
2043
  field.nonWhitespace ? "; non-whitespace" : ""
2041
2044
  }`;
2045
+ // TR01329: a field with no default key is absent until an author sets it.
2046
+ const requirement = field.required
2047
+ ? "required"
2048
+ : Object.hasOwn(field, "default")
2049
+ ? `default ${JSON.stringify(field.default)}`
2050
+ : "optional; no default";
2042
2051
  const line = `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} ${type}${
2043
2052
  field.nullable ? " | null" : ""
2044
- }; ${field.required ? "required" : `default ${JSON.stringify(field.default)}`}${bounds}${itemBounds}${scalarBounds}`;
2053
+ }; ${requirement}${bounds}${itemBounds}${scalarBounds}`;
2045
2054
  return [line, ...(field.fields ?? []).map((child) => formatRegistryField(child, `${indent} `))].join("\n");
2046
2055
  }
2047
2056
 
@@ -2128,7 +2137,7 @@ export function formatReferenceResult(result) {
2128
2137
  }\n delivery URLs ${page.sections.decoration.deliveryUrlPolicy}\n\nTint mapping:\n${
2129
2138
  Object.entries(page.sections.decoration.tintTokens).map(([name, token]) => ` ${name.padEnd(20)} ${token}`)
2130
2139
  .join("\n")
2131
- }\n\nTransparent-mask example:\n${page.sections.decoration.markdownExample}\n\nMask guidance: ${page.sections.decoration.maskGuidance}\nWarning: ${page.sections.decoration.opaqueWarning}\n\nRoot defaults and placement:\n${
2140
+ }\n\nTransparent-mask example:\n${page.sections.decoration.markdownExample}\n\nMask guidance: ${page.sections.decoration.maskGuidance}\nSize guidance: ${page.sections.decoration.sizeGuidance}\nWarning: ${page.sections.decoration.opaqueWarning}\n\nRoot defaults and placement:\n${
2132
2141
  page.sections.placementMatrix.map(formatPlacement).join("\n")
2133
2142
  }\n\nSemantic tables:\n placement document root or directly inside a top-level section; wide/full-well measure\n rows exactly ${page.tables.structure.headerRows} header row, then ${page.tables.limits.dataRows.min} through ${page.tables.limits.dataRows.max} data rows\n columns ${page.tables.limits.columns.min} through ${page.tables.limits.columns.max}; delimiter and every row must match the header width\n caption optional non-empty plain text; maximum ${page.tables.limits.captionScalars.max} Unicode scalar values; syntax ${page.tables.markdown.captionSyntax}\n caption guidance ${page.tables.captionGuidance}\n cells exactly ${page.tables.structure.paragraphChildrenPerCell} paragraph; headers need non-whitespace text; data cells may be empty; maximum ${page.tables.limits.cellTextScalars.max} text scalars\n inline ${
2134
2143
  page.tables.structure.inlineNodes.join(", ")
@@ -83,6 +83,15 @@ const TAPROOT_STYLES_ENTRIES = {
83
83
  "The color scheme a first-time visitor sees: light, dark, or system to follow the visitor's device.",
84
84
  "The starting scheme of every published page. A visitor who uses the theme toggle keeps their own choice.",
85
85
  ),
86
+ blockSpacing: entry(
87
+ `${BRAND} > Block spacing`,
88
+ "The space between blocks on every page: compact, standard or roomy. Standard keeps the gap equal to the text "
89
+ + "spacing; compact scales it to three quarters and roomy to one and a half times. Spacer blocks add their "
90
+ + "height on top.",
91
+ "Both color schemes, on every published page. A site shows the change after its next whole-site render, which "
92
+ + "happens when any settings group or navigation is selected for a stage or deploy. Pages copied forward from "
93
+ + "an earlier deploy keep their old spacing until then.",
94
+ ),
86
95
  lightLogoId: entry(
87
96
  `${BRAND} > Logo`,
88
97
  "The site-owned image used as the brand logo in the light scheme.",
@@ -58,6 +58,7 @@ export const SETTINGS_GROUPS = Object.freeze([
58
58
  { name: "darkTheme", wireType: "theme" },
59
59
  ...stringFields(
60
60
  "defaultScheme",
61
+ "blockSpacing",
61
62
  "lightLogoId",
62
63
  "lightLogoUrl",
63
64
  "darkLogoId",