@omniharmonic/openspace-cli 0.1.13 → 0.1.15

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 (30) hide show
  1. package/dist/{agents-md-QBMFXTIQ.js → agents-md-OWDSHBGF.js} +2 -2
  2. package/dist/bin.js +3 -3
  3. package/dist/{chunk-IXN2CNGL.js → chunk-GMKHKHUR.js} +206 -39
  4. package/dist/chunk-GMKHKHUR.js.map +1 -0
  5. package/dist/{chunk-B5C7S4S5.js → chunk-PBN36VUE.js} +2775 -184
  6. package/dist/chunk-PBN36VUE.js.map +1 -0
  7. package/dist/{chunk-EFSGZWAO.js → chunk-YAPLQ4OA.js} +155 -16
  8. package/dist/chunk-YAPLQ4OA.js.map +1 -0
  9. package/dist/{crawl-TAADG5BR.js → crawl-WQQDFI7W.js} +2 -2
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/starter/site/AGENTS.md +4 -4
  13. package/dist/templates/darkroom/content/pages/about.json +2 -2
  14. package/dist/templates/encore/content/pages/home.json +1 -1
  15. package/dist/templates/folio/content/pages/home.json +2 -2
  16. package/dist/templates/gather/content/pages/home.json +2 -2
  17. package/dist/templates/harbor/content/pages/home.json +1 -1
  18. package/dist/templates/hearth/content/pages/home.json +6 -6
  19. package/dist/templates/inkwell/content/pages/about.json +2 -2
  20. package/dist/templates/inkwell/content/pages/books.json +1 -1
  21. package/dist/templates/keystone/content/pages/about.json +2 -2
  22. package/dist/templates/kiln/content/pages/home.json +7 -5
  23. package/dist/templates/ledger/content/pages/about.json +4 -4
  24. package/dist/templates/vow/content/pages/home.json +1 -1
  25. package/package.json +8 -8
  26. package/dist/chunk-B5C7S4S5.js.map +0 -1
  27. package/dist/chunk-EFSGZWAO.js.map +0 -1
  28. package/dist/chunk-IXN2CNGL.js.map +0 -1
  29. /package/dist/{agents-md-QBMFXTIQ.js.map → agents-md-OWDSHBGF.js.map} +0 -0
  30. /package/dist/{crawl-TAADG5BR.js.map → crawl-WQQDFI7W.js.map} +0 -0
