@omniharmonic/openspace-cli 0.1.14 → 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-63S3T7CX.js → agents-md-OWDSHBGF.js} +2 -2
  2. package/dist/bin.js +3 -3
  3. package/dist/{chunk-SY5NDWS2.js → chunk-GMKHKHUR.js} +127 -25
  4. package/dist/chunk-GMKHKHUR.js.map +1 -0
  5. package/dist/{chunk-TDLUJHWB.js → chunk-PBN36VUE.js} +1965 -122
  6. package/dist/chunk-PBN36VUE.js.map +1 -0
  7. package/dist/{chunk-N7WFQYFE.js → chunk-YAPLQ4OA.js} +24 -1
  8. package/dist/chunk-YAPLQ4OA.js.map +1 -0
  9. package/dist/{crawl-YPDLNAN7.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 +1 -1
  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-N7WFQYFE.js.map +0 -1
  27. package/dist/chunk-SY5NDWS2.js.map +0 -1
  28. package/dist/chunk-TDLUJHWB.js.map +0 -1
  29. /package/dist/{agents-md-63S3T7CX.js.map → agents-md-OWDSHBGF.js.map} +0 -0
  30. /package/dist/{crawl-YPDLNAN7.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-N7WFQYFE.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-SY5NDWS2.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
  });