@@ -22,14 +22,16 @@ import {
22
22
  platformLabel,
23
23
  sectionsFrom,
24
24
  sectionsFromLayoutHtml,
25
+ signupAsksName,
25
26
  sitePath,
26
27
  socialNetwork,
27
28
  stripSuffix,
28
29
  stripTags,
30
+ subscribersAdvice,
29
31
  text,
30
32
  themeFromCss,
31
33
  videoUrl
32
- } from "./chunk-EFSGZWAO.js";
34
+ } from "./chunk-YAPLQ4OA.js";
33
35
  import {
34
36
  BREAKPOINT_INFO,
35
37
  BUTTON_STYLES,
@@ -130,6 +132,8 @@ import {
130
132
  parseColor,
131
133
  parseComponentPreviewPath,
132
134
  parseCss,
135
+ phoneColumns,
136
+ phonePadding,
133
137
  plainText,
134
138
  presetCatalog,
135
139
  presetSampleStrings,
@@ -147,7 +151,7 @@ import {
147
151
  toHex,
148
152
  toJsonSchema,
149
153
  toMarkdown
150
- } from "./chunk-IXN2CNGL.js";
154
+ } from "./chunk-GMKHKHUR.js";
151
155
  import {
152
156
  __commonJS,
153
157
  __export,
@@ -7788,10 +7792,10 @@ function withPages(doc, pages) {
7788
7792
  // ../schema/src/secrets.ts
7789
7793
  function shannonEntropy(value) {
7790
7794
  if (!value) return 0;
7791
- const counts = /* @__PURE__ */ new Map();
7792
- for (const c of value) counts.set(c, (counts.get(c) ?? 0) + 1);
7795
+ const counts2 = /* @__PURE__ */ new Map();
7796
+ for (const c of value) counts2.set(c, (counts2.get(c) ?? 0) + 1);
7793
7797
  let h = 0;
7794
- for (const n of counts.values()) {
7798
+ for (const n of counts2.values()) {
7795
7799
  const p = n / value.length;
7796
7800
  h -= p * Math.log2(p);
7797
7801
  }
@@ -12600,7 +12604,15 @@ function autoMobileLayout(section, options = {}) {
12600
12604
  }
12601
12605
  };
12602
12606
  for (const pl of [...placed]) placeLayered(pl.id);
12603
- for (const { id, rect: d } of readingOrder(pending.filter((it) => !hostOf.has(it.id)))) {
12607
+ let stacking = readingOrder(pending.filter((it) => !hostOf.has(it.id)));
12608
+ if (options.textFirst) {
12609
+ const isText2 = (id) => {
12610
+ const b = blocks2.get(id);
12611
+ return !!b && isTextBlock(b);
12612
+ };
12613
+ stacking = [...stacking.filter((i) => isText2(i.id)), ...stacking.filter((i) => !isText2(i.id))];
12614
+ }
12615
+ for (const { id, rect: d } of stacking) {
12604
12616
  if (placedById.has(id)) continue;
12605
12617
  const block4 = blocks2.get(id);
12606
12618
  const m = { x: 1, y: cursor, w: cols, h: mobileHeight(block4, d, width) };
@@ -12641,6 +12653,690 @@ function autoMobileLayoutOps(doc, page, sectionId, options = {}) {
12641
12653
  return ops;
12642
12654
  }
12643
12655
 
12656
+ // ../lint/src/phone.ts
12657
+ var PHONE = 360;
12658
+ var GLYPH = 0.48;
12659
+ var GLYPH_CAPS = 0.62;
12660
+ function* sectionsOf2(doc, pageId) {
12661
+ const pages = pageId ? [doc.pages[pageId]].filter((p) => !!p) : Object.values(doc.pages);
12662
+ for (const p of pages)
12663
+ for (const s2 of p.sections) if (!s2.hidden) yield { page: p.id, section: s2 };
12664
+ yield { page: FOOTER, section: doc.site.footer };
12665
+ }
12666
+ function phonePx(value) {
12667
+ if (!value) return void 0;
12668
+ const v = value.trim();
12669
+ const m = /^clamp\(\s*([\d.]+)(rem|px)/.exec(v) ?? /^([\d.]+)(rem|px)$/.exec(v);
12670
+ if (!m) return void 0;
12671
+ return Number(m[1]) * (m[2] === "rem" ? 16 : 1);
12672
+ }
12673
+ var words2 = (s2) => s2.match(/[\p{L}\p{N}]{2,}/gu)?.length ?? 0;
12674
+ var PhoneCtx = class {
12675
+ constructor(doc, options) {
12676
+ this.doc = doc;
12677
+ this.options = options;
12678
+ this.meta = opMeta(options);
12679
+ this.root = compileStyles(doc.styles).tokens.vars.root;
12680
+ const density = doc.styles.choices.density;
12681
+ this.space = SPACE_SCALES[density ?? "comfortable"] ?? SPACE_SCALES.comfortable;
12682
+ this.gutter = phonePx(this.root["--os-section-gutter"]) ?? 24;
12683
+ }
12684
+ doc;
12685
+ options;
12686
+ issues = [];
12687
+ meta;
12688
+ root;
12689
+ space;
12690
+ gutter;
12691
+ add(i) {
12692
+ this.issues.push(issue(i));
12693
+ }
12694
+ section({ page, section }) {
12695
+ if (section.layout.mode === "stack") this.columns(page, section);
12696
+ else this.grid(page, section, section.layout);
12697
+ this.padding(page, section);
12698
+ this.tallPhoto(page, section);
12699
+ for (const b of section.blocks) if (!b.hidden) this.headingWords(page, section, b);
12700
+ }
12701
+ /** Text columns kept side by side on phones (an explicit phone column setting). */
12702
+ columns(page, section) {
12703
+ const layout = section.layout;
12704
+ if (layout.mode !== "stack") return;
12705
+ const cols = phoneColumns(layout.columns);
12706
+ if (cols.length < 2) return;
12707
+ const content3 = PHONE - 2 * this.gutter;
12708
+ const squeezed = cols.some((span, i) => {
12709
+ const longest = section.blocks.filter((b) => !b.hidden && (b.column ?? 0) === i && isTextBlock(b)).reduce((n, b) => Math.max(n, words2(blockText(b))), 0);
12710
+ return longest >= 25 && span / STACK_UNITS * content3 < 170;
12711
+ });
12712
+ if (!squeezed) return;
12713
+ this.add({
12714
+ rule: "phone-columns",
12715
+ severity: "warning",
12716
+ page,
12717
+ section: section.id,
12718
+ message: `On phones this section keeps ${cols.length} columns side by side, so its text is squeezed into a narrow strip.`,
12719
+ fix: "Stack the columns on phones (one under the other).",
12720
+ autofix: {
12721
+ label: "Stack the columns on phones",
12722
+ ops: [
12723
+ {
12724
+ op: "section.update",
12725
+ ...this.meta(),
12726
+ page,
12727
+ section: section.id,
12728
+ set: { "layout.columns.m": [STACK_UNITS] }
12729
+ }
12730
+ ]
12731
+ }
12732
+ });
12733
+ }
12734
+ /** Grid sections: missing or partial phone layouts, text squeezed into narrow phone columns. */
12735
+ grid(page, section, layout) {
12736
+ const items = Object.entries(layout.items);
12737
+ if (!items.length) return;
12738
+ const placed = items.filter(([, i]) => i.m).length;
12739
+ const blocks2 = new Map(section.blocks.map((b) => [b.id, b]));
12740
+ const fill = () => autoMobileLayoutOps(this.doc, page, section.id, this.options);
12741
+ if (placed > 0 && placed < items.length) {
12742
+ this.add({
12743
+ rule: "phone-layout-missing",
12744
+ severity: "warning",
12745
+ page,
12746
+ section: section.id,
12747
+ message: `${items.length - placed === 1 ? "A block was" : `${items.length - placed} blocks were`} added on desktop but not placed for phones, so on a phone ${items.length - placed === 1 ? "it shows" : "they show"} at the bottom of the section.`,
12748
+ fix: "Place the new blocks in reading order on phones (your other phone placements stay).",
12749
+ autofix: { label: "Place them for phones", ops: fill() }
12750
+ });
12751
+ } else if (placed === 0 && items.length > 1) {
12752
+ const fallback2 = fallbackMobileRects(layout);
12753
+ const stacked = Object.entries(fallback2).sort(([, a], [, b]) => a.y - b.y).map(([id]) => id);
12754
+ const reading = readingOrder(items.map(([id, i]) => ({ id, rect: i.d }))).map((i) => i.id);
12755
+ if (stacked.join() !== reading.join())
12756
+ this.add({
12757
+ rule: "phone-layout-order",
12758
+ severity: "warning",
12759
+ page,
12760
+ section: section.id,
12761
+ message: "On phones this section's blocks would stack in a confusing order (by where each one starts, not how the section reads).",
12762
+ fix: "Lay the section out for phones in reading order.",
12763
+ autofix: { label: "Lay it out for phones", ops: fill() }
12764
+ });
12765
+ }
12766
+ for (const [id, item2] of items) {
12767
+ const b = blocks2.get(id);
12768
+ if (!item2.m || !b || b.hidden || !isTextBlock(b)) continue;
12769
+ const n = words2(blockText(b));
12770
+ const width = item2.m.w / 8 * (PHONE - 2 * this.gutter);
12771
+ if (n < 8 || width >= 150 || width >= 120 && n < 25) continue;
12772
+ const restack = autoMobileLayout(section, { reset: true });
12773
+ this.add({
12774
+ rule: "phone-narrow-text",
12775
+ severity: "warning",
12776
+ page,
12777
+ section: section.id,
12778
+ block: id,
12779
+ message: `On phones this ${b.type} block is only about ${Math.round(width)}px wide, so its text is squeezed into a narrow strip.`,
12780
+ fix: "Re-stack the section for phones so every block is full width.",
12781
+ ...restack ? {
12782
+ autofix: { label: "Re-stack for phones", ops: this.placeOps(page, section, restack) }
12783
+ } : {}
12784
+ });
12785
+ }
12786
+ }
12787
+ placeOps(page, section, items) {
12788
+ return Object.entries(items).flatMap(
12789
+ ([block4, item2]) => item2.m ? [
12790
+ {
12791
+ op: "grid.place",
12792
+ ...this.meta(),
12793
+ page,
12794
+ section: section.id,
12795
+ block: block4,
12796
+ bp: "m",
12797
+ rect: item2.m
12798
+ }
12799
+ ] : []
12800
+ );
12801
+ }
12802
+ /** A phone padding so large the screen is mostly empty space. */
12803
+ padding(page, section) {
12804
+ const pad3 = section.style.padding;
12805
+ if (!pad3) return;
12806
+ const token = phonePadding(pad3);
12807
+ const px3 = this.space[token] ?? 0;
12808
+ if (px3 <= 100) return;
12809
+ this.add({
12810
+ rule: "phone-padding",
12811
+ severity: "warning",
12812
+ page,
12813
+ section: section.id,
12814
+ message: `On phones this section has ${px3}px of empty space above and below it, which pushes its content off the screen.`,
12815
+ fix: "Use less padding on phones (your desktop spacing stays).",
12816
+ autofix: {
12817
+ label: "Tighten the spacing on phones",
12818
+ ops: [
12819
+ {
12820
+ op: "section.update",
12821
+ ...this.meta(),
12822
+ page,
12823
+ section: section.id,
12824
+ set: { "style.padding.m": "lg" }
12825
+ }
12826
+ ]
12827
+ }
12828
+ });
12829
+ }
12830
+ /** A photo section taller than most of a phone screen (the words and button get pushed down). */
12831
+ tallPhoto(page, section) {
12832
+ const bg = section.style.background;
12833
+ const mh = section.style.minHeight;
12834
+ if (!mh || !bg || bg.type !== "image" && bg.type !== "video") return;
12835
+ const m = mh.m ?? mh.t ?? mh.d;
12836
+ const vh = /^(\d+)(?:vh|svh|dvh|lvh)$/.exec(m);
12837
+ const px3 = /^(\d+)px$/.exec(m);
12838
+ if (!(vh && Number(vh[1]) > 75) && !(px3 && Number(px3[1]) > 640)) return;
12839
+ this.add({
12840
+ rule: "phone-tall-photo",
12841
+ severity: "warning",
12842
+ page,
12843
+ section: section.id,
12844
+ message: `On phones this photo section is at least ${m} tall, so the photo fills most of the screen and pushes everything else down.`,
12845
+ fix: "Make it a little shorter on phones (70% of the screen); desktop keeps its height.",
12846
+ autofix: {
12847
+ label: "Shorten it on phones",
12848
+ ops: [
12849
+ {
12850
+ op: "section.update",
12851
+ ...this.meta(),
12852
+ page,
12853
+ section: section.id,
12854
+ set: { "style.minHeight.m": "70svh" }
12855
+ }
12856
+ ]
12857
+ }
12858
+ });
12859
+ }
12860
+ /** Heading words that can't fit on one phone line at the heading's phone size. */
12861
+ headingWords(page, section, b) {
12862
+ if (b.type !== "heading") return;
12863
+ const fields = b.fields;
12864
+ const size2 = fields.phoneSize && fields.phoneSize !== "auto" ? fields.phoneSize : b.style?.typography?.heading ?? (fields.size && fields.size !== "auto" ? fields.size : fields.level ?? "h2");
12865
+ const px3 = this.typePx(size2);
12866
+ if (!px3) return;
12867
+ const width = PHONE - 2 * this.gutter - this.blockPad(b);
12868
+ const caps = this.root[`--os-type-${size2}-text-transform`] === "uppercase";
12869
+ const longest = (fields.text ?? "").split(/[\s\-‐–—/]+/).reduce((a, w) => w.length > a.length ? w : a, "");
12870
+ const est = longest.length * px3 * (caps || longest === longest.toUpperCase() ? GLYPH_CAPS : GLYPH);
12871
+ if (est <= width * 1.1) return;
12872
+ const smaller = this.fittingSize(longest, width, caps);
12873
+ this.add({
12874
+ rule: "phone-long-word",
12875
+ severity: "warning",
12876
+ page,
12877
+ section: section.id,
12878
+ block: b.id,
12879
+ message: `\u201C${longest}\u201D is too long to fit on one line of a phone at this heading size, so it would break in the middle of the word.`,
12880
+ fix: smaller ? "Use a smaller heading size on phones (desktop keeps its size), or a shorter word." : "Use a shorter word or phrase.",
12881
+ ...smaller ? {
12882
+ autofix: {
12883
+ label: "Use a smaller size on phones",
12884
+ ops: [
12885
+ {
12886
+ op: "block.update",
12887
+ ...this.meta(),
12888
+ page,
12889
+ block: b.id,
12890
+ set: { phoneSize: smaller }
12891
+ }
12892
+ ]
12893
+ }
12894
+ } : {}
12895
+ });
12896
+ }
12897
+ typePx(preset) {
12898
+ const px3 = phonePx(this.root[`--os-type-${preset}-font-size`]);
12899
+ if (!px3) return void 0;
12900
+ const content3 = PHONE - 2 * this.gutter;
12901
+ if (preset === "display") return Math.min(px3, 0.16 * content3);
12902
+ if (preset === "h1") return Math.min(px3, 0.12 * content3);
12903
+ return px3;
12904
+ }
12905
+ blockPad(b) {
12906
+ const pad3 = b.style?.padding;
12907
+ const token = pad3 ? pad3.m ?? pad3.t ?? pad3.d : b.style?.colorTheme ? "md" : void 0;
12908
+ return token ? 2 * (this.space[token] ?? 0) : 0;
12909
+ }
12910
+ fittingSize(word, width, caps) {
12911
+ for (const s2 of ["h1", "h2", "h3", "h4"]) {
12912
+ const px3 = this.typePx(s2);
12913
+ if (px3 && word.length * px3 * (caps ? GLYPH_CAPS : GLYPH) <= width) return s2;
12914
+ }
12915
+ return void 0;
12916
+ }
12917
+ /** A header logo that leaves no room for the menu button on phones. */
12918
+ header() {
12919
+ const h = this.doc.site.header.logoHeight;
12920
+ if (!h) return;
12921
+ const m = h.m ?? h.t ?? h.d;
12922
+ if (m <= 64) return;
12923
+ this.add({
12924
+ rule: "phone-logo",
12925
+ severity: "warning",
12926
+ message: `The logo is ${m}px tall on phones, which makes the header take up a big part of a phone screen.`,
12927
+ fix: "Use a smaller logo on phones (around 40px; desktop keeps its size).",
12928
+ autofix: {
12929
+ label: "Use a 40px logo on phones",
12930
+ ops: [{ op: "site.update", ...this.meta(), set: { "header.logoHeight.m": 40 } }]
12931
+ }
12932
+ });
12933
+ }
12934
+ /** Custom CSS or components that fix something wider than a phone. */
12935
+ css() {
12936
+ const sources = [];
12937
+ if (this.doc.site.customCss)
12938
+ sources.push({ css: this.doc.site.customCss, where: "Your custom CSS" });
12939
+ for (const [name, c] of Object.entries(this.doc.components ?? {}))
12940
+ if (c.css) sources.push({ css: c.css, where: `The \u201C${name}\u201D component`, component: name });
12941
+ for (const s2 of sources) {
12942
+ const wide = wideRules(s2.css);
12943
+ if (!wide.length) continue;
12944
+ this.add({
12945
+ rule: "phone-fixed-width",
12946
+ severity: "warning",
12947
+ key: s2.component ?? "custom-css",
12948
+ message: `${s2.where} sets a fixed width (${wide.slice(0, 2).join(", ")}) that is wider than a phone screen, so the page may scroll sideways or be cut off on phones.`,
12949
+ fix: "Use max-width (or a percentage) instead of a fixed width, or add a phone rule (@media (max-width: 767px))."
12950
+ });
12951
+ }
12952
+ }
12953
+ };
12954
+ function wideRules(css) {
12955
+ let plain3 = css;
12956
+ for (let guard = 0; guard < 50; guard++) {
12957
+ const m = /@media[^{]*min-width\s*:\s*(\d+)/i.exec(plain3);
12958
+ if (!m || Number(m[1]) < 400) break;
12959
+ const start = plain3.indexOf("{", m.index);
12960
+ let depth = 0;
12961
+ let end = start;
12962
+ for (; end < plain3.length; end++) {
12963
+ if (plain3[end] === "{") depth++;
12964
+ else if (plain3[end] === "}" && --depth === 0) break;
12965
+ }
12966
+ plain3 = plain3.slice(0, m.index) + plain3.slice(end + 1);
12967
+ }
12968
+ const out = [];
12969
+ for (const m of plain3.matchAll(/(?:^|[;{\s])((?:min-)?width)\s*:\s*([\d.]+)(px|rem|em)\b/gi)) {
12970
+ const px3 = Number(m[2]) * (m[3]?.toLowerCase() === "px" ? 1 : 16);
12971
+ if (px3 > 340) out.push(`${m[1]}: ${m[2]}${m[3]}`);
12972
+ }
12973
+ return out;
12974
+ }
12975
+ function phoneCheck(doc, pageId, options = {}) {
12976
+ const ctx = new PhoneCtx(doc, options);
12977
+ for (const w of sectionsOf2(doc, pageId)) ctx.section(w);
12978
+ ctx.header();
12979
+ ctx.css();
12980
+ return sortIssues(ctx.issues);
12981
+ }
12982
+ var SEVERITY = {
12983
+ "page-overflow": "error",
12984
+ "cut-off": "error",
12985
+ overlap: "error",
12986
+ "photo-contrast": "error",
12987
+ "low-contrast": "error",
12988
+ "broken-word": "warning",
12989
+ "big-heading": "warning",
12990
+ "small-text": "warning",
12991
+ "tiny-text": "warning",
12992
+ "narrow-text": "warning",
12993
+ "tap-target": "warning",
12994
+ "edge-text": "warning",
12995
+ "header-crowded": "warning",
12996
+ "loose-padding": "warning",
12997
+ "tall-image": "info",
12998
+ "heavy-crop": "info",
12999
+ "long-footer": "info",
13000
+ "cta-below-fold": "warning"
13001
+ };
13002
+ var MESSAGE = {
13003
+ "page-overflow": (_f, w) => [
13004
+ `On phones the page is wider than the screen, so it scrolls sideways${w}.`,
13005
+ "Something has a fixed width wider than a phone. Re-stack the section for phones or make the element narrower."
13006
+ ],
13007
+ "cut-off": (_f, w) => [
13008
+ `On phones something${w} is cut off at the edge of the screen.`,
13009
+ "Re-stack the section for phones, or make the element narrower."
13010
+ ],
13011
+ overlap: (_f, w) => [
13012
+ `On phones two pieces of text${w} are drawn on top of each other, so neither can be read.`,
13013
+ "Re-stack the section for phones so the blocks sit one under the other."
13014
+ ],
13015
+ "photo-contrast": (f, w) => [
13016
+ `On phones the text \u201C${f.text ?? ""}\u201D${w} sits on a photo where it can drop to ${f.value}:1 contrast (it needs ${f.limit}:1), so it is hard to read on bright or dark parts of the photo.`,
13017
+ "Darken (or lighten) the photo overlay, or put the text on a solid panel."
13018
+ ],
13019
+ "low-contrast": (f, w) => [
13020
+ `On phones the text \u201C${f.text ?? ""}\u201D${w} has only ${f.value}:1 contrast with its background (it needs ${f.limit}:1).`,
13021
+ "Use a color theme instead of a custom color, or a darker/lighter text color."
13022
+ ],
13023
+ "broken-word": (f, w) => [
13024
+ `On phones a word in the heading \u201C${f.text ?? ""}\u201D${w} doesn't fit on one line and breaks in the middle.`,
13025
+ "Use a smaller heading size on phones, or a shorter word."
13026
+ ],
13027
+ "big-heading": (f, w) => [
13028
+ `On phones the heading \u201C${f.text ?? ""}\u201D${w} is so large it fills most of the screen.`,
13029
+ "Use a smaller heading size on phones, or a shorter heading."
13030
+ ],
13031
+ "small-text": (f, w) => [
13032
+ `On phones the text${w} is ${f.value}px \u2014 too small to read comfortably (${f.limit === 14 ? "14px is the minimum for small print" : "16px is the minimum for reading text"}).`,
13033
+ f.limit === 14 ? "Use the body text style for it, or shorten it to a note." : "Use the body text style for paragraphs instead of the small style."
13034
+ ],
13035
+ "tiny-text": (f, w) => [
13036
+ `On phones the label \u201C${f.text ?? ""}\u201D${w} is only ${f.value}px tall, too small to read.`,
13037
+ "Make it at least 12px."
13038
+ ],
13039
+ "narrow-text": (_f, w) => [
13040
+ `On phones the text${w} is squeezed into a very narrow column.`,
13041
+ "Stack the columns on phones so the text is full width."
13042
+ ],
13043
+ "tap-target": (f, w) => [
13044
+ `On phones the link or button \u201C${f.text ?? ""}\u201D${w} is small and close to another one, so it's easy to tap the wrong one.`,
13045
+ "Make it a button, or put more space around it."
13046
+ ],
13047
+ "edge-text": (_f, w) => [
13048
+ `On phones the text${w} touches the edge of the screen.`,
13049
+ "Keep text inside the page margins (don't let text blocks bleed to the edge)."
13050
+ ],
13051
+ "header-crowded": () => [
13052
+ "On phones the header is crowded: its logo, title and buttons overlap or wrap.",
13053
+ "Use a smaller logo on phones or a shorter site title."
13054
+ ],
13055
+ "loose-padding": (f, w) => [
13056
+ `On phones there is ${f.value}px of empty space above or below${w || " a section"}.`,
13057
+ "Use less padding on phones."
13058
+ ],
13059
+ "tall-image": (_f, w) => [
13060
+ `On phones an image${w} is taller than the whole screen.`,
13061
+ "Crop it to a landscape or square shape, or set a focus point."
13062
+ ],
13063
+ "heavy-crop": (_f, w) => [
13064
+ `On phones most of a photo${w} is cropped away.`,
13065
+ "Set the photo's focus point so the important part stays in view on phones."
13066
+ ],
13067
+ "cta-below-fold": (f) => [
13068
+ `On phones the main button (\u201C${f.text ?? ""}\u201D) is about ${Math.round(f.value ?? 0)}px down the page, below the first screen \u2014 visitors have to scroll before they see what to do.`,
13069
+ "Put the words and button first on phones (photos after them), or use a shorter headline."
13070
+ ],
13071
+ "long-footer": () => [
13072
+ "On phones the footer is several screens long.",
13073
+ "Keep the footer to the essentials (a few links, contact details, social icons)."
13074
+ ]
13075
+ };
13076
+ function locate(doc, pageId, f) {
13077
+ const lookup = (id) => {
13078
+ if (!id) return void 0;
13079
+ const pages = [pageId, FOOTER];
13080
+ for (const p of pages) {
13081
+ const hit = findSection(doc, p, id);
13082
+ if (hit) return { page: p, section: hit.section };
13083
+ }
13084
+ return void 0;
13085
+ };
13086
+ const sec = lookup(f.section);
13087
+ if (sec) {
13088
+ const block4 = f.block ? sec.section.blocks.find((b) => b.id === f.block) : void 0;
13089
+ return { page: sec.page, section: sec.section, ...block4 ? { block: block4 } : {} };
13090
+ }
13091
+ if (f.area === "footer") return { page: FOOTER, section: doc.site.footer };
13092
+ return { page: pageId };
13093
+ }
13094
+ function fixFor(at, f, meta) {
13095
+ const { section, block: block4, page } = at;
13096
+ if (!section) return void 0;
13097
+ const restack = (label) => {
13098
+ if (section.layout.mode === "grid") {
13099
+ const items = autoMobileLayout(section, { reset: true });
13100
+ if (!items) return void 0;
13101
+ const ops = Object.entries(items).flatMap(
13102
+ ([b, item2]) => item2.m ? [
13103
+ {
13104
+ op: "grid.place",
13105
+ ...meta(),
13106
+ page,
13107
+ section: section.id,
13108
+ block: b,
13109
+ bp: "m",
13110
+ rect: item2.m
13111
+ }
13112
+ ] : []
13113
+ );
13114
+ return ops.length ? { label, ops } : void 0;
13115
+ }
13116
+ if (phoneColumns(section.layout.columns).length > 1)
13117
+ return {
13118
+ label: "Stack the columns on phones",
13119
+ ops: [
13120
+ {
13121
+ op: "section.update",
13122
+ ...meta(),
13123
+ page,
13124
+ section: section.id,
13125
+ set: { "layout.columns.m": [STACK_UNITS] }
13126
+ }
13127
+ ]
13128
+ };
13129
+ return void 0;
13130
+ };
13131
+ switch (f.kind) {
13132
+ case "overlap":
13133
+ case "cut-off":
13134
+ case "page-overflow":
13135
+ case "narrow-text":
13136
+ case "edge-text":
13137
+ return f.component ? void 0 : restack("Re-stack for phones");
13138
+ case "photo-contrast": {
13139
+ const bg = section.style.background;
13140
+ if (bg && (bg.type === "image" || bg.type === "video")) {
13141
+ const now = bg.overlay?.opacity ?? 0;
13142
+ if (now >= 0.85) break;
13143
+ const target = Math.min(0.85, Math.ceil((now + 0.15) * 20) / 20);
13144
+ return {
13145
+ label: `Raise the photo overlay to ${Math.round(target * 100)}%`,
13146
+ ops: [
13147
+ {
13148
+ op: "section.update",
13149
+ ...meta(),
13150
+ page,
13151
+ section: section.id,
13152
+ set: {
13153
+ "style.background.overlay": {
13154
+ color: bg.overlay?.color ?? "background",
13155
+ opacity: target
13156
+ }
13157
+ }
13158
+ }
13159
+ ]
13160
+ };
13161
+ }
13162
+ if (block4 && isTextBlock(block4) && !block4.style?.colorTheme)
13163
+ return {
13164
+ label: "Put the text on a panel",
13165
+ ops: [
13166
+ {
13167
+ op: "block.update",
13168
+ ...meta(),
13169
+ page,
13170
+ block: block4.id,
13171
+ style: {
13172
+ ...block4.style ?? {},
13173
+ colorTheme: section.style.colorTheme ?? "light",
13174
+ padding: block4.style?.padding ?? { d: "lg", m: "md" }
13175
+ }
13176
+ }
13177
+ ]
13178
+ };
13179
+ return void 0;
13180
+ }
13181
+ case "broken-word":
13182
+ case "big-heading": {
13183
+ if (block4?.type !== "heading") return void 0;
13184
+ const fields = block4.fields;
13185
+ const order = ["display", "h1", "h2", "h3", "h4"];
13186
+ const current2 = fields.phoneSize && fields.phoneSize !== "auto" ? fields.phoneSize : fields.size && fields.size !== "auto" ? fields.size : fields.level ?? "h2";
13187
+ const next = order[order.indexOf(current2) + 1];
13188
+ if (!next) return void 0;
13189
+ return {
13190
+ label: "Use a smaller size on phones",
13191
+ ops: [
13192
+ {
13193
+ op: "block.update",
13194
+ ...meta(),
13195
+ page,
13196
+ block: block4.id,
13197
+ set: { phoneSize: next }
13198
+ }
13199
+ ]
13200
+ };
13201
+ }
13202
+ case "cta-below-fold": {
13203
+ if (section.layout.mode === "grid") {
13204
+ const items = autoMobileLayout(section, { reset: true, textFirst: true });
13205
+ if (!items) return void 0;
13206
+ return {
13207
+ label: "Put the words first on phones",
13208
+ ops: Object.entries(items).flatMap(
13209
+ ([b, item2]) => item2.m ? [
13210
+ {
13211
+ op: "grid.place",
13212
+ ...meta(),
13213
+ page,
13214
+ section: section.id,
13215
+ block: b,
13216
+ bp: "m",
13217
+ rect: item2.m
13218
+ }
13219
+ ] : []
13220
+ )
13221
+ };
13222
+ }
13223
+ const col = section.blocks.find((b) => b.type === "button")?.column ?? 0;
13224
+ const count2 = section.layout.columns.d.length;
13225
+ if (count2 < 2 || col === 0) return void 0;
13226
+ const order = [col, ...Array.from({ length: count2 }, (_, i) => i).filter((i) => i !== col)];
13227
+ return {
13228
+ label: "Put the words first on phones",
13229
+ ops: [
13230
+ {
13231
+ op: "section.update",
13232
+ ...meta(),
13233
+ page,
13234
+ section: section.id,
13235
+ set: { "layout.order.m": order }
13236
+ }
13237
+ ]
13238
+ };
13239
+ }
13240
+ case "loose-padding":
13241
+ return {
13242
+ label: "Tighten the spacing on phones",
13243
+ ops: [
13244
+ {
13245
+ op: "section.update",
13246
+ ...meta(),
13247
+ page,
13248
+ section: section.id,
13249
+ set: { "style.padding.m": "lg" }
13250
+ }
13251
+ ]
13252
+ };
13253
+ default:
13254
+ return void 0;
13255
+ }
13256
+ return void 0;
13257
+ }
13258
+ function phoneIssuesFromProbe(doc, pageId, results, options = {}) {
13259
+ const meta = opMeta(options);
13260
+ const out = /* @__PURE__ */ new Map();
13261
+ const sorted = [...results].sort((a, b) => a.width - b.width);
13262
+ for (const r of sorted)
13263
+ for (const f of r.findings) {
13264
+ const at = locate(doc, pageId, f);
13265
+ if (f.kind === "cta-below-fold" && at.section?.preset && !at.section.preset.startsWith("hero/"))
13266
+ continue;
13267
+ const sectionName = at.section && at.page !== FOOTER ? " in this section" : at.page === FOOTER ? " in the footer" : "";
13268
+ const where2 = f.component ? ` in the \u201C${f.component}\u201D component` : sectionName;
13269
+ const [message, fix] = MESSAGE[f.kind](f, where2);
13270
+ const key2 = `${f.kind}:${at.block?.id ?? f.block ?? ""}:${f.kind === "tap-target" || f.kind === "photo-contrast" ? f.text ?? "" : ""}`;
13271
+ const i = issue({
13272
+ rule: `phone-${f.kind}`,
13273
+ severity: SEVERITY[f.kind],
13274
+ page: at.page,
13275
+ ...at.section ? { section: at.section.id } : {},
13276
+ ...at.block ? { block: at.block.id } : {},
13277
+ key: `${r.width}`,
13278
+ message,
13279
+ fix
13280
+ });
13281
+ const id = `${i.rule}:${i.page}:${i.section ?? ""}:${key2}`;
13282
+ if (out.has(id)) continue;
13283
+ const autofix = fixFor(at, f, meta);
13284
+ out.set(id, { ...i, id: id.replace(/:+$/, ""), ...autofix ? { autofix } : {} });
13285
+ }
13286
+ return sortIssues([...out.values()]);
13287
+ }
13288
+ function fixForPhones(doc, opts = {}) {
13289
+ let registry3;
13290
+ const getRegistry = () => {
13291
+ registry3 ??= blocksRegistry();
13292
+ return registry3;
13293
+ };
13294
+ const apply2 = opts.validateOp ?? ((d, op2) => {
13295
+ const r = validate(d, op2, { trusted: true, registry: getRegistry() });
13296
+ return r.ok ? r.doc : null;
13297
+ });
13298
+ let cur = doc;
13299
+ const ops = [];
13300
+ const fixed = [];
13301
+ const tried = /* @__PURE__ */ new Set();
13302
+ const run2 = (i) => {
13303
+ if (!i.autofix || tried.has(i.id)) return;
13304
+ tried.add(i.id);
13305
+ let work = cur;
13306
+ for (const op2 of i.autofix.ops) {
13307
+ const next = apply2(work, op2);
13308
+ if (!next) return;
13309
+ work = next;
13310
+ }
13311
+ cur = work;
13312
+ ops.push(...i.autofix.ops);
13313
+ fixed.push({ id: i.id, label: i.autofix.label });
13314
+ };
13315
+ for (let guard = 0; guard < 200; guard++) {
13316
+ const next = phoneCheck(cur, opts.pageId, opts).find((i) => i.autofix && !tried.has(i.id));
13317
+ if (!next) break;
13318
+ run2(next);
13319
+ }
13320
+ const restacked = /* @__PURE__ */ new Set();
13321
+ for (const i of opts.rendered ?? []) {
13322
+ if (!i.autofix) continue;
13323
+ const key2 = `${i.section}:${i.autofix.label}`;
13324
+ if (restacked.has(key2)) {
13325
+ tried.add(i.id);
13326
+ fixed.push({ id: i.id, label: i.autofix.label });
13327
+ continue;
13328
+ }
13329
+ const before = fixed.length;
13330
+ run2(i);
13331
+ if (fixed.length > before) restacked.add(key2);
13332
+ }
13333
+ const remaining = [
13334
+ ...phoneCheck(cur, opts.pageId, opts),
13335
+ ...(opts.rendered ?? []).filter((i) => !tried.has(i.id))
13336
+ ];
13337
+ return { ops, fixed, doc: cur, remaining };
13338
+ }
13339
+
12644
13340
  // ../lint/src/photo-contrast.ts
12645
13341
  var SOLID_PRIMARY = /* @__PURE__ */ new Set(["solid", "pill-solid"]);
12646
13342
  function richChecks(doc, out) {
@@ -12679,6 +13375,11 @@ function photoTextChecks(block4, buttons) {
12679
13375
  if (f.note) out.push({ role: "text-muted", label: "Button note", min: 4.5 });
12680
13376
  break;
12681
13377
  }
13378
+ case "quote":
13379
+ case "countdown":
13380
+ out.push({ role: "text", label: "Text", min: 4.5 });
13381
+ out.push({ role: "text-muted", label: "Secondary text", min: 4.5 });
13382
+ break;
12682
13383
  default:
12683
13384
  out.push({ role: "text", label: "Text", min: 4.5 });
12684
13385
  }
@@ -12732,6 +13433,7 @@ function designCheck(doc, pageId, options = {}) {
12732
13433
  ctx.container(FOOTER, [doc.site.footer], { footer: true });
12733
13434
  ctx.issues.push(...linkIssues(doc, pageId, options, ctx.meta));
12734
13435
  ctx.issues.push(...componentIssues(doc, pageId));
13436
+ ctx.issues.push(...phoneCheck(doc, pageId, options));
12735
13437
  for (const w of hostingWarnings(doc, pageId))
12736
13438
  ctx.issues.push(
12737
13439
  issue({
@@ -13407,9 +14109,25 @@ var Ctx = class {
13407
14109
  return this.tokens.palette[m[1]][Number(m[2])];
13408
14110
  return /^(?:#|oklch|oklab|rgb|hsl|lab|lch)/.test(value) ? value : void 0;
13409
14111
  }
14112
+ /**
14113
+ * A custom component (an imported or uploaded design) whose template draws a button from a
14114
+ * filled link field: that's a call to action too (wave 4: "no clear call to action" on a hero
14115
+ * with two buttons).
14116
+ */
14117
+ componentHasButton(b) {
14118
+ if (!b.type.startsWith("component/")) return false;
14119
+ const def = this.doc.components?.[b.type.slice("component/".length)];
14120
+ if (!def || !/\{\{\s*(?:button|#link)\s/.test(def.template)) return false;
14121
+ const fields = b.fields;
14122
+ return Object.entries(def.fields).some(
14123
+ ([k, f]) => f.kind === "link" && fields[k] !== void 0 && fields[k] !== null
14124
+ );
14125
+ }
13410
14126
  cta(page, sections) {
13411
14127
  if (sections.length === 0) return;
13412
- const has3 = sections.some((s2) => s2.blocks.some((b) => ACTION_BLOCKS.has(b.type)));
14128
+ const has3 = sections.some(
14129
+ (s2) => s2.blocks.some((b) => ACTION_BLOCKS.has(b.type) || this.componentHasButton(b))
14130
+ );
13413
14131
  const navButton = this.doc.site.nav.some((n) => n.type === "button" && !n.hidden);
13414
14132
  if (has3 || navButton) return;
13415
14133
  this.add({
@@ -13483,6 +14201,457 @@ function plain(node2) {
13483
14201
  return parts.join("");
13484
14202
  }
13485
14203
 
14204
+ // ../lint/src/phone-probe.ts
14205
+ var PHONE_LIMITS = {
14206
+ /** Apple HIG / Material: 44 CSS px square. */
14207
+ tapTarget: 44,
14208
+ /** Nothing smaller than this, however isolated. */
14209
+ tapTargetMin: 16,
14210
+ /** Reading text (paragraphs, lists, quotes). */
14211
+ bodyText: 16,
14212
+ /** Any text (labels, eyebrows, captions). */
14213
+ anyText: 12,
14214
+ /** Text never closer than this to the screen edge. */
14215
+ edge: 12,
14216
+ /** Heading font size as a share of the viewport width. */
14217
+ headingShare: 0.16,
14218
+ /** Heading block height as a share of the viewport height. */
14219
+ headingScreen: 0.6,
14220
+ /** Images taller than this share of the viewport height. */
14221
+ imageScreen: 1.05,
14222
+ /** Section padding (each side) above which a phone shows "a lot of empty space". */
14223
+ loosePadding: 128,
14224
+ /** Text columns narrower than this (px) with more than a few words. */
14225
+ narrowColumn: 130,
14226
+ /** Visible share of a photo below which it counts as heavily cropped. */
14227
+ crop: 0.34
14228
+ };
14229
+ var PHONE_WIDTHS = [360, 390];
14230
+ var PHONE_HEIGHT = 780;
14231
+ var PHONE_PROBE_SCRIPT = `(async () => {
14232
+ const L = ${JSON.stringify(PHONE_LIMITS)};
14233
+ const vw = document.documentElement.clientWidth || window.innerWidth;
14234
+ // The phone screen's height (an editor canvas may be as tall as the page: it says so here).
14235
+ const vh = Number(window.__osPhoneScreenHeight) || window.innerHeight;
14236
+ const viewH = window.innerHeight;
14237
+ const findings = [];
14238
+ const seen = new Set();
14239
+ const style = document.createElement("style");
14240
+ // elementsFromPoint skips pointer-events:none layers (photo backgrounds, overlays).
14241
+ style.textContent = "*,*::before,*::after{pointer-events:auto!important;scroll-behavior:auto!important}";
14242
+ document.head.appendChild(style);
14243
+ const cs = (el) => getComputedStyle(el);
14244
+ const txt = (el) => (el.textContent || "").replace(/\\s+/g, " ").trim();
14245
+ const short = (s) => (s.length > 60 ? s.slice(0, 59) + "\u2026" : s);
14246
+ const where = (el) => {
14247
+ const out = {};
14248
+ const blk = el.closest("[data-os-block]");
14249
+ const sec = el.closest("[data-os-section]");
14250
+ if (blk && blk.getAttribute("data-os-id")) out.block = blk.getAttribute("data-os-id");
14251
+ if (sec && sec.getAttribute("data-os-id")) out.section = sec.getAttribute("data-os-id");
14252
+ const comp = el.closest("[data-os-component]");
14253
+ if (comp) out.component = comp.getAttribute("data-os-component") || "component";
14254
+ out.area = el.closest(".os-menu") ? "menu" : el.closest("[data-os-header],.os-header,.os-announcement") ? "header" : el.closest(".os-footer,footer") ? "footer" : el.closest(".os-popup,dialog") ? "other" : "main";
14255
+ return out;
14256
+ };
14257
+ const add = (kind, el, extra) => {
14258
+ const w = where(el);
14259
+ const key = kind + "|" + (w.block || "") + "|" + (w.section || "") + "|" + (extra && extra.key ? extra.key : "") + "|" + (w.block || w.section ? "" : el.tagName + txt(el).slice(0, 30));
14260
+ if (seen.has(key)) return;
14261
+ seen.add(key);
14262
+ const f = { kind, ...w, tag: el.tagName.toLowerCase() };
14263
+ const cls = (e) => (e && typeof e.className === "string" ? e.className.split(/\\s+/).filter((c) => c.startsWith("os-") && !/^os-(theme|align|talign|malign)/.test(c)).slice(0, 2).join(".") : "");
14264
+ const where2 = [cls(el), cls(el.parentElement), cls(el.parentElement && el.parentElement.parentElement)].filter(Boolean).join(" < ");
14265
+ if (where2) f.path = where2.slice(0, 120);
14266
+ const t = txt(el);
14267
+ if (t) f.text = short(t);
14268
+ if (extra) { if (extra.value !== undefined) f.value = Math.round(extra.value * 100) / 100; if (extra.limit !== undefined) f.limit = extra.limit; }
14269
+ findings.push(f);
14270
+ };
14271
+ const hiddenByStyle = (el) => {
14272
+ for (let e = el; e && e !== document.documentElement; e = e.parentElement) {
14273
+ const s = cs(e);
14274
+ if (s.display === "none" || s.visibility === "hidden" || s.visibility === "collapse" || Number(s.opacity) === 0) return true;
14275
+ if (e.hasAttribute("hidden") || e.getAttribute("aria-hidden") === "true") return true;
14276
+ if (e.classList && (e.classList.contains("os-visually-hidden") || e.classList.contains("os-skip-link") || e.classList.contains("os-hp") || e.classList.contains("os-empty"))) return true;
14277
+ // Editor-only notes and Studio's own canvas chrome are not part of the published page.
14278
+ if (e.hasAttribute && e.hasAttribute("data-os-editor")) return true;
14279
+ if (e.tagName === "DIALOG" && !e.open) return true;
14280
+ if (e.tagName === "DETAILS" && !e.open && e !== el && !(el.closest("summary") && el.closest("summary").parentElement === e)) return true;
14281
+ }
14282
+ return false;
14283
+ };
14284
+ const visible = (el) => {
14285
+ const r = el.getBoundingClientRect();
14286
+ if (r.width < 1 || r.height < 1) return false;
14287
+ return !hiddenByStyle(el);
14288
+ };
14289
+ /** Inside something that scrolls sideways on purpose (carousels, tab bars, marquees, code). */
14290
+ const inScroller = (el) => {
14291
+ for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
14292
+ const ox = cs(e).overflowX;
14293
+ if (ox === "auto" || ox === "scroll") return true;
14294
+ if (e.classList.contains("os-marquee") || e.classList.contains("os-carousel")) return true;
14295
+ }
14296
+ return false;
14297
+ };
14298
+ /** The nearest ancestor that clips horizontally (overflow hidden/clip), if any. */
14299
+ const clipper = (el) => {
14300
+ for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
14301
+ const ox = cs(e).overflowX;
14302
+ if (ox === "hidden" || ox === "clip") return e;
14303
+ }
14304
+ return null;
14305
+ };
14306
+ /** Bounding box of an element's own text (not hidden child spans). */
14307
+ const ownRect = (el) => {
14308
+ let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
14309
+ for (const n of el.childNodes) {
14310
+ if (n.nodeType !== 3 || !n.textContent.trim()) continue;
14311
+ const range = document.createRange();
14312
+ range.selectNodeContents(n);
14313
+ for (const x of range.getClientRects()) {
14314
+ if (x.width < 0.5) continue;
14315
+ l = Math.min(l, x.left); t = Math.min(t, x.top); r = Math.max(r, x.right); b = Math.max(b, x.bottom);
14316
+ }
14317
+ }
14318
+ return l === Infinity ? { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } : { left: l, top: t, right: r, bottom: b, width: r - l, height: b - t };
14319
+ };
14320
+ const ownText = (el) => { let s = ""; for (const n of el.childNodes) if (n.nodeType === 3) s += n.textContent; return s.replace(/\\s+/g, " ").trim(); };
14321
+ const decorative = (el) => !!el.closest(".os-section__bg,.os-img,.os-section__overlay,[aria-hidden=true],.os-marquee,.os-header__cart-count");
14322
+
14323
+ // Text leaves: elements with their own (non-blank) text.
14324
+ const all = Array.from(document.body.querySelectorAll("*"));
14325
+ const SKIP = new Set(["SCRIPT","STYLE","NOSCRIPT","TEMPLATE","SVG","PATH","OPTION","HEAD","TITLE","META","LINK","BR"]);
14326
+ const leaves = [];
14327
+ const leafSet = new Set();
14328
+ for (const el of all) {
14329
+ if (SKIP.has(el.tagName.toUpperCase())) continue;
14330
+ let own = "";
14331
+ for (const n of el.childNodes) if (n.nodeType === 3) own += n.textContent;
14332
+ if (!own.trim()) continue;
14333
+ if (!visible(el)) continue;
14334
+ leaves.push(el);
14335
+ leafSet.add(el);
14336
+ }
14337
+
14338
+ // --- sideways scrolling and cut-off content ---------------------------------------------
14339
+ const scrollWidth = Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0);
14340
+ const content = all.filter((el) => {
14341
+ const t = el.tagName;
14342
+ return (t === "IMG" || t === "IFRAME" || t === "VIDEO" || t === "TABLE" || t === "PRE" || t === "INPUT" || t === "SELECT" || t === "TEXTAREA" || t === "BUTTON" || t === "A" || t === "svg" || t === "SVG" || el.classList.contains("os-img") || el.classList.contains("os-block") || leafSet.has(el));
14343
+ });
14344
+ let overflowCulprit = null;
14345
+ for (const el of content) {
14346
+ if (el.tagName === "IMG" || el.tagName === "svg" || el.tagName === "SVG") continue; // cropped by their frame on purpose
14347
+ if (!visible(el) || decorative(el) && !el.classList.contains("os-img") || inScroller(el)) continue;
14348
+ if (el.classList.contains("os-img") && el.closest(".os-section__bg")) continue;
14349
+ const r = leafSet.has(el) && !el.classList.contains("os-block") ? ownRect(el) : el.getBoundingClientRect();
14350
+ if (r.width < 1) continue;
14351
+ const over = Math.max(r.right - vw, -r.left);
14352
+ if (over <= 2) continue;
14353
+ // Full-bleed media is drawn edge to edge on purpose.
14354
+ if ((el.tagName === "IMG" || el.classList.contains("os-img") || el.tagName === "VIDEO") && r.left >= -2 && r.right <= vw + 2) continue;
14355
+ const clip = clipper(el);
14356
+ const clipRect = clip ? clip.getBoundingClientRect() : null;
14357
+ if (clipRect && clipRect.left >= -2 && clipRect.right <= vw + 2) {
14358
+ // Clipped by a box inside the screen: is the element cut by it?
14359
+ if (r.right > clipRect.right + 2 || r.left < clipRect.left - 2) {
14360
+ add("cut-off", el, { value: over, key: el.tagName });
14361
+ }
14362
+ } else if (!overflowCulprit || r.right > overflowCulprit.getBoundingClientRect().right) overflowCulprit = el;
14363
+ }
14364
+ if (scrollWidth > vw + 1) add("page-overflow", overflowCulprit || document.body, { value: scrollWidth - vw });
14365
+
14366
+ // --- headings: broken words and huge sizes ------------------------------------------------
14367
+ const heads = all.filter((el) => /^H[1-6]$/.test(el.tagName) || el.classList.contains("os-type-display") || el.classList.contains("os-type-h1") || el.classList.contains("os-header__title"));
14368
+ for (const h of heads) {
14369
+ if (!visible(h)) continue;
14370
+ const s = cs(h);
14371
+ const size = parseFloat(s.fontSize);
14372
+ const r = h.getBoundingClientRect();
14373
+ if (size > vw * L.headingShare || r.height > vh * L.headingScreen) add("big-heading", h, { value: size, limit: Math.round(vw * L.headingShare) });
14374
+ // A word broken across lines: one word, more than one line box.
14375
+ const walker = document.createTreeWalker(h, NodeFilter.SHOW_TEXT);
14376
+ let broken = false;
14377
+ for (let n = walker.nextNode(); n && !broken; n = walker.nextNode()) {
14378
+ const text = n.textContent || "";
14379
+ const re = /[^\\s\\-\u2010\u2013\u2014/]+/g;
14380
+ let m;
14381
+ while ((m = re.exec(text)) && !broken) {
14382
+ if (m[0].length < 4) continue;
14383
+ const range = document.createRange();
14384
+ range.setStart(n, m.index);
14385
+ range.setEnd(n, m.index + m[0].length);
14386
+ const rects = Array.from(range.getClientRects()).filter((x) => x.width > 0.5);
14387
+ const tops = new Set(rects.map((x) => Math.round(x.top)));
14388
+ if (tops.size > 1) broken = true;
14389
+ else if (rects.length && h.getBoundingClientRect().right + 1 < Math.max(...rects.map((x) => x.right))) broken = true;
14390
+ }
14391
+ }
14392
+ if (broken) add("broken-word", h, { value: size });
14393
+ }
14394
+
14395
+ // --- text size, narrow columns, edges ------------------------------------------------------
14396
+ const READING = new Set(["P","LI","DD","BLOCKQUOTE","TD","FIGCAPTION"]);
14397
+ // The site's small-print size (notes, captions), measured once.
14398
+ const smallProbe = document.createElement("span");
14399
+ smallProbe.className = "os-type-small";
14400
+ smallProbe.textContent = "x";
14401
+ smallProbe.style.cssText = "position:absolute;visibility:hidden";
14402
+ (document.querySelector("main") || document.body).appendChild(smallProbe);
14403
+ const smallPx = parseFloat(cs(smallProbe).fontSize) || 14;
14404
+ smallProbe.remove();
14405
+ for (const el of leaves) {
14406
+ if (decorative(el) && !el.closest(".os-img figcaption")) continue;
14407
+ const s = cs(el);
14408
+ const size = parseFloat(s.fontSize);
14409
+ const t = ownText(el);
14410
+ // Reading text: block-level paragraphs and list items (inline code or links are part of them).
14411
+ const inline = s.display.startsWith("inline");
14412
+ const reading = !inline && (READING.has(el.tagName) || (!!el.closest(".os-rich") && !el.closest("h1,h2,h3,h4,h5,h6,.os-type-eyebrow,small,label,button,.os-button")));
14413
+ // Body text must be 16px. Text styled as small print (notes, captions) may be 14px.
14414
+ const smallPrint = !!el.closest(".os-type-small,small,figcaption,.os-muted") || Math.abs(size - smallPx) < 0.5;
14415
+ if (reading && t.length >= 60 && (smallPrint ? size < 13.5 : size < L.bodyText - 0.5)) add("small-text", el, { value: size, limit: smallPrint ? 14 : L.bodyText });
14416
+ else if (size < L.anyText - 0.5 && t.length > 1) add("tiny-text", el, { value: size, limit: L.anyText });
14417
+ const r = el.getBoundingClientRect();
14418
+ if (!el.closest("h1,h2,h3,h4,h5,h6") && (t.match(/[A-Za-z\\u00C0-\\uFFFF]{2,}/g) || []).length >= 6 && r.width < L.narrowColumn && !inScroller(el)) add("narrow-text", el, { value: r.width, limit: L.narrowColumn });
14419
+ if (!inScroller(el) && r.right <= vw + 2 && r.left >= -2) {
14420
+ // The text itself (not its box) against the screen edges.
14421
+ const tr = ownRect(el);
14422
+ if (tr.width > 0 && (tr.left < L.edge - 0.5 || tr.right > vw - L.edge + 0.5) && !el.closest(".os-announcement")) add("edge-text", el, { value: Math.min(tr.left, vw - tr.right), limit: L.edge });
14423
+ }
14424
+ }
14425
+
14426
+ // --- tap targets ---------------------------------------------------------------------------
14427
+ // (A map's legal attribution link is exempt: it's required, tiny by convention and rarely tapped.)
14428
+ const targets = all.filter((el) => el.matches("a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button],[role=tab]") && visible(el) && !el.closest(".os-hp,.os-map__attribution"));
14429
+ const proseLink = (el) => el.tagName === "A" && !el.classList.contains("os-button") && !!el.parentElement && el.parentElement.closest("p,li,dd,blockquote,td,figcaption") && txt(el.parentElement.closest("p,li,dd,blockquote,td,figcaption")).length > txt(el).length + 10;
14430
+ /** A "stretched" link (an absolutely positioned ::after) is as big as the card it covers. */
14431
+ const hitRect = (el) => {
14432
+ const after = getComputedStyle(el, "::after");
14433
+ if (after && after.content && after.content !== "none" && after.position === "absolute") {
14434
+ for (let e = el.parentElement; e && e !== document.body; e = e.parentElement)
14435
+ if (cs(e).position !== "static") return e.getBoundingClientRect();
14436
+ }
14437
+ return el.getBoundingClientRect();
14438
+ };
14439
+ const rects = targets.map(hitRect);
14440
+ // What a neighbour visibly occupies (its padding is tap area, not something to collide with).
14441
+ const inner = targets.map((el, i) => {
14442
+ const r = rects[i], st = cs(el), b = el.getBoundingClientRect();
14443
+ if (Math.abs(r.width - b.width) > 0.5 || Math.abs(r.height - b.height) > 0.5) return r; // a stretched link
14444
+ const pt = parseFloat(st.paddingTop) || 0, pb = parseFloat(st.paddingBottom) || 0;
14445
+ return { left: r.left, right: r.right, top: r.top + pt, bottom: r.bottom - pb };
14446
+ });
14447
+ targets.forEach((el, i) => {
14448
+ if (proseLink(el)) return;
14449
+ if (el.type === "checkbox" || el.type === "radio") { const lab = el.closest("label"); if (lab) return; }
14450
+ const r = rects[i];
14451
+ if (r.width >= L.tapTarget - 0.5 && r.height >= L.tapTarget - 0.5) return;
14452
+ // Too short to tap reliably (44px), or so narrow and close to another target that it's easy to miss.
14453
+ const tiny = r.height < L.tapTarget - 0.5 || r.width < L.tapTargetMin - 0.5;
14454
+ // Tall enough: is it so narrow that a neighbour on the same line sits inside its 44px-wide zone?
14455
+ const cx = r.left + r.width / 2, half = L.tapTarget / 2;
14456
+ const zone = { left: cx - half, right: cx + half, top: r.top + r.height * 0.25, bottom: r.bottom - r.height * 0.25 };
14457
+ let crowded = false;
14458
+ targets.forEach((o, j) => {
14459
+ if (j === i || crowded || o.contains(el) || el.contains(o)) return;
14460
+ const q = inner[j];
14461
+ if (q.left < zone.right && q.right > zone.left && q.top < zone.bottom && q.bottom > zone.top) crowded = true;
14462
+ });
14463
+ if (tiny || crowded) add("tap-target", el, { value: Math.min(r.width, r.height), limit: L.tapTarget, key: txt(el).slice(0, 20) || el.getAttribute("aria-label") || String(i) });
14464
+ });
14465
+
14466
+ // --- text over text --------------------------------------------------------------------------
14467
+ const blockOf = (el) => el.closest("[data-os-block]") || el.closest("[data-os-id]");
14468
+ /** Line boxes of an element's own text (a drop cap or a tall first letter stays within its line). */
14469
+ const lineRects = (el) => {
14470
+ const out = [];
14471
+ for (const n of el.childNodes) {
14472
+ if (n.nodeType !== 3 || !n.textContent.trim()) continue;
14473
+ const range = document.createRange();
14474
+ range.selectNodeContents(n);
14475
+ for (const x of range.getClientRects()) if (x.width >= 0.5) out.push(x);
14476
+ }
14477
+ return out;
14478
+ };
14479
+ const boxes = leaves.filter((el) => !decorative(el) && ownText(el).length > 1).map((el) => ({ el, r: ownRect(el), lines: lineRects(el), b: blockOf(el) }));
14480
+ const linesMeet = (a, b) => a.lines.some((x) => b.lines.some((y) => Math.min(x.right, y.right) - Math.max(x.left, y.left) > 4 && Math.min(x.bottom, y.bottom) - Math.max(x.top, y.top) > Math.max(8, 0.3 * Math.min(x.height, y.height))));
14481
+ for (let i = 0; i < boxes.length; i++)
14482
+ for (let j = i + 1; j < boxes.length; j++) {
14483
+ const a = boxes[i], b = boxes[j];
14484
+ if (!a.b || !b.b || a.b === b.b || a.b.contains(b.b) || b.b.contains(a.b)) continue;
14485
+ const w = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
14486
+ const hgt = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
14487
+ if (w > 4 && hgt > 4 && linesMeet(a, b)) add("overlap", b.el, { key: a.b.getAttribute("data-os-id") || String(i) });
14488
+ }
14489
+
14490
+ // --- contrast (text over photos and on solid colors) ---------------------------------------
14491
+ const canvas = document.createElement("canvas");
14492
+ canvas.width = canvas.height = 1;
14493
+ const ctx2d = canvas.getContext("2d", { willReadFrequently: true });
14494
+ const colors = new Map();
14495
+ /** Any CSS color (rgb, color(), oklab, color-mix results\u2026) as 0\u2013255 RGB + alpha. */
14496
+ const parse = (c) => {
14497
+ if (!c || c === "transparent") return null;
14498
+ if (colors.has(c)) return colors.get(c);
14499
+ let out = null;
14500
+ const m = /^rgba?\\(([^)]+)\\)$/.exec(c);
14501
+ if (m) {
14502
+ const p = m[1].split(/[ ,/]+/).filter(Boolean).map(Number);
14503
+ out = { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
14504
+ } else if (ctx2d) {
14505
+ const a = /\\/\\s*([\\d.]+)(%?)\\s*\\)$/.exec(c);
14506
+ const alpha = a ? Number(a[1]) / (a[2] ? 100 : 1) : 1;
14507
+ ctx2d.clearRect(0, 0, 1, 1);
14508
+ ctx2d.fillStyle = "#000";
14509
+ ctx2d.fillStyle = c.replace(/\\/\\s*[\\d.]+%?\\s*\\)$/, ")");
14510
+ ctx2d.fillRect(0, 0, 1, 1);
14511
+ const d = ctx2d.getImageData(0, 0, 1, 1).data;
14512
+ out = { r: d[0], g: d[1], b: d[2], a: alpha };
14513
+ }
14514
+ colors.set(c, out);
14515
+ return out;
14516
+ };
14517
+ const lin = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
14518
+ const lum = (c) => 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
14519
+ const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
14520
+ const over = (top, base) => ({ r: top.r * top.a + base.r * (1 - top.a), g: top.g * top.a + base.g * (1 - top.a), b: top.b * top.a + base.b * (1 - top.a), a: 1 });
14521
+ const opacityOf = (el) => { let o = 1; for (let e = el; e && e !== document.documentElement; e = e.parentElement) o *= Number(cs(e).opacity); return o; };
14522
+ const PHOTO = new Set(["IMG","VIDEO","CANVAS","PICTURE","IFRAME"]);
14523
+ const pageBg = parse(cs(document.body).backgroundColor) || { r: 255, g: 255, b: 255, a: 1 };
14524
+ const checkPoint = (el, x, y) => {
14525
+ const stack = document.elementsFromPoint(x, y);
14526
+ const at = stack.indexOf(el);
14527
+ if (at < 0) return null;
14528
+ const layers = [];
14529
+ let photo = false, base = null;
14530
+ for (const e of stack.slice(at)) {
14531
+ if (PHOTO.has(e.tagName)) { photo = true; break; }
14532
+ const s = cs(e);
14533
+ if (/url\\(/.test(s.backgroundImage)) { photo = true; break; }
14534
+ if (s.backgroundImage && s.backgroundImage !== "none") return null; // gradients: checked from the document
14535
+ const bg = parse(s.backgroundColor);
14536
+ if (bg && bg.a > 0) {
14537
+ const a = bg.a * opacityOf(e);
14538
+ if (a >= 0.98) { base = { ...bg, a: 1 }; break; }
14539
+ layers.push({ ...bg, a });
14540
+ }
14541
+ }
14542
+ return { photo, base: base || (photo ? null : pageBg), layers };
14543
+ };
14544
+ for (const el of leaves) {
14545
+ if (decorative(el) || ownText(el).length < 2) continue;
14546
+ const s = cs(el);
14547
+ const fg = parse(s.color);
14548
+ if (!fg || fg.a === 0) continue;
14549
+ const size = parseFloat(s.fontSize);
14550
+ const bold = Number(s.fontWeight) >= 700;
14551
+ const need = size >= 24 || (bold && size >= 18.66) ? 3 : 4.5;
14552
+ const r = ownRect(el);
14553
+ if (r.width < 1) continue;
14554
+ const sy = window.scrollY;
14555
+ const pts = [[r.left + Math.min(8, r.width / 2), r.top + sy + r.height / 2], [r.left + r.width / 2, r.top + sy + r.height / 2]];
14556
+ let worst = Infinity, photo = false;
14557
+ for (const [px, docY] of pts) {
14558
+ if (docY < window.scrollY + 80 || docY > window.scrollY + viewH - 20) window.scrollTo(0, Math.max(0, docY - viewH / 2));
14559
+ const py = docY - window.scrollY;
14560
+ if (py < 0 || py > viewH) continue;
14561
+ const hit = checkPoint(el, Math.max(0, Math.min(vw - 1, px)), py);
14562
+ if (!hit) continue;
14563
+ const bases = hit.photo ? [{ r: 0, g: 0, b: 0, a: 1 }, { r: 255, g: 255, b: 255, a: 1 }] : [hit.base];
14564
+ for (const b0 of bases) {
14565
+ let c = b0;
14566
+ for (const l of hit.layers.slice().reverse()) c = over(l, c);
14567
+ const text = fg.a < 1 ? over(fg, c) : fg;
14568
+ worst = Math.min(worst, ratio(text, c));
14569
+ }
14570
+ photo = photo || hit.photo;
14571
+ }
14572
+ if (worst < need - 0.05) add(photo ? "photo-contrast" : "low-contrast", el, { value: worst, limit: need });
14573
+ }
14574
+ window.scrollTo(0, 0);
14575
+
14576
+ // --- images --------------------------------------------------------------------------------
14577
+ for (const box of document.querySelectorAll(".os-img,iframe,video")) {
14578
+ if (!visible(box) || box.closest(".os-section__bg,.os-header,.os-lightbox,.os-carousel__track,.os-marquee")) continue;
14579
+ const r = box.getBoundingClientRect();
14580
+ if (r.height > vh * L.imageScreen) add("tall-image", box, { value: r.height, limit: Math.round(vh * L.imageScreen) });
14581
+ const img = box.querySelector && box.querySelector("img");
14582
+ if (img && img.naturalWidth > 0 && cs(img).objectFit === "cover") {
14583
+ const ir = img.getBoundingClientRect();
14584
+ const nat = img.naturalWidth / img.naturalHeight, shown = ir.width / Math.max(1, ir.height);
14585
+ const share = Math.min(nat / shown, shown / nat);
14586
+ if (share < L.crop && !box.style.getPropertyValue("--focal") && !img.style.getPropertyValue("--focal")) add("heavy-crop", box, { value: share, limit: L.crop });
14587
+ }
14588
+ }
14589
+ for (const bg of document.querySelectorAll(".os-section__bg img")) {
14590
+ const sec = bg.closest("[data-os-section]");
14591
+ if (!sec || !visible(sec) || bg.naturalWidth === 0) continue;
14592
+ const r = sec.getBoundingClientRect();
14593
+ const nat = bg.naturalWidth / bg.naturalHeight, shown = r.width / Math.max(1, r.height);
14594
+ const share = Math.min(nat / shown, shown / nat);
14595
+ const focal = (bg.getAttribute("style") || "") + (bg.closest("[style*='--focal']") ? "focal" : "");
14596
+ if (share < L.crop && !/--focal|focal/.test(focal)) add("heavy-crop", sec, { value: share, limit: L.crop, key: "bg" });
14597
+ }
14598
+
14599
+ // --- section rhythm, header, footer ---------------------------------------------------------
14600
+ for (const sec of document.querySelectorAll("main [data-os-section]")) {
14601
+ if (!visible(sec)) continue;
14602
+ const s = cs(sec);
14603
+ const pad = Math.max(parseFloat(s.paddingTop), parseFloat(s.paddingBottom));
14604
+ const header = document.querySelector(".os-header");
14605
+ const overlayExtra = sec.classList.contains("os-section--first") && document.querySelector(".has-overlay-header") && header ? header.getBoundingClientRect().height : 0;
14606
+ const top = parseFloat(s.paddingTop) - overlayExtra, bottom = parseFloat(s.paddingBottom);
14607
+ const minh = parseFloat(s.minHeight) || 0;
14608
+ if (Math.max(top, bottom) > L.loosePadding && minh < vh * 0.5) add("loose-padding", sec, { value: Math.max(top, bottom), limit: L.loosePadding });
14609
+ void pad;
14610
+ }
14611
+ const bar = document.querySelector(".os-header__bar");
14612
+ if (bar && visible(bar)) {
14613
+ const parts = Array.from(bar.children).filter(visible);
14614
+ let crowded = bar.getBoundingClientRect().height > 120;
14615
+ for (let i = 0; i < parts.length && !crowded; i++)
14616
+ for (let j = i + 1; j < parts.length; j++) {
14617
+ const a = parts[i].getBoundingClientRect(), b = parts[j].getBoundingClientRect();
14618
+ if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 2 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 2) { crowded = true; break; }
14619
+ }
14620
+ if (bar.scrollWidth > bar.clientWidth + 2) crowded = true;
14621
+ if (crowded) add("header-crowded", bar, { value: bar.getBoundingClientRect().height });
14622
+ }
14623
+ // The first section's main action: visible without scrolling?
14624
+ const first = Array.from(document.querySelectorAll("main [data-os-section]")).find(visible);
14625
+ // Only a first section with photos or video (a hero) can push its words and button down.
14626
+ if (first && first.querySelector("img,video,.os-section__bg")) {
14627
+ // A form's own submit button sits at its end, wherever that is; look at call-to-action buttons.
14628
+ const cta = Array.from(first.querySelectorAll(".os-button--primary")).find((b) => visible(b) && !b.closest("form,.os-form,.os-newsletter"));
14629
+ if (cta) {
14630
+ const top = cta.getBoundingClientRect().top + window.scrollY;
14631
+ if (top + Math.min(44, cta.getBoundingClientRect().height) > vh) add("cta-below-fold", cta, { value: top, limit: vh });
14632
+ }
14633
+ }
14634
+ const footer = document.querySelector(".os-footer");
14635
+ if (footer && visible(footer) && footer.getBoundingClientRect().height > vh * 2.5) add("long-footer", footer, { value: footer.getBoundingClientRect().height, limit: Math.round(vh * 2.5) });
14636
+
14637
+ style.remove();
14638
+ return { width: vw, height: vh, scrollWidth, findings };
14639
+ })()`;
14640
+ var PHONE_PREPARE_SCRIPT = `(async () => {
14641
+ for (const img of Array.from(document.images)) { img.loading = "eager"; img.decoding = "sync"; }
14642
+ document.documentElement.classList.remove("os-reveal-ready");
14643
+ const step = Math.max(300, Math.floor(window.innerHeight * 0.8));
14644
+ for (let y = 0, i = 0; y < document.documentElement.scrollHeight && i < 80; y += step, i++) {
14645
+ window.scrollTo(0, y);
14646
+ await new Promise((r) => setTimeout(r, 25));
14647
+ }
14648
+ window.scrollTo(0, 0);
14649
+ const done = Promise.all(Array.from(document.images).map((img) => img.complete ? null :
14650
+ new Promise((r) => { img.addEventListener("load", r, { once: true }); img.addEventListener("error", r, { once: true }); })));
14651
+ await Promise.race([done, new Promise((r) => setTimeout(r, 5000))]);
14652
+ if (document.fonts) await Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 3000))]);
14653
+ })()`;
14654
+
13486
14655
  // ../lint/src/seo.ts
13487
14656
  function seoCheck(page, doc, options = {}) {
13488
14657
  const meta = opMeta(options);
@@ -13575,12 +14744,12 @@ function seoCheck(page, doc, options = {}) {
13575
14744
  } : {}
13576
14745
  });
13577
14746
  }
13578
- const words3 = blocks2.map(blockText).join(" ").split(/\s+/).filter(Boolean).length;
13579
- if (!page.seo?.noindex && words3 < 80) {
14747
+ const words4 = blocks2.map(blockText).join(" ").split(/\s+/).filter(Boolean).length;
14748
+ if (!page.seo?.noindex && words4 < 80) {
13580
14749
  add({
13581
14750
  rule: "seo-thin-content",
13582
14751
  severity: "info",
13583
- message: `The page has about ${words3} words of text; search engines have little to go on.`,
14752
+ message: `The page has about ${words4} words of text; search engines have little to go on.`,
13584
14753
  fix: "Add a short paragraph that explains what's on the page in your own words."
13585
14754
  });
13586
14755
  }
@@ -14206,6 +15375,20 @@ async function runCheck(root, options = {}) {
14206
15375
  });
14207
15376
  }
14208
15377
  }
15378
+ if (doc) {
15379
+ try {
15380
+ for (const i of phoneCheck(doc)) {
15381
+ if (i.severity === "info") continue;
15382
+ add({
15383
+ severity: "warning",
15384
+ rule: "phone",
15385
+ file: i.page && i.page !== "footer" ? pageFiles2[i.page] ?? PAGES_DIR : i.page === "footer" ? SITE_FILE : SITE_FILE,
15386
+ message: `${i.message} ${i.autofix ? `Fix: \`openspace check --phone --fix\` (${i.autofix.label.toLowerCase()}).` : i.fix}`
15387
+ });
15388
+ }
15389
+ } catch {
15390
+ }
15391
+ }
14209
15392
  const mediaOnDisk = /* @__PURE__ */ new Map();
14210
15393
  for (const rel of await listFiles(root, "media")) {
14211
15394
  const bytes = (await stat(join2(root, rel))).size;
@@ -14515,7 +15698,7 @@ function setupChecklist(sq, build) {
14515
15698
  id: "newsletter",
14516
15699
  kind: "newsletter",
14517
15700
  title: "Bring your newsletter subscribers",
14518
- detail: `Sign-up forms came across, but subscribers stay with ${from}. Export them there and import them here.`,
15701
+ detail: subscribersAdvice(sq.platform),
14519
15702
  where: newsletter.where,
14520
15703
  status: "todo"
14521
15704
  });
@@ -15069,7 +16252,7 @@ function nearestOpenFont(family, metrics) {
15069
16252
  let best;
15070
16253
  for (const [name, cat, xHeight, capHeight, width] of list5) {
15071
16254
  let d = metricDistance(metrics, { xHeight, capHeight, width });
15072
- if (eq.includes(name)) d -= 0.02 * (eq.length - eq.indexOf(name));
16255
+ if (eq.includes(name)) d -= 4e-3 * (eq.length - eq.indexOf(name));
15073
16256
  if (!best || d < (best.distance ?? Number.POSITIVE_INFINITY))
15074
16257
  best = { family: name, category: cat, via: "metrics", distance: Math.max(0, d) };
15075
16258
  }
@@ -15169,6 +16352,11 @@ async function fitGoogleWeights(font, fetchImpl = fetch) {
15169
16352
  // ../import/src/fidelity/look.ts
15170
16353
  var norm2 = (s2) => (s2 ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
15171
16354
  var GENERIC = /^(?:serif|sans-serif|monospace|system-ui|ui-\w+|-apple-system|blinkmacsystemfont)$/i;
16355
+ var colorGap = (a, b) => {
16356
+ const p = (h) => [1, 3, 5].map((i) => Number.parseInt(h.slice(i, i + 2), 16) || 0);
16357
+ const [x, y] = [p(a), p(b)];
16358
+ return Math.sqrt(x.reduce((n, v, i) => n + (v - (y[i] ?? 0)) ** 2, 0));
16359
+ };
15172
16360
  var round = (n, d = 1) => Math.round(n * 10 ** d) / 10 ** d;
15173
16361
  function stepOf(t, mobile, opts = {}) {
15174
16362
  return {
@@ -15241,7 +16429,12 @@ function lookFromProbe(d, m) {
15241
16429
  scale[p] = { ...scale[p], font: "heading", ...w >= 100 && w <= 900 ? { weight: w } : {} };
15242
16430
  }
15243
16431
  }
15244
- if (scale.h1) scale.display = { ...scale.h1 };
16432
+ if (P.display)
16433
+ scale.display = {
16434
+ ...stepOf(P.display, M.display, { weight: true }),
16435
+ font: roleOf(P.display)
16436
+ };
16437
+ else if (scale.h1) scale.display = { ...scale.h1 };
15245
16438
  if (P.lead) scale.lead = { ...stepOf(P.lead, M.lead), font: roleOf(P.lead) };
15246
16439
  if (P.small) scale.small = stepOf(P.small, M.small);
15247
16440
  if (P.eyebrow)
@@ -15263,6 +16456,17 @@ function lookFromProbe(d, m) {
15263
16456
  const mobileLeft = m?.layout.left;
15264
16457
  if (L2.contentWidth && L2.contentWidth >= 480 && L2.contentWidth <= 2400 && L2.contentWidth < d.viewport - 40)
15265
16458
  layout.contentWidth = Math.round(L2.contentWidth / 8) * 8;
16459
+ const page = d.pageBackground;
16460
+ const panels = d.sections.filter(
16461
+ (s2) => page && s2.background && s2.left !== void 0 && s2.width !== void 0 && s2.left >= 24 && s2.left + s2.width <= d.viewport - 24 && s2.raised === true && colorGap(s2.background, page) >= 10
16462
+ );
16463
+ if (page && d.viewport >= 768 && panels.length && panels.length * 2 >= d.sections.length) {
16464
+ layout.surface = "card";
16465
+ layout.pageBackground = page;
16466
+ const measures = panels.map((s2) => s2.textWidth).filter((w2) => !!w2);
16467
+ const w = measures.sort((a, b) => a - b)[Math.floor(measures.length / 2)];
16468
+ if (w && w >= 480 && w <= 1600) layout.contentWidth = Math.round(w / 8) * 8;
16469
+ }
15266
16470
  if (L2.sectionPadding && L2.sectionPadding >= 16 && L2.sectionPadding <= 320)
15267
16471
  layout.sectionPadding = Math.round(L2.sectionPadding);
15268
16472
  if (layout.sectionPadding && m?.layout.sectionPadding && m.layout.sectionPadding <= 240)
@@ -15304,6 +16508,17 @@ function lookFromProbe(d, m) {
15304
16508
  ...d.eyebrowRule ? { eyebrowRule: true } : {},
15305
16509
  ...d.fonts && Object.keys(d.fonts).length ? { metrics: { ...m?.fonts, ...d.fonts } } : {},
15306
16510
  ...hosts.length ? { fontHosts: hosts } : {},
16511
+ ...d.logoHeight && d.logoHeight >= 16 ? {
16512
+ logoHeight: {
16513
+ d: Math.min(240, d.logoHeight),
16514
+ ...m?.logoHeight && m.logoHeight >= 16 ? { m: Math.min(240, m.logoHeight) } : {}
16515
+ }
16516
+ } : {},
16517
+ ...d.footerBackground ? { footerBackground: d.footerBackground } : {},
16518
+ ...d.footerColumns && d.footerColumns >= 2 && d.footerColumns <= 4 ? {
16519
+ footerColumns: d.footerColumns,
16520
+ ...d.footerText && d.footerText.size <= 14 && (d.footerText.case === "uppercase" || d.footerText.letterSpacing >= 0.08) ? { footerLabels: true } : {}
16521
+ } : {},
15307
16522
  ...d.sidebar ? {
15308
16523
  sidebar: {
15309
16524
  side: d.sidebar.side,
@@ -15331,6 +16546,13 @@ function applyLook(theme, look, source, mode = "override") {
15331
16546
  theme.headerLayout = "sidebar";
15332
16547
  if (look.sidebar.background) theme.headerBackground = look.sidebar.background;
15333
16548
  }
16549
+ if (look.logoHeight && !keep(theme.logoHeight)) theme.logoHeight = look.logoHeight;
16550
+ if (look.footerBackground && !keep(theme.footerBackground))
16551
+ theme.footerBackground = look.footerBackground;
16552
+ if (look.footerColumns && !keep(theme.footerColumns)) {
16553
+ theme.footerColumns = look.footerColumns;
16554
+ if (look.footerLabels) theme.footerLabels = true;
16555
+ }
15334
16556
  if (look.metrics) theme.fontMetrics = { ...look.metrics, ...theme.fontMetrics ?? {} };
15335
16557
  if (look.fontHosts)
15336
16558
  theme.fontHosts = [.../* @__PURE__ */ new Set([...theme.fontHosts ?? [], ...look.fontHosts])];
@@ -15541,7 +16763,15 @@ var PROBE_SCRIPT = String.raw`(async () => {
15541
16763
  const er = e.getBoundingClientRect();
15542
16764
  return visible(e) && er.width >= r.width * 0.9 && er.height >= r.height * 0.7;
15543
16765
  });
15544
- const res = { index, top: Math.round(r.top), height: Math.round(r.height), background: bgOf(sec.els ? sec.els[0] : sec), columns: 1, italicLines: 0, text: ownText(sec).slice(0, 120), words: ownText(sec).slice(0, 20000) };
16766
+ const res = { index, top: Math.round(r.top), height: Math.round(r.height), left: Math.round(r.left), width: Math.round(r.width), background: bgOf(sec.els ? sec.els[0] : sec), columns: 1, italicLines: 0, text: ownText(sec).slice(0, 120), words: ownText(sec).slice(0, 20000) };
16767
+ // A raised panel: the section's own box draws a shadow or a border (a theme's content card).
16768
+ // (Its own box, or a panel around it narrower than the page: an article card around a band.)
16769
+ for (let e = sec.els ? sec.els[0] : sec, k = 0; e && e !== document.body && k < 5; e = e.parentElement, k++) {
16770
+ const st = cs(e);
16771
+ const er = e.getBoundingClientRect();
16772
+ if (k > 0 && er.width >= vw * 0.95) break;
16773
+ if ((st.boxShadow && st.boxShadow !== "none") || (k === 0 && !sec.els && parseFloat(st.borderTopWidth) >= 1)) { res.raised = true; break; }
16774
+ }
15545
16775
  if (bgEl || coverImg) res.backgroundImage = true;
15546
16776
  const h = [...sec.querySelectorAll(headSel)].find((e) => visible(e) && ownText(e).length > 1);
15547
16777
  if (h) {
@@ -15600,18 +16830,79 @@ var PROBE_SCRIPT = String.raw`(async () => {
15600
16830
  res.body = best.t;
15601
16831
  if (best.t.color) res.textColor = best.t.color;
15602
16832
  }
16833
+ // How wide the running text is set (the measure), and where it starts.
16834
+ const measures = paras.map((p) => rect(p)).filter((x) => x.width > 120);
16835
+ if (measures.length) {
16836
+ res.textWidth = Math.round(Math.max(...measures.map((x) => x.width)));
16837
+ res.textLeft = Math.round(Math.min(...measures.map((x) => x.left)));
16838
+ }
15603
16839
  // Columns: text items whose vertical spans overlap, at distinct left edges.
15604
- const items = [...sec.querySelectorAll(textSel + ", h2, img")].filter((e) => visible(e) && (e.tagName === "IMG" ? e.getBoundingClientRect().width > 60 : ownText(e).length >= 12)).map(rect);
15605
- for (const a of items) {
15606
- const mid = a.top + Math.min(a.height, 40) / 2;
15607
- const lefts = [];
15608
- for (const b of items) if (b.top <= mid && b.bottom >= mid && !lefts.some((l) => Math.abs(l - b.left) < 40)) lefts.push(b.left);
15609
- res.columns = Math.max(res.columns, Math.min(6, lefts.length));
16840
+ const itemEls = [...sec.querySelectorAll(textSel + ", h2, img")].filter((e) => visible(e) && (e.tagName === "IMG" ? e.getBoundingClientRect().width > 60 : ownText(e).length >= 12));
16841
+ const columnsOf = (items) => {
16842
+ let n = 1;
16843
+ for (const a of items) {
16844
+ const mid = a.top + Math.min(a.height, 40) / 2;
16845
+ const lefts = [];
16846
+ for (const b of items) if (b.top <= mid && b.bottom >= mid && !lefts.some((l) => Math.abs(l - b.left) < 40)) lefts.push(b.left);
16847
+ n = Math.max(n, Math.min(6, lefts.length));
16848
+ }
16849
+ return n;
16850
+ };
16851
+ res.columns = columnsOf(itemEls.map(rect));
16852
+ // Corrected score only (the strict v3 count above is kept): columns a visitor can see.
16853
+ // A box that clips content running sideways out of it (a scroll-snap slideshow, a map's
16854
+ // tiles) is one picture on screen, however many slides or tiles it holds; anything else
16855
+ // counts when at least half of it (or half the viewport's width) is inside the viewport.
16856
+ // (A clearfix row with
16857
+ // overflow: hidden doesn't overflow sideways, so its columns stay columns.)
16858
+ const clipBox = (el) => {
16859
+ for (let e = el.parentElement; e && e !== document.body && e !== doc; e = e.parentElement) {
16860
+ const st = cs(e);
16861
+ if ((st.overflowX !== "visible" || st.overflow !== "visible") && e.scrollWidth > e.clientWidth + 2) return e;
16862
+ }
16863
+ return null;
16864
+ };
16865
+ const shownRects = [];
16866
+ const boxes = new Set();
16867
+ for (const el of itemEls) {
16868
+ const box = clipBox(el);
16869
+ if (box) {
16870
+ if (boxes.has(box)) continue;
16871
+ boxes.add(box);
16872
+ const br = rect(box);
16873
+ const L = Math.max(0, br.left), R = Math.min(vw, br.right);
16874
+ if (R - L > 2) shownRects.push({ ...br, left: L, right: R, width: R - L });
16875
+ continue;
16876
+ }
16877
+ const r = el.getBoundingClientRect();
16878
+ const L = Math.max(0, r.left), R = Math.min(vw, r.right);
16879
+ // Half of it on screen — or half the screen (a wide cover photo cropped at both sides).
16880
+ if (R - L >= Math.min(r.width, vw) * 0.5) shownRects.push(rect(el));
15610
16881
  }
16882
+ const shown = columnsOf(shownRects);
16883
+ if (shown !== res.columns) res.columnsOnScreen = shown;
15611
16884
  // The section's parts and where they sit (to recreate it as a component when needed).
15612
16885
  if (!sec.querySelector("form, input, textarea, select, iframe, video, canvas, embed, object")) {
15613
16886
  const parts = [];
15614
16887
  const inPart = (e) => parts.some((p) => p.el.contains(e));
16888
+ // Inline links and emphasis inside a paragraph (kept as rich text when it's recreated).
16889
+ const runsOf = (el) => {
16890
+ const out = [];
16891
+ const walk = (n, m) => {
16892
+ for (const c of n.childNodes) {
16893
+ if (c.nodeType === 3) { const t = (c.textContent || "").replace(/\s+/g, " "); if (t) out.push({ t, ...m }); }
16894
+ else if (c.nodeType === 1 && cs(c).display !== "none") {
16895
+ const mm = { ...m };
16896
+ if (c.tagName === "A" && c.getAttribute("href")) mm.href = c.href;
16897
+ if (/^(STRONG|B)$/.test(c.tagName)) mm.b = 1;
16898
+ if (/^(EM|I)$/.test(c.tagName)) mm.i = 1;
16899
+ if (c.tagName === "BR") out.push({ t: "\n" }); else walk(c, mm);
16900
+ }
16901
+ }
16902
+ };
16903
+ walk(el, {});
16904
+ return out.length <= 80 && out.some((r) => r.href || r.b || r.i) ? out : undefined;
16905
+ };
15615
16906
  const btnLike = (e) => { const st = cs(e); return (toHex(st.backgroundColor) || parseFloat(st.borderTopWidth) >= 1) && parseFloat(st.paddingLeft) >= 8; };
15616
16907
  const els = [...sec.querySelectorAll("h1, h2, h3, h4, h5, h6, p, li, blockquote, img, a, button")].filter((e) => visible(e));
15617
16908
  for (const e of els) {
@@ -15630,10 +16921,34 @@ var PROBE_SCRIPT = String.raw`(async () => {
15630
16921
  parts.push({ el: e, item: { kind: btnLike(e) ? "button" : "link", text: t, href: e.getAttribute("href") || "", box: at, style: { ...textOf(e), align: cs(e).textAlign } } });
15631
16922
  } else {
15632
16923
  if (!t || e.querySelector("p, li, h1, h2, h3, h4, h5, h6, img")) continue;
15633
- parts.push({ el: e, item: { kind: "text", text: t.slice(0, 1500), box: at, style: { ...textOf(e), align: cs(e).textAlign } } });
16924
+ const runs = runsOf(e);
16925
+ parts.push({ el: e, item: { kind: "text", text: t.slice(0, 1500), box: at, style: { ...textOf(e), align: cs(e).textAlign }, ...(runs ? { runs } : {}) } });
16926
+ }
16927
+ }
16928
+ // Accent rules (thin bars, <hr>) and panels (boxes with their own fill or border) behind
16929
+ // the words: recreated on theme colors.
16930
+ const secBg = res.background;
16931
+ let decor = 0;
16932
+ for (const e of sec.querySelectorAll("hr, div, span, section, article, aside, figure, li")) {
16933
+ if (decor >= 6 || !visible(e) || parts.some((p) => p.el === e)) continue;
16934
+ const b = rect(e);
16935
+ const st = cs(e);
16936
+ const fill = toHex(st.backgroundColor);
16937
+ const bw = parseFloat(st.borderTopWidth) || 0;
16938
+ const line = e.tagName === "HR" || (b.height <= 4 && b.width >= 16 && (fill || bw >= 1));
16939
+ if (line) {
16940
+ parts.push({ el: e, item: { kind: "rule", box: { x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.max(1, Math.round(e.tagName === "HR" ? bw || 1 : b.height)) }, color: fill || toHex(st.borderTopColor) } });
16941
+ decor++;
16942
+ continue;
16943
+ }
16944
+ const own = fill && fill !== secBg;
16945
+ const framed = bw >= 1 && st.borderTopStyle !== "none" && parseFloat(st.borderLeftWidth) >= 1;
16946
+ if ((own || framed) && b.width >= 120 && b.height >= 60 && b.width < r.width * 0.95 && !e.querySelector("img, video, iframe") && ownText(e)) {
16947
+ parts.push({ el: e, item: { kind: "panel", box: { x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) }, ...(own ? { color: fill } : {}), ...(framed ? { border: { width: Math.round(bw), color: toHex(st.borderTopColor) } } : {}), radius: parseFloat(st.borderTopLeftRadius) || 0 } });
16948
+ decor++;
15634
16949
  }
15635
16950
  }
15636
- if (parts.length && parts.length <= 30) res.blueprint = parts.map((p) => p.item);
16951
+ if (parts.length && parts.length <= 36) res.blueprint = parts.map((p) => p.item);
15637
16952
  }
15638
16953
  // Where the section's own content starts and ends (for the spacing between sections).
15639
16954
  const content = [...sec.querySelectorAll(textSel + ", h1, h2, img, picture, video, iframe, form, button, a")].filter((e) => visible(e) && (/^(IMG|PICTURE|VIDEO|IFRAME|FORM)$/.test(e.tagName) || ownText(e).length > 1)).map(rect);
@@ -15661,12 +16976,28 @@ var PROBE_SCRIPT = String.raw`(async () => {
15661
16976
  const inMain = (el) => !chrome(el) && !el.closest("footer, .os-footer");
15662
16977
  const styleOfAll = (sel) => [...document.querySelectorAll(sel)].filter((e) => visible(e) && inMain(e) && ownText(e).length > 1).map(textOf);
15663
16978
  const presets = {};
16979
+ // Measurements only the corrected score uses (the strict v3 ones above stay as they were).
16980
+ const corrections = {};
15664
16981
  const h1s = styleOfAll("h1");
15665
16982
  if (h1s.length) presets.h1 = h1s.sort((a, b) => b.size - a.size)[0];
15666
16983
  const h2 = median(styleOfAll("h2"));
15667
16984
  if (h2) presets.h2 = h2;
15668
16985
  const h3 = median(styleOfAll("h3"));
15669
16986
  if (h3) presets.h3 = h3;
16987
+ // A display line: the page's largest short text, well above its headings (a banner's title
16988
+ // set in a paragraph). Read when importing; the comparison doesn't check it.
16989
+ {
16990
+ const ref = Math.max(presets.h1 ? presets.h1.size : 0, presets.h2 ? presets.h2.size : 0, 16);
16991
+ let best;
16992
+ for (const e of document.querySelectorAll("p, strong, b, span, h1, h2, h3, div")) {
16993
+ if (!visible(e) || !inMain(e)) continue;
16994
+ const t = ownText(e);
16995
+ if (t.length < 3 || t.length > 60 || [...e.children].some((k) => ownText(k) === t && k.tagName !== "BR" && !/^(STRONG|B|EM|I|SPAN)$/.test(k.tagName))) continue;
16996
+ const st = textOf(e);
16997
+ if (st.size >= ref * 1.3 && (!best || st.size > best.size)) best = st;
16998
+ }
16999
+ if (best) presets.display = best;
17000
+ }
15670
17001
  const tally = new Map();
15671
17002
  for (const { el, t } of all) {
15672
17003
  const k = t.family + "|" + t.size;
@@ -15697,6 +17028,21 @@ var PROBE_SCRIPT = String.raw`(async () => {
15697
17028
  if (!links.length) links = [...header.querySelectorAll("a")].filter(linkOk);
15698
17029
  const mid = links[Math.floor(links.length / 2)];
15699
17030
  if (mid) presets.nav = textOf(mid);
17031
+ else {
17032
+ // Corrected score only: "[class*=brand]" above is meant for logo boxes, but it also
17033
+ // matches a header's colour theme (OpenSpace's "os-theme-brand" sidebar). A class naming a
17034
+ // theme isn't a logo box: look again without it.
17035
+ const logoBox = (a) => {
17036
+ for (let e = a; e && e.nodeType === 1; e = e.parentElement)
17037
+ if ([...e.classList].some((c) => /brand/i.test(c) && !/theme[-_]?brand/i.test(c))) return true;
17038
+ return false;
17039
+ };
17040
+ const linkOk2 = (a) => visible(a) && ownText(a).length > 1 && ownText(a).length < 30 && toHex(cs(a).backgroundColor) === undefined && !a.matches(".os-header__brand, .os-button, [rel=home]") && !a.closest(".os-header__brand, [class*=site-title]") && !logoBox(a) && !homeLink(a);
17041
+ let again = navEl ? [...navEl.querySelectorAll("a")].filter(linkOk2) : [];
17042
+ if (!again.length) again = [...header.querySelectorAll("a")].filter(linkOk2);
17043
+ const mid2 = again[Math.floor(again.length / 2)];
17044
+ if (mid2) corrections.nav = textOf(mid2);
17045
+ }
15700
17046
  // The site title set as text (a wordmark), not a logo image.
15701
17047
  // The innermost element holding the title text (an OpenSpace title span, not its link).
15702
17048
  const brandOk = (e) => visible(e) && ownText(e).length > 1 && ownText(e).length <= 40 && !e.querySelector("img, svg");
@@ -15717,7 +17063,7 @@ var PROBE_SCRIPT = String.raw`(async () => {
15717
17063
  if (btn) {
15718
17064
  presets.button = textOf(btn);
15719
17065
  const s = cs(btn);
15720
- buttons = { radius: parseFloat(s.borderTopLeftRadius) || 0, background: toHex(s.backgroundColor), border: parseFloat(s.borderTopWidth) || 0, paddingX: parseFloat(s.paddingLeft) || 0, text: ownText(btn).slice(0, 40), element: btn.tagName.toLowerCase() + (btn.className && typeof btn.className === "string" ? "." + btn.className.trim().split(/\s+/).slice(0, 2).join(".") : "") };
17066
+ buttons = { radius: parseFloat(s.borderTopLeftRadius) || 0, background: toHex(s.backgroundColor), border: parseFloat(s.borderTopWidth) || 0, paddingX: parseFloat(s.paddingLeft) || 0, text: ownText(btn).slice(0, 40), element: btn.tagName.toLowerCase() + (btn.className && typeof btn.className === "string" ? "." + btn.className.trim().split(/\s+/).slice(0, 2).join(".") : ""), ...(inMain(btn) ? {} : { chrome: true }) };
15721
17067
  }
15722
17068
 
15723
17069
  // --- layout ---------------------------------------------------------------------------------
@@ -15806,10 +17152,40 @@ var PROBE_SCRIPT = String.raw`(async () => {
15806
17152
  ...(buttons ? { buttons } : {}),
15807
17153
  ...(eyebrows.length ? { eyebrowRule: out.filter((s) => s.rule).length >= Math.max(1, eyebrows.length / 2) } : {}),
15808
17154
  layout,
17155
+ ...(Object.keys(corrections).length ? { corrections } : {}),
15809
17156
  sections: out,
15810
17157
  height: doc.scrollHeight,
15811
17158
  fonts,
15812
17159
  fontRequests,
17160
+ pageBackground: toHex(cs(document.body).backgroundColor) || toHex(cs(doc).backgroundColor) || "#ffffff",
17161
+ ...(() => {
17162
+ // The header's logo picture (its shown height) and the footer's own color.
17163
+ const out = {};
17164
+ const head = document.querySelector(".os-header, header, [role=banner], #header");
17165
+ const logo = head && [...head.querySelectorAll("img, svg")].find((e) => visible(e) && e.getBoundingClientRect().height >= 16 && e.getBoundingClientRect().width >= 24);
17166
+ if (logo) out.logoHeight = Math.round(logo.getBoundingClientRect().height);
17167
+ const foot = [...document.querySelectorAll(".os-footer, footer, [role=contentinfo], #footer")].filter(visible).pop();
17168
+ if (foot) {
17169
+ const c = toHex(cs(foot).backgroundColor) || [...foot.querySelectorAll("*")].map((e) => toHex(cs(e).backgroundColor)).find(Boolean);
17170
+ if (c) out.footerBackground = c;
17171
+ // Its first line of words: how many groups sit side by side, and their style.
17172
+ const leaves = [...foot.querySelectorAll("p, li, a, span, small, div, h2, h3, h4")].filter((e) => visible(e) && ownText(e) && ![...e.children].some((k) => ownText(k) && /^(P|DIV|UL|LI|H2|H3|H4)$/.test(k.tagName)));
17173
+ const lines = leaves.map((e) => ({ e, r: rect(e) }));
17174
+ if (lines.length) {
17175
+ const top = Math.min(...lines.map((l) => l.r.top));
17176
+ const row = lines.filter((l) => l.r.top <= top + 24);
17177
+ const groups = [];
17178
+ for (const l of row.sort((a, b) => a.r.left - b.r.left)) {
17179
+ const g = groups[groups.length - 1];
17180
+ if (g && l.r.left - g.right < 48) g.right = Math.max(g.right, l.r.right);
17181
+ else groups.push({ left: l.r.left, right: l.r.right });
17182
+ }
17183
+ out.footerColumns = groups.length;
17184
+ out.footerText = textOf(row[0].e);
17185
+ }
17186
+ }
17187
+ return out;
17188
+ })(),
15813
17189
  ...(sidebar ? { sidebar } : {}),
15814
17190
  };
15815
17191
  })()`;
@@ -19510,15 +20886,15 @@ function truncate(v, l = 50, o = "...") {
19510
20886
  return v;
19511
20887
  return str5.substring(0, l - o.length) + o;
19512
20888
  }
19513
- function truncatewords(v, words3 = 15, o = "...") {
20889
+ function truncatewords(v, words4 = 15, o = "...") {
19514
20890
  const str5 = stringify(v);
19515
20891
  o = stringify(o);
19516
20892
  this.context.memoryLimit.use(str5.length + o.length);
19517
20893
  const arr = str5.split(/\s+/);
19518
- if (words3 <= 0)
19519
- words3 = 1;
19520
- let ret = arr.slice(0, words3).join(" ");
19521
- if (arr.length >= words3)
20894
+ if (words4 <= 0)
20895
+ words4 = 1;
20896
+ let ret = arr.slice(0, words4).join(" ");
20897
+ if (arr.length >= words4)
19522
20898
  ret += o;
19523
20899
  return ret;
19524
20900
  }
@@ -28355,28 +29731,28 @@ function gfmFootnoteHtml(options) {
28355
29731
  },
28356
29732
  gfmFootnoteCallString(token) {
28357
29733
  let calls = this.getData("gfmFootnoteCallOrder");
28358
- let counts = this.getData("gfmFootnoteCallCounts");
29734
+ let counts2 = this.getData("gfmFootnoteCallCounts");
28359
29735
  const id = normalizeIdentifier(this.sliceSerialize(token));
28360
29736
  let counter;
28361
29737
  this.resume();
28362
29738
  if (!calls) this.setData("gfmFootnoteCallOrder", calls = []);
28363
- if (!counts) this.setData("gfmFootnoteCallCounts", counts = {});
29739
+ if (!counts2) this.setData("gfmFootnoteCallCounts", counts2 = {});
28364
29740
  const index = calls.indexOf(id);
28365
29741
  const safeId = sanitizeUri(id.toLowerCase());
28366
29742
  if (index === -1) {
28367
29743
  calls.push(id);
28368
- counts[id] = 1;
29744
+ counts2[id] = 1;
28369
29745
  counter = calls.length;
28370
29746
  } else {
28371
- counts[id]++;
29747
+ counts2[id]++;
28372
29748
  counter = index + 1;
28373
29749
  }
28374
- const reuseCounter = counts[id];
29750
+ const reuseCounter = counts2[id];
28375
29751
  this.tag('<sup><a href="#' + clobberPrefix + "fn-" + safeId + '" id="' + clobberPrefix + "fnref-" + safeId + (reuseCounter > 1 ? "-" + reuseCounter : "") + '" data-footnote-ref="" aria-describedby="footnote-label">' + String(counter) + "</a></sup>");
28376
29752
  },
28377
29753
  null() {
28378
29754
  const calls = this.getData("gfmFootnoteCallOrder") || [];
28379
- const counts = this.getData("gfmFootnoteCallCounts") || {};
29755
+ const counts2 = this.getData("gfmFootnoteCallCounts") || {};
28380
29756
  const definitions = this.getData("gfmFootnoteDefinitions") || {};
28381
29757
  let index = -1;
28382
29758
  if (calls.length > 0) {
@@ -28392,7 +29768,7 @@ function gfmFootnoteHtml(options) {
28392
29768
  const safeId = sanitizeUri(id.toLowerCase());
28393
29769
  let referenceIndex = 0;
28394
29770
  const references = [];
28395
- while (++referenceIndex <= counts[id]) {
29771
+ while (++referenceIndex <= counts2[id]) {
28396
29772
  references.push('<a href="#' + clobberPrefix + "fnref-" + safeId + (referenceIndex > 1 ? "-" + referenceIndex : "") + '" data-footnote-backref="" aria-label="' + this.encode(typeof backLabel === "string" ? backLabel : backLabel(index, referenceIndex)) + '" class="data-footnote-backref">\u21A9' + (referenceIndex > 1 ? "<sup>" + referenceIndex + "</sup>" : "") + "</a>");
28397
29773
  }
28398
29774
  const reference = references.join(" ");
@@ -30809,7 +32185,7 @@ var typeOf = (o) => {
30809
32185
  for (const k of TYPE_KEYS) if (typeof o[k] === "string") return o[k];
30810
32186
  return void 0;
30811
32187
  };
30812
- function sectionsOf2(o) {
32188
+ function sectionsOf3(o) {
30813
32189
  if (!isObj3(o)) return void 0;
30814
32190
  for (const k of SECTION_KEYS) {
30815
32191
  const v = o[k];
@@ -30840,7 +32216,7 @@ async function findStructuredPages(source, files) {
30840
32216
  data = p.data;
30841
32217
  body = p.body;
30842
32218
  }
30843
- if (sectionsOf2(data)) out.push({ path: f, data, ...body ? { body } : {} });
32219
+ if (sectionsOf3(data)) out.push({ path: f, data, ...body ? { body } : {} });
30844
32220
  }
30845
32221
  return out;
30846
32222
  }
@@ -30956,6 +32332,7 @@ function formFrom(v, ctx) {
30956
32332
  if (signupOnly)
30957
32333
  return {
30958
32334
  kind: "newsletter",
32335
+ ...fields.some((f) => f.type !== "email") ? { askName: true } : {},
30959
32336
  ...button ? { buttonLabel: plain2(button) } : {},
30960
32337
  ...str3(v.email_placeholder) ? { placeholder: str3(v.email_placeholder) } : {},
30961
32338
  ...filled(v.success_message) ? { success: filled(v.success_message) } : {}
@@ -31407,7 +32784,7 @@ ${siteBg ? `body{background-color:${siteBg};}` : ""}`
31407
32784
  placeholders,
31408
32785
  fg: fgFor
31409
32786
  };
31410
- const raw2 = sectionsOf2(file.data) ?? [];
32787
+ const raw2 = sectionsOf3(file.data) ?? [];
31411
32788
  const sections = [];
31412
32789
  for (const s2 of raw2) {
31413
32790
  typesSeen.set(typeOf(s2) ?? "?", (typesSeen.get(typeOf(s2) ?? "?") ?? 0) + 1);
@@ -40919,13 +42296,14 @@ function fontCredit(a, b, o, i) {
40919
42296
  const d = metricDistance(ma, mb);
40920
42297
  return d <= 0.04 ? 0.85 : d <= 0.07 ? 0.7 : d <= 0.11 ? 0.5 : d <= 0.16 ? 0.25 : 0;
40921
42298
  }
42299
+ var SCORER = "v3.1-corrected";
40922
42300
  var norm3 = (s2) => (s2 ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
40923
- var words2 = (s2) => new Set(
42301
+ var words3 = (s2) => new Set(
40924
42302
  s2.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2)
40925
42303
  );
40926
42304
  var jaccard = (a, b) => {
40927
- const A = words2(a);
40928
- const B = words2(b);
42305
+ const A = words3(a);
42306
+ const B = words3(b);
40929
42307
  if (!A.size || !B.size) return 0;
40930
42308
  let n = 0;
40931
42309
  for (const w of A) if (B.has(w)) n++;
@@ -40966,7 +42344,12 @@ var ROLE_OF = {
40966
42344
  nav: "label",
40967
42345
  wordmark: "label"
40968
42346
  };
40969
- function typeChecks(o, i, tol) {
42347
+ function presetOf(p, k, mode) {
42348
+ const v = p.presets[k];
42349
+ if (v || mode === "strict" || k !== "nav") return v;
42350
+ return p.corrections?.nav;
42351
+ }
42352
+ function typeChecks(o, i, tol, mode = "corrected") {
40970
42353
  const out = [];
40971
42354
  const mobile = o.viewport < 700;
40972
42355
  const sizeKey = mobile ? "sizeMobile" : "size";
@@ -40982,8 +42365,8 @@ function typeChecks(o, i, tol) {
40982
42365
  "nav",
40983
42366
  "wordmark"
40984
42367
  ]) {
40985
- const a = o.presets[k];
40986
- const b = i.presets[k];
42368
+ const a = presetOf(o, k, mode);
42369
+ const b = presetOf(i, k, mode);
40987
42370
  if (!a) continue;
40988
42371
  if (k === "nav" && mobile) continue;
40989
42372
  if ((k === "h1" || k === "h2" || k === "h3") && a.case === "uppercase" && a.size < 16) continue;
@@ -41128,12 +42511,12 @@ function matchSections(o, i) {
41128
42511
  });
41129
42512
  o.forEach((s2, k) => {
41130
42513
  if (out[k] !== void 0) return;
41131
- const A = words2(`${s2.heading?.text ?? ""} ${s2.words ?? s2.text}`);
42514
+ const A = words3(`${s2.heading?.text ?? ""} ${s2.words ?? s2.text}`);
41132
42515
  if (A.size < 4) return;
41133
42516
  let best = -1;
41134
42517
  let score = 0.6;
41135
42518
  i.forEach((t, n) => {
41136
- const B = words2(`${t.heading?.text ?? ""} ${t.words ?? t.text}`);
42519
+ const B = words3(`${t.heading?.text ?? ""} ${t.words ?? t.text}`);
41137
42520
  let hit = 0;
41138
42521
  for (const w of A) if (B.has(w)) hit++;
41139
42522
  if (hit / A.size > score) {
@@ -41161,7 +42544,10 @@ function matchSections(o, i) {
41161
42544
  });
41162
42545
  return out;
41163
42546
  }
41164
- function sectionChecks(a, b, tol, o, i) {
42547
+ var columnsOf2 = (s2, mode) => mode === "corrected" ? s2.columnsOnScreen ?? s2.columns : s2.columns;
42548
+ function sectionChecks(a0, b0, tol, o, i, mode = "corrected") {
42549
+ const a = { ...a0, columns: columnsOf2(a0, mode) };
42550
+ const b = { ...b0, columns: columnsOf2(b0, mode) };
41165
42551
  const out = [];
41166
42552
  const add = (c) => out.push({ area: "section", ...c });
41167
42553
  if (a.background && b.background && !a.backgroundImage) {
@@ -41298,56 +42684,159 @@ function withVisual(p, visual) {
41298
42684
  const base = p.exact ?? structure;
41299
42685
  const v = Math.max(0, Math.min(1, visual));
41300
42686
  const exact = (1 - VISUAL_WEIGHT) * base + VISUAL_WEIGHT * 100 * v;
42687
+ const strictExact = p.strict ? (1 - VISUAL_WEIGHT) * p.strict.checks + VISUAL_WEIGHT * 100 * v : void 0;
41301
42688
  return {
41302
42689
  ...p,
41303
42690
  structure,
41304
42691
  visual: Math.round(v * 1e3) / 1e3,
41305
42692
  exact,
41306
- score: Math.round(exact)
41307
- };
42693
+ score: Math.round(exact),
42694
+ ...p.strict && strictExact !== void 0 ? { strict: { ...p.strict, exact: strictExact, score: Math.round(strictExact) } } : {}
42695
+ };
42696
+ }
42697
+ var pictureOnly = (s2) => !s2.heading && (s2.text ?? "").replace(/\s+/g, "").length < 3 && Boolean(s2.image || s2.backgroundImage);
42698
+ function pictureHost(k, match, o, i) {
42699
+ let pk = k - 1;
42700
+ while (pk >= 0 && match[pk] === void 0) pk--;
42701
+ let nk = k + 1;
42702
+ while (nk < match.length && match[nk] === void 0) nk++;
42703
+ const lo = pk >= 0 ? match[pk] : 0;
42704
+ const hi = nk < match.length ? match[nk] : i.sections.length - 1;
42705
+ const at = (o.sections[k]?.top ?? 0) / Math.max(1, o.height);
42706
+ let best;
42707
+ let gap = Number.POSITIVE_INFINITY;
42708
+ for (let n = Math.min(lo, hi); n <= Math.max(lo, hi); n++) {
42709
+ const t = i.sections[n];
42710
+ if (!t || !(t.image || t.backgroundImage)) continue;
42711
+ const d = Math.abs(t.top / Math.max(1, i.height) - at);
42712
+ if (d < gap) {
42713
+ gap = d;
42714
+ best = n;
42715
+ }
42716
+ }
42717
+ return best;
41308
42718
  }
41309
- function compareLooks(original, imported, path2, opts = {}) {
41310
- const tol = opts.sizeTolerance ?? 0.12;
41311
- const checks = [...typeChecks(original, imported, tol), ...layoutChecks(original, imported)];
42719
+ function comparePage(original, imported, tol, mode, notes) {
42720
+ const checks = [
42721
+ ...typeChecks(original, imported, tol, mode),
42722
+ ...layoutChecks(original, imported)
42723
+ ];
42724
+ if (notes && original.viewport >= 700 && !original.presets.nav !== !presetOf(original, "nav", mode))
42725
+ notes.push(`The original's navigation sits in a header themed "brand": read as navigation.`);
42726
+ if (notes && !imported.presets.nav && presetOf(imported, "nav", mode) && original.viewport >= 700)
42727
+ notes.push(
42728
+ `The new site's navigation sits in a header themed "brand" (os-theme-brand): read as navigation, not as a logo.`
42729
+ );
41312
42730
  const match = matchSections(original.sections, imported.sections);
42731
+ const phone = original.viewport < 700;
41313
42732
  const sections = original.sections.map((s2, k) => {
41314
42733
  const j = match[k];
41315
42734
  const other = j === void 0 ? void 0 : imported.sections[j];
41316
- const c = other ? sectionChecks(s2, other, tol, original, imported) : [
41317
- {
41318
- area: "section",
41319
- what: "Present",
41320
- original: s2.heading?.text ?? s2.text.slice(0, 40),
41321
- imported: "no matching section",
41322
- ok: false,
41323
- fix: "Bring this section across (or check it was merged into another).",
41324
- weight: 4
42735
+ let c;
42736
+ let shared;
42737
+ if (other) {
42738
+ c = sectionChecks(s2, other, tol, original, imported, mode);
42739
+ if (notes && mode === "corrected") {
42740
+ const was = [s2.columns, other.columns];
42741
+ const now = [columnsOf2(s2, mode), columnsOf2(other, mode)];
42742
+ if (was[0] !== was[1] !== (now[0] !== now[1]))
42743
+ notes.push(
42744
+ `Section ${k + 1}: columns counted on screen only (${was[0]} \u2192 ${was[1]} counted as ${now[0]} \u2192 ${now[1]}; what's off screen or inside one slideshow or map isn't a column).`
42745
+ );
41325
42746
  }
41326
- ];
42747
+ } else if (mode === "corrected" && phone && pictureOnly(s2)) {
42748
+ shared = pictureHost(k, match, original, imported);
42749
+ c = [];
42750
+ } else c = [];
42751
+ if (shared !== void 0) {
42752
+ const host = imported.sections[shared];
42753
+ notes?.push(
42754
+ `Section ${k + 1} (a picture alone on a phone) is the picture of section ${shared + 1}, which holds it with its words.`
42755
+ );
42756
+ c = [
42757
+ {
42758
+ area: "section",
42759
+ what: "Present",
42760
+ original: "(a picture)",
42761
+ imported: `with its words in section ${shared + 1}`,
42762
+ ok: true,
42763
+ weight: 4
42764
+ }
42765
+ ];
42766
+ const sa = s2.image?.aspect;
42767
+ const sb = host.image?.aspect;
42768
+ if (sa && sb) {
42769
+ const r = sb / sa;
42770
+ const same = r > 0.85 && r < 1.18;
42771
+ c.push({
42772
+ area: "section",
42773
+ what: "Image crop",
42774
+ original: `${sa}:1`,
42775
+ imported: `${sb}:1`,
42776
+ ok: same,
42777
+ ...same ? {} : { fix: "Use the original's image proportions (the block's aspect ratio)." },
42778
+ weight: 1
42779
+ });
42780
+ }
42781
+ } else if (!other)
42782
+ c = [
42783
+ {
42784
+ area: "section",
42785
+ what: "Present",
42786
+ original: s2.heading?.text ?? s2.text.slice(0, 40),
42787
+ imported: "no matching section",
42788
+ ok: false,
42789
+ fix: "Bring this section across (or check it was merged into another).",
42790
+ weight: 4
42791
+ }
42792
+ ];
42793
+ const at = j ?? shared;
41327
42794
  return {
41328
42795
  index: k,
41329
42796
  ...s2.heading ? { heading: s2.heading.text } : {},
41330
- ...j !== void 0 ? { importedIndex: j } : {},
42797
+ ...at !== void 0 ? { importedIndex: at } : {},
41331
42798
  checks: c,
41332
42799
  score: scoreOf(c)
41333
42800
  };
41334
42801
  });
41335
42802
  const all = [...checks, ...sections.flatMap((s2) => s2.checks)];
42803
+ return { checks, sections, exact: exactScore(all) };
42804
+ }
42805
+ function compareLooks(original, imported, path2, opts = {}) {
42806
+ const tol = opts.sizeTolerance ?? 0.12;
42807
+ const notes = [];
42808
+ const page = comparePage(original, imported, tol, "corrected", notes);
42809
+ const strict = comparePage(original, imported, tol, "strict");
41336
42810
  return {
41337
42811
  path: path2,
41338
42812
  original: original.url,
41339
42813
  imported: imported.url,
41340
42814
  viewport: original.viewport,
41341
- score: scoreOf(all),
41342
- structure: scoreOf(all),
41343
- exact: exactScore(all),
41344
- checks,
41345
- sections
42815
+ score: Math.round(page.exact),
42816
+ structure: Math.round(page.exact),
42817
+ exact: page.exact,
42818
+ checks: page.checks,
42819
+ sections: page.sections,
42820
+ strict: {
42821
+ score: Math.round(strict.exact),
42822
+ exact: strict.exact,
42823
+ structure: Math.round(strict.exact),
42824
+ checks: strict.exact
42825
+ },
42826
+ ...notes.length ? { corrections: notes } : {}
41346
42827
  };
41347
42828
  }
41348
42829
  function fidelityReport(pages) {
41349
- const exact = pages.length ? pages.reduce((n, p) => n + (p.exact ?? p.score), 0) / pages.length : 100;
41350
- return { score: Math.round(exact), exact: Math.round(exact * 100) / 100, pages };
42830
+ const mean = (f) => pages.length ? pages.reduce((n, p) => n + f(p), 0) / pages.length : 100;
42831
+ const exact = mean((p) => p.exact ?? p.score);
42832
+ const strict = mean((p) => p.strict?.exact ?? p.exact ?? p.score);
42833
+ return {
42834
+ score: Math.round(exact),
42835
+ exact: Math.round(exact * 100) / 100,
42836
+ strict: { score: Math.round(strict), exact: Math.round(strict * 100) / 100 },
42837
+ scorer: SCORER,
42838
+ pages
42839
+ };
41351
42840
  }
41352
42841
  var cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\n/g, " ");
41353
42842
  function fidelityMarkdown(report, opts = {}) {
@@ -41355,7 +42844,11 @@ function fidelityMarkdown(report, opts = {}) {
41355
42844
  "# Visual fidelity",
41356
42845
  "",
41357
42846
  `Overall match: **${report.score}/100** \u2014 fonts, type sizes, spacing and each section measured in a browser on both sites (75%), and how alike the pages look as pictures (25%). How it's scored: docs/qa/import-fidelity.md.`,
41358
- ""
42847
+ "",
42848
+ ...report.strict ? [
42849
+ `Scored two ways, side by side: **${report.score}/100 corrected**, ${report.strict.score}/100 by the strict v3 scorer. The corrected score reads three things the strict one gets wrong (navigation in a header themed "brand", off-screen slides counted as columns, a picture-only band on a phone); the pages below list where it did.`,
42850
+ ""
42851
+ ] : []
41359
42852
  ];
41360
42853
  if (opts.tuning) {
41361
42854
  lines.push(
@@ -41370,7 +42863,12 @@ function fidelityMarkdown(report, opts = {}) {
41370
42863
  );
41371
42864
  }
41372
42865
  for (const p of report.pages) {
41373
- lines.push(`## ${p.path} at ${p.viewport}px \u2014 ${p.score}/100`, "");
42866
+ lines.push(
42867
+ `## ${p.path} at ${p.viewport}px \u2014 ${p.score}/100${p.strict && p.strict.score !== p.score ? ` (strict v3: ${p.strict.score}/100)` : ""}`,
42868
+ ""
42869
+ );
42870
+ if (p.corrections?.length)
42871
+ lines.push("Corrected scoring on this page:", "", ...p.corrections.map((c) => `- ${c}`), "");
41374
42872
  if (p.visual !== void 0)
41375
42873
  lines.push(
41376
42874
  `Checks ${p.structure ?? p.score}/100 \xB7 looks alike ${Math.round(p.visual * 100)}/100 (screenshots compared).`,
@@ -41657,9 +43155,9 @@ function htmlToRichText(html, rewrite2 = (h) => h) {
41657
43155
  )
41658
43156
  });
41659
43157
  }
41660
- const counts = /* @__PURE__ */ new Map();
41661
- for (const a of ctx.aligns) counts.set(a, (counts.get(a) ?? 0) + 1);
41662
- const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
43158
+ const counts2 = /* @__PURE__ */ new Map();
43159
+ for (const a of ctx.aligns) counts2.set(a, (counts2.get(a) ?? 0) + 1);
43160
+ const top = [...counts2.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
41663
43161
  return {
41664
43162
  doc,
41665
43163
  ...top === "center" ? { align: "center" } : top === "right" ? { align: "end" } : {}
@@ -42058,13 +43556,19 @@ async function mapBlock(ctx, sq, opts = {}) {
42058
43556
  const href = sq.href ?? sq.card?.button?.href;
42059
43557
  const link2 = href ? await linkFor(ctx, href) : void 0;
42060
43558
  return one(
42061
- makeBlock(ctx, "image", {
42062
- image: image2,
42063
- aspect: "original",
42064
- lightbox: false,
42065
- ...caption ? { caption: clip2(caption, 200) } : {},
42066
- ...link2 ? { link: link2 } : {}
42067
- })
43559
+ makeBlock(
43560
+ ctx,
43561
+ "image",
43562
+ {
43563
+ image: image2,
43564
+ aspect: "original",
43565
+ lightbox: false,
43566
+ ...caption ? { caption: clip2(caption, 200) } : {},
43567
+ ...link2 ? { link: link2 } : {},
43568
+ ...sq.natural ? { size: "original" } : {}
43569
+ },
43570
+ sq.natural ? { style: { align: { d: "start" } } } : {}
43571
+ )
42068
43572
  );
42069
43573
  }
42070
43574
  case "gallery": {
@@ -42091,7 +43595,8 @@ async function mapBlock(ctx, sq, opts = {}) {
42091
43595
  "gallery",
42092
43596
  {
42093
43597
  images,
42094
- aspect: sq.layout === "grid" ? "1:1" : "original",
43598
+ aspect: sq.layout === "grid" || sq.fit === "contain" ? "1:1" : "original",
43599
+ ...sq.fit === "contain" ? { fit: "contain" } : {},
42095
43600
  columns: images.length >= 4 && sq.layout === "grid" ? "4" : "3",
42096
43601
  lightbox: true,
42097
43602
  showCaptions: images.some((i) => i.caption)
@@ -42121,7 +43626,7 @@ async function mapBlock(ctx, sq, opts = {}) {
42121
43626
  ctx.notices.push({
42122
43627
  kind: "form",
42123
43628
  where: ctx.where,
42124
- message: ctx.platform && ctx.platform !== "Squarespace" ? `Newsletter signup recreated. Subscribers are not moved: export your list from ${ctx.platform} (or your email service) and import it in the Hub.` : "Newsletter signup recreated. Squarespace Email Campaigns / Mailchimp subscribers are not moved: export your list from Squarespace and import it in the Hub."
43629
+ message: ctx.platform && /^(?:GitHub Pages|Jekyll|Hugo|Eleventy|the original site)/.test(ctx.platform) ? "Newsletter signup recreated. The old site didn't keep a subscriber list itself: if its form sent sign-ups to an email service, export the list there and import it in the Hub." : ctx.platform && ctx.platform !== "Squarespace" ? `Newsletter signup recreated. Subscribers are not moved: export your list from ${ctx.platform} (or your email service) and import it in the Hub.` : "Newsletter signup recreated. Squarespace Email Campaigns / Mailchimp subscribers are not moved: export your list from Squarespace and import it in the Hub."
42125
43630
  });
42126
43631
  return one(
42127
43632
  makeBlock(
@@ -42130,6 +43635,7 @@ async function mapBlock(ctx, sq, opts = {}) {
42130
43635
  {
42131
43636
  ...sq.heading ? { heading: clip2(sq.heading, 80) } : {},
42132
43637
  ...sq.description ? { description: clip2(sq.description, 240) } : {},
43638
+ ...sq.askName ? { askName: true } : {},
42133
43639
  ...sq.buttonLabel ? { buttonLabel: clip2(sq.buttonLabel, 30) } : {},
42134
43640
  ...sq.placeholder ? { placeholder: clip2(sq.placeholder, 60) } : {},
42135
43641
  ...sq.success ? { successMessage: clip2(sq.success, 200) } : {}
@@ -42298,7 +43804,12 @@ async function mapBlock(ctx, sq, opts = {}) {
42298
43804
  makeBlock(
42299
43805
  ctx,
42300
43806
  "product",
42301
- { product: { collection: item2.collection, items: [item2.slug] } },
43807
+ {
43808
+ product: { collection: item2.collection, items: [item2.slug] },
43809
+ ...sq.show?.excerpt === false ? { showDescription: false } : {},
43810
+ ...sq.show?.price === false ? { showPrice: false } : {},
43811
+ ...sq.show?.buy === false ? { showBuy: false, imageAspect: "1:1" } : {}
43812
+ },
42302
43813
  { variant: "card" }
42303
43814
  )
42304
43815
  );
@@ -42543,7 +44054,8 @@ function stackSection(blocks2, spans, style, width, align) {
42543
44054
  layout: {
42544
44055
  mode: "stack",
42545
44056
  columns: cols > 1 ? { d: spans, m: [12] } : { d: [12] },
42546
- ...cols > 1 ? { align: "center" } : {},
44057
+ // Words beside a picture sit centred on it; columns of words start at the top together.
44058
+ ...cols > 1 && blocks2.some(({ block: block4 }) => ["image", "gallery", "video", "map", "embed"].includes(block4.type)) ? { align: "center" } : {},
42547
44059
  ...width && width !== "content" ? { width } : {}
42548
44060
  },
42549
44061
  style,
@@ -42553,6 +44065,75 @@ function stackSection(blocks2, spans, style, width, align) {
42553
44065
  if (isStarter) section.preset = starterRef({ columns: spans, ...align ? { align } : {} });
42554
44066
  return section;
42555
44067
  }
44068
+ var headingLevelOf = (b) => b.type === "heading" ? Number(String(b.fields.level ?? "h2").slice(1)) || 2 : void 0;
44069
+ var textOfNodes = (nodes) => JSON.stringify(nodes ?? "").length;
44070
+ function splitAtHeadings(blocks2) {
44071
+ const count2 = (level2) => {
44072
+ let n = 0;
44073
+ for (const { block: block4 } of blocks2) {
44074
+ if (headingLevelOf(block4) === level2) n++;
44075
+ if (block4.type === "text") {
44076
+ const doc = block4.fields.content;
44077
+ for (const node2 of doc?.content ?? [])
44078
+ if (node2.type === "heading" && node2.attrs?.level === level2) n++;
44079
+ }
44080
+ }
44081
+ return n;
44082
+ };
44083
+ const size2 = blocks2.reduce(
44084
+ (n, { block: block4 }) => n + (block4.type === "text" ? textOfNodes(block4.fields.content) : textOfNodes(block4.fields)),
44085
+ 0
44086
+ );
44087
+ const media = blocks2.filter(
44088
+ ({ block: block4 }) => block4.type === "image" && block4.fields.size === "original"
44089
+ ).length;
44090
+ const rules = blocks2.filter(({ block: block4 }) => block4.type === "line").length;
44091
+ if (size2 < 2400 && media < 2 && rules < 1) return [blocks2];
44092
+ const level = count2(2) >= 2 ? 2 : count2(3) >= 3 ? 3 : void 0;
44093
+ if (!level) return [blocks2];
44094
+ const out = [[]];
44095
+ const cur = () => out[out.length - 1];
44096
+ const start = () => {
44097
+ if (!cur().length) return;
44098
+ const last2 = cur().at(-1);
44099
+ const carry = last2.block.type === "image" && last2.block.fields.size === "original";
44100
+ if (!carry) out.push([]);
44101
+ else if (cur().length > 1) {
44102
+ cur().pop();
44103
+ out.push([last2]);
44104
+ }
44105
+ };
44106
+ for (const p of blocks2) {
44107
+ if (headingLevelOf(p.block) === level) {
44108
+ start();
44109
+ cur().push(p);
44110
+ continue;
44111
+ }
44112
+ if (p.block.type !== "text") {
44113
+ cur().push(p);
44114
+ continue;
44115
+ }
44116
+ const doc = p.block.fields.content;
44117
+ const parts = [[]];
44118
+ for (const node2 of doc.content ?? []) {
44119
+ if (node2.type === "heading" && node2.attrs?.level === level && parts.at(-1).length)
44120
+ parts.push([]);
44121
+ parts.at(-1).push(node2);
44122
+ }
44123
+ parts.forEach((nodes, i) => {
44124
+ if (i > 0 || nodes[0]?.type === "heading" && nodes[0].attrs?.level === level) start();
44125
+ cur().push({
44126
+ ...p,
44127
+ block: {
44128
+ ...p.block,
44129
+ ...i > 0 ? { id: newId("blk") } : {},
44130
+ fields: { ...p.block.fields, content: { ...doc, content: nodes } }
44131
+ }
44132
+ });
44133
+ });
44134
+ }
44135
+ return out.filter((c) => c.length);
44136
+ }
42556
44137
  async function mapBlocks(ctx, blocks2, inGrid = false) {
42557
44138
  const out = [];
42558
44139
  for (const b of blocks2) out.push(...await mapBlock(ctx, b, { inGrid }));
@@ -42651,6 +44232,15 @@ async function mapSectionParts(ctx, sq) {
42651
44232
  }
42652
44233
  return [section];
42653
44234
  }
44235
+ if (sq.together && sq.layout.rows.length > 1) {
44236
+ const filled2 = sq.layout.rows.map((r) => r.columns.filter((c) => c.blocks.length));
44237
+ const multi = filled2.some((cols) => cols.length > 1);
44238
+ const shapes = new Set(filled2.map((cols) => cols.map((c) => c.span).join("-")));
44239
+ if (multi && shapes.size > 1) {
44240
+ const grid = await rowsAsGrid(ctx, sq.layout.rows, style, width);
44241
+ if (grid) return [grid];
44242
+ }
44243
+ }
42654
44244
  const out = [];
42655
44245
  let run2;
42656
44246
  const flush = () => {
@@ -42662,20 +44252,22 @@ async function mapSectionParts(ctx, sq) {
42662
44252
  run2 = void 0;
42663
44253
  return;
42664
44254
  }
42665
- out.push(
42666
- stackSection(
42667
- run2.blocks,
42668
- run2.spans,
42669
- out.length ? {
42670
- ...style,
42671
- // A photo stays with the first part; a plain color continues behind every part.
42672
- background: style.background?.type === "color" ? style.background : void 0,
42673
- minHeight: void 0
42674
- } : style,
42675
- width,
42676
- align
42677
- )
42678
- );
44255
+ const parts = run2.spans.length === 1 ? splitAtHeadings(run2.blocks) : [run2.blocks];
44256
+ for (const part of parts)
44257
+ out.push(
44258
+ stackSection(
44259
+ part,
44260
+ run2.spans,
44261
+ out.length ? {
44262
+ ...style,
44263
+ // A photo stays with the first part; a plain color continues behind every part.
44264
+ background: style.background?.type === "color" ? style.background : void 0,
44265
+ minHeight: void 0
44266
+ } : style,
44267
+ width,
44268
+ align
44269
+ )
44270
+ );
42679
44271
  run2 = void 0;
42680
44272
  };
42681
44273
  for (const row of sq.layout.rows) {
@@ -42704,6 +44296,11 @@ async function mapSectionParts(ctx, sq) {
42704
44296
  flush();
42705
44297
  if (!out.length && bg) out.push(stackSection([], [12], style, width, align));
42706
44298
  if (sq.role && out.length === 1) applyNearestPreset(out[0], sq.role, !sq.theme);
44299
+ for (const s2 of out)
44300
+ if (s2.layout.mode === "stack" && s2.blocks.length === 1 && s2.blocks[0]?.type === "gallery" && s2.blocks[0].fields.fit === "contain" && (!s2.layout.width || s2.layout.width === "wide" || s2.layout.width === "full")) {
44301
+ s2.layout.width = "narrow";
44302
+ delete s2.preset;
44303
+ }
42707
44304
  for (const s2 of out)
42708
44305
  for (const k of Object.keys(s2.style))
42709
44306
  if (s2.style[k] === void 0) delete s2.style[k];
@@ -42852,6 +44449,57 @@ function applyNearestPreset(section, role, themeUnknown) {
42852
44449
  if (model.style?.align && !b.style?.align) b.style = { ...b.style, align: model.style.align };
42853
44450
  }
42854
44451
  }
44452
+ async function rowsAsGrid(ctx, rows, style, width) {
44453
+ const ROW_PX = 24;
44454
+ const contentPx = width === "wide" || width === "full" ? 1440 : width === "narrow" ? 720 : 1280;
44455
+ const blocks2 = [];
44456
+ const items = {};
44457
+ let y = 1;
44458
+ for (const row of rows) {
44459
+ const cols = row.columns.filter((c) => c.blocks.length);
44460
+ if (!cols.length) continue;
44461
+ const total = row.columns.reduce((n, c) => n + c.span, 0) || 12;
44462
+ let x = 1;
44463
+ let rowH = 1;
44464
+ for (const col of row.columns) {
44465
+ const w = Math.max(
44466
+ 1,
44467
+ Math.min(DESKTOP_COLS - x + 1, Math.round(col.span / Math.max(12, total) * DESKTOP_COLS))
44468
+ );
44469
+ let cy = y;
44470
+ for (const b of await mapBlocks(ctx, col.blocks, true)) {
44471
+ const img2 = b.type === "image" ? b.fields.image : void 0;
44472
+ const ratio = img2?.width && img2?.height ? img2.width / img2.height : void 0;
44473
+ const h = b.type === "image" ? Math.max(4, Math.round(w / DESKTOP_COLS * contentPx / (ratio ?? 1.5) / ROW_PX)) : 1;
44474
+ blocks2.push(b);
44475
+ items[b.id] = { d: { x, y: cy, w, h } };
44476
+ cy += h;
44477
+ }
44478
+ rowH = Math.max(rowH, cy - y);
44479
+ x = Math.min(DESKTOP_COLS, x + w);
44480
+ }
44481
+ y += rowH;
44482
+ }
44483
+ if (blocks2.length < 2) return void 0;
44484
+ const section = {
44485
+ id: newId("sec"),
44486
+ layout: {
44487
+ mode: "grid",
44488
+ rows: { d: y - 1 },
44489
+ items,
44490
+ ...width && width !== "content" ? { width } : {}
44491
+ },
44492
+ style,
44493
+ blocks: blocks2
44494
+ };
44495
+ const mobile = autoMobileLayout(section, { reset: true });
44496
+ if (mobile && section.layout.mode === "grid") {
44497
+ section.layout.items = mobile;
44498
+ const mRows = Math.max(1, ...Object.values(mobile).map((i) => i.m ? i.m.y + i.m.h - 1 : 1));
44499
+ section.layout.rows = { d: y - 1, m: mRows };
44500
+ }
44501
+ return section;
44502
+ }
42855
44503
 
42856
44504
  // ../import/src/map/site.ts
42857
44505
  var clip3 = (s2, n) => s2.length > n ? `${s2.slice(0, n - 1).trimEnd()}\u2026` : s2;
@@ -43151,6 +44799,12 @@ function headerFor(site2, siteDark, brand) {
43151
44799
  }
43152
44800
  return {
43153
44801
  variant,
44802
+ ...t.logoHeight ? {
44803
+ logoHeight: {
44804
+ d: Math.round(t.logoHeight.d),
44805
+ ...t.logoHeight.m ? { m: Math.round(t.logoHeight.m) } : {}
44806
+ }
44807
+ } : {},
43154
44808
  sticky: t.headerFixed === true,
43155
44809
  transparentOnHero: t.headerTransparent === true,
43156
44810
  mobileMenu: "fullscreen",
@@ -43393,6 +45047,18 @@ async function buildSite(sq, opts) {
43393
45047
  blocks: text7 ? [text7] : []
43394
45048
  };
43395
45049
  }
45050
+ if (footer) splitFooterLine(footer, sq.theme.footerColumns, sq.theme.footerLabels === true);
45051
+ const footBg = sq.theme.footerBackground;
45052
+ if (footer && footBg && /^#[0-9a-f]{6}$/i.test(footBg) && !footer.style.background) {
45053
+ const dark = relativeLuminance(footBg) < 0.18;
45054
+ if (dark || relativeLuminance(footBg) < 0.97) {
45055
+ footer.style = {
45056
+ ...footer.style,
45057
+ colorTheme: dark ? "dark" : footer.style.colorTheme ?? "light",
45058
+ background: { type: "color", color: footBg }
45059
+ };
45060
+ }
45061
+ }
43396
45062
  const logo = sq.logo ? await opts.media.image(sq.logo, "logo") : void 0;
43397
45063
  const favicon = sq.favicon && /^https?:\/\//.test(sq.favicon) && !/\.ico(?:[?#]|$)/i.test(sq.favicon) ? await opts.media.image({ url: sq.favicon, alt: "" }, "favicon") : void 0;
43398
45064
  const homeSeoImage = sq.pages.find((p) => p.path === "/")?.seo.image;
@@ -43485,6 +45151,35 @@ async function buildSite(sq, opts) {
43485
45151
  linkFixes
43486
45152
  };
43487
45153
  }
45154
+ function splitFooterLine(footer, columns, labels = false) {
45155
+ if (!columns || columns < 2 || columns > 4) return;
45156
+ if (footer.layout.mode !== "stack" || footer.layout.columns.d.length !== 1) return;
45157
+ const texts = footer.blocks.filter((b) => b.type === "text");
45158
+ if (footer.blocks.length !== 1 || texts.length !== 1) return;
45159
+ const block4 = texts[0];
45160
+ const doc = block4.fields.content;
45161
+ const paras = doc.content ?? [];
45162
+ if (paras.length !== columns || paras.some((p) => p.type !== "paragraph")) return;
45163
+ const span = Math.floor(12 / columns);
45164
+ const spans = paras.map((_, i) => i === paras.length - 1 ? 12 - span * (columns - 1) : span);
45165
+ const aligns = paras.map(
45166
+ (_, i) => i === 0 ? "start" : i === paras.length - 1 ? "end" : "center"
45167
+ );
45168
+ footer.blocks = paras.map((p, i) => ({
45169
+ ...block4,
45170
+ id: i === 0 ? block4.id : newId("blk"),
45171
+ column: i,
45172
+ fields: {
45173
+ ...block4.fields,
45174
+ content: {
45175
+ ...doc,
45176
+ content: [labels ? { ...p, attrs: { ...p.attrs ?? {}, preset: "eyebrow" } } : p]
45177
+ }
45178
+ },
45179
+ style: { ...block4.style ?? {}, align: { d: aligns[i], m: "start" } }
45180
+ }));
45181
+ footer.layout = { ...footer.layout, columns: { d: spans, m: [12] }, align: "center" };
45182
+ }
43488
45183
 
43489
45184
  // ../import/src/media.ts
43490
45185
  import { createHash as createHash2 } from "crypto";
@@ -43714,7 +45409,7 @@ The same rules power Studio's built-in Design check. Follow them so pages look d
43714
45409
  6. **Two typefaces maximum.** Change the pairing in Site Styles rather than per block.
43715
45410
  7. **Never invent facts, testimonials, prices or credentials.** Ask the owner.
43716
45411
  8. **Alt text describes the image's purpose,** not just its contents; decorative images are marked decorative.
43717
- 9. **Check mobile.** After any grid change, review the mobile placement.
45412
+ 9. **Great on phones, every time.** Most visitors use a phone. Before any publish run phone_check (it measures every page as a phone at 360 and 390 px where a browser is available), then fix_for_phones (phone-only fixes; desktop stays as designed), until there are zero phone errors and warnings. The bar: the hero's headline and main button on the first screen, no sideways scrolling or overlapping text, reading text 16px or larger, tap targets 44px or larger, text never squeezed into narrow columns, blocks in reading order, readable text over photos, no photo taller than the screen.
43718
45413
  10. **Prefer fewer, stronger sections** over many thin ones.
43719
45414
  11. **Custom components for bespoke moments only.** When no preset can express a distinctive section (an editorial hero, a layered collage, a horizontal story), use a custom component (list_components \u2192 create_component or insert_component from the Designer components library). Expose every word, image and link as a field (owners edit fields, never code), style only with theme tokens (var(--os-color-\u2026), var(--os-font-\u2026), var(--os-space-\u2026)), use the 767/768/1199/1200 breakpoints, honour reduced motion, and look at it with preview_component before showing the owner.
43720
45415
 
@@ -43733,12 +45428,12 @@ var ITERATION_GUIDANCE = `# Design iteration loop
43733
45428
  Repeat until the page is right, keeping each pass small:
43734
45429
 
43735
45430
  1. **Inspect** \u2014 get_page (detail "outline") for ids and copy; get_styles for the current look.
43736
- 2. **Check** \u2014 design_check on the page. Fix errors first (apply_fix for autofixable issues), then warnings that matter.
45431
+ 2. **Check** \u2014 design_check and phone_check on the page. Fix errors first (apply_fix for autofixable issues, fix_for_phones for phone issues), then warnings that matter.
43737
45432
  3. **Edit** \u2014 one intent per pass: replace demo copy (update_block), swap a section for a better preset (insert_section + delete_section), adjust a section's color theme for rhythm (update_section colorTheme), or restyle globally (set_styles / apply_template_look).
43738
45433
  4. **Look** \u2014 screenshot the page (or just the section you changed) at desktop and mobile; use render_page when screenshots are unavailable.
43739
45434
  5. **Judge** \u2014 hierarchy (one H1, clear headline), one primary action per screen, rhythm of themes, copy within preset ranges, images consistent, nothing cramped on mobile. Then go back to 2.
43740
45435
 
43741
- Finish with design_check showing zero errors and get_draft_diff to summarise the work for the owner.
45436
+ Finish with design_check showing zero errors, phone_check showing zero phone errors and warnings, and get_draft_diff to summarise the work for the owner.
43742
45437
  `;
43743
45438
 
43744
45439
  // ../mcp-tools/src/images.ts
@@ -44158,7 +45853,7 @@ function renderPreviewHtml(doc, pageId, opts = {}) {
44158
45853
  const html = renderPageHtml({
44159
45854
  doc,
44160
45855
  pageId: target,
44161
- mode: "preview",
45856
+ mode: opts.production ? "production" : "preview",
44162
45857
  basePath: opts.basePath ?? "",
44163
45858
  behaviorsUrl: opts.behaviorsUrl,
44164
45859
  extraHead: blocksStyleTag()
@@ -45316,10 +47011,10 @@ var listSectionPresets = defineTool({
45316
47011
  let list5 = presetCatalog.slice();
45317
47012
  if (input.category) list5 = list5.filter((p) => p.category === input.category);
45318
47013
  if (input.query) {
45319
- const words3 = input.query.toLowerCase().split(/\s+/).filter(Boolean);
47014
+ const words4 = input.query.toLowerCase().split(/\s+/).filter(Boolean);
45320
47015
  list5 = list5.filter((p) => {
45321
47016
  const hay = `${p.id} ${p.label} ${p.description} ${p.tags.join(" ")} ${p.ai.whenToUse}`.toLowerCase();
45322
- return words3.every((w) => hay.includes(w));
47017
+ return words4.every((w) => hay.includes(w));
45323
47018
  });
45324
47019
  }
45325
47020
  const full = input.detail === "full";
@@ -45678,12 +47373,12 @@ var designCheckTool = defineTool({
45678
47373
  const issues = runChecks(doc, pageId, input.checks ?? CHECKS, ctx.now()).filter(
45679
47374
  (i) => RANK[i.severity] <= min
45680
47375
  );
45681
- const counts = { error: 0, warning: 0, info: 0 };
45682
- for (const i of issues) counts[i.severity]++;
47376
+ const counts2 = { error: 0, warning: 0, info: 0 };
47377
+ for (const i of issues) counts2[i.severity]++;
45683
47378
  return {
45684
47379
  data: {
45685
47380
  ...pageId ? { page: pageId } : {},
45686
- counts,
47381
+ counts: counts2,
45687
47382
  issues: issues.map((i) => ({
45688
47383
  id: i.id,
45689
47384
  rule: i.rule,
@@ -45744,9 +47439,9 @@ var applyFix = defineTool({
45744
47439
  });
45745
47440
  }
45746
47441
  const remaining = runChecks(doc, pageId, CHECKS, ctx.now());
45747
- const counts = { error: 0, warning: 0, info: 0 };
45748
- for (const i of remaining) counts[i.severity]++;
45749
- return { data: { applied, skipped, remaining: counts } };
47442
+ const counts2 = { error: 0, warning: 0, info: 0 };
47443
+ for (const i of remaining) counts2[i.severity]++;
47444
+ return { data: { applied, skipped, remaining: counts2 } };
45750
47445
  }
45751
47446
  });
45752
47447
  var preview = defineTool({
@@ -46872,6 +48567,143 @@ var pageTools = [
46872
48567
  listPosts
46873
48568
  ];
46874
48569
 
48570
+ // ../mcp-tools/src/tools/phone.ts
48571
+ var PHONE_PAGE_LIMIT = 12;
48572
+ var stripMeta2 = (op2) => {
48573
+ const { id: _i, actor: _a3, ts: _t, ...body } = op2;
48574
+ return body;
48575
+ };
48576
+ async function measure(ctx, siteId, doc, pages, wantRendered) {
48577
+ const now = ctx.now();
48578
+ const statics = phoneCheck(doc, pages.length === 1 ? pages[0]?.id : void 0, {
48579
+ actor: "phone-check",
48580
+ now
48581
+ });
48582
+ const store = ctx.store;
48583
+ if (!wantRendered || !store.capabilities.canMeasurePhone || !store.measurePhone)
48584
+ return {
48585
+ issues: statics,
48586
+ rendered: false,
48587
+ measuredPages: [],
48588
+ ...wantRendered ? {
48589
+ note: store.capabilities.publishNote?.includes("git") ? "Only the rules that don't need a browser ran. For the full phone check install Playwright next to the CLI (npm i -D playwright && npx playwright install chromium) and restart the MCP server." : "Only the rules that don't need a browser ran here; screenshot the page on mobile to look it over."
48590
+ } : {}
48591
+ };
48592
+ const measured = pages.slice(0, PHONE_PAGE_LIMIT);
48593
+ const rendered2 = [];
48594
+ let note;
48595
+ for (const page of measured) {
48596
+ const html = renderPreviewHtml(doc, page.id, { production: true });
48597
+ const results = [];
48598
+ try {
48599
+ for (const width of PHONE_WIDTHS)
48600
+ results.push(await store.measurePhone(siteId, { pageId: page.id, width, html }));
48601
+ } catch (e) {
48602
+ note = `The browser check stopped at \u201C${page.title}\u201D: ${e instanceof Error ? e.message : String(e)}`;
48603
+ break;
48604
+ }
48605
+ rendered2.push(...phoneIssuesFromProbe(doc, page.id, results, { actor: "phone-check", now }));
48606
+ }
48607
+ if (!note && pages.length > measured.length)
48608
+ note = `Measured the first ${measured.length} of ${pages.length} pages; run phone_check with page for the others.`;
48609
+ const seen = /* @__PURE__ */ new Set();
48610
+ const deduped = rendered2.filter((i) => {
48611
+ if (i.page !== "footer") return true;
48612
+ const key2 = `${i.rule}|${i.message}`;
48613
+ if (seen.has(key2)) return false;
48614
+ seen.add(key2);
48615
+ return true;
48616
+ });
48617
+ return {
48618
+ issues: sortIssues([...statics, ...deduped]),
48619
+ rendered: true,
48620
+ measuredPages: measured.map((p) => p.id),
48621
+ ...note ? { note } : {}
48622
+ };
48623
+ }
48624
+ var pagesFor = (doc, page) => page ? [resolvePage(doc, page).page] : Object.values(doc.pages);
48625
+ var describe2 = (i) => ({
48626
+ id: i.id,
48627
+ rule: i.rule,
48628
+ severity: i.severity,
48629
+ ...i.page ? { page: i.page } : {},
48630
+ ...i.section ? { section: i.section } : {},
48631
+ ...i.block ? { block: i.block } : {},
48632
+ message: i.message,
48633
+ fix: i.fix,
48634
+ autofix: i.autofix?.label ?? null
48635
+ });
48636
+ var counts = (issues) => {
48637
+ const c = { error: 0, warning: 0, info: 0 };
48638
+ for (const i of issues) c[i.severity]++;
48639
+ return c;
48640
+ };
48641
+ var phoneCheckTool = defineTool({
48642
+ name: "phone_check",
48643
+ title: "Phone check",
48644
+ description: "Check how the site works on phones: the rules that need no browser (sections not laid out for phones, columns squeezed side by side, padding that empties the screen, heading words too long for a phone line, tall photo sections, fixed widths in custom CSS) plus, where a browser is available, a real phone layout at 360 and 390 px (sideways scrolling, cut-off or overlapping text, text too small, buttons too small to tap, text over photos, the main button below the first screen). Run it before every publish; fix with fix_for_phones. Aim for zero errors and warnings.",
48645
+ input: external_exports.object({
48646
+ site: siteArg,
48647
+ page: external_exports.string().optional().describe("Page id/slug/title (default: every page)."),
48648
+ rendered: external_exports.boolean().optional().describe("Measure in a real browser when available (default true).")
48649
+ }),
48650
+ annotations: { readOnlyHint: true },
48651
+ async handler(ctx, input) {
48652
+ const siteId = await ctx.siteId(input.site);
48653
+ const doc = await ctx.doc(siteId);
48654
+ const r = await measure(ctx, siteId, doc, pagesFor(doc, input.page), input.rendered !== false);
48655
+ const c = counts(r.issues);
48656
+ return {
48657
+ data: {
48658
+ rendered: r.rendered,
48659
+ ...r.note ? { note: r.note } : {},
48660
+ counts: c,
48661
+ issues: r.issues.map(describe2),
48662
+ next: c.error + c.warning === 0 ? "Looks great on phones." : r.issues.some((i) => i.autofix) ? "Run fix_for_phones to fix everything that can be fixed automatically, then phone_check again; handle the rest by editing." : "Edit the content as each fix says, then run phone_check again."
48663
+ }
48664
+ };
48665
+ }
48666
+ });
48667
+ var fixForPhonesTool = defineTool({
48668
+ name: "fix_for_phones",
48669
+ title: "Fix everything for phones",
48670
+ description: "Apply every automatic phone fix in one change (phone-only settings: phone layouts in reading order, words first in heroes, stacked columns, lighter phone padding, shorter photo sections, smaller phone heading sizes, stronger photo overlays). Desktop stays as designed. Re-measures and returns what's left.",
48671
+ input: external_exports.object({
48672
+ site: siteArg,
48673
+ page: external_exports.string().optional().describe("Limit to a page (default: every page).")
48674
+ }),
48675
+ annotations: {},
48676
+ async handler(ctx, input) {
48677
+ const siteId = await ctx.siteId(input.site);
48678
+ let doc = await ctx.doc(siteId);
48679
+ const pages = pagesFor(doc, input.page);
48680
+ const before = await measure(ctx, siteId, doc, pages, true);
48681
+ const r = fixForPhones(doc, {
48682
+ ...input.page ? { pageId: pages[0]?.id } : {},
48683
+ actor: "phone-check",
48684
+ now: ctx.now(),
48685
+ rendered: before.issues
48686
+ });
48687
+ if (r.ops.length) {
48688
+ const applied = await ctx.apply(siteId, r.ops.map(stripMeta2));
48689
+ doc = applied.doc;
48690
+ }
48691
+ const after = await measure(ctx, siteId, doc, pagesFor(doc, input.page), true);
48692
+ const labels = /* @__PURE__ */ new Map();
48693
+ for (const f of r.fixed) labels.set(f.label, (labels.get(f.label) ?? 0) + 1);
48694
+ return {
48695
+ data: {
48696
+ fixed: [...labels].map(([label, n]) => n > 1 ? `${label} (${n}\xD7)` : label),
48697
+ rendered: after.rendered,
48698
+ remaining: counts(after.issues),
48699
+ issues: after.issues.filter((i) => i.severity !== "info").map(describe2),
48700
+ next: after.issues.filter((i) => i.severity !== "info").length === 0 ? "Looks great on phones." : "These need an edit (no automatic fix): follow each fix, then run phone_check again."
48701
+ }
48702
+ };
48703
+ }
48704
+ });
48705
+ var phoneTools = [phoneCheckTool, fixForPhonesTool];
48706
+
46875
48707
  // ../mcp-tools/src/tools/publish.ts
46876
48708
  var getDraftDiff = defineTool({
46877
48709
  name: "get_draft_diff",
@@ -47017,6 +48849,7 @@ var ALL_TOOLS = [
47017
48849
  ...pageTools,
47018
48850
  ...designSystemTools,
47019
48851
  ...designTools,
48852
+ ...phoneTools,
47020
48853
  ...componentTools,
47021
48854
  ...publishTools
47022
48855
  ];
@@ -47808,6 +49641,25 @@ function squarespaceRenditions(u, width, height, canResize = true) {
47808
49641
  ];
47809
49642
  }
47810
49643
 
49644
+ // ../import/src/phone.ts
49645
+ function phoneReady(result) {
49646
+ let fixed;
49647
+ try {
49648
+ fixed = fixForPhones(result.doc, { actor: "import", now: 0 });
49649
+ } catch {
49650
+ return result;
49651
+ }
49652
+ if (!fixed.ops.length) return result;
49653
+ const labels = [...new Set(fixed.fixed.map((f) => f.label.toLowerCase()))];
49654
+ const line = `
49655
+ ## Phones
49656
+
49657
+ Made ${fixed.fixed.length} phone-only adjustment${fixed.fixed.length === 1 ? "" : "s"} so the site reads well on phones (${labels.join(", ")}). The desktop design is unchanged.
49658
+ `;
49659
+ return { ...result, doc: fixed.doc, report: `${result.report.trimEnd()}
49660
+ ${line}` };
49661
+ }
49662
+
47811
49663
  // ../import/src/report.ts
47812
49664
  var NOTICE_TITLES = {
47813
49665
  "unsupported-block": "Blocks that need a look",
@@ -47943,17 +49795,17 @@ function renderReport(r) {
47943
49795
  any = true;
47944
49796
  L2.push(`### ${NOTICE_TITLES[kind]}`);
47945
49797
  L2.push("");
47946
- const counts = /* @__PURE__ */ new Map();
49798
+ const counts2 = /* @__PURE__ */ new Map();
47947
49799
  for (const n of list5) {
47948
- const e = counts.get(n.message) ?? { n: 0, where: /* @__PURE__ */ new Set() };
49800
+ const e = counts2.get(n.message) ?? { n: 0, where: /* @__PURE__ */ new Set() };
47949
49801
  e.n++;
47950
49802
  e.where.add(n.where);
47951
- counts.set(n.message, e);
49803
+ counts2.set(n.message, e);
47952
49804
  }
47953
49805
  let shown = 0;
47954
- for (const [message, e] of counts) {
49806
+ for (const [message, e] of counts2) {
47955
49807
  if (++shown > 15) {
47956
- L2.push(`- \u2026 and ${counts.size - 15} more like these.`);
49808
+ L2.push(`- \u2026 and ${counts2.size - 15} more like these.`);
47957
49809
  break;
47958
49810
  }
47959
49811
  const where2 = [...e.where];
@@ -48346,7 +50198,7 @@ async function crawlWordPress(opts) {
48346
50198
  ...it.excerptHtml ? { caption: it.excerptHtml } : {}
48347
50199
  });
48348
50200
  const entries = [];
48349
- const pathOf = (link2, fallback2) => (link2 ? sitePath(link2, base) : void 0) ?? fallback2;
50201
+ const pathOf2 = (link2, fallback2) => (link2 ? sitePath(link2, base) : void 0) ?? fallback2;
48350
50202
  if (rest) {
48351
50203
  const [pages2, posts] = [
48352
50204
  await listAll("wp/v2/pages", "_embed=1&orderby=menu_order&order=asc"),
@@ -48373,7 +50225,7 @@ async function crawlWordPress(opts) {
48373
50225
  }
48374
50226
  entries.push({
48375
50227
  type,
48376
- path: pathOf(link2, `/${slug}`),
50228
+ path: pathOf2(link2, `/${slug}`),
48377
50229
  slug,
48378
50230
  title: decodeEntities2(rendered(p.title)) || slug || "Untitled",
48379
50231
  html: rendered(p.content),
@@ -48440,7 +50292,7 @@ async function crawlWordPress(opts) {
48440
50292
  });
48441
50293
  }
48442
50294
  if (!rest && !wxr && home) {
48443
- const { crawlGeneric: crawlGeneric2 } = await import("./crawl-TAADG5BR.js");
50295
+ const { crawlGeneric: crawlGeneric2 } = await import("./crawl-WQQDFI7W.js");
48444
50296
  const site2 = await crawlGeneric2({
48445
50297
  url: base,
48446
50298
  fetcher: f,
@@ -48496,6 +50348,9 @@ async function crawlWordPress(opts) {
48496
50348
  }
48497
50349
  const pages = [];
48498
50350
  const builders = /* @__PURE__ */ new Set();
50351
+ const showsTitles = /<h1[^>]*class="[^"]*\b(?:entry-title|page-title|wp-block-post-title)\b/.test(
50352
+ homeHtml
50353
+ );
48499
50354
  for (const e of pageEntries.sort((a, b) => (a.menuOrder ?? 0) - (b.menuOrder ?? 0))) {
48500
50355
  if (e === postsPage) continue;
48501
50356
  if (COMMERCE_SLUGS.test(e.slug) && e.path.split("/").filter(Boolean).length === 1) {
@@ -48513,7 +50368,11 @@ async function crawlWordPress(opts) {
48513
50368
  path: e.path,
48514
50369
  title: e.title,
48515
50370
  seo: { ...e.image && !e.seo.image ? { image: e.image } : {}, ...e.seo },
48516
- sections: bodySections(e.html, base, postsPath, notices, e.path),
50371
+ sections: withPageTitle(
50372
+ bodySections(e.html, base, postsPath, notices, e.path),
50373
+ e.title,
50374
+ showsTitles
50375
+ ),
48517
50376
  ...nav.length && !navPaths.has(e.path) && e.path !== "/" ? { hidden: true } : {}
48518
50377
  });
48519
50378
  }
@@ -48660,6 +50519,32 @@ function wxrNav(wxr, base, entries) {
48660
50519
  }
48661
50520
  return out;
48662
50521
  }
50522
+ function withPageTitle(sections, title, shows) {
50523
+ if (!shows || !title.trim()) return sections;
50524
+ const norm7 = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "");
50525
+ const first2 = sections[0];
50526
+ const blocks2 = first2?.layout.mode === "classic" ? first2.layout.rows.flatMap((r) => r.columns.flatMap((c) => c.blocks)) : [];
50527
+ const opening = blocks2[0];
50528
+ if (opening && (opening.kind === "heading" && norm7(opening.text) === norm7(title) || opening.kind === "text" && /^\s*<h1/i.test(opening.html)))
50529
+ return sections;
50530
+ const heading2 = { kind: "heading", text: title, level: 1 };
50531
+ if (first2 && first2.layout.mode === "classic" && !first2.background?.image && !first2.role) {
50532
+ const row = first2.layout.rows[0];
50533
+ if (row && row.columns.length === 1) {
50534
+ row.columns[0]?.blocks.unshift(heading2);
50535
+ return sections;
50536
+ }
50537
+ first2.layout.rows.unshift({ columns: [{ span: 12, blocks: [heading2] }] });
50538
+ return sections;
50539
+ }
50540
+ return [
50541
+ {
50542
+ theme: "",
50543
+ layout: { mode: "classic", rows: [{ columns: [{ span: 12, blocks: [heading2] }] }] }
50544
+ },
50545
+ ...sections
50546
+ ];
50547
+ }
48663
50548
 
48664
50549
  // ../import/src/fidelity/component-gen.ts
48665
50550
  var norm5 = (s2) => (s2 ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -48667,7 +50552,7 @@ var esc3 = (s2) => s2.replace(/[<>&"]/g, "");
48667
50552
  var round5 = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
48668
50553
  function canGenerate(section) {
48669
50554
  const b = section.blueprint;
48670
- return !!b && b.length >= 2 && b.length <= 30 && section.height <= 2400;
50555
+ return !!b && b.filter((i) => i.kind !== "rule" && i.kind !== "panel").length >= 2 && b.length <= 36 && section.height <= 2400;
48671
50556
  }
48672
50557
  function componentFromBlueprint(input) {
48673
50558
  const items = (input.section.blueprint ?? []).filter(
@@ -48685,10 +50570,13 @@ function componentFromBlueprint(input) {
48685
50570
  const css = [
48686
50571
  ":scope { width: min(100%, " + Math.round(width) + "px); margin-inline: auto; }",
48687
50572
  ".gx { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); grid-auto-rows: minmax(8px, auto); column-gap: 0; }",
48688
- ".gx__i { margin: 0; min-width: 0; overflow-wrap: break-word; }",
50573
+ ".gx__i { position: relative; z-index: 1; margin: 0; min-width: 0; overflow-wrap: break-word; }",
50574
+ ".gx__i p { margin: 0; }",
50575
+ ".gx__p { z-index: 0; }",
48689
50576
  ".gx__img picture, .gx__img img { display: block; width: 100%; height: 100%; object-fit: cover; }"
48690
50577
  ];
48691
50578
  const phone = [];
50579
+ const named = [];
48692
50580
  let headingSeen = false;
48693
50581
  const role = (family) => {
48694
50582
  if (norm5(family) && norm5(family) === norm5(input.roles.label)) return "label";
@@ -48723,6 +50611,10 @@ function componentFromBlueprint(input) {
48723
50611
  ]
48724
50612
  ].join("; ");
48725
50613
  };
50614
+ const tokenColor = (hex, fallback2) => {
50615
+ const v = colorVar(hex);
50616
+ return v === "inherit" ? fallback2 : v;
50617
+ };
48726
50618
  items.forEach((it, k) => {
48727
50619
  const n = k + 1;
48728
50620
  const cls = `gx__i gx__i${n}`;
@@ -48731,10 +50623,26 @@ function componentFromBlueprint(input) {
48731
50623
  const row = Math.floor((it.box.y - top) / 8) + 1;
48732
50624
  const rows = Math.max(1, Math.round(it.box.h / 8));
48733
50625
  const place = `grid-column: ${col} / span ${span}; grid-row: ${row} / span ${rows}`;
50626
+ if (it.kind === "rule") {
50627
+ parts.push(`<hr class="gx__p gx__r${n}">`);
50628
+ css.push(
50629
+ `.gx__r${n} { ${place.replace(/span \d+$/, "span 1")}; align-self: start; width: 100%; height: 0; margin: 0; border: 0; border-top: ${Math.max(1, Math.min(8, it.box.h))}px solid ${tokenColor(it.color, "var(--os-color-border)")}; }`
50630
+ );
50631
+ return;
50632
+ }
50633
+ if (it.kind === "panel") {
50634
+ parts.push(`<div class="gx__p gx__p${n}" aria-hidden="true"></div>`);
50635
+ css.push(
50636
+ `.gx__p${n} { ${place}; ${it.color ? `background: ${tokenColor(it.color, "var(--os-color-surface)")};` : ""} ${it.border ? `border: ${Math.max(1, Math.min(8, it.border.width))}px solid ${tokenColor(it.border.color, "var(--os-color-border)")};` : ""} border-radius: ${Math.min(48, Math.round(it.radius ?? 0))}px; }`
50637
+ );
50638
+ phone.push(`.gx__p${n} { display: none; }`);
50639
+ return;
50640
+ }
48734
50641
  if (it.kind === "image") {
48735
50642
  const key3 = `image${n}`;
48736
50643
  fields[key3] = { kind: "image", label: `Picture ${n}` };
48737
50644
  values[key3] = { src: it.src, alt: (it.alt ?? "").slice(0, 300) };
50645
+ named.push({ role: "picture", text: it.alt ?? "", keys: [key3] });
48738
50646
  parts.push(
48739
50647
  `<div class="${cls} gx__img">{{image ${key3} sizes="(max-width: 767px) 100vw, ${Math.max(10, Math.round(it.box.w / 1440 * 100))}vw"}}</div>`
48740
50648
  );
@@ -48753,6 +50661,7 @@ function componentFromBlueprint(input) {
48753
50661
  };
48754
50662
  values[label] = (it.text ?? "").slice(0, 60);
48755
50663
  if (!target) {
50664
+ named.push({ role: it.kind, text: it.text ?? "", keys: [label] });
48756
50665
  parts.push(`<p class="${cls}">{{${label}}}</p>`);
48757
50666
  } else {
48758
50667
  const lk = `link${n}`;
@@ -48761,6 +50670,7 @@ function componentFromBlueprint(input) {
48761
50670
  label: `${it.kind === "button" ? "Button" : "Link"} ${n} goes to`
48762
50671
  };
48763
50672
  values[lk] = target;
50673
+ named.push({ role: it.kind, text: it.text ?? "", keys: [label, lk] });
48764
50674
  parts.push(
48765
50675
  it.kind === "button" ? `<div class="${cls}">{{button ${lk} label=${label} variant="primary"}}</div>` : `<p class="${cls}">{{#link ${lk}}}{{${label}}}{{/link}}</p>`
48766
50676
  );
@@ -48772,6 +50682,22 @@ function componentFromBlueprint(input) {
48772
50682
  return;
48773
50683
  }
48774
50684
  const key2 = `text${n}`;
50685
+ const doc = it.kind === "text" ? richDoc(it.runs, input.origin) : void 0;
50686
+ if (doc) {
50687
+ fields[key2] = {
50688
+ kind: "richText",
50689
+ label: `Text ${n}`,
50690
+ marks: ["bold", "italic", "link"],
50691
+ blocks: ["paragraph"],
50692
+ maxLength: 3e3
50693
+ };
50694
+ values[key2] = doc;
50695
+ named.push({ role: textRole(it), text: it.text ?? "", keys: [key2] });
50696
+ parts.push(`<div class="${cls}">{{rich ${key2}}}</div>`);
50697
+ css.push(`.gx__i${n} { ${place}; ${typeCss(it.style, false)}; }`);
50698
+ phone.push(`.gx__i${n} { ${typeCss(it.style, true)}; }`);
50699
+ return;
50700
+ }
48775
50701
  const text7 = (it.text ?? "").slice(0, it.kind === "heading" ? 300 : 1500);
48776
50702
  fields[key2] = {
48777
50703
  kind: "text",
@@ -48780,6 +50706,7 @@ function componentFromBlueprint(input) {
48780
50706
  ...it.kind === "text" ? { multiline: true } : {}
48781
50707
  };
48782
50708
  values[key2] = text7;
50709
+ named.push({ role: it.kind === "heading" ? "heading" : textRole(it), text: text7, keys: [key2] });
48783
50710
  const tag = it.kind === "heading" ? headingSeen ? "h3" : "h2" : "p";
48784
50711
  if (it.kind === "heading") headingSeen = true;
48785
50712
  parts.push(`<${tag} class="${cls}">{{${key2}}}</${tag}>`);
@@ -48787,6 +50714,14 @@ function componentFromBlueprint(input) {
48787
50714
  phone.push(`.gx__i${n} { ${typeCss(it.style, true)}; }`);
48788
50715
  });
48789
50716
  if (!parts.length) return void 0;
50717
+ for (const [i, label] of partLabels(named).entries()) {
50718
+ const part = named[i];
50719
+ const [first2, second] = part.keys;
50720
+ const f = fields[first2];
50721
+ if (f) fields[first2] = { ...f, label };
50722
+ const l = second ? fields[second] : void 0;
50723
+ if (second && l) fields[second] = { ...l, label: `${label} goes to` };
50724
+ }
48790
50725
  css.push(
48791
50726
  `@media (max-width: 767px) {
48792
50727
  .gx { display: flex; flex-direction: column; gap: var(--os-space-sm); }
@@ -48809,15 +50744,109 @@ ${parts.map((p) => ` ${p}`).join("\n")}
48809
50744
  };
48810
50745
  return { def, fields: values };
48811
50746
  }
50747
+ function textRole(it) {
50748
+ const words4 = (it.text ?? "").trim().split(/\s+/).filter(Boolean).length;
50749
+ const caps = it.style?.case === "uppercase" || /^[^a-z]*[A-Z][^a-z]*$/.test(it.text ?? "");
50750
+ return words4 <= 5 && (caps || (it.style?.size ?? 16) <= 14) ? "small" : "paragraph";
50751
+ }
50752
+ var ROLE_LABEL = {
50753
+ heading: "Heading",
50754
+ small: "Small label",
50755
+ paragraph: "Paragraph",
50756
+ picture: "Picture",
50757
+ button: "Button",
50758
+ link: "Link"
50759
+ };
50760
+ function snippet(text7) {
50761
+ const words4 = text7.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
50762
+ let out = "";
50763
+ for (const w of words4) {
50764
+ if (`${out} ${w}`.trim().length > 24) break;
50765
+ out = `${out} ${w}`.trim();
50766
+ }
50767
+ if (!out && words4[0]) out = words4[0].slice(0, 22);
50768
+ if (!out) return "";
50769
+ const cased = /[a-z]/.test(out) ? out : out.charAt(0) + out.slice(1).toLowerCase();
50770
+ return `${cased}${out.length < text7.trim().length ? "\u2026" : ""}`;
50771
+ }
50772
+ function partLabels(parts) {
50773
+ const counts2 = /* @__PURE__ */ new Map();
50774
+ for (const p of parts) counts2.set(p.role, (counts2.get(p.role) ?? 0) + 1);
50775
+ const used = /* @__PURE__ */ new Set();
50776
+ const seen = /* @__PURE__ */ new Map();
50777
+ return parts.map((p) => {
50778
+ const nth = (seen.get(p.role) ?? 0) + 1;
50779
+ seen.set(p.role, nth);
50780
+ const base = ROLE_LABEL[p.role];
50781
+ let label;
50782
+ if ((counts2.get(p.role) ?? 0) === 1) label = base;
50783
+ else if (p.role === "button" && nth === 1) label = "Main button";
50784
+ else {
50785
+ const words4 = p.role === "picture" ? "" : snippet(p.text);
50786
+ label = words4 ? `${base} \u201C${words4}\u201D` : `${base} ${nth}`;
50787
+ }
50788
+ let unique = label;
50789
+ for (let k = 2; used.has(unique); k++) unique = `${label} ${k}`;
50790
+ used.add(unique);
50791
+ return unique.slice(0, 60);
50792
+ });
50793
+ }
50794
+ function richDoc(runs, origin) {
50795
+ if (!runs?.length) return void 0;
50796
+ const content3 = [];
50797
+ for (const r of runs) {
50798
+ if (r.t === "\n") {
50799
+ content3.push({ type: "hardBreak" });
50800
+ continue;
50801
+ }
50802
+ const text7 = r.t;
50803
+ if (!text7) continue;
50804
+ const marks = [];
50805
+ if (r.b) marks.push({ type: "bold" });
50806
+ if (r.i) marks.push({ type: "italic" });
50807
+ const href = r.href && safeHref(r.href, origin);
50808
+ if (href) marks.push({ type: "link", attrs: { href } });
50809
+ const last2 = content3[content3.length - 1];
50810
+ if (last2?.type === "text" && JSON.stringify(last2.marks ?? []) === JSON.stringify(marks)) {
50811
+ last2.text = `${last2.text}${text7}`;
50812
+ continue;
50813
+ }
50814
+ content3.push({ type: "text", text: text7, ...marks.length ? { marks } : {} });
50815
+ }
50816
+ const first2 = content3[0];
50817
+ if (first2?.type === "text" && first2.text) first2.text = first2.text.replace(/^\s+/, "");
50818
+ const end = content3[content3.length - 1];
50819
+ if (end?.type === "text" && end.text) end.text = end.text.replace(/\s+$/, "");
50820
+ const kept = content3.filter((c) => c.type !== "text" || c.text);
50821
+ if (!kept.length) return void 0;
50822
+ return { type: "doc", content: [{ type: "paragraph", content: kept }] };
50823
+ }
50824
+ function safeHref(href, origin) {
50825
+ if (/^(?:mailto:|tel:)/i.test(href)) return href;
50826
+ try {
50827
+ const u = new URL(href);
50828
+ if (u.protocol !== "https:" && u.protocol !== "http:") return void 0;
50829
+ if (origin && u.origin === origin) return `${u.pathname}${u.hash}` || "/";
50830
+ return u.toString();
50831
+ } catch {
50832
+ return void 0;
50833
+ }
50834
+ }
48812
50835
 
48813
50836
  // ../import/src/fidelity/tune.ts
48814
50837
  var TUNE_GROUPS = [
48815
50838
  "type",
48816
50839
  "fonts",
48817
50840
  "rhythm",
50841
+ "rhythm-phone",
50842
+ "widths",
50843
+ "chapters",
48818
50844
  "colors",
50845
+ "page-color",
48819
50846
  "buttons",
48820
50847
  "layout",
50848
+ "headings",
50849
+ "heights",
48821
50850
  "backgrounds",
48822
50851
  "crops",
48823
50852
  "phone-images",
@@ -48866,21 +50895,29 @@ var pageOf = (doc, path2) => {
48866
50895
  const slug = path2.replace(/^\/+|\/+$/g, "");
48867
50896
  return Object.values(doc.pages).find((p) => p.slug === slug);
48868
50897
  };
48869
- function tuneRound(input, measured, groups = TUNE_GROUPS) {
50898
+ function tuneRound(input, measured, groups = TUNE_GROUPS, opts = {}) {
48870
50899
  const doc = structuredClone(input);
48871
50900
  const on = (g) => groups.includes(g);
48872
50901
  const changes = [];
48873
50902
  const choices = doc.styles.choices;
48874
50903
  const typography = { ...choices.typography ?? {} };
48875
50904
  const scale = { ...typography.scale ?? {} };
50905
+ const forPreset = (preset, list5) => {
50906
+ if (preset !== "button") return list5;
50907
+ const content3 = list5.filter((m) => !m.original.buttons?.chrome);
50908
+ return content3.length ? content3 : list5;
50909
+ };
48876
50910
  for (const preset of on("type") ? new Set(Object.values(PRESET_OF)) : []) {
48877
50911
  const label = Object.entries(PRESET_OF).find(([, p]) => p === preset)?.[0];
48878
50912
  const step = { ...scale[preset] ?? {} };
48879
50913
  let touched = false;
48880
50914
  for (const phone2 of [false, true]) {
48881
50915
  if (phone2 && preset === "nav") continue;
48882
- const list5 = measured.filter(
48883
- (m) => m.viewport < 700 === phone2 && m.original.presets[preset] && m.imported.presets[preset]
50916
+ const list5 = forPreset(
50917
+ preset,
50918
+ measured.filter(
50919
+ (m) => m.viewport < 700 === phone2 && m.original.presets[preset] && m.imported.presets[preset]
50920
+ )
48884
50921
  );
48885
50922
  if (!list5.length) continue;
48886
50923
  const sizes = list5.map((m) => m.original.presets[preset]?.size);
@@ -48897,14 +50934,21 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
48897
50934
  touched = true;
48898
50935
  }
48899
50936
  if (["eyebrow", "button", "nav", "wordmark"].includes(preset)) {
48900
- const list5 = measured.filter(
48901
- (m) => m.viewport >= 700 && m.original.presets[preset] && m.imported.presets[preset]
50937
+ const all = forPreset(
50938
+ preset,
50939
+ measured.filter((m) => m.original.presets[preset] && m.imported.presets[preset])
48902
50940
  );
48903
- const failing = list5.filter(
48904
- (m) => m.comparison.checks.some((c) => c.what === `${label} letter spacing and case` && !c.ok)
48905
- );
48906
- if (list5.length && failing.length * 2 >= list5.length) {
48907
- const ls = median(list5.map((m) => m.original.presets[preset]?.letterSpacing));
50941
+ const list5 = all.filter((m) => m.viewport >= 700);
50942
+ const fails = (m) => m.comparison.checks.some((c) => c.what === `${label} letter spacing and case` && !c.ok);
50943
+ const failing = list5.filter(fails);
50944
+ const phones = all.filter((m) => m.viewport < 700);
50945
+ const phoneFailing = phones.filter(fails);
50946
+ if (list5.length && (failing.length * 2 >= list5.length || phones.length && phoneFailing.length * 2 >= phones.length)) {
50947
+ const lsDesk = median(list5.map((m) => m.original.presets[preset]?.letterSpacing));
50948
+ const lsPhone = median(
50949
+ phones.map((m) => m.original.presets[preset]?.letterSpacing)
50950
+ );
50951
+ const ls = lsDesk !== void 0 && lsPhone !== void 0 && Math.abs(lsDesk - lsPhone) <= 0.075 ? (lsDesk + lsPhone) / 2 : lsDesk;
48908
50952
  const cases = list5.map((m) => m.original.presets[preset]?.case);
48909
50953
  if (ls !== void 0 && Math.abs((step.letterSpacing ?? -1) - ls) > 0.01) {
48910
50954
  step.letterSpacing = round6(clamp2(ls, -0.2, 1), 3);
@@ -48942,7 +50986,7 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
48942
50986
  for (const preset of ["h2", "h3"]) {
48943
50987
  const fams = origs.map((p) => p[preset]?.family).filter((f2) => !!f2);
48944
50988
  const f = fams[0];
48945
- if (!f || fams.length < 2 || !fams.every((x) => norm6(x) === norm6(f))) continue;
50989
+ if (!f || !fams.every((x) => norm6(x) === norm6(f))) continue;
48946
50990
  const role = norm6(f) === norm6(heading2) ? void 0 : norm6(f) === norm6(label) ? "label" : norm6(f) === norm6(body) ? "body" : void 0;
48947
50991
  if (!role || scale[preset]?.font === role) continue;
48948
50992
  const sample2 = origs.map((p) => p[preset]).find(Boolean);
@@ -48965,6 +51009,29 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
48965
51009
  changes.push(`${role} font: ${match.family} for ${c.original}`);
48966
51010
  }
48967
51011
  }
51012
+ if (on("fonts")) {
51013
+ const roleFamily = (r) => norm6(
51014
+ typography[r]?.family ?? (r === "label" ? typography.body?.family ?? void 0 : void 0)
51015
+ );
51016
+ for (const preset of ["eyebrow", "button", "nav", "wordmark"]) {
51017
+ const fams = forPreset(
51018
+ preset,
51019
+ measured.filter((m) => m.viewport >= 700 && m.original.presets[preset])
51020
+ ).map((m) => m.original.presets[preset]?.family).filter((f2) => !!f2);
51021
+ const f = fams[0];
51022
+ if (!f || !fams.every((x) => norm6(x) === norm6(f))) continue;
51023
+ const want = ["label", "heading", "body"].find(
51024
+ (r) => roleFamily(r) && roleFamily(r) === norm6(f)
51025
+ );
51026
+ if (!want) continue;
51027
+ const cur = scale[preset]?.font ?? "label";
51028
+ const curFamily = roleFamily(cur);
51029
+ if (cur === want || curFamily === norm6(f)) continue;
51030
+ scale[preset] = { ...scale[preset] ?? {}, font: want };
51031
+ changes.push(`${preset}: in the ${want} font (${f}), as on the original`);
51032
+ }
51033
+ if (Object.keys(scale).length) typography.scale = scale;
51034
+ }
48968
51035
  choices.typography = typography;
48969
51036
  const layout = { ...choices.layout ?? {} };
48970
51037
  const desk = measured.filter((m) => m.viewport >= 700);
@@ -48978,13 +51045,89 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
48978
51045
  layout.contentWidth = Math.round(cw / 8) * 8;
48979
51046
  changes.push(`content width ${layout.contentWidth}px`);
48980
51047
  }
51048
+ if (on("widths")) {
51049
+ const content3 = layout.contentWidth;
51050
+ for (const m of desk) {
51051
+ const check = m.comparison.checks.find((c) => c.what === "Content width" && !c.ok);
51052
+ const want = m.original.layout.contentWidth;
51053
+ const have = m.imported.layout.contentWidth;
51054
+ const page = pageOf(doc, m.path);
51055
+ if (!check || !want || !have || !page || want >= have) continue;
51056
+ let width;
51057
+ if (content3 && Math.abs(content3 - want) / want <= 0.06) width = "content";
51058
+ else if (want >= 360 && want <= 1600) {
51059
+ layout.narrowWidth ??= Math.round(want / 8) * 8;
51060
+ if (Math.abs(layout.narrowWidth - want) / want <= 0.06) width = "narrow";
51061
+ }
51062
+ if (!width) continue;
51063
+ let n = 0;
51064
+ for (const section of page.sections) {
51065
+ if (section.layout.mode !== "stack" || section.layout.width === "full") continue;
51066
+ if (section.style.background?.type === "image" || section.style.background?.type === "video")
51067
+ continue;
51068
+ const cur = section.layout.width ?? "content";
51069
+ if (cur === width) continue;
51070
+ if (width === "content") delete section.layout.width;
51071
+ else section.layout.width = width;
51072
+ delete section.preset;
51073
+ n++;
51074
+ }
51075
+ if (n) changes.push(`${m.path}: ${n} section(s) at the ${width} width, as on the original`);
51076
+ }
51077
+ }
51078
+ if (on("chapters")) {
51079
+ const bag = (t) => new Set(
51080
+ t.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3)
51081
+ );
51082
+ for (const m of desk) {
51083
+ const page = pageOf(doc, m.path);
51084
+ if (!page || m.imported.sections.length !== page.sections.length) continue;
51085
+ const keep = new Set(page.sections.map((_, j) => j));
51086
+ for (const o of m.original.sections) {
51087
+ const mine = bag(`${o.heading?.text ?? ""} ${o.words ?? o.text}`);
51088
+ if (mine.size < 20) continue;
51089
+ const inside = m.imported.sections.map((x, j) => {
51090
+ const theirs = bag(`${x.heading?.text ?? ""} ${x.words ?? x.text}`);
51091
+ let n = 0;
51092
+ for (const w of theirs) if (mine.has(w)) n++;
51093
+ return theirs.size >= 3 && n / theirs.size >= 0.85 ? j : -1;
51094
+ }).filter((j) => j >= 0);
51095
+ let run2 = [];
51096
+ const join21 = () => {
51097
+ if (run2.length >= 2) {
51098
+ const [first2, ...rest] = run2.map((j) => page.sections[j]);
51099
+ for (const r of rest) {
51100
+ first2?.blocks.push(...r.blocks);
51101
+ keep.delete(page.sections.indexOf(r));
51102
+ }
51103
+ changes.push(`${m.path}: ${run2.length} sections joined into one, as on the original`);
51104
+ }
51105
+ run2 = [];
51106
+ };
51107
+ for (const j of inside) {
51108
+ const a = page.sections[j];
51109
+ const prev = run2.length ? page.sections[run2[run2.length - 1]] : void 0;
51110
+ const plain3 = a.layout.mode === "stack" && a.layout.columns.d.length === 1 && !a.anchor && a.style.background?.type !== "image" && a.style.background?.type !== "video";
51111
+ const same = prev && j === run2[run2.length - 1] + 1 && (prev.style.colorTheme ?? "light") === (a.style.colorTheme ?? "light") && prev.layout.mode === "stack" && (prev.layout.width ?? "content") === (a.layout.mode === "stack" ? a.layout.width ?? "content" : "");
51112
+ if (plain3 && (!run2.length || same)) run2.push(j);
51113
+ else {
51114
+ join21();
51115
+ if (plain3) run2.push(j);
51116
+ }
51117
+ }
51118
+ join21();
51119
+ }
51120
+ if (keep.size !== page.sections.length)
51121
+ page.sections = page.sections.filter((_, j) => keep.has(j));
51122
+ }
51123
+ }
48981
51124
  const pad3 = (list5) => median(
48982
51125
  list5.filter((m) => m.comparison.checks.some((c) => c.what === "Section padding" && !c.ok)).map(
48983
51126
  (m) => m.original.layout.sectionPadding && m.imported.layout.sectionPadding ? m.original.layout.sectionPadding / m.imported.layout.sectionPadding : 0
48984
51127
  ).filter((n) => n > 0)
48985
51128
  );
48986
51129
  const pd = on("rhythm") ? pad3(desk) : void 0;
48987
- const pm = on("rhythm") ? pad3(phone) : void 0;
51130
+ const pm = on("rhythm-phone") ? pad3(phone) : void 0;
48988
51131
  if (pd) {
48989
51132
  const base = layout.sectionPadding ?? median(desk.map((m) => m.imported.layout.sectionPadding ?? 0).filter(Boolean));
48990
51133
  if (base) {
@@ -49010,7 +51153,7 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
49010
51153
  }
49011
51154
  }
49012
51155
  const inks = /* @__PURE__ */ new Map();
49013
- for (const m of on("colors") ? desk : []) {
51156
+ for (const m of on("colors") || on("page-color") ? desk : []) {
49014
51157
  const page = pageOf(doc, m.path);
49015
51158
  if (!page || m.imported.sections.length !== page.sections.length) continue;
49016
51159
  for (const s2 of m.comparison.sections) {
@@ -49020,21 +51163,38 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
49020
51163
  const section = page.sections[s2.importedIndex];
49021
51164
  if (!o || !i || !section || o.backgroundImage) continue;
49022
51165
  const theme = section.style.colorTheme ?? "light";
49023
- const e = inks.get(theme) ?? { text: [], heading: [] };
49024
- if (o.textColor && i.textColor && colorDistance(o.textColor, i.textColor) >= 16)
49025
- e.text.push(o.textColor);
49026
- if (o.heading?.color && i.heading?.color && colorDistance(o.heading.color, i.heading.color) >= 16)
49027
- e.heading.push(o.heading.color);
51166
+ const e = inks.get(theme) ?? { text: [], heading: [], background: [] };
51167
+ if (o.background && i.background && !section.style.background)
51168
+ e.background.push({ o: o.background, i: i.background });
51169
+ if (o.textColor && i.textColor) e.text.push({ o: o.textColor, i: i.textColor });
51170
+ if (o.heading?.color && i.heading?.color)
51171
+ e.heading.push({ o: o.heading.color, i: i.heading.color });
49028
51172
  inks.set(theme, e);
49029
51173
  }
49030
51174
  }
49031
51175
  for (const [theme, e] of inks)
49032
- for (const role of ["text", "heading"]) {
51176
+ for (const role of [
51177
+ ...on("colors") ? ["text", "heading"] : [],
51178
+ ...on("page-color") ? ["background"] : []
51179
+ ]) {
49033
51180
  const list5 = e[role];
49034
- const first2 = list5[0];
49035
- if (!first2 || list5.length < 2 || !list5.every((c) => colorDistance(c, first2) < 12)) continue;
49036
- setRole(doc, theme, role, first2);
49037
- changes.push(`${role} on ${theme} sections ${first2}, as on the original`);
51181
+ const tol = role === "background" ? 16 : 28;
51182
+ const passes = (c) => list5.filter((p) => colorDistance(p.o, c(p)) < tol).length;
51183
+ const now = passes((p) => p.i);
51184
+ let best;
51185
+ let bestN = now;
51186
+ for (const cand of new Set(list5.map((p) => p.o))) {
51187
+ const n = passes(() => cand);
51188
+ if (n > bestN) {
51189
+ best = cand;
51190
+ bestN = n;
51191
+ }
51192
+ }
51193
+ if (!best || list5.length < 2) continue;
51194
+ if (!setRole(doc, theme, role, best)) continue;
51195
+ changes.push(
51196
+ `${role} on ${theme} sections ${best}, as on the original (${bestN} of ${list5.length} sections)`
51197
+ );
49038
51198
  }
49039
51199
  const fills = /* @__PURE__ */ new Map();
49040
51200
  for (const m of on("buttons") ? desk : []) {
@@ -49063,6 +51223,135 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
49063
51223
  } catch {
49064
51224
  themes = void 0;
49065
51225
  }
51226
+ if (on("heights")) {
51227
+ const scaleOf = SPACE_SCALES[choices.density] ?? SPACE_SCALES.comfortable;
51228
+ const tokens = Object.entries(scaleOf).sort((a, b) => a[1] - b[1]);
51229
+ const fit = (o, i) => {
51230
+ if (i.contentTop === void 0 || i.contentBottom === void 0) return void 0;
51231
+ const content3 = i.contentBottom - i.contentTop;
51232
+ const room = (o.height - content3) / 2;
51233
+ const token = [...tokens].reverse().find(([, px3]) => px3 <= room)?.[0] ?? "none";
51234
+ return { token, height: Math.round(o.height) };
51235
+ };
51236
+ for (const m of desk) {
51237
+ const page = pageOf(doc, m.path);
51238
+ if (!page || m.imported.sections.length !== page.sections.length) continue;
51239
+ const mp = phone.find((x) => x.path === m.path);
51240
+ for (const s2 of m.comparison.sections) {
51241
+ if (s2.importedIndex === void 0) continue;
51242
+ const h = s2.checks.find((c) => c.what === "Height");
51243
+ const o = m.original.sections[s2.index];
51244
+ const i = m.imported.sections[s2.importedIndex];
51245
+ const section = page.sections[s2.importedIndex];
51246
+ if (!h || h.ok || !o?.backgroundImage || !i || !section) continue;
51247
+ if (section.style.background?.type !== "image" && section.style.background?.type !== "video")
51248
+ continue;
51249
+ const d = fit(o, i);
51250
+ if (!d) continue;
51251
+ const ps = mp?.comparison.sections.find((x) => x.importedIndex === s2.importedIndex);
51252
+ const po = ps ? mp?.original.sections[ps.index] : void 0;
51253
+ const pi = mp?.imported.sections[s2.importedIndex];
51254
+ const p = po?.backgroundImage && pi ? fit(po, pi) : void 0;
51255
+ section.style.padding = { d: d.token, ...p ? { m: p.token } : {} };
51256
+ section.style.minHeight = {
51257
+ d: `${clamp2(d.height, 10, 9999)}px`,
51258
+ ...p ? { m: `${clamp2(p.height, 10, 9999)}px` } : {}
51259
+ };
51260
+ changes.push(`${m.path}: section ${s2.index + 1} ${d.height}px tall, as on the original`);
51261
+ }
51262
+ }
51263
+ }
51264
+ if (on("headings")) {
51265
+ const presetSizes = (list5) => {
51266
+ const out = /* @__PURE__ */ new Map();
51267
+ for (const k of ["display", "h1", "h2", "h3", "h4", "lead", "eyebrow"]) {
51268
+ const xs = list5.map((m) => m.imported.presets[k]).filter((t) => !!t);
51269
+ const size2 = median(xs.map((t) => t.size));
51270
+ if (size2 && xs[0]) out.set(k, { size: size2, family: xs[0].family });
51271
+ }
51272
+ return out;
51273
+ };
51274
+ const deskSizes = presetSizes(desk);
51275
+ const phoneSizes = presetSizes(phone);
51276
+ const fits = (a, b) => Math.abs(a - b) <= Math.max(1, a * 0.1);
51277
+ for (const m of desk) {
51278
+ const page = pageOf(doc, m.path);
51279
+ if (!page || m.imported.sections.length !== page.sections.length) continue;
51280
+ const mp = phone.find((x) => x.path === m.path);
51281
+ for (const s2 of m.comparison.sections) {
51282
+ if (s2.importedIndex === void 0) continue;
51283
+ const hc = s2.checks.find((c) => c.what === "Heading");
51284
+ if (!hc || hc.ok) continue;
51285
+ const o = m.original.sections[s2.index]?.heading;
51286
+ const i = m.imported.sections[s2.importedIndex]?.heading;
51287
+ const section = page.sections[s2.importedIndex];
51288
+ if (!o || !i || !section || fits(o.size, i.size)) continue;
51289
+ const po = mp?.original.sections.find((x) => norm6(x.heading?.text) === norm6(o.text))?.heading?.size;
51290
+ const label = o.size < 18 && (o.case === "uppercase" || o.letterSpacing >= 0.05);
51291
+ const pick = [...deskSizes].filter(([k, v]) => {
51292
+ if (!fits(o.size, v.size)) return false;
51293
+ if (k === "eyebrow" !== label) return false;
51294
+ const ph = phoneSizes.get(k);
51295
+ return po === void 0 || !ph || fits(po, ph.size);
51296
+ }).sort(([, a], [, b]) => Math.abs(a.size - o.size) - Math.abs(b.size - o.size))[0]?.[0];
51297
+ if (!pick) continue;
51298
+ const block4 = section.blocks.find(
51299
+ (b) => b.type === "heading" && norm6(String(b.fields.text ?? "")) === norm6(i.text)
51300
+ );
51301
+ const holder = block4 ? void 0 : section.blocks.find(
51302
+ (b) => b.type === "card" && norm6(String(b.fields.heading ?? "")) === norm6(i.text) || b.type === "columns" && Array.isArray(b.fields.items) && b.fields.items.some(
51303
+ (x) => norm6(x.heading) === norm6(i.text)
51304
+ )
51305
+ );
51306
+ if (holder && pick !== "lead") {
51307
+ const style = holder.style ?? {};
51308
+ if (style.typography?.heading === pick) continue;
51309
+ holder.style = {
51310
+ ...style,
51311
+ typography: { ...style.typography ?? {}, heading: pick }
51312
+ };
51313
+ changes.push(
51314
+ `${m.path}: "${o.text.slice(0, 40)}" set at the ${pick} size, as on the original`
51315
+ );
51316
+ continue;
51317
+ }
51318
+ if (!block4) {
51319
+ let done = false;
51320
+ for (const b of section.blocks) {
51321
+ if (b.type !== "text" || done) continue;
51322
+ const doc2 = b.fields.content;
51323
+ for (const node2 of doc2?.content ?? []) {
51324
+ const words4 = (node2.content ?? []).map((x) => x.text ?? "").join("");
51325
+ if (node2.type !== "heading" || norm6(words4) !== norm6(i.text)) continue;
51326
+ if (pick === "lead" || node2.attrs?.preset === pick) break;
51327
+ node2.attrs = { ...node2.attrs ?? {}, preset: pick };
51328
+ done = true;
51329
+ break;
51330
+ }
51331
+ }
51332
+ if (done)
51333
+ changes.push(
51334
+ `${m.path}: "${o.text.slice(0, 40)}" set at the ${pick} size, as on the original`
51335
+ );
51336
+ continue;
51337
+ }
51338
+ if (["display", "h1", "h2", "h3", "h4"].includes(pick)) {
51339
+ if (block4.fields.size === pick) continue;
51340
+ block4.fields.size = pick;
51341
+ } else {
51342
+ const style = block4.style ?? {};
51343
+ if (style.typography?.heading === pick) continue;
51344
+ block4.style = {
51345
+ ...style,
51346
+ typography: { ...style.typography ?? {}, heading: pick }
51347
+ };
51348
+ }
51349
+ changes.push(
51350
+ `${m.path}: "${o.text.slice(0, 40)}" set at the ${pick} size, as on the original`
51351
+ );
51352
+ }
51353
+ }
51354
+ }
49066
51355
  for (const m of on("layout") || on("backgrounds") || on("crops") ? desk : []) {
49067
51356
  const page = pageOf(doc, m.path);
49068
51357
  if (!page) continue;
@@ -49156,23 +51445,42 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
49156
51445
  if (u.protocol === "mailto:") return { type: "email", email: u.pathname };
49157
51446
  return u.protocol === "https:" ? { type: "url", url: u.toString() } : void 0;
49158
51447
  };
49159
- for (const m of desk) {
51448
+ for (const m of desk.filter((x) => !opts.paths || opts.paths.includes(x.path))) {
49160
51449
  const page = pageOf(doc, m.path);
49161
51450
  if (!page || m.imported.sections.length !== page.sections.length) continue;
49162
51451
  const used = /* @__PURE__ */ new Set();
49163
51452
  for (const s2 of m.comparison.sections) {
49164
- if (s2.importedIndex === void 0 || s2.score >= COMPONENT_THRESHOLD) continue;
51453
+ if (s2.importedIndex === void 0 || s2.score >= (opts.threshold ?? COMPONENT_THRESHOLD))
51454
+ continue;
49165
51455
  if (used.has(s2.importedIndex)) continue;
49166
51456
  const o = m.original.sections[s2.index];
49167
51457
  const old = page.sections[s2.importedIndex];
49168
51458
  if (!o || !old || !canGenerate(o)) continue;
49169
51459
  if (m.comparison.sections.filter((x) => x.importedIndex === s2.importedIndex).length > 1)
49170
51460
  continue;
51461
+ const bag = (t) => new Set(
51462
+ t.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3)
51463
+ );
51464
+ const mine = bag(`${o.heading?.text ?? ""} ${o.words ?? o.text}`);
51465
+ const shared = m.imported.sections.some((x, j) => {
51466
+ if (j === s2.importedIndex) return false;
51467
+ const theirs = bag(x.words ?? x.text);
51468
+ if (theirs.size < 4) return false;
51469
+ let n = 0;
51470
+ for (const w of theirs) if (mine.has(w)) n++;
51471
+ return n / theirs.size >= 0.5;
51472
+ });
51473
+ if (shared) continue;
49171
51474
  const name = `imported-${(page.slug || "home").replace(/[^a-z0-9]+/g, "-")}-${s2.index + 1}`.replace(/-+/g, "-").slice(0, 40).replace(/-$/, "");
49172
51475
  const theme = themes?.[old.style.colorTheme ?? "light"];
49173
51476
  const gen = componentFromBlueprint({
49174
51477
  name,
49175
- label: `${s2.heading ?? "Section"} (as on the original)`.slice(0, 60),
51478
+ label: sectionLabel(
51479
+ s2.heading ?? o.heading?.text,
51480
+ o.blueprint,
51481
+ page.title || (page.slug ? page.slug : "Home"),
51482
+ s2.index + 1
51483
+ ),
49176
51484
  section: o,
49177
51485
  roles,
49178
51486
  ...theme ? {
@@ -49181,10 +51489,14 @@ function tuneRound(input, measured, groups = TUNE_GROUPS) {
49181
51489
  heading: theme.heading,
49182
51490
  "text-muted": theme["text-muted"],
49183
51491
  accent: theme.accent,
49184
- link: theme.link
51492
+ link: theme.link,
51493
+ surface: theme.surface,
51494
+ border: theme.border,
51495
+ background: theme.background
49185
51496
  }
49186
51497
  } : {},
49187
- link: linkFor2(m.original.url)
51498
+ link: linkFor2(m.original.url),
51499
+ origin: new URL(m.original.url).origin
49188
51500
  });
49189
51501
  if (!gen) continue;
49190
51502
  const check = checkComponent(gen.def);
@@ -49229,6 +51541,14 @@ function setRole(doc, theme, role, value) {
49229
51541
  doc.styles.overrides = overrides;
49230
51542
  return true;
49231
51543
  }
51544
+ function sectionLabel(heading2, blueprint, pageTitle, n) {
51545
+ const first2 = heading2?.trim() || blueprint?.find((i) => i.kind === "heading" && i.text?.trim())?.text || blueprint?.find((i) => i.kind === "text" && i.text?.trim())?.text;
51546
+ const words4 = (first2 ?? "").replace(/\s+/g, " ").trim();
51547
+ if (!words4) return `${pageTitle} section ${n}`.slice(0, 60);
51548
+ const short = words4.length > 40 ? `${words4.slice(0, 40).replace(/\s+\S*$/, "")}\u2026` : words4;
51549
+ const cased = /[a-z]/.test(short) ? short : short.charAt(0) + short.slice(1).toLowerCase();
51550
+ return cased.slice(0, 60);
51551
+ }
49232
51552
 
49233
51553
  // ../import/src/index.ts
49234
51554
  var REPORT_FILE = "IMPORT-REPORT.md";
@@ -49438,6 +51758,9 @@ async function detectSite(url, opts = {}) {
49438
51758
  return { ...detectPlatform(r.text, { "content-type": r.contentType }), url: r.url || base };
49439
51759
  }
49440
51760
  async function importSite(opts) {
51761
+ return phoneReady(await importAnySite(opts));
51762
+ }
51763
+ async function importAnySite(opts) {
49441
51764
  let detection;
49442
51765
  let platform;
49443
51766
  if (opts.repoDir) return importGitHub(opts);
@@ -49500,7 +51823,7 @@ async function renderAgentsMd(root, siteTitle) {
49500
51823
  } catch {
49501
51824
  }
49502
51825
  }
49503
- const { generateAgentsMd: generateAgentsMd2 } = await import("./agents-md-QBMFXTIQ.js");
51826
+ const { generateAgentsMd: generateAgentsMd2 } = await import("./agents-md-OWDSHBGF.js");
49504
51827
  return generateAgentsMd2(title ? { siteTitle: title } : {});
49505
51828
  }
49506
51829
  async function writeAgentsMd(root, siteTitle) {
@@ -49779,7 +52102,7 @@ function compareFidelityTool(opts) {
49779
52102
  [],
49780
52103
  "Install Playwright next to the CLI (npm i -D playwright && npx playwright install chromium) and restart the MCP server."
49781
52104
  );
49782
- const widths = input.device === "desktop" ? [DESKTOP] : input.device === "mobile" ? [PHONE] : [DESKTOP, PHONE];
52105
+ const widths = input.device === "desktop" ? [DESKTOP] : input.device === "mobile" ? [PHONE2] : [DESKTOP, PHONE2];
49783
52106
  const run2 = opts.compare ?? runCompare;
49784
52107
  const r = await run2(u.toString(), opts.siteRoot, {
49785
52108
  ...input.pages?.length ? { pages: input.pages } : {},
@@ -49982,8 +52305,8 @@ var DEFAULTS = {
49982
52305
  products: []
49983
52306
  };
49984
52307
  var FILE = {
49985
- rss: { file: "rss.xml", label: "RSS", mime: "application/rss+xml" },
49986
- atom: { file: "atom.xml", label: "Atom", mime: "application/atom+xml" },
52308
+ rss: { file: "rss.xml", label: "RSS feed", mime: "application/rss+xml" },
52309
+ atom: { file: "atom.xml", label: "Atom feed", mime: "application/atom+xml" },
49987
52310
  ical: { file: "calendar.ics", label: "Add to calendar (iCal)", mime: "text/calendar" },
49988
52311
  podcast: { file: "podcast.xml", label: "Podcast feed", mime: "application/rss+xml" }
49989
52312
  };
@@ -50798,7 +53121,11 @@ async function startPreviewServer(root, load, opts = {}) {
50798
53121
  page = Object.values(view.pages).find((p) => p.slug === slug);
50799
53122
  }
50800
53123
  if (page) {
50801
- const html = renderPreviewHtml(view, page.id, { behaviorsUrl: "/_os/behaviors" });
53124
+ const html = renderPreviewHtml(view, page.id, {
53125
+ behaviorsUrl: "/_os/behaviors",
53126
+ // `?os_mode=production`: exactly as published (the Phone check measures this).
53127
+ production: url.searchParams.get("os_mode") === "production"
53128
+ });
50802
53129
  res.writeHead(200, {
50803
53130
  "content-type": "text/html; charset=utf-8",
50804
53131
  "cache-control": "no-store"
@@ -50862,6 +53189,7 @@ var FileSiteStore = class _FileSiteStore {
50862
53189
  canPublish: store.hasGit,
50863
53190
  canRollback: store.hasGit,
50864
53191
  canScreenshot: Boolean(options.screenshotter),
53192
+ canMeasurePhone: Boolean(options.screenshotter),
50865
53193
  publishNote: store.hasGit ? "Local site in git: publishing commits content/ and media/ on the current branch; push it to deploy." : "Local site without git: edits are saved to the files; there is no publish step."
50866
53194
  };
50867
53195
  return store;
@@ -51170,6 +53498,16 @@ Unpublished work saved as ${tag}`,
51170
53498
  const url = await this.previewUrl(siteId, path2);
51171
53499
  return this.options.screenshotter.capture(url, request);
51172
53500
  }
53501
+ async measurePhone(siteId, request) {
53502
+ this.check(siteId);
53503
+ if (!this.options.screenshotter)
53504
+ throw new ToolError("The browser phone check is not available.", "unsupported");
53505
+ const doc = await loadSiteDocument(this.root);
53506
+ const page = doc.pages[request.pageId];
53507
+ const path2 = page?.slug ? `/${page.slug}/` : "/";
53508
+ const url = await this.previewUrl(siteId, `${path2}?os_mode=production`);
53509
+ return this.options.screenshotter.measure(url, request.width);
53510
+ }
51173
53511
  async agentsMd() {
51174
53512
  try {
51175
53513
  return await readFile9(join11(this.root, "AGENTS.md"), "utf8");
@@ -51285,6 +53623,32 @@ function createScreenshotter(chromium) {
51285
53623
  await page.close().catch(() => void 0);
51286
53624
  }
51287
53625
  },
53626
+ async measure(url, width) {
53627
+ browser ??= launch2();
53628
+ let b;
53629
+ try {
53630
+ b = await browser;
53631
+ } catch (e) {
53632
+ browser = void 0;
53633
+ throw e;
53634
+ }
53635
+ const context = await b.newContext({
53636
+ viewport: { width, height: PHONE_HEIGHT },
53637
+ deviceScaleFactor: 2,
53638
+ isMobile: true,
53639
+ hasTouch: true,
53640
+ reducedMotion: "reduce"
53641
+ });
53642
+ const page = await context.newPage();
53643
+ try {
53644
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
53645
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => void 0);
53646
+ await page.evaluate(PHONE_PREPARE_SCRIPT).catch(() => void 0);
53647
+ return await page.evaluate(PHONE_PROBE_SCRIPT);
53648
+ } finally {
53649
+ await context.close().catch(() => void 0);
53650
+ }
53651
+ },
51288
53652
  async close() {
51289
53653
  if (!browser) return;
51290
53654
  const b = await browser.catch(() => void 0);
@@ -51504,6 +53868,147 @@ function rebrand(value, from, to) {
51504
53868
  return changed ? out : null;
51505
53869
  }
51506
53870
 
53871
+ // src/phone.ts
53872
+ var pathOf = (p) => p.slug ? `/${p.slug}/` : "/";
53873
+ async function measureSite(root, load, doc, shooter, pages, widths, now) {
53874
+ const server = await startPreviewServer(root, load);
53875
+ const out = /* @__PURE__ */ new Map();
53876
+ try {
53877
+ for (const page of pages) {
53878
+ const results = [];
53879
+ for (const w of widths)
53880
+ results.push(await shooter.measure(`${server.url}${pathOf(page)}?os_mode=production`, w));
53881
+ out.set(page.id, phoneIssuesFromProbe(doc, page.id, results, { now, actor: "phone-check" }));
53882
+ }
53883
+ } finally {
53884
+ await server.close();
53885
+ }
53886
+ return out;
53887
+ }
53888
+ async function runPhoneCheck(root, opts = {}) {
53889
+ const now = opts.now ?? Date.now();
53890
+ const widths = opts.widths ?? PHONE_WIDTHS;
53891
+ let doc = await loadSiteDocument(root);
53892
+ const pick = (d) => Object.values(d.pages).filter(
53893
+ (p) => !opts.pages?.length || opts.pages.includes(p.id) || opts.pages.includes(p.slug)
53894
+ );
53895
+ let shooter = opts.screenshotter;
53896
+ let note;
53897
+ let owned = false;
53898
+ if (!shooter && opts.rendered !== false) {
53899
+ const pw = await loadPlaywright();
53900
+ if ("error" in pw)
53901
+ note = "The browser check needs Playwright: run `npm i -D playwright && npx playwright install chromium` in the site folder. Only the rules that don't need a browser ran.";
53902
+ else {
53903
+ shooter = createScreenshotter(pw.chromium);
53904
+ owned = true;
53905
+ }
53906
+ }
53907
+ let current2 = doc;
53908
+ const load = async () => current2;
53909
+ const measure2 = async () => shooter && opts.rendered !== false ? measureSite(root, load, current2, shooter, pick(current2), widths, now).catch((e) => {
53910
+ note = `The browser check couldn't run: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`;
53911
+ return /* @__PURE__ */ new Map();
53912
+ }) : /* @__PURE__ */ new Map();
53913
+ try {
53914
+ let rendered2 = await measure2();
53915
+ const fixed = [];
53916
+ const written = [];
53917
+ if (opts.fix) {
53918
+ const r = fixForPhones(doc, {
53919
+ now,
53920
+ actor: "phone-check",
53921
+ rendered: [...rendered2.values()].flat()
53922
+ });
53923
+ if (r.ops.length) {
53924
+ doc = r.doc;
53925
+ current2 = doc;
53926
+ const w = await writeSiteDocument(root, doc);
53927
+ written.push(...w.written);
53928
+ fixed.push(...r.fixed);
53929
+ rendered2 = await measure2();
53930
+ }
53931
+ }
53932
+ const statics = phoneCheck(doc, void 0, { now, actor: "phone-check" });
53933
+ const pages = pick(doc).map((p) => ({
53934
+ page: p.id,
53935
+ title: p.title,
53936
+ path: pathOf(p),
53937
+ issues: sortIssues([
53938
+ ...statics.filter((i) => i.page === p.id),
53939
+ ...(rendered2.get(p.id) ?? []).filter((i) => i.page === p.id)
53940
+ ])
53941
+ }));
53942
+ const elsewhere = [
53943
+ ...statics.filter((i) => !i.page || !doc.pages[i.page]),
53944
+ // Footer findings repeat on every page: report each once.
53945
+ ...dedupe([...rendered2.values()].flat().filter((i) => i.page === "footer"))
53946
+ ];
53947
+ const issues = sortIssues([...elsewhere, ...pages.flatMap((p) => p.issues)]);
53948
+ const counts2 = { error: 0, warning: 0, info: 0 };
53949
+ for (const i of issues) counts2[i.severity]++;
53950
+ return {
53951
+ rendered: !!shooter && opts.rendered !== false && !note,
53952
+ ...note ? { note } : {},
53953
+ issues,
53954
+ pages,
53955
+ counts: counts2,
53956
+ fixed,
53957
+ written
53958
+ };
53959
+ } finally {
53960
+ if (owned) await shooter?.close();
53961
+ }
53962
+ }
53963
+ function dedupe(issues) {
53964
+ const seen = /* @__PURE__ */ new Set();
53965
+ return issues.filter((i) => {
53966
+ const key2 = `${i.rule}|${i.message}`;
53967
+ if (seen.has(key2)) return false;
53968
+ seen.add(key2);
53969
+ return true;
53970
+ });
53971
+ }
53972
+ function formatPhoneReport(r, paint2) {
53973
+ const lines = [];
53974
+ lines.push(paint2(1, r.rendered ? "Phone check (measured at 360 and 390 px)" : "Phone check"));
53975
+ if (r.note) lines.push(paint2(33, r.note));
53976
+ if (r.fixed.length) {
53977
+ lines.push(
53978
+ paint2(32, `Fixed for phones: ${r.fixed.length} change${r.fixed.length === 1 ? "" : "s"}.`)
53979
+ );
53980
+ for (const f of uniqueLabels(r.fixed)) lines.push(` \u2713 ${f}`);
53981
+ }
53982
+ const groups = /* @__PURE__ */ new Map();
53983
+ for (const i of r.issues) {
53984
+ const page = r.pages.find((p) => p.page === i.page);
53985
+ const key2 = page ? `${page.title} (${page.path})` : i.page === "footer" ? "Footer" : "Whole site";
53986
+ groups.set(key2, [...groups.get(key2) ?? [], i]);
53987
+ }
53988
+ for (const [where2, list5] of groups) {
53989
+ lines.push("", paint2(1, where2));
53990
+ for (const i of list5) {
53991
+ const sev = i.severity === "error" ? paint2(31, "error ") : i.severity === "warning" ? paint2(33, "warning") : "info ";
53992
+ lines.push(` ${sev} ${i.message}`);
53993
+ lines.push(` ${i.autofix ? `Fix for phones: ${i.autofix.label}.` : i.fix}`);
53994
+ }
53995
+ }
53996
+ const { error: error2, warning, info } = r.counts;
53997
+ lines.push("");
53998
+ lines.push(
53999
+ error2 + warning === 0 ? paint2(
54000
+ 32,
54001
+ `Looks great on phones${info ? ` (${info} suggestion${info === 1 ? "" : "s"})` : ""}.`
54002
+ ) : `${error2} error${error2 === 1 ? "" : "s"}, ${warning} warning${warning === 1 ? "" : "s"} on phones.${r.issues.some((i) => i.autofix) && !r.fixed.length ? " Run `openspace check --phone --fix` to fix what can be fixed automatically." : ""}`
54003
+ );
54004
+ return lines.join("\n");
54005
+ }
54006
+ function uniqueLabels(fixed) {
54007
+ const counts2 = /* @__PURE__ */ new Map();
54008
+ for (const f of fixed) counts2.set(f.label, (counts2.get(f.label) ?? 0) + 1);
54009
+ return [...counts2].map(([l, n]) => n > 1 ? `${l} (${n}\xD7)` : l);
54010
+ }
54011
+
51507
54012
  // src/wow-first.ts
51508
54013
  var WOW_FIRST_MESSAGE = "New and imported sites always start on free GitHub Pages, so the first version goes live without any extra accounts. Shops, donations, member areas and password pages show a friendly \u201Copening soon\u201D note until you switch them on in Studio \u2192 Finish setting up (it moves the site to Cloudflare for you, with no downtime).";
51509
54014
  function assertGitHubPagesStart(target) {
@@ -51682,6 +54187,7 @@ async function runImportSite(io, url, dirArg, opts = {}) {
51682
54187
  await writeShell(dir, existing.deploy?.target ?? "github-pages", slug);
51683
54188
  await writeImport(dir, result);
51684
54189
  await writeAgentsMd(dir, result.doc.site.title);
54190
+ await phoneFixRendered(dir, opts, io);
51685
54191
  const check2 = await runCheck(dir);
51686
54192
  const r2 = { dir, result, check: check2 };
51687
54193
  if (opts.summary) await writeSummary(resolve6(io.cwd, opts.summary), r2);
@@ -51699,11 +54205,21 @@ async function runImportSite(io, url, dirArg, opts = {}) {
51699
54205
  await rm6(join15(dir, entry), { recursive: true, force: true });
51700
54206
  throw e;
51701
54207
  }
54208
+ await phoneFixRendered(dir, opts, io);
51702
54209
  const check = await runCheck(dir);
51703
54210
  const r = { dir, result, check };
51704
54211
  if (opts.summary) await writeSummary(resolve6(io.cwd, opts.summary), r);
51705
54212
  return r;
51706
54213
  }
54214
+ async function phoneFixRendered(dir, opts, io) {
54215
+ if (!opts.render) return;
54216
+ try {
54217
+ const r = await runPhoneCheck(dir, { fix: true });
54218
+ if (r.fixed.length) io.err(` Fixed ${r.fixed.length} thing(s) for phones.`);
54219
+ } catch (e) {
54220
+ io.err(` Couldn't check the pages on a phone: ${e.message.split("\n")[0]}`);
54221
+ }
54222
+ }
51707
54223
  function importCounts(r) {
51708
54224
  const { result, check } = r;
51709
54225
  return {
@@ -55970,8 +58486,8 @@ var escapeText3 = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replac
55970
58486
  var escapeAttr2 = (s2) => s2.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\{\{/g, "&#123;&#123;");
55971
58487
  var UTILITY = /^(?:display|italic|muted|measure|body|lead|text|small|large|wrap|wrapper|inner|container|row|col|cols|grid|flex|block|inline|content|section|item|items|list|link|links|mt|mb|my|mx|px|py|pt|pb|pl|pr|ml|mr|m|p|d|g|is|has|js|u|sr|visually|astro|w|h|max|min|font|bg|color|center|left|right|hidden|show|active|reveal|fade|animate|anim|in|out|copy|art|wf|w|elementor|wp|x|y|z|lg|md|sm|xl|xs|xxl|space|gap|align|justify|order|relative|absolute|fixed|sticky|overflow|shadow|rounded|border|ring|opacity|transition|duration|ease|delay|transform|scale|rotate|translate|uppercase|lowercase|capitalize|tracking|leading|truncate|break|whitespace|select|cursor|pointer|z)$/;
55972
58488
  function camel(s2) {
55973
- const words3 = s2.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
55974
- const out = words3.map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)).join("").replace(/^[^a-z]+/, "");
58489
+ const words4 = s2.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
58490
+ const out = words4.map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)).join("").replace(/^[^a-z]+/, "");
55975
58491
  return out.slice(0, 32);
55976
58492
  }
55977
58493
  function semanticName(el) {
@@ -56014,9 +58530,34 @@ function nameFor(el, fallback2 = "text") {
56014
58530
  if (!el) return fallback2;
56015
58531
  return semanticName(el) ?? TAG_NAME[tagOf(el)] ?? fallback2;
56016
58532
  }
56017
- function humanize2(key2) {
56018
- const words3 = key2.replace(/([a-z])([A-Z0-9])/g, "$1 $2").toLowerCase();
56019
- return (words3.charAt(0).toUpperCase() + words3.slice(1)).slice(0, 60);
58533
+ var PLAIN_WORDS = {
58534
+ cta: "button",
58535
+ btn: "button",
58536
+ img: "picture",
58537
+ image: "picture",
58538
+ pic: "picture",
58539
+ photo: "picture",
58540
+ desc: "description",
58541
+ txt: "text",
58542
+ href: "link",
58543
+ url: "link",
58544
+ kicker: "small label",
58545
+ eyebrow: "small label",
58546
+ overline: "small label",
58547
+ lede: "intro",
58548
+ lead: "intro",
58549
+ subhead: "subheading",
58550
+ bg: "background",
58551
+ nav: "menu"
58552
+ };
58553
+ function humanize2(key2, kind) {
58554
+ const words4 = key2.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/([A-Za-z])(\d)/g, "$1 $2").replace(/(\d)([A-Za-z])/g, "$1 $2").toLowerCase().split(/[\s_-]+/).filter(Boolean).map((w) => PLAIN_WORDS[w] ?? w);
58555
+ const linky = words4.some((w) => w === "button" || w === "link");
58556
+ if (linky && words4.length > 1 && words4[words4.length - 1] === "label")
58557
+ words4[words4.length - 1] = "text";
58558
+ if (kind === "link" && words4[words4.length - 1] !== "link") words4.push("goes to");
58559
+ const out = words4.join(" ") || "text";
58560
+ return (out.charAt(0).toUpperCase() + out.slice(1)).slice(0, 60);
56020
58561
  }
56021
58562
  var RESERVED2 = /* @__PURE__ */ new Set([
56022
58563
  "rich",
@@ -56058,7 +58599,7 @@ var FieldScope = class {
56058
58599
  }
56059
58600
  add(base, spec, value) {
56060
58601
  const key2 = this.key(base);
56061
- const label = humanize2(key2);
58602
+ const label = humanize2(key2, spec.kind);
56062
58603
  this.fields[key2] = { ...spec, label };
56063
58604
  this.values[key2] = value;
56064
58605
  return key2;
@@ -56333,10 +58874,10 @@ var SectionConverter = class {
56333
58874
  async shapes(els, w) {
56334
58875
  const map2 = /* @__PURE__ */ new Map();
56335
58876
  const sig = (e) => `${tagOf(e)}.${classes(e).sort().join(".")}`;
56336
- const counts = /* @__PURE__ */ new Map();
56337
- for (const e of els) counts.set(sig(e), (counts.get(sig(e)) ?? 0) + 1);
58877
+ const counts2 = /* @__PURE__ */ new Map();
58878
+ for (const e of els) counts2.set(sig(e), (counts2.get(sig(e)) ?? 0) + 1);
56338
58879
  for (const e of els) {
56339
- if ((counts.get(sig(e)) ?? 0) < 2) continue;
58880
+ if ((counts2.get(sig(e)) ?? 0) < 2) continue;
56340
58881
  const scope = new FieldScope();
56341
58882
  const tpl = await this.gen(e, scope, { ...w, listDepth: w.listDepth + 1 });
56342
58883
  const kinds = Object.entries(scope.fields).map(([k, f]) => `${k}:${f.kind}`).join(",");
@@ -57300,7 +59841,11 @@ function tokensFromPage(css, fontLinks) {
57300
59841
  const btn = step(/class:(?:btn|button)\b/i);
57301
59842
  if (btn) type.button = btn;
57302
59843
  if (Object.keys(type).length) tokens.type = type;
57303
- return { tokens, notes: read?.notes ?? [], families };
59844
+ const nColors = Object.keys(tokens.colors ?? {}).length;
59845
+ const nType = Object.keys(tokens.type ?? {}).length;
59846
+ const pl = (k, w) => `${k} ${w}${k === 1 ? "" : "s"}`;
59847
+ const note = `Read your page\u2019s styles: ${pl(nColors, "colour")}${families.length ? `, its fonts (${families.slice(0, 4).join(", ")})` : ""}${nType ? ` and ${pl(nType, "text style")}` : ""}.`;
59848
+ return { tokens, notes: [note], families };
57304
59849
  }
57305
59850
  function linkFamilies(hrefs) {
57306
59851
  const out = [];
@@ -57346,6 +59891,9 @@ function siteHref(bundle, fromPath, href, pagePaths) {
57346
59891
  return resolved;
57347
59892
  }
57348
59893
  async function importHtmlBundle(bundle, opts) {
59894
+ return phoneReady(await importBundle(bundle, opts));
59895
+ }
59896
+ async function importBundle(bundle, opts) {
57349
59897
  const progress = opts.onProgress ?? (() => void 0);
57350
59898
  const now = opts.now ?? /* @__PURE__ */ new Date();
57351
59899
  const notices = [];
@@ -57506,7 +60054,7 @@ ${await r.text()}`;
57506
60054
  }
57507
60055
  const docTitle = text(find(home.root, (e) => e.tagName === "title"));
57508
60056
  const wordmark = brand ? text(brand).replace(/\s+/g, " ").trim() : "";
57509
- const siteTitle = (opts.title ?? homePage.siteName ?? (wordmark && wordmark.length <= 60 ? titleCase(wordmark) : void 0) ?? (docTitle.split(/\s+[|·—–-]\s+/)[0] || "My site")).slice(0, 120);
60057
+ const siteTitle = ((wordmark && wordmark.length <= 60 ? titleCase(wordmark) : void 0) ?? opts.title ?? homePage.siteName ?? (docTitle.split(/\s+[|·—–-]\s+/)[0] || "My site")).slice(0, 120);
57510
60058
  const logoImg = brand ? find(brand, (e) => e.tagName === "img") : void 0;
57511
60059
  const logoRef = logoImg ? resolveRef(bundle, attr(logoImg, "src") ?? "", home.path) : void 0;
57512
60060
  const segsByPage = /* @__PURE__ */ new Map();
@@ -57570,6 +60118,7 @@ ${await r.text()}`;
57570
60118
  const placeholder = email ? attr(email, "placeholder") : void 0;
57571
60119
  blocks2.push({
57572
60120
  kind: "newsletter",
60121
+ ...signupAsksName(f) ? { askName: true } : {},
57573
60122
  ...label3 ? { buttonLabel: label3.slice(0, 40) } : {},
57574
60123
  ...placeholder ? { placeholder } : {}
57575
60124
  });
@@ -57933,8 +60482,8 @@ function componentNameFor(el, pagePath2) {
57933
60482
  )
57934
60483
  );
57935
60484
  const heading2 = find(el, (e) => /^h[1-3]$/.test(e.tagName));
57936
- const words3 = text(heading2).split(/\s+/).slice(0, 3).join(" ");
57937
- const raw2 = (id && id.length > 2 && !/^(?:top|main|content|section\d*)$/i.test(id) ? id : void 0) ?? cls ?? (words3 || el.tagName);
60485
+ const words4 = text(heading2).split(/\s+/).slice(0, 3).join(" ");
60486
+ const raw2 = (id && id.length > 2 && !/^(?:top|main|content|section\d*)$/i.test(id) ? id : void 0) ?? cls ?? (words4 || el.tagName);
57938
60487
  const slug = raw2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^(\d)/, "s-$1").slice(0, 30).replace(/-+$/, "");
57939
60488
  const prefix = pagePath2 === "/" ? "" : `${pagePath2.split("/").filter(Boolean).pop()}-`;
57940
60489
  return `${prefix}${slug || "section"}`.replace(/^[^a-z]+/, "s-").slice(0, 36).replace(/-+$/, "");
@@ -58573,7 +61122,8 @@ async function runMcp(io, dir, opts = {}) {
58573
61122
 
58574
61123
  // src/fidelity.ts
58575
61124
  var DESKTOP = 1440;
58576
- var PHONE = 390;
61125
+ var COMPONENT_RETRY_BELOW = 75;
61126
+ var PHONE2 = 390;
58577
61127
  async function launch() {
58578
61128
  const pw = await loadPlaywright();
58579
61129
  if ("error" in pw) throw new CliError(pw.error);
@@ -58616,7 +61166,7 @@ async function lookProber() {
58616
61166
  const out = {};
58617
61167
  for (const [key2, width] of [
58618
61168
  ["desktop", DESKTOP],
58619
- ["mobile", PHONE]
61169
+ ["mobile", PHONE2]
58620
61170
  ]) {
58621
61171
  const page = await open(browser, url, width);
58622
61172
  try {
@@ -58765,7 +61315,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
58765
61315
  await mkdir8(out, { recursive: true });
58766
61316
  if (!opts.out) await writeFile10(join18(out, ".gitignore"), "*\n");
58767
61317
  const pages = (opts.pages?.length ? opts.pages : defaultPages(doc)).map((p) => `/${slugOf2(p)}`);
58768
- const widths = opts.widths ?? [DESKTOP, PHONE];
61318
+ const widths = opts.widths ?? [DESKTOP, PHONE2];
58769
61319
  const server = await startPreviewServer(root, () => loadSiteDocument(root));
58770
61320
  const browser = await launch();
58771
61321
  const oldPath = /* @__PURE__ */ new Map();
@@ -58785,7 +61335,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
58785
61335
  await page.close().catch(() => void 0);
58786
61336
  }
58787
61337
  };
58788
- const measure = async () => {
61338
+ const measure2 = async () => {
58789
61339
  const comparisons = [];
58790
61340
  const measured = [];
58791
61341
  const shots = /* @__PURE__ */ new Map();
@@ -58830,7 +61380,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
58830
61380
  let best;
58831
61381
  let tuning;
58832
61382
  try {
58833
- best = await measure();
61383
+ best = await measure2();
58834
61384
  const first2 = fidelityReport(best.comparisons);
58835
61385
  const rounds = Math.max(0, Math.min(5, opts.tune ?? 0));
58836
61386
  if (rounds && best.measured.length) {
@@ -58840,14 +61390,33 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
58840
61390
  let ran = 0;
58841
61391
  let withoutComponents;
58842
61392
  let withComponents2;
58843
- let budget = 2 + rounds * 3;
61393
+ let budget = 2 + rounds * 5;
61394
+ let componentBudget = 2 + rounds * 2;
58844
61395
  for (let pass = 0; pass < rounds && budget > 0; pass++) {
58845
61396
  let improved = false;
61397
+ const attempts = [];
58846
61398
  for (const group of TUNE_GROUPS) {
58847
- if (budget <= 0) break;
58848
- const t = tuneRound(bestDoc, best.measured, [group]);
61399
+ if (group !== "components") {
61400
+ attempts.push({ group });
61401
+ continue;
61402
+ }
61403
+ const paths = [
61404
+ ...new Set(
61405
+ best.measured.filter(
61406
+ (m) => m.viewport >= 700 && m.comparison.sections.some((x) => x.score < COMPONENT_RETRY_BELOW)
61407
+ ).map((m) => m.path)
61408
+ )
61409
+ ];
61410
+ for (const path2 of paths) attempts.push({ group, paths: [path2] });
61411
+ }
61412
+ for (const { group, paths } of attempts) {
61413
+ if (group === "components" ? componentBudget <= 0 : budget <= 0) continue;
61414
+ const t = tuneRound(bestDoc, best.measured, [group], {
61415
+ ...paths ? { paths, threshold: COMPONENT_RETRY_BELOW } : {}
61416
+ });
58849
61417
  if (!t.changes.length) continue;
58850
- budget--;
61418
+ if (group === "components") componentBudget--;
61419
+ else budget--;
58851
61420
  log(
58852
61421
  `Tuning ${group}: ${t.changes.slice(0, 4).join("; ")}${t.changes.length > 4 ? "\u2026" : ""}`
58853
61422
  );
@@ -58862,7 +61431,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
58862
61431
  await writeSiteDocument(root, bestDoc);
58863
61432
  continue;
58864
61433
  }
58865
- const next = await measure();
61434
+ const next = await measure2();
58866
61435
  const score = fidelityReport(next.comparisons).exact ?? 0;
58867
61436
  const better = score > bestScore + 0.1;
58868
61437
  log(
@@ -58980,10 +61549,17 @@ function fidelitySummary(report, tuning, thumbs) {
58980
61549
  const before = new Map(
58981
61550
  (tuning?.before.pages ?? []).map((p) => [`${p.path}@${p.viewport}`, p.score])
58982
61551
  );
61552
+ const strictBefore = new Map(
61553
+ (tuning?.before.pages ?? []).map((p) => [`${p.path}@${p.viewport}`, p.strict?.score])
61554
+ );
58983
61555
  return {
58984
61556
  version: 1,
58985
61557
  score: report.score,
61558
+ // The same pages by the strict v3 scorer, side by side (additive: older readers ignore it).
61559
+ ...report.strict ? { strict: report.strict.score } : {},
61560
+ ...report.scorer ? { scorer: report.scorer } : {},
58986
61561
  ...tuning ? { before: tuning.before.score, changes: tuning.changes.slice(0, 40) } : {},
61562
+ ...tuning?.before.strict ? { strictBefore: tuning.before.strict.score } : {},
58987
61563
  ...tuning?.withoutComponents !== void 0 ? {
58988
61564
  withoutComponents: tuning.withoutComponents,
58989
61565
  ...tuning.withComponents !== void 0 ? { withComponents: tuning.withComponents } : {}
@@ -58992,11 +61568,15 @@ function fidelitySummary(report, tuning, thumbs) {
58992
61568
  const key2 = `${p.path}@${p.viewport}`;
58993
61569
  const b = before.get(key2);
58994
61570
  const worst = [...p.checks, ...p.sections.flatMap((s2) => s2.checks)].filter((c) => !c.ok && !c.credit).sort((x, y) => y.weight - x.weight).slice(0, 3).map((c) => c.what);
61571
+ const sb = strictBefore.get(key2);
58995
61572
  return {
58996
61573
  path: p.path,
58997
61574
  device: p.viewport < 700 ? "phone" : "desktop",
58998
61575
  score: p.score,
61576
+ ...p.strict ? { strict: p.strict.score } : {},
61577
+ ...p.corrections?.length ? { corrections: p.corrections.slice(0, 6) } : {},
58999
61578
  ...b !== void 0 ? { before: b } : {},
61579
+ ...sb !== void 0 ? { strictBefore: sb } : {},
59000
61580
  ...thumbs[key2] ? { image: thumbs[key2] } : {},
59001
61581
  ...worst.length ? { differs: worst } : {}
59002
61582
  };
@@ -59040,6 +61620,7 @@ async function addFidelityToSummary(file, r) {
59040
61620
  const summary = JSON.parse(await readFile14(file, "utf8"));
59041
61621
  summary.fidelity = {
59042
61622
  score: r.report.score,
61623
+ ...r.report.strict ? { strict: r.report.strict.score } : {},
59043
61624
  ...r.tuning ? { before: r.tuning.before.score } : {}
59044
61625
  };
59045
61626
  await writeFile10(file, `${JSON.stringify(summary, null, 2)}
@@ -59048,8 +61629,9 @@ async function addFidelityToSummary(file, r) {
59048
61629
  }
59049
61630
  }
59050
61631
  function compareSummary(r) {
61632
+ const strict = r.report.strict ? r.tuning?.before.strict ? ` Strict v3 scorer: ${r.tuning.before.strict.score}/100 \u2192 ${r.report.strict.score}/100 (the corrected score reads three things v3 gets wrong; see FIDELITY-REPORT.md).` : ` Strict v3 scorer: ${r.report.strict.score}/100 (the corrected score reads three things v3 gets wrong; see FIDELITY-REPORT.md).` : "";
59051
61633
  const lines = [
59052
- r.tuning ? `Visual match with the original: ${r.tuning.before.score}/100 \u2192 ${r.report.score}/100 after ${r.tuning.rounds} tuning round(s)${r.tuning.withoutComponents !== void 0 ? ` (${r.tuning.withoutComponents}/100 before recreating sections as custom components${r.tuning.withComponents !== void 0 ? "" : "; none kept"})` : ""}.` : `Visual match with the original: ${r.report.score}/100.`
61634
+ r.tuning ? `Visual match with the original: ${r.tuning.before.score}/100 \u2192 ${r.report.score}/100 after ${r.tuning.rounds} tuning round(s)${r.tuning.withoutComponents !== void 0 ? ` (${r.tuning.withoutComponents}/100 before recreating sections as custom components${r.tuning.withComponents !== void 0 ? "" : "; none kept"})` : ""}.${strict}` : `Visual match with the original: ${r.report.score}/100.${strict}`
59053
61635
  ];
59054
61636
  for (const p of r.report.pages) {
59055
61637
  const off = [
@@ -59057,7 +61639,7 @@ function compareSummary(r) {
59057
61639
  ...p.sections.filter((s2) => s2.score < 80).map((s2) => `section ${s2.index + 1}${s2.heading ? ` "${s2.heading.slice(0, 30)}"` : ""}`)
59058
61640
  ];
59059
61641
  lines.push(
59060
- ` ${p.path} ${p.viewport}px: ${p.score}/100${off.length ? ` \u2014 differs: ${off.slice(0, 6).join(", ")}${off.length > 6 ? "\u2026" : ""}` : ""}`
61642
+ ` ${p.path} ${p.viewport}px: ${p.score}/100${p.strict && p.strict.score !== p.score ? ` (strict v3 ${p.strict.score})` : ""}${off.length ? ` \u2014 differs: ${off.slice(0, 6).join(", ")}${off.length > 6 ? "\u2026" : ""}` : ""}`
59061
61643
  );
59062
61644
  }
59063
61645
  lines.push(` Report and side-by-side images: ${join18(r.out, "FIDELITY-REPORT.md")}`);
@@ -59277,14 +61859,23 @@ async function runMigrate(root, opts = {}) {
59277
61859
  var VERSION = "0.1.0";
59278
61860
  function createProgram(io, setExit, deps = {}) {
59279
61861
  const program = new Command("openspace").description("Check, preview, migrate, scaffold and serve OpenSpace sites to Claude (MCP).").version(VERSION).configureOutput({ writeOut: (s2) => io.out(s2.trimEnd()), writeErr: (s2) => io.err(s2.trimEnd()) }).exitOverride();
59280
- program.command("check").argument("[dir]", "site folder (default: the current folder)").description("Validate content: schemas, links, images, size budgets and secrets.").option("--ci", "CI mode: also print GitHub Actions annotations").option("--json", "print the report as JSON").option("--media-warn-mb <n>", "media folder warning threshold in MB", Number).option("--media-fail-mb <n>", "media folder failure threshold in MB", Number).action(
61862
+ program.command("check").argument("[dir]", "site folder (default: the current folder)").description("Validate content: schemas, links, images, size budgets and secrets.").option("--ci", "CI mode: also print GitHub Actions annotations").option("--json", "print the report as JSON").option("--media-warn-mb <n>", "media folder warning threshold in MB", Number).option("--media-fail-mb <n>", "media folder failure threshold in MB", Number).option("--phone", "also run the Phone check in a real browser (Playwright) at 360 and 390 px").option("--fix", "with --phone: fix everything for phones (writes the content files)").action(
59281
61863
  async (dir, opts) => {
61864
+ let phone;
61865
+ if (opts.phone || opts.fix) {
61866
+ phone = await runPhoneCheck(resolve10(io.cwd, dir ?? "."), { fix: opts.fix });
61867
+ if (!opts.json) {
61868
+ io.out(formatPhoneReport(phone, (code3, s2) => paint(colorEnabled(io), code3, s2)));
61869
+ io.out("");
61870
+ }
61871
+ if (phone.counts.error) setExit(1);
61872
+ }
59282
61873
  const budgets = {};
59283
61874
  if (opts.mediaWarnMb !== void 0) budgets.mediaWarnBytes = opts.mediaWarnMb * 1024 * 1024;
59284
61875
  if (opts.mediaFailMb !== void 0) budgets.mediaFailBytes = opts.mediaFailMb * 1024 * 1024;
59285
61876
  const report = await runCheck(resolve10(io.cwd, dir ?? "."), { budgets });
59286
61877
  if (opts.json) {
59287
- io.out(JSON.stringify(report, null, 2));
61878
+ io.out(JSON.stringify(phone ? { ...report, phone } : report, null, 2));
59288
61879
  } else {
59289
61880
  if (opts.ci) for (const line of githubAnnotations(report)) io.out(line);
59290
61881
  const color = colorEnabled(io);
@@ -59640,4 +62231,4 @@ export {
59640
62231
  createProgram,
59641
62232
  run
59642
62233
  };
59643
- //# sourceMappingURL=chunk-B5C7S4S5.js.map
62234
+ //# sourceMappingURL=chunk-PBN36VUE.js.map