@@ -15654,13 +16837,50 @@ var PROBE_SCRIPT = String.raw`(async () => {
15654
16837
  res.textLeft = Math.round(Math.min(...measures.map((x) => x.left)));
15655
16838
  }
15656
16839
  // Columns: text items whose vertical spans overlap, at distinct left edges.
15657
- const items = [...sec.querySelectorAll(textSel + ", h2, img")].filter((e) => visible(e) && (e.tagName === "IMG" ? e.getBoundingClientRect().width > 60 : ownText(e).length >= 12)).map(rect);
15658
- for (const a of items) {
15659
- const mid = a.top + Math.min(a.height, 40) / 2;
15660
- const lefts = [];
15661
- 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);
15662
- 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));
15663
16881
  }
16882
+ const shown = columnsOf(shownRects);
16883
+ if (shown !== res.columns) res.columnsOnScreen = shown;
15664
16884
  // The section's parts and where they sit (to recreate it as a component when needed).
15665
16885
  if (!sec.querySelector("form, input, textarea, select, iframe, video, canvas, embed, object")) {
15666
16886
  const parts = [];
@@ -15756,6 +16976,8 @@ var PROBE_SCRIPT = String.raw`(async () => {
15756
16976
  const inMain = (el) => !chrome(el) && !el.closest("footer, .os-footer");
15757
16977
  const styleOfAll = (sel) => [...document.querySelectorAll(sel)].filter((e) => visible(e) && inMain(e) && ownText(e).length > 1).map(textOf);
15758
16978
  const presets = {};
16979
+ // Measurements only the corrected score uses (the strict v3 ones above stay as they were).
16980
+ const corrections = {};
15759
16981
  const h1s = styleOfAll("h1");
15760
16982
  if (h1s.length) presets.h1 = h1s.sort((a, b) => b.size - a.size)[0];
15761
16983
  const h2 = median(styleOfAll("h2"));
@@ -15806,6 +17028,21 @@ var PROBE_SCRIPT = String.raw`(async () => {
15806
17028
  if (!links.length) links = [...header.querySelectorAll("a")].filter(linkOk);
15807
17029
  const mid = links[Math.floor(links.length / 2)];
15808
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
+ }
15809
17046
  // The site title set as text (a wordmark), not a logo image.
15810
17047
  // The innermost element holding the title text (an OpenSpace title span, not its link).
15811
17048
  const brandOk = (e) => visible(e) && ownText(e).length > 1 && ownText(e).length <= 40 && !e.querySelector("img, svg");
@@ -15915,6 +17152,7 @@ var PROBE_SCRIPT = String.raw`(async () => {
15915
17152
  ...(buttons ? { buttons } : {}),
15916
17153
  ...(eyebrows.length ? { eyebrowRule: out.filter((s) => s.rule).length >= Math.max(1, eyebrows.length / 2) } : {}),
15917
17154
  layout,
17155
+ ...(Object.keys(corrections).length ? { corrections } : {}),
15918
17156
  sections: out,
15919
17157
  height: doc.scrollHeight,
15920
17158
  fonts,
@@ -19648,15 +20886,15 @@ function truncate(v, l = 50, o = "...") {
19648
20886
  return v;
19649
20887
  return str5.substring(0, l - o.length) + o;
19650
20888
  }
19651
- function truncatewords(v, words3 = 15, o = "...") {
20889
+ function truncatewords(v, words4 = 15, o = "...") {
19652
20890
  const str5 = stringify(v);
19653
20891
  o = stringify(o);
19654
20892
  this.context.memoryLimit.use(str5.length + o.length);
19655
20893
  const arr = str5.split(/\s+/);
19656
- if (words3 <= 0)
19657
- words3 = 1;
19658
- let ret = arr.slice(0, words3).join(" ");
19659
- if (arr.length >= words3)
20894
+ if (words4 <= 0)
20895
+ words4 = 1;
20896
+ let ret = arr.slice(0, words4).join(" ");
20897
+ if (arr.length >= words4)
19660
20898
  ret += o;
19661
20899
  return ret;
19662
20900
  }
@@ -28493,28 +29731,28 @@ function gfmFootnoteHtml(options) {
28493
29731
  },
28494
29732
  gfmFootnoteCallString(token) {
28495
29733
  let calls = this.getData("gfmFootnoteCallOrder");
28496
- let counts = this.getData("gfmFootnoteCallCounts");
29734
+ let counts2 = this.getData("gfmFootnoteCallCounts");
28497
29735
  const id = normalizeIdentifier(this.sliceSerialize(token));
28498
29736
  let counter;
28499
29737
  this.resume();
28500
29738
  if (!calls) this.setData("gfmFootnoteCallOrder", calls = []);
28501
- if (!counts) this.setData("gfmFootnoteCallCounts", counts = {});
29739
+ if (!counts2) this.setData("gfmFootnoteCallCounts", counts2 = {});
28502
29740
  const index = calls.indexOf(id);
28503
29741
  const safeId = sanitizeUri(id.toLowerCase());
28504
29742
  if (index === -1) {
28505
29743
  calls.push(id);
28506
- counts[id] = 1;
29744
+ counts2[id] = 1;
28507
29745
  counter = calls.length;
28508
29746
  } else {
28509
- counts[id]++;
29747
+ counts2[id]++;
28510
29748
  counter = index + 1;
28511
29749
  }
28512
- const reuseCounter = counts[id];
29750
+ const reuseCounter = counts2[id];
28513
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>");
28514
29752
  },
28515
29753
  null() {
28516
29754
  const calls = this.getData("gfmFootnoteCallOrder") || [];
28517
- const counts = this.getData("gfmFootnoteCallCounts") || {};
29755
+ const counts2 = this.getData("gfmFootnoteCallCounts") || {};
28518
29756
  const definitions = this.getData("gfmFootnoteDefinitions") || {};
28519
29757
  let index = -1;
28520
29758
  if (calls.length > 0) {
@@ -28530,7 +29768,7 @@ function gfmFootnoteHtml(options) {
28530
29768
  const safeId = sanitizeUri(id.toLowerCase());
28531
29769
  let referenceIndex = 0;
28532
29770
  const references = [];
28533
- while (++referenceIndex <= counts[id]) {
29771
+ while (++referenceIndex <= counts2[id]) {
28534
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>");
28535
29773
  }
28536
29774
  const reference = references.join(" ");
@@ -30947,7 +32185,7 @@ var typeOf = (o) => {
30947
32185
  for (const k of TYPE_KEYS) if (typeof o[k] === "string") return o[k];
30948
32186
  return void 0;
30949
32187
  };
30950
- function sectionsOf2(o) {
32188
+ function sectionsOf3(o) {
30951
32189
  if (!isObj3(o)) return void 0;
30952
32190
  for (const k of SECTION_KEYS) {
30953
32191
  const v = o[k];
@@ -30978,7 +32216,7 @@ async function findStructuredPages(source, files) {
30978
32216
  data = p.data;
30979
32217
  body = p.body;
30980
32218
  }
30981
- if (sectionsOf2(data)) out.push({ path: f, data, ...body ? { body } : {} });
32219
+ if (sectionsOf3(data)) out.push({ path: f, data, ...body ? { body } : {} });
30982
32220
  }
30983
32221
  return out;
30984
32222
  }
@@ -31094,6 +32332,7 @@ function formFrom(v, ctx) {
31094
32332
  if (signupOnly)
31095
32333
  return {
31096
32334
  kind: "newsletter",
32335
+ ...fields.some((f) => f.type !== "email") ? { askName: true } : {},
31097
32336
  ...button ? { buttonLabel: plain2(button) } : {},
31098
32337
  ...str3(v.email_placeholder) ? { placeholder: str3(v.email_placeholder) } : {},
31099
32338
  ...filled(v.success_message) ? { success: filled(v.success_message) } : {}
@@ -31545,7 +32784,7 @@ ${siteBg ? `body{background-color:${siteBg};}` : ""}`
31545
32784
  placeholders,
31546
32785
  fg: fgFor
31547
32786
  };
31548
- const raw2 = sectionsOf2(file.data) ?? [];
32787
+ const raw2 = sectionsOf3(file.data) ?? [];
31549
32788
  const sections = [];
31550
32789
  for (const s2 of raw2) {
31551
32790
  typesSeen.set(typeOf(s2) ?? "?", (typesSeen.get(typeOf(s2) ?? "?") ?? 0) + 1);
@@ -41057,13 +42296,14 @@ function fontCredit(a, b, o, i) {
41057
42296
  const d = metricDistance(ma, mb);
41058
42297
  return d <= 0.04 ? 0.85 : d <= 0.07 ? 0.7 : d <= 0.11 ? 0.5 : d <= 0.16 ? 0.25 : 0;
41059
42298
  }
42299
+ var SCORER = "v3.1-corrected";
41060
42300
  var norm3 = (s2) => (s2 ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
41061
- var words2 = (s2) => new Set(
42301
+ var words3 = (s2) => new Set(
41062
42302
  s2.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2)
41063
42303
  );
41064
42304
  var jaccard = (a, b) => {
41065
- const A = words2(a);
41066
- const B = words2(b);
42305
+ const A = words3(a);
42306
+ const B = words3(b);
41067
42307
  if (!A.size || !B.size) return 0;
41068
42308
  let n = 0;
41069
42309
  for (const w of A) if (B.has(w)) n++;
@@ -41104,7 +42344,12 @@ var ROLE_OF = {
41104
42344
  nav: "label",
41105
42345
  wordmark: "label"
41106
42346
  };
41107
- 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") {
41108
42353
  const out = [];
41109
42354
  const mobile = o.viewport < 700;
41110
42355
  const sizeKey = mobile ? "sizeMobile" : "size";
@@ -41120,8 +42365,8 @@ function typeChecks(o, i, tol) {
41120
42365
  "nav",
41121
42366
  "wordmark"
41122
42367
  ]) {
41123
- const a = o.presets[k];
41124
- const b = i.presets[k];
42368
+ const a = presetOf(o, k, mode);
42369
+ const b = presetOf(i, k, mode);
41125
42370
  if (!a) continue;
41126
42371
  if (k === "nav" && mobile) continue;
41127
42372
  if ((k === "h1" || k === "h2" || k === "h3") && a.case === "uppercase" && a.size < 16) continue;
@@ -41266,12 +42511,12 @@ function matchSections(o, i) {
41266
42511
  });
41267
42512
  o.forEach((s2, k) => {
41268
42513
  if (out[k] !== void 0) return;
41269
- const A = words2(`${s2.heading?.text ?? ""} ${s2.words ?? s2.text}`);
42514
+ const A = words3(`${s2.heading?.text ?? ""} ${s2.words ?? s2.text}`);
41270
42515
  if (A.size < 4) return;
41271
42516
  let best = -1;
41272
42517
  let score = 0.6;
41273
42518
  i.forEach((t, n) => {
41274
- const B = words2(`${t.heading?.text ?? ""} ${t.words ?? t.text}`);
42519
+ const B = words3(`${t.heading?.text ?? ""} ${t.words ?? t.text}`);
41275
42520
  let hit = 0;
41276
42521
  for (const w of A) if (B.has(w)) hit++;
41277
42522
  if (hit / A.size > score) {
@@ -41299,7 +42544,10 @@ function matchSections(o, i) {
41299
42544
  });
41300
42545
  return out;
41301
42546
  }
41302
- 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) };
41303
42551
  const out = [];
41304
42552
  const add = (c) => out.push({ area: "section", ...c });
41305
42553
  if (a.background && b.background && !a.backgroundImage) {
@@ -41436,56 +42684,159 @@ function withVisual(p, visual) {
41436
42684
  const base = p.exact ?? structure;
41437
42685
  const v = Math.max(0, Math.min(1, visual));
41438
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;
41439
42688
  return {
41440
42689
  ...p,
41441
42690
  structure,
41442
42691
  visual: Math.round(v * 1e3) / 1e3,
41443
42692
  exact,
41444
- score: Math.round(exact)
41445
- };
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;
41446
42718
  }
41447
- function compareLooks(original, imported, path2, opts = {}) {
41448
- const tol = opts.sizeTolerance ?? 0.12;
41449
- 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
+ );
41450
42730
  const match = matchSections(original.sections, imported.sections);
42731
+ const phone = original.viewport < 700;
41451
42732
  const sections = original.sections.map((s2, k) => {
41452
42733
  const j = match[k];
41453
42734
  const other = j === void 0 ? void 0 : imported.sections[j];
41454
- const c = other ? sectionChecks(s2, other, tol, original, imported) : [
41455
- {
41456
- area: "section",
41457
- what: "Present",
41458
- original: s2.heading?.text ?? s2.text.slice(0, 40),
41459
- imported: "no matching section",
41460
- ok: false,
41461
- fix: "Bring this section across (or check it was merged into another).",
41462
- 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
+ );
41463
42746
  }
41464
- ];
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;
41465
42794
  return {
41466
42795
  index: k,
41467
42796
  ...s2.heading ? { heading: s2.heading.text } : {},
41468
- ...j !== void 0 ? { importedIndex: j } : {},
42797
+ ...at !== void 0 ? { importedIndex: at } : {},
41469
42798
  checks: c,
41470
42799
  score: scoreOf(c)
41471
42800
  };
41472
42801
  });
41473
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");
41474
42810
  return {
41475
42811
  path: path2,
41476
42812
  original: original.url,
41477
42813
  imported: imported.url,
41478
42814
  viewport: original.viewport,
41479
- score: scoreOf(all),
41480
- structure: scoreOf(all),
41481
- exact: exactScore(all),
41482
- checks,
41483
- 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 } : {}
41484
42827
  };
41485
42828
  }
41486
42829
  function fidelityReport(pages) {
41487
- const exact = pages.length ? pages.reduce((n, p) => n + (p.exact ?? p.score), 0) / pages.length : 100;
41488
- 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
+ };
41489
42840
  }
41490
42841
  var cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\n/g, " ");
41491
42842
  function fidelityMarkdown(report, opts = {}) {
@@ -41493,7 +42844,11 @@ function fidelityMarkdown(report, opts = {}) {
41493
42844
  "# Visual fidelity",
41494
42845
  "",
41495
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.`,
41496
- ""
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
+ ] : []
41497
42852
  ];
41498
42853
  if (opts.tuning) {
41499
42854
  lines.push(
@@ -41508,7 +42863,12 @@ function fidelityMarkdown(report, opts = {}) {
41508
42863
  );
41509
42864
  }
41510
42865
  for (const p of report.pages) {
41511
- 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}`), "");
41512
42872
  if (p.visual !== void 0)
41513
42873
  lines.push(
41514
42874
  `Checks ${p.structure ?? p.score}/100 \xB7 looks alike ${Math.round(p.visual * 100)}/100 (screenshots compared).`,
@@ -41795,9 +43155,9 @@ function htmlToRichText(html, rewrite2 = (h) => h) {
41795
43155
  )
41796
43156
  });
41797
43157
  }
41798
- const counts = /* @__PURE__ */ new Map();
41799
- for (const a of ctx.aligns) counts.set(a, (counts.get(a) ?? 0) + 1);
41800
- 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];
41801
43161
  return {
41802
43162
  doc,
41803
43163
  ...top === "center" ? { align: "center" } : top === "right" ? { align: "end" } : {}
@@ -42266,7 +43626,7 @@ async function mapBlock(ctx, sq, opts = {}) {
42266
43626
  ctx.notices.push({
42267
43627
  kind: "form",
42268
43628
  where: ctx.where,
42269
- 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."
42270
43630
  });
42271
43631
  return one(
42272
43632
  makeBlock(
@@ -42275,6 +43635,7 @@ async function mapBlock(ctx, sq, opts = {}) {
42275
43635
  {
42276
43636
  ...sq.heading ? { heading: clip2(sq.heading, 80) } : {},
42277
43637
  ...sq.description ? { description: clip2(sq.description, 240) } : {},
43638
+ ...sq.askName ? { askName: true } : {},
42278
43639
  ...sq.buttonLabel ? { buttonLabel: clip2(sq.buttonLabel, 30) } : {},
42279
43640
  ...sq.placeholder ? { placeholder: clip2(sq.placeholder, 60) } : {},
42280
43641
  ...sq.success ? { successMessage: clip2(sq.success, 200) } : {}
@@ -44048,7 +45409,7 @@ The same rules power Studio's built-in Design check. Follow them so pages look d
44048
45409
  6. **Two typefaces maximum.** Change the pairing in Site Styles rather than per block.
44049
45410
  7. **Never invent facts, testimonials, prices or credentials.** Ask the owner.
44050
45411
  8. **Alt text describes the image's purpose,** not just its contents; decorative images are marked decorative.
44051
- 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.
44052
45413
  10. **Prefer fewer, stronger sections** over many thin ones.
44053
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.
44054
45415
 
@@ -44067,12 +45428,12 @@ var ITERATION_GUIDANCE = `# Design iteration loop
44067
45428
  Repeat until the page is right, keeping each pass small:
44068
45429
 
44069
45430
  1. **Inspect** \u2014 get_page (detail "outline") for ids and copy; get_styles for the current look.
44070
- 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.
44071
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).
44072
45433
  4. **Look** \u2014 screenshot the page (or just the section you changed) at desktop and mobile; use render_page when screenshots are unavailable.
44073
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.
44074
45435
 
44075
- 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.
44076
45437
  `;
44077
45438
 
44078
45439
  // ../mcp-tools/src/images.ts
@@ -44492,7 +45853,7 @@ function renderPreviewHtml(doc, pageId, opts = {}) {
44492
45853
  const html = renderPageHtml({
44493
45854
  doc,
44494
45855
  pageId: target,
44495
- mode: "preview",
45856
+ mode: opts.production ? "production" : "preview",
44496
45857
  basePath: opts.basePath ?? "",
44497
45858
  behaviorsUrl: opts.behaviorsUrl,
44498
45859
  extraHead: blocksStyleTag()
@@ -45650,10 +47011,10 @@ var listSectionPresets = defineTool({
45650
47011
  let list5 = presetCatalog.slice();
45651
47012
  if (input.category) list5 = list5.filter((p) => p.category === input.category);
45652
47013
  if (input.query) {
45653
- const words3 = input.query.toLowerCase().split(/\s+/).filter(Boolean);
47014
+ const words4 = input.query.toLowerCase().split(/\s+/).filter(Boolean);
45654
47015
  list5 = list5.filter((p) => {
45655
47016
  const hay = `${p.id} ${p.label} ${p.description} ${p.tags.join(" ")} ${p.ai.whenToUse}`.toLowerCase();
45656
- return words3.every((w) => hay.includes(w));
47017
+ return words4.every((w) => hay.includes(w));
45657
47018
  });
45658
47019
  }
45659
47020
  const full = input.detail === "full";
@@ -46012,12 +47373,12 @@ var designCheckTool = defineTool({
46012
47373
  const issues = runChecks(doc, pageId, input.checks ?? CHECKS, ctx.now()).filter(
46013
47374
  (i) => RANK[i.severity] <= min
46014
47375
  );
46015
- const counts = { error: 0, warning: 0, info: 0 };
46016
- 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]++;
46017
47378
  return {
46018
47379
  data: {
46019
47380
  ...pageId ? { page: pageId } : {},
46020
- counts,
47381
+ counts: counts2,
46021
47382
  issues: issues.map((i) => ({
46022
47383
  id: i.id,
46023
47384
  rule: i.rule,
@@ -46078,9 +47439,9 @@ var applyFix = defineTool({
46078
47439
  });
46079
47440
  }
46080
47441
  const remaining = runChecks(doc, pageId, CHECKS, ctx.now());
46081
- const counts = { error: 0, warning: 0, info: 0 };
46082
- for (const i of remaining) counts[i.severity]++;
46083
- 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 } };
46084
47445
  }
46085
47446
  });
46086
47447
  var preview = defineTool({
@@ -47206,6 +48567,143 @@ var pageTools = [
47206
48567
  listPosts
47207
48568
  ];
47208
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
+
47209
48707
  // ../mcp-tools/src/tools/publish.ts
47210
48708
  var getDraftDiff = defineTool({
47211
48709
  name: "get_draft_diff",
@@ -47351,6 +48849,7 @@ var ALL_TOOLS = [
47351
48849
  ...pageTools,
47352
48850
  ...designSystemTools,
47353
48851
  ...designTools,
48852
+ ...phoneTools,
47354
48853
  ...componentTools,
47355
48854
  ...publishTools
47356
48855
  ];
@@ -48142,6 +49641,25 @@ function squarespaceRenditions(u, width, height, canResize = true) {
48142
49641
  ];
48143
49642
  }
48144
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
+
48145
49663
  // ../import/src/report.ts
48146
49664
  var NOTICE_TITLES = {
48147
49665
  "unsupported-block": "Blocks that need a look",
@@ -48277,17 +49795,17 @@ function renderReport(r) {
48277
49795
  any = true;
48278
49796
  L2.push(`### ${NOTICE_TITLES[kind]}`);
48279
49797
  L2.push("");
48280
- const counts = /* @__PURE__ */ new Map();
49798
+ const counts2 = /* @__PURE__ */ new Map();
48281
49799
  for (const n of list5) {
48282
- 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() };
48283
49801
  e.n++;
48284
49802
  e.where.add(n.where);
48285
- counts.set(n.message, e);
49803
+ counts2.set(n.message, e);
48286
49804
  }
48287
49805
  let shown = 0;
48288
- for (const [message, e] of counts) {
49806
+ for (const [message, e] of counts2) {
48289
49807
  if (++shown > 15) {
48290
- L2.push(`- \u2026 and ${counts.size - 15} more like these.`);
49808
+ L2.push(`- \u2026 and ${counts2.size - 15} more like these.`);
48291
49809
  break;
48292
49810
  }
48293
49811
  const where2 = [...e.where];
@@ -48680,7 +50198,7 @@ async function crawlWordPress(opts) {
48680
50198
  ...it.excerptHtml ? { caption: it.excerptHtml } : {}
48681
50199
  });
48682
50200
  const entries = [];
48683
- const pathOf = (link2, fallback2) => (link2 ? sitePath(link2, base) : void 0) ?? fallback2;
50201
+ const pathOf2 = (link2, fallback2) => (link2 ? sitePath(link2, base) : void 0) ?? fallback2;
48684
50202
  if (rest) {
48685
50203
  const [pages2, posts] = [
48686
50204
  await listAll("wp/v2/pages", "_embed=1&orderby=menu_order&order=asc"),
@@ -48707,7 +50225,7 @@ async function crawlWordPress(opts) {
48707
50225
  }
48708
50226
  entries.push({
48709
50227
  type,
48710
- path: pathOf(link2, `/${slug}`),
50228
+ path: pathOf2(link2, `/${slug}`),
48711
50229
  slug,
48712
50230
  title: decodeEntities2(rendered(p.title)) || slug || "Untitled",
48713
50231
  html: rendered(p.content),
@@ -48774,7 +50292,7 @@ async function crawlWordPress(opts) {
48774
50292
  });
48775
50293
  }
48776
50294
  if (!rest && !wxr && home) {
48777
- const { crawlGeneric: crawlGeneric2 } = await import("./crawl-YPDLNAN7.js");
50295
+ const { crawlGeneric: crawlGeneric2 } = await import("./crawl-WQQDFI7W.js");
48778
50296
  const site2 = await crawlGeneric2({
48779
50297
  url: base,
48780
50298
  fetcher: f,
@@ -49058,6 +50576,7 @@ function componentFromBlueprint(input) {
49058
50576
  ".gx__img picture, .gx__img img { display: block; width: 100%; height: 100%; object-fit: cover; }"
49059
50577
  ];
49060
50578
  const phone = [];
50579
+ const named = [];
49061
50580
  let headingSeen = false;
49062
50581
  const role = (family) => {
49063
50582
  if (norm5(family) && norm5(family) === norm5(input.roles.label)) return "label";
@@ -49123,6 +50642,7 @@ function componentFromBlueprint(input) {
49123
50642
  const key3 = `image${n}`;
49124
50643
  fields[key3] = { kind: "image", label: `Picture ${n}` };
49125
50644
  values[key3] = { src: it.src, alt: (it.alt ?? "").slice(0, 300) };
50645
+ named.push({ role: "picture", text: it.alt ?? "", keys: [key3] });
49126
50646
  parts.push(
49127
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>`
49128
50648
  );
@@ -49141,6 +50661,7 @@ function componentFromBlueprint(input) {
49141
50661
  };
49142
50662
  values[label] = (it.text ?? "").slice(0, 60);
49143
50663
  if (!target) {
50664
+ named.push({ role: it.kind, text: it.text ?? "", keys: [label] });
49144
50665
  parts.push(`<p class="${cls}">{{${label}}}</p>`);
49145
50666
  } else {
49146
50667
  const lk = `link${n}`;
@@ -49149,6 +50670,7 @@ function componentFromBlueprint(input) {
49149
50670
  label: `${it.kind === "button" ? "Button" : "Link"} ${n} goes to`
49150
50671
  };
49151
50672
  values[lk] = target;
50673
+ named.push({ role: it.kind, text: it.text ?? "", keys: [label, lk] });
49152
50674
  parts.push(
49153
50675
  it.kind === "button" ? `<div class="${cls}">{{button ${lk} label=${label} variant="primary"}}</div>` : `<p class="${cls}">{{#link ${lk}}}{{${label}}}{{/link}}</p>`
49154
50676
  );
@@ -49170,6 +50692,7 @@ function componentFromBlueprint(input) {
49170
50692
  maxLength: 3e3
49171
50693
  };
49172
50694
  values[key2] = doc;
50695
+ named.push({ role: textRole(it), text: it.text ?? "", keys: [key2] });
49173
50696
  parts.push(`<div class="${cls}">{{rich ${key2}}}</div>`);
49174
50697
  css.push(`.gx__i${n} { ${place}; ${typeCss(it.style, false)}; }`);
49175
50698
  phone.push(`.gx__i${n} { ${typeCss(it.style, true)}; }`);
@@ -49183,6 +50706,7 @@ function componentFromBlueprint(input) {
49183
50706
  ...it.kind === "text" ? { multiline: true } : {}
49184
50707
  };
49185
50708
  values[key2] = text7;
50709
+ named.push({ role: it.kind === "heading" ? "heading" : textRole(it), text: text7, keys: [key2] });
49186
50710
  const tag = it.kind === "heading" ? headingSeen ? "h3" : "h2" : "p";
49187
50711
  if (it.kind === "heading") headingSeen = true;
49188
50712
  parts.push(`<${tag} class="${cls}">{{${key2}}}</${tag}>`);
@@ -49190,6 +50714,14 @@ function componentFromBlueprint(input) {
49190
50714
  phone.push(`.gx__i${n} { ${typeCss(it.style, true)}; }`);
49191
50715
  });
49192
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
+ }
49193
50725
  css.push(
49194
50726
  `@media (max-width: 767px) {
49195
50727
  .gx { display: flex; flex-direction: column; gap: var(--os-space-sm); }
@@ -49212,6 +50744,53 @@ ${parts.map((p) => ` ${p}`).join("\n")}
49212
50744
  };
49213
50745
  return { def, fields: values };
49214
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
+ }
49215
50794
  function richDoc(runs, origin) {
49216
50795
  if (!runs?.length) return void 0;
49217
50796
  const content3 = [];
@@ -49742,8 +51321,8 @@ function tuneRound(input, measured, groups = TUNE_GROUPS, opts = {}) {
49742
51321
  if (b.type !== "text" || done) continue;
49743
51322
  const doc2 = b.fields.content;
49744
51323
  for (const node2 of doc2?.content ?? []) {
49745
- const words3 = (node2.content ?? []).map((x) => x.text ?? "").join("");
49746
- if (node2.type !== "heading" || norm6(words3) !== norm6(i.text)) continue;
51324
+ const words4 = (node2.content ?? []).map((x) => x.text ?? "").join("");
51325
+ if (node2.type !== "heading" || norm6(words4) !== norm6(i.text)) continue;
49747
51326
  if (pick === "lead" || node2.attrs?.preset === pick) break;
49748
51327
  node2.attrs = { ...node2.attrs ?? {}, preset: pick };
49749
51328
  done = true;
@@ -49896,7 +51475,12 @@ function tuneRound(input, measured, groups = TUNE_GROUPS, opts = {}) {
49896
51475
  const theme = themes?.[old.style.colorTheme ?? "light"];
49897
51476
  const gen = componentFromBlueprint({
49898
51477
  name,
49899
- 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
+ ),
49900
51484
  section: o,
49901
51485
  roles,
49902
51486
  ...theme ? {
@@ -49957,6 +51541,14 @@ function setRole(doc, theme, role, value) {
49957
51541
  doc.styles.overrides = overrides;
49958
51542
  return true;
49959
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
+ }
49960
51552
 
49961
51553
  // ../import/src/index.ts
49962
51554
  var REPORT_FILE = "IMPORT-REPORT.md";
@@ -50166,6 +51758,9 @@ async function detectSite(url, opts = {}) {
50166
51758
  return { ...detectPlatform(r.text, { "content-type": r.contentType }), url: r.url || base };
50167
51759
  }
50168
51760
  async function importSite(opts) {
51761
+ return phoneReady(await importAnySite(opts));
51762
+ }
51763
+ async function importAnySite(opts) {
50169
51764
  let detection;
50170
51765
  let platform;
50171
51766
  if (opts.repoDir) return importGitHub(opts);
@@ -50228,7 +51823,7 @@ async function renderAgentsMd(root, siteTitle) {
50228
51823
  } catch {
50229
51824
  }
50230
51825
  }
50231
- const { generateAgentsMd: generateAgentsMd2 } = await import("./agents-md-63S3T7CX.js");
51826
+ const { generateAgentsMd: generateAgentsMd2 } = await import("./agents-md-OWDSHBGF.js");
50232
51827
  return generateAgentsMd2(title ? { siteTitle: title } : {});
50233
51828
  }
50234
51829
  async function writeAgentsMd(root, siteTitle) {
@@ -50507,7 +52102,7 @@ function compareFidelityTool(opts) {
50507
52102
  [],
50508
52103
  "Install Playwright next to the CLI (npm i -D playwright && npx playwright install chromium) and restart the MCP server."
50509
52104
  );
50510
- 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];
50511
52106
  const run2 = opts.compare ?? runCompare;
50512
52107
  const r = await run2(u.toString(), opts.siteRoot, {
50513
52108
  ...input.pages?.length ? { pages: input.pages } : {},
@@ -50710,8 +52305,8 @@ var DEFAULTS = {
50710
52305
  products: []
50711
52306
  };
50712
52307
  var FILE = {
50713
- rss: { file: "rss.xml", label: "RSS", mime: "application/rss+xml" },
50714
- 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" },
50715
52310
  ical: { file: "calendar.ics", label: "Add to calendar (iCal)", mime: "text/calendar" },
50716
52311
  podcast: { file: "podcast.xml", label: "Podcast feed", mime: "application/rss+xml" }
50717
52312
  };
@@ -51526,7 +53121,11 @@ async function startPreviewServer(root, load, opts = {}) {
51526
53121
  page = Object.values(view.pages).find((p) => p.slug === slug);
51527
53122
  }
51528
53123
  if (page) {
51529
- 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
+ });
51530
53129
  res.writeHead(200, {
51531
53130
  "content-type": "text/html; charset=utf-8",
51532
53131
  "cache-control": "no-store"
@@ -51590,6 +53189,7 @@ var FileSiteStore = class _FileSiteStore {
51590
53189
  canPublish: store.hasGit,
51591
53190
  canRollback: store.hasGit,
51592
53191
  canScreenshot: Boolean(options.screenshotter),
53192
+ canMeasurePhone: Boolean(options.screenshotter),
51593
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."
51594
53194
  };
51595
53195
  return store;
@@ -51898,6 +53498,16 @@ Unpublished work saved as ${tag}`,
51898
53498
  const url = await this.previewUrl(siteId, path2);
51899
53499
  return this.options.screenshotter.capture(url, request);
51900
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
+ }
51901
53511
  async agentsMd() {
51902
53512
  try {
51903
53513
  return await readFile9(join11(this.root, "AGENTS.md"), "utf8");
@@ -52013,6 +53623,32 @@ function createScreenshotter(chromium) {
52013
53623
  await page.close().catch(() => void 0);
52014
53624
  }
52015
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
+ },
52016
53652
  async close() {
52017
53653
  if (!browser) return;
52018
53654
  const b = await browser.catch(() => void 0);
@@ -52232,6 +53868,147 @@ function rebrand(value, from, to) {
52232
53868
  return changed ? out : null;
52233
53869
  }
52234
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
+
52235
54012
  // src/wow-first.ts
52236
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).";
52237
54014
  function assertGitHubPagesStart(target) {
@@ -52410,6 +54187,7 @@ async function runImportSite(io, url, dirArg, opts = {}) {
52410
54187
  await writeShell(dir, existing.deploy?.target ?? "github-pages", slug);
52411
54188
  await writeImport(dir, result);
52412
54189
  await writeAgentsMd(dir, result.doc.site.title);
54190
+ await phoneFixRendered(dir, opts, io);
52413
54191
  const check2 = await runCheck(dir);
52414
54192
  const r2 = { dir, result, check: check2 };
52415
54193
  if (opts.summary) await writeSummary(resolve6(io.cwd, opts.summary), r2);
@@ -52427,11 +54205,21 @@ async function runImportSite(io, url, dirArg, opts = {}) {
52427
54205
  await rm6(join15(dir, entry), { recursive: true, force: true });
52428
54206
  throw e;
52429
54207
  }
54208
+ await phoneFixRendered(dir, opts, io);
52430
54209
  const check = await runCheck(dir);
52431
54210
  const r = { dir, result, check };
52432
54211
  if (opts.summary) await writeSummary(resolve6(io.cwd, opts.summary), r);
52433
54212
  return r;
52434
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
+ }
52435
54223
  function importCounts(r) {
52436
54224
  const { result, check } = r;
52437
54225
  return {
@@ -56698,8 +58486,8 @@ var escapeText3 = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replac
56698
58486
  var escapeAttr2 = (s2) => s2.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\{\{/g, "&#123;&#123;");
56699
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)$/;
56700
58488
  function camel(s2) {
56701
- const words3 = s2.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
56702
- 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]+/, "");
56703
58491
  return out.slice(0, 32);
56704
58492
  }
56705
58493
  function semanticName(el) {
@@ -56742,9 +58530,34 @@ function nameFor(el, fallback2 = "text") {
56742
58530
  if (!el) return fallback2;
56743
58531
  return semanticName(el) ?? TAG_NAME[tagOf(el)] ?? fallback2;
56744
58532
  }
56745
- function humanize2(key2) {
56746
- const words3 = key2.replace(/([a-z])([A-Z0-9])/g, "$1 $2").toLowerCase();
56747
- 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);
56748
58561
  }
56749
58562
  var RESERVED2 = /* @__PURE__ */ new Set([
56750
58563
  "rich",
@@ -56786,7 +58599,7 @@ var FieldScope = class {
56786
58599
  }
56787
58600
  add(base, spec, value) {
56788
58601
  const key2 = this.key(base);
56789
- const label = humanize2(key2);
58602
+ const label = humanize2(key2, spec.kind);
56790
58603
  this.fields[key2] = { ...spec, label };
56791
58604
  this.values[key2] = value;
56792
58605
  return key2;
@@ -57061,10 +58874,10 @@ var SectionConverter = class {
57061
58874
  async shapes(els, w) {
57062
58875
  const map2 = /* @__PURE__ */ new Map();
57063
58876
  const sig = (e) => `${tagOf(e)}.${classes(e).sort().join(".")}`;
57064
- const counts = /* @__PURE__ */ new Map();
57065
- 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);
57066
58879
  for (const e of els) {
57067
- if ((counts.get(sig(e)) ?? 0) < 2) continue;
58880
+ if ((counts2.get(sig(e)) ?? 0) < 2) continue;
57068
58881
  const scope = new FieldScope();
57069
58882
  const tpl = await this.gen(e, scope, { ...w, listDepth: w.listDepth + 1 });
57070
58883
  const kinds = Object.entries(scope.fields).map(([k, f]) => `${k}:${f.kind}`).join(",");
@@ -58028,7 +59841,11 @@ function tokensFromPage(css, fontLinks) {
58028
59841
  const btn = step(/class:(?:btn|button)\b/i);
58029
59842
  if (btn) type.button = btn;
58030
59843
  if (Object.keys(type).length) tokens.type = type;
58031
- 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 };
58032
59849
  }
58033
59850
  function linkFamilies(hrefs) {
58034
59851
  const out = [];
@@ -58074,6 +59891,9 @@ function siteHref(bundle, fromPath, href, pagePaths) {
58074
59891
  return resolved;
58075
59892
  }
58076
59893
  async function importHtmlBundle(bundle, opts) {
59894
+ return phoneReady(await importBundle(bundle, opts));
59895
+ }
59896
+ async function importBundle(bundle, opts) {
58077
59897
  const progress = opts.onProgress ?? (() => void 0);
58078
59898
  const now = opts.now ?? /* @__PURE__ */ new Date();
58079
59899
  const notices = [];
@@ -58234,7 +60054,7 @@ ${await r.text()}`;
58234
60054
  }
58235
60055
  const docTitle = text(find(home.root, (e) => e.tagName === "title"));
58236
60056
  const wordmark = brand ? text(brand).replace(/\s+/g, " ").trim() : "";
58237
- 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);
58238
60058
  const logoImg = brand ? find(brand, (e) => e.tagName === "img") : void 0;
58239
60059
  const logoRef = logoImg ? resolveRef(bundle, attr(logoImg, "src") ?? "", home.path) : void 0;
58240
60060
  const segsByPage = /* @__PURE__ */ new Map();
@@ -58298,6 +60118,7 @@ ${await r.text()}`;
58298
60118
  const placeholder = email ? attr(email, "placeholder") : void 0;
58299
60119
  blocks2.push({
58300
60120
  kind: "newsletter",
60121
+ ...signupAsksName(f) ? { askName: true } : {},
58301
60122
  ...label3 ? { buttonLabel: label3.slice(0, 40) } : {},
58302
60123
  ...placeholder ? { placeholder } : {}
58303
60124
  });
@@ -58661,8 +60482,8 @@ function componentNameFor(el, pagePath2) {
58661
60482
  )
58662
60483
  );
58663
60484
  const heading2 = find(el, (e) => /^h[1-3]$/.test(e.tagName));
58664
- const words3 = text(heading2).split(/\s+/).slice(0, 3).join(" ");
58665
- 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);
58666
60487
  const slug = raw2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^(\d)/, "s-$1").slice(0, 30).replace(/-+$/, "");
58667
60488
  const prefix = pagePath2 === "/" ? "" : `${pagePath2.split("/").filter(Boolean).pop()}-`;
58668
60489
  return `${prefix}${slug || "section"}`.replace(/^[^a-z]+/, "s-").slice(0, 36).replace(/-+$/, "");
@@ -59302,7 +61123,7 @@ async function runMcp(io, dir, opts = {}) {
59302
61123
  // src/fidelity.ts
59303
61124
  var DESKTOP = 1440;
59304
61125
  var COMPONENT_RETRY_BELOW = 75;
59305
- var PHONE = 390;
61126
+ var PHONE2 = 390;
59306
61127
  async function launch() {
59307
61128
  const pw = await loadPlaywright();
59308
61129
  if ("error" in pw) throw new CliError(pw.error);
@@ -59345,7 +61166,7 @@ async function lookProber() {
59345
61166
  const out = {};
59346
61167
  for (const [key2, width] of [
59347
61168
  ["desktop", DESKTOP],
59348
- ["mobile", PHONE]
61169
+ ["mobile", PHONE2]
59349
61170
  ]) {
59350
61171
  const page = await open(browser, url, width);
59351
61172
  try {
@@ -59494,7 +61315,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59494
61315
  await mkdir8(out, { recursive: true });
59495
61316
  if (!opts.out) await writeFile10(join18(out, ".gitignore"), "*\n");
59496
61317
  const pages = (opts.pages?.length ? opts.pages : defaultPages(doc)).map((p) => `/${slugOf2(p)}`);
59497
- const widths = opts.widths ?? [DESKTOP, PHONE];
61318
+ const widths = opts.widths ?? [DESKTOP, PHONE2];
59498
61319
  const server = await startPreviewServer(root, () => loadSiteDocument(root));
59499
61320
  const browser = await launch();
59500
61321
  const oldPath = /* @__PURE__ */ new Map();
@@ -59514,7 +61335,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59514
61335
  await page.close().catch(() => void 0);
59515
61336
  }
59516
61337
  };
59517
- const measure = async () => {
61338
+ const measure2 = async () => {
59518
61339
  const comparisons = [];
59519
61340
  const measured = [];
59520
61341
  const shots = /* @__PURE__ */ new Map();
@@ -59559,7 +61380,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59559
61380
  let best;
59560
61381
  let tuning;
59561
61382
  try {
59562
- best = await measure();
61383
+ best = await measure2();
59563
61384
  const first2 = fidelityReport(best.comparisons);
59564
61385
  const rounds = Math.max(0, Math.min(5, opts.tune ?? 0));
59565
61386
  if (rounds && best.measured.length) {
@@ -59610,7 +61431,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59610
61431
  await writeSiteDocument(root, bestDoc);
59611
61432
  continue;
59612
61433
  }
59613
- const next = await measure();
61434
+ const next = await measure2();
59614
61435
  const score = fidelityReport(next.comparisons).exact ?? 0;
59615
61436
  const better = score > bestScore + 0.1;
59616
61437
  log(
@@ -59728,10 +61549,17 @@ function fidelitySummary(report, tuning, thumbs) {
59728
61549
  const before = new Map(
59729
61550
  (tuning?.before.pages ?? []).map((p) => [`${p.path}@${p.viewport}`, p.score])
59730
61551
  );
61552
+ const strictBefore = new Map(
61553
+ (tuning?.before.pages ?? []).map((p) => [`${p.path}@${p.viewport}`, p.strict?.score])
61554
+ );
59731
61555
  return {
59732
61556
  version: 1,
59733
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 } : {},
59734
61561
  ...tuning ? { before: tuning.before.score, changes: tuning.changes.slice(0, 40) } : {},
61562
+ ...tuning?.before.strict ? { strictBefore: tuning.before.strict.score } : {},
59735
61563
  ...tuning?.withoutComponents !== void 0 ? {
59736
61564
  withoutComponents: tuning.withoutComponents,
59737
61565
  ...tuning.withComponents !== void 0 ? { withComponents: tuning.withComponents } : {}
@@ -59740,11 +61568,15 @@ function fidelitySummary(report, tuning, thumbs) {
59740
61568
  const key2 = `${p.path}@${p.viewport}`;
59741
61569
  const b = before.get(key2);
59742
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);
59743
61572
  return {
59744
61573
  path: p.path,
59745
61574
  device: p.viewport < 700 ? "phone" : "desktop",
59746
61575
  score: p.score,
61576
+ ...p.strict ? { strict: p.strict.score } : {},
61577
+ ...p.corrections?.length ? { corrections: p.corrections.slice(0, 6) } : {},
59747
61578
  ...b !== void 0 ? { before: b } : {},
61579
+ ...sb !== void 0 ? { strictBefore: sb } : {},
59748
61580
  ...thumbs[key2] ? { image: thumbs[key2] } : {},
59749
61581
  ...worst.length ? { differs: worst } : {}
59750
61582
  };
@@ -59788,6 +61620,7 @@ async function addFidelityToSummary(file, r) {
59788
61620
  const summary = JSON.parse(await readFile14(file, "utf8"));
59789
61621
  summary.fidelity = {
59790
61622
  score: r.report.score,
61623
+ ...r.report.strict ? { strict: r.report.strict.score } : {},
59791
61624
  ...r.tuning ? { before: r.tuning.before.score } : {}
59792
61625
  };
59793
61626
  await writeFile10(file, `${JSON.stringify(summary, null, 2)}
@@ -59796,8 +61629,9 @@ async function addFidelityToSummary(file, r) {
59796
61629
  }
59797
61630
  }
59798
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).` : "";
59799
61633
  const lines = [
59800
- 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}`
59801
61635
  ];
59802
61636
  for (const p of r.report.pages) {
59803
61637
  const off = [
@@ -59805,7 +61639,7 @@ function compareSummary(r) {
59805
61639
  ...p.sections.filter((s2) => s2.score < 80).map((s2) => `section ${s2.index + 1}${s2.heading ? ` "${s2.heading.slice(0, 30)}"` : ""}`)
59806
61640
  ];
59807
61641
  lines.push(
59808
- ` ${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" : ""}` : ""}`
59809
61643
  );
59810
61644
  }
59811
61645
  lines.push(` Report and side-by-side images: ${join18(r.out, "FIDELITY-REPORT.md")}`);
@@ -60025,14 +61859,23 @@ async function runMigrate(root, opts = {}) {
60025
61859
  var VERSION = "0.1.0";
60026
61860
  function createProgram(io, setExit, deps = {}) {
60027
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();
60028
- 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(
60029
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
+ }
60030
61873
  const budgets = {};
60031
61874
  if (opts.mediaWarnMb !== void 0) budgets.mediaWarnBytes = opts.mediaWarnMb * 1024 * 1024;
60032
61875
  if (opts.mediaFailMb !== void 0) budgets.mediaFailBytes = opts.mediaFailMb * 1024 * 1024;
60033
61876
  const report = await runCheck(resolve10(io.cwd, dir ?? "."), { budgets });
60034
61877
  if (opts.json) {
60035
- io.out(JSON.stringify(report, null, 2));
61878
+ io.out(JSON.stringify(phone ? { ...report, phone } : report, null, 2));
60036
61879
  } else {
60037
61880
  if (opts.ci) for (const line of githubAnnotations(report)) io.out(line);
60038
61881
  const color = colorEnabled(io);
@@ -60388,4 +62231,4 @@ export {
60388
62231
  createProgram,
60389
62232
  run
60390
62233
  };
60391
- //# sourceMappingURL=chunk-TDLUJHWB.js.map
62234
+ //# sourceMappingURL=chunk-PBN36VUE.js.map