@omniharmonic/openspace-cli 0.1.14 → 0.1.16

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-JDVCOOGD.js} +1990 -129
  6. package/dist/chunk-JDVCOOGD.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) } : {}
@@ -43913,13 +45274,24 @@ var ToolContext = class {
43913
45274
  const allowed = await this.allowedSites();
43914
45275
  if (input) {
43915
45276
  if (allowed.includes(input)) return input;
45277
+ const wanted = input.trim().toLowerCase();
45278
+ const named = (await this.store.listSites()).filter(
45279
+ (s2) => allowed.includes(s2.id) && s2.title.trim().toLowerCase() === wanted
45280
+ );
45281
+ if (named.length === 1 && named[0]) return named[0].id;
45282
+ const explained = await this.store.explainUnreachable?.(input);
45283
+ if (explained) throw explained;
43916
45284
  throw new ToolError(
43917
45285
  `This connection can't reach site "${input}". Sites available: ${allowed.join(", ") || "none"}.`,
43918
45286
  "forbidden"
43919
45287
  );
43920
45288
  }
43921
45289
  if (allowed.length === 1) return allowed[0];
43922
- if (!allowed.length) throw new ToolError("This connection has no sites.", "forbidden");
45290
+ if (!allowed.length) {
45291
+ const explained = await this.store.explainUnreachable?.(void 0);
45292
+ if (explained) throw explained;
45293
+ throw new ToolError("This connection has no sites.", "forbidden");
45294
+ }
43923
45295
  throw new ToolError(
43924
45296
  `Several sites are available; pass "site" (one of: ${allowed.join(", ")}).`,
43925
45297
  "invalid_arguments"
@@ -44048,7 +45420,7 @@ The same rules power Studio's built-in Design check. Follow them so pages look d
44048
45420
  6. **Two typefaces maximum.** Change the pairing in Site Styles rather than per block.
44049
45421
  7. **Never invent facts, testimonials, prices or credentials.** Ask the owner.
44050
45422
  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.
45423
+ 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
45424
  10. **Prefer fewer, stronger sections** over many thin ones.
44053
45425
  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
45426
 
@@ -44067,12 +45439,12 @@ var ITERATION_GUIDANCE = `# Design iteration loop
44067
45439
  Repeat until the page is right, keeping each pass small:
44068
45440
 
44069
45441
  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.
45442
+ 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
45443
  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
45444
  4. **Look** \u2014 screenshot the page (or just the section you changed) at desktop and mobile; use render_page when screenshots are unavailable.
44073
45445
  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
45446
 
44075
- Finish with design_check showing zero errors and get_draft_diff to summarise the work for the owner.
45447
+ 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
45448
  `;
44077
45449
 
44078
45450
  // ../mcp-tools/src/images.ts
@@ -44454,11 +45826,11 @@ ${ITERATION_GUIDANCE}
44454
45826
  Do at most five passes, and after each pass say in one line what you changed and what you saw in the screenshot. Stop when design_check shows no errors and the page reads clearly on desktop and mobile.`
44455
45827
  }
44456
45828
  ];
44457
- function getPrompt(name, args = {}) {
44458
- const p = PROMPTS.find((x) => x.name === name);
45829
+ function getPrompt(name, args = {}, prompts = PROMPTS) {
45830
+ const p = prompts.find((x) => x.name === name);
44459
45831
  if (!p)
44460
45832
  throw new ToolError(
44461
- `Unknown prompt "${name}". Prompts: ${PROMPTS.map((x) => x.name).join(", ")}.`,
45833
+ `Unknown prompt "${name}". Prompts: ${prompts.map((x) => x.name).join(", ")}.`,
44462
45834
  "not_found"
44463
45835
  );
44464
45836
  const missing = p.arguments.filter((x) => x.required && !args[x.name]?.trim()).map((x) => x.name);
@@ -44492,7 +45864,7 @@ function renderPreviewHtml(doc, pageId, opts = {}) {
44492
45864
  const html = renderPageHtml({
44493
45865
  doc,
44494
45866
  pageId: target,
44495
- mode: "preview",
45867
+ mode: opts.production ? "production" : "preview",
44496
45868
  basePath: opts.basePath ?? "",
44497
45869
  behaviorsUrl: opts.behaviorsUrl,
44498
45870
  extraHead: blocksStyleTag()
@@ -45650,10 +47022,10 @@ var listSectionPresets = defineTool({
45650
47022
  let list5 = presetCatalog.slice();
45651
47023
  if (input.category) list5 = list5.filter((p) => p.category === input.category);
45652
47024
  if (input.query) {
45653
- const words3 = input.query.toLowerCase().split(/\s+/).filter(Boolean);
47025
+ const words4 = input.query.toLowerCase().split(/\s+/).filter(Boolean);
45654
47026
  list5 = list5.filter((p) => {
45655
47027
  const hay = `${p.id} ${p.label} ${p.description} ${p.tags.join(" ")} ${p.ai.whenToUse}`.toLowerCase();
45656
- return words3.every((w) => hay.includes(w));
47028
+ return words4.every((w) => hay.includes(w));
45657
47029
  });
45658
47030
  }
45659
47031
  const full = input.detail === "full";
@@ -46012,12 +47384,12 @@ var designCheckTool = defineTool({
46012
47384
  const issues = runChecks(doc, pageId, input.checks ?? CHECKS, ctx.now()).filter(
46013
47385
  (i) => RANK[i.severity] <= min
46014
47386
  );
46015
- const counts = { error: 0, warning: 0, info: 0 };
46016
- for (const i of issues) counts[i.severity]++;
47387
+ const counts2 = { error: 0, warning: 0, info: 0 };
47388
+ for (const i of issues) counts2[i.severity]++;
46017
47389
  return {
46018
47390
  data: {
46019
47391
  ...pageId ? { page: pageId } : {},
46020
- counts,
47392
+ counts: counts2,
46021
47393
  issues: issues.map((i) => ({
46022
47394
  id: i.id,
46023
47395
  rule: i.rule,
@@ -46078,9 +47450,9 @@ var applyFix = defineTool({
46078
47450
  });
46079
47451
  }
46080
47452
  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 } };
47453
+ const counts2 = { error: 0, warning: 0, info: 0 };
47454
+ for (const i of remaining) counts2[i.severity]++;
47455
+ return { data: { applied, skipped, remaining: counts2 } };
46084
47456
  }
46085
47457
  });
46086
47458
  var preview = defineTool({
@@ -47206,6 +48578,143 @@ var pageTools = [
47206
48578
  listPosts
47207
48579
  ];
47208
48580
 
48581
+ // ../mcp-tools/src/tools/phone.ts
48582
+ var PHONE_PAGE_LIMIT = 12;
48583
+ var stripMeta2 = (op2) => {
48584
+ const { id: _i, actor: _a3, ts: _t, ...body } = op2;
48585
+ return body;
48586
+ };
48587
+ async function measure(ctx, siteId, doc, pages, wantRendered) {
48588
+ const now = ctx.now();
48589
+ const statics = phoneCheck(doc, pages.length === 1 ? pages[0]?.id : void 0, {
48590
+ actor: "phone-check",
48591
+ now
48592
+ });
48593
+ const store = ctx.store;
48594
+ if (!wantRendered || !store.capabilities.canMeasurePhone || !store.measurePhone)
48595
+ return {
48596
+ issues: statics,
48597
+ rendered: false,
48598
+ measuredPages: [],
48599
+ ...wantRendered ? {
48600
+ 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."
48601
+ } : {}
48602
+ };
48603
+ const measured = pages.slice(0, PHONE_PAGE_LIMIT);
48604
+ const rendered2 = [];
48605
+ let note;
48606
+ for (const page of measured) {
48607
+ const html = renderPreviewHtml(doc, page.id, { production: true });
48608
+ const results = [];
48609
+ try {
48610
+ for (const width of PHONE_WIDTHS)
48611
+ results.push(await store.measurePhone(siteId, { pageId: page.id, width, html }));
48612
+ } catch (e) {
48613
+ note = `The browser check stopped at \u201C${page.title}\u201D: ${e instanceof Error ? e.message : String(e)}`;
48614
+ break;
48615
+ }
48616
+ rendered2.push(...phoneIssuesFromProbe(doc, page.id, results, { actor: "phone-check", now }));
48617
+ }
48618
+ if (!note && pages.length > measured.length)
48619
+ note = `Measured the first ${measured.length} of ${pages.length} pages; run phone_check with page for the others.`;
48620
+ const seen = /* @__PURE__ */ new Set();
48621
+ const deduped = rendered2.filter((i) => {
48622
+ if (i.page !== "footer") return true;
48623
+ const key2 = `${i.rule}|${i.message}`;
48624
+ if (seen.has(key2)) return false;
48625
+ seen.add(key2);
48626
+ return true;
48627
+ });
48628
+ return {
48629
+ issues: sortIssues([...statics, ...deduped]),
48630
+ rendered: true,
48631
+ measuredPages: measured.map((p) => p.id),
48632
+ ...note ? { note } : {}
48633
+ };
48634
+ }
48635
+ var pagesFor = (doc, page) => page ? [resolvePage(doc, page).page] : Object.values(doc.pages);
48636
+ var describe2 = (i) => ({
48637
+ id: i.id,
48638
+ rule: i.rule,
48639
+ severity: i.severity,
48640
+ ...i.page ? { page: i.page } : {},
48641
+ ...i.section ? { section: i.section } : {},
48642
+ ...i.block ? { block: i.block } : {},
48643
+ message: i.message,
48644
+ fix: i.fix,
48645
+ autofix: i.autofix?.label ?? null
48646
+ });
48647
+ var counts = (issues) => {
48648
+ const c = { error: 0, warning: 0, info: 0 };
48649
+ for (const i of issues) c[i.severity]++;
48650
+ return c;
48651
+ };
48652
+ var phoneCheckTool = defineTool({
48653
+ name: "phone_check",
48654
+ title: "Phone check",
48655
+ 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.",
48656
+ input: external_exports.object({
48657
+ site: siteArg,
48658
+ page: external_exports.string().optional().describe("Page id/slug/title (default: every page)."),
48659
+ rendered: external_exports.boolean().optional().describe("Measure in a real browser when available (default true).")
48660
+ }),
48661
+ annotations: { readOnlyHint: true },
48662
+ async handler(ctx, input) {
48663
+ const siteId = await ctx.siteId(input.site);
48664
+ const doc = await ctx.doc(siteId);
48665
+ const r = await measure(ctx, siteId, doc, pagesFor(doc, input.page), input.rendered !== false);
48666
+ const c = counts(r.issues);
48667
+ return {
48668
+ data: {
48669
+ rendered: r.rendered,
48670
+ ...r.note ? { note: r.note } : {},
48671
+ counts: c,
48672
+ issues: r.issues.map(describe2),
48673
+ 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."
48674
+ }
48675
+ };
48676
+ }
48677
+ });
48678
+ var fixForPhonesTool = defineTool({
48679
+ name: "fix_for_phones",
48680
+ title: "Fix everything for phones",
48681
+ 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.",
48682
+ input: external_exports.object({
48683
+ site: siteArg,
48684
+ page: external_exports.string().optional().describe("Limit to a page (default: every page).")
48685
+ }),
48686
+ annotations: {},
48687
+ async handler(ctx, input) {
48688
+ const siteId = await ctx.siteId(input.site);
48689
+ let doc = await ctx.doc(siteId);
48690
+ const pages = pagesFor(doc, input.page);
48691
+ const before = await measure(ctx, siteId, doc, pages, true);
48692
+ const r = fixForPhones(doc, {
48693
+ ...input.page ? { pageId: pages[0]?.id } : {},
48694
+ actor: "phone-check",
48695
+ now: ctx.now(),
48696
+ rendered: before.issues
48697
+ });
48698
+ if (r.ops.length) {
48699
+ const applied = await ctx.apply(siteId, r.ops.map(stripMeta2));
48700
+ doc = applied.doc;
48701
+ }
48702
+ const after = await measure(ctx, siteId, doc, pagesFor(doc, input.page), true);
48703
+ const labels = /* @__PURE__ */ new Map();
48704
+ for (const f of r.fixed) labels.set(f.label, (labels.get(f.label) ?? 0) + 1);
48705
+ return {
48706
+ data: {
48707
+ fixed: [...labels].map(([label, n]) => n > 1 ? `${label} (${n}\xD7)` : label),
48708
+ rendered: after.rendered,
48709
+ remaining: counts(after.issues),
48710
+ issues: after.issues.filter((i) => i.severity !== "info").map(describe2),
48711
+ 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."
48712
+ }
48713
+ };
48714
+ }
48715
+ });
48716
+ var phoneTools = [phoneCheckTool, fixForPhonesTool];
48717
+
47209
48718
  // ../mcp-tools/src/tools/publish.ts
47210
48719
  var getDraftDiff = defineTool({
47211
48720
  name: "get_draft_diff",
@@ -47351,6 +48860,7 @@ var ALL_TOOLS = [
47351
48860
  ...pageTools,
47352
48861
  ...designSystemTools,
47353
48862
  ...designTools,
48863
+ ...phoneTools,
47354
48864
  ...componentTools,
47355
48865
  ...publishTools
47356
48866
  ];
@@ -47396,14 +48906,21 @@ var OpenSpaceMcpServer = class {
47396
48906
  this.initialized = Boolean(options.assumeInitialized);
47397
48907
  for (const t of ALL_TOOLS) this.tools.set(t.name, t);
47398
48908
  for (const t of options.tools ?? []) this.tools.set(t.name, t);
48909
+ const extra = options.prompts ?? [];
48910
+ this.prompts = [...PROMPTS.filter((p) => !extra.some((e) => e.name === p.name)), ...extra];
47399
48911
  }
47400
48912
  options;
47401
48913
  ctx;
47402
48914
  tools = /* @__PURE__ */ new Map();
48915
+ prompts;
47403
48916
  clientName;
47404
48917
  initialized;
47405
48918
  protocolVersion;
47406
48919
  closed = false;
48920
+ /** Prompts served by this instance. */
48921
+ promptList() {
48922
+ return [...this.prompts];
48923
+ }
47407
48924
  /** Tool definitions served by this instance. */
47408
48925
  toolList() {
47409
48926
  return [...this.tools.values()];
@@ -47525,7 +49042,7 @@ var OpenSpaceMcpServer = class {
47525
49042
  }
47526
49043
  case "prompts/list":
47527
49044
  return {
47528
- prompts: PROMPTS.map((p) => ({
49045
+ prompts: this.prompts.map((p) => ({
47529
49046
  name: p.name,
47530
49047
  title: p.title,
47531
49048
  description: p.description,
@@ -47536,7 +49053,7 @@ var OpenSpaceMcpServer = class {
47536
49053
  if (typeof params.name !== "string")
47537
49054
  throw new RpcError(ErrorCode.InvalidParams, "name is required");
47538
49055
  const args = isRecord5(params.arguments) ? Object.fromEntries(Object.entries(params.arguments).map(([k, v]) => [k, String(v)])) : {};
47539
- return getPrompt(params.name, args);
49056
+ return getPrompt(params.name, args, this.prompts);
47540
49057
  }
47541
49058
  case "completion/complete":
47542
49059
  return { completion: { values: [], hasMore: false } };
@@ -47564,7 +49081,7 @@ var OpenSpaceMcpServer = class {
47564
49081
  logging: {}
47565
49082
  },
47566
49083
  serverInfo: { name: SERVER_NAME, title: "OpenSpace", version: SERVER_VERSION },
47567
- instructions: SERVER_INSTRUCTIONS
49084
+ instructions: this.options.instructions ?? SERVER_INSTRUCTIONS
47568
49085
  };
47569
49086
  }
47570
49087
  /** Name the client reported in `initialize`. */
@@ -48142,6 +49659,25 @@ function squarespaceRenditions(u, width, height, canResize = true) {
48142
49659
  ];
48143
49660
  }
48144
49661
 
49662
+ // ../import/src/phone.ts
49663
+ function phoneReady(result) {
49664
+ let fixed;
49665
+ try {
49666
+ fixed = fixForPhones(result.doc, { actor: "import", now: 0 });
49667
+ } catch {
49668
+ return result;
49669
+ }
49670
+ if (!fixed.ops.length) return result;
49671
+ const labels = [...new Set(fixed.fixed.map((f) => f.label.toLowerCase()))];
49672
+ const line = `
49673
+ ## Phones
49674
+
49675
+ 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.
49676
+ `;
49677
+ return { ...result, doc: fixed.doc, report: `${result.report.trimEnd()}
49678
+ ${line}` };
49679
+ }
49680
+
48145
49681
  // ../import/src/report.ts
48146
49682
  var NOTICE_TITLES = {
48147
49683
  "unsupported-block": "Blocks that need a look",
@@ -48277,17 +49813,17 @@ function renderReport(r) {
48277
49813
  any = true;
48278
49814
  L2.push(`### ${NOTICE_TITLES[kind]}`);
48279
49815
  L2.push("");
48280
- const counts = /* @__PURE__ */ new Map();
49816
+ const counts2 = /* @__PURE__ */ new Map();
48281
49817
  for (const n of list5) {
48282
- const e = counts.get(n.message) ?? { n: 0, where: /* @__PURE__ */ new Set() };
49818
+ const e = counts2.get(n.message) ?? { n: 0, where: /* @__PURE__ */ new Set() };
48283
49819
  e.n++;
48284
49820
  e.where.add(n.where);
48285
- counts.set(n.message, e);
49821
+ counts2.set(n.message, e);
48286
49822
  }
48287
49823
  let shown = 0;
48288
- for (const [message, e] of counts) {
49824
+ for (const [message, e] of counts2) {
48289
49825
  if (++shown > 15) {
48290
- L2.push(`- \u2026 and ${counts.size - 15} more like these.`);
49826
+ L2.push(`- \u2026 and ${counts2.size - 15} more like these.`);
48291
49827
  break;
48292
49828
  }
48293
49829
  const where2 = [...e.where];
@@ -48680,7 +50216,7 @@ async function crawlWordPress(opts) {
48680
50216
  ...it.excerptHtml ? { caption: it.excerptHtml } : {}
48681
50217
  });
48682
50218
  const entries = [];
48683
- const pathOf = (link2, fallback2) => (link2 ? sitePath(link2, base) : void 0) ?? fallback2;
50219
+ const pathOf2 = (link2, fallback2) => (link2 ? sitePath(link2, base) : void 0) ?? fallback2;
48684
50220
  if (rest) {
48685
50221
  const [pages2, posts] = [
48686
50222
  await listAll("wp/v2/pages", "_embed=1&orderby=menu_order&order=asc"),
@@ -48707,7 +50243,7 @@ async function crawlWordPress(opts) {
48707
50243
  }
48708
50244
  entries.push({
48709
50245
  type,
48710
- path: pathOf(link2, `/${slug}`),
50246
+ path: pathOf2(link2, `/${slug}`),
48711
50247
  slug,
48712
50248
  title: decodeEntities2(rendered(p.title)) || slug || "Untitled",
48713
50249
  html: rendered(p.content),
@@ -48774,7 +50310,7 @@ async function crawlWordPress(opts) {
48774
50310
  });
48775
50311
  }
48776
50312
  if (!rest && !wxr && home) {
48777
- const { crawlGeneric: crawlGeneric2 } = await import("./crawl-YPDLNAN7.js");
50313
+ const { crawlGeneric: crawlGeneric2 } = await import("./crawl-WQQDFI7W.js");
48778
50314
  const site2 = await crawlGeneric2({
48779
50315
  url: base,
48780
50316
  fetcher: f,
@@ -49058,6 +50594,7 @@ function componentFromBlueprint(input) {
49058
50594
  ".gx__img picture, .gx__img img { display: block; width: 100%; height: 100%; object-fit: cover; }"
49059
50595
  ];
49060
50596
  const phone = [];
50597
+ const named = [];
49061
50598
  let headingSeen = false;
49062
50599
  const role = (family) => {
49063
50600
  if (norm5(family) && norm5(family) === norm5(input.roles.label)) return "label";
@@ -49123,6 +50660,7 @@ function componentFromBlueprint(input) {
49123
50660
  const key3 = `image${n}`;
49124
50661
  fields[key3] = { kind: "image", label: `Picture ${n}` };
49125
50662
  values[key3] = { src: it.src, alt: (it.alt ?? "").slice(0, 300) };
50663
+ named.push({ role: "picture", text: it.alt ?? "", keys: [key3] });
49126
50664
  parts.push(
49127
50665
  `<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
50666
  );
@@ -49141,6 +50679,7 @@ function componentFromBlueprint(input) {
49141
50679
  };
49142
50680
  values[label] = (it.text ?? "").slice(0, 60);
49143
50681
  if (!target) {
50682
+ named.push({ role: it.kind, text: it.text ?? "", keys: [label] });
49144
50683
  parts.push(`<p class="${cls}">{{${label}}}</p>`);
49145
50684
  } else {
49146
50685
  const lk = `link${n}`;
@@ -49149,6 +50688,7 @@ function componentFromBlueprint(input) {
49149
50688
  label: `${it.kind === "button" ? "Button" : "Link"} ${n} goes to`
49150
50689
  };
49151
50690
  values[lk] = target;
50691
+ named.push({ role: it.kind, text: it.text ?? "", keys: [label, lk] });
49152
50692
  parts.push(
49153
50693
  it.kind === "button" ? `<div class="${cls}">{{button ${lk} label=${label} variant="primary"}}</div>` : `<p class="${cls}">{{#link ${lk}}}{{${label}}}{{/link}}</p>`
49154
50694
  );
@@ -49170,6 +50710,7 @@ function componentFromBlueprint(input) {
49170
50710
  maxLength: 3e3
49171
50711
  };
49172
50712
  values[key2] = doc;
50713
+ named.push({ role: textRole(it), text: it.text ?? "", keys: [key2] });
49173
50714
  parts.push(`<div class="${cls}">{{rich ${key2}}}</div>`);
49174
50715
  css.push(`.gx__i${n} { ${place}; ${typeCss(it.style, false)}; }`);
49175
50716
  phone.push(`.gx__i${n} { ${typeCss(it.style, true)}; }`);
@@ -49183,6 +50724,7 @@ function componentFromBlueprint(input) {
49183
50724
  ...it.kind === "text" ? { multiline: true } : {}
49184
50725
  };
49185
50726
  values[key2] = text7;
50727
+ named.push({ role: it.kind === "heading" ? "heading" : textRole(it), text: text7, keys: [key2] });
49186
50728
  const tag = it.kind === "heading" ? headingSeen ? "h3" : "h2" : "p";
49187
50729
  if (it.kind === "heading") headingSeen = true;
49188
50730
  parts.push(`<${tag} class="${cls}">{{${key2}}}</${tag}>`);
@@ -49190,6 +50732,14 @@ function componentFromBlueprint(input) {
49190
50732
  phone.push(`.gx__i${n} { ${typeCss(it.style, true)}; }`);
49191
50733
  });
49192
50734
  if (!parts.length) return void 0;
50735
+ for (const [i, label] of partLabels(named).entries()) {
50736
+ const part = named[i];
50737
+ const [first2, second] = part.keys;
50738
+ const f = fields[first2];
50739
+ if (f) fields[first2] = { ...f, label };
50740
+ const l = second ? fields[second] : void 0;
50741
+ if (second && l) fields[second] = { ...l, label: `${label} goes to` };
50742
+ }
49193
50743
  css.push(
49194
50744
  `@media (max-width: 767px) {
49195
50745
  .gx { display: flex; flex-direction: column; gap: var(--os-space-sm); }
@@ -49212,6 +50762,53 @@ ${parts.map((p) => ` ${p}`).join("\n")}
49212
50762
  };
49213
50763
  return { def, fields: values };
49214
50764
  }
50765
+ function textRole(it) {
50766
+ const words4 = (it.text ?? "").trim().split(/\s+/).filter(Boolean).length;
50767
+ const caps = it.style?.case === "uppercase" || /^[^a-z]*[A-Z][^a-z]*$/.test(it.text ?? "");
50768
+ return words4 <= 5 && (caps || (it.style?.size ?? 16) <= 14) ? "small" : "paragraph";
50769
+ }
50770
+ var ROLE_LABEL = {
50771
+ heading: "Heading",
50772
+ small: "Small label",
50773
+ paragraph: "Paragraph",
50774
+ picture: "Picture",
50775
+ button: "Button",
50776
+ link: "Link"
50777
+ };
50778
+ function snippet(text7) {
50779
+ const words4 = text7.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
50780
+ let out = "";
50781
+ for (const w of words4) {
50782
+ if (`${out} ${w}`.trim().length > 24) break;
50783
+ out = `${out} ${w}`.trim();
50784
+ }
50785
+ if (!out && words4[0]) out = words4[0].slice(0, 22);
50786
+ if (!out) return "";
50787
+ const cased = /[a-z]/.test(out) ? out : out.charAt(0) + out.slice(1).toLowerCase();
50788
+ return `${cased}${out.length < text7.trim().length ? "\u2026" : ""}`;
50789
+ }
50790
+ function partLabels(parts) {
50791
+ const counts2 = /* @__PURE__ */ new Map();
50792
+ for (const p of parts) counts2.set(p.role, (counts2.get(p.role) ?? 0) + 1);
50793
+ const used = /* @__PURE__ */ new Set();
50794
+ const seen = /* @__PURE__ */ new Map();
50795
+ return parts.map((p) => {
50796
+ const nth = (seen.get(p.role) ?? 0) + 1;
50797
+ seen.set(p.role, nth);
50798
+ const base = ROLE_LABEL[p.role];
50799
+ let label;
50800
+ if ((counts2.get(p.role) ?? 0) === 1) label = base;
50801
+ else if (p.role === "button" && nth === 1) label = "Main button";
50802
+ else {
50803
+ const words4 = p.role === "picture" ? "" : snippet(p.text);
50804
+ label = words4 ? `${base} \u201C${words4}\u201D` : `${base} ${nth}`;
50805
+ }
50806
+ let unique = label;
50807
+ for (let k = 2; used.has(unique); k++) unique = `${label} ${k}`;
50808
+ used.add(unique);
50809
+ return unique.slice(0, 60);
50810
+ });
50811
+ }
49215
50812
  function richDoc(runs, origin) {
49216
50813
  if (!runs?.length) return void 0;
49217
50814
  const content3 = [];
@@ -49742,8 +51339,8 @@ function tuneRound(input, measured, groups = TUNE_GROUPS, opts = {}) {
49742
51339
  if (b.type !== "text" || done) continue;
49743
51340
  const doc2 = b.fields.content;
49744
51341
  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;
51342
+ const words4 = (node2.content ?? []).map((x) => x.text ?? "").join("");
51343
+ if (node2.type !== "heading" || norm6(words4) !== norm6(i.text)) continue;
49747
51344
  if (pick === "lead" || node2.attrs?.preset === pick) break;
49748
51345
  node2.attrs = { ...node2.attrs ?? {}, preset: pick };
49749
51346
  done = true;
@@ -49896,7 +51493,12 @@ function tuneRound(input, measured, groups = TUNE_GROUPS, opts = {}) {
49896
51493
  const theme = themes?.[old.style.colorTheme ?? "light"];
49897
51494
  const gen = componentFromBlueprint({
49898
51495
  name,
49899
- label: `${s2.heading ?? "Section"} (as on the original)`.slice(0, 60),
51496
+ label: sectionLabel(
51497
+ s2.heading ?? o.heading?.text,
51498
+ o.blueprint,
51499
+ page.title || (page.slug ? page.slug : "Home"),
51500
+ s2.index + 1
51501
+ ),
49900
51502
  section: o,
49901
51503
  roles,
49902
51504
  ...theme ? {
@@ -49957,6 +51559,14 @@ function setRole(doc, theme, role, value) {
49957
51559
  doc.styles.overrides = overrides;
49958
51560
  return true;
49959
51561
  }
51562
+ function sectionLabel(heading2, blueprint, pageTitle, n) {
51563
+ const first2 = heading2?.trim() || blueprint?.find((i) => i.kind === "heading" && i.text?.trim())?.text || blueprint?.find((i) => i.kind === "text" && i.text?.trim())?.text;
51564
+ const words4 = (first2 ?? "").replace(/\s+/g, " ").trim();
51565
+ if (!words4) return `${pageTitle} section ${n}`.slice(0, 60);
51566
+ const short = words4.length > 40 ? `${words4.slice(0, 40).replace(/\s+\S*$/, "")}\u2026` : words4;
51567
+ const cased = /[a-z]/.test(short) ? short : short.charAt(0) + short.slice(1).toLowerCase();
51568
+ return cased.slice(0, 60);
51569
+ }
49960
51570
 
49961
51571
  // ../import/src/index.ts
49962
51572
  var REPORT_FILE = "IMPORT-REPORT.md";
@@ -50166,6 +51776,9 @@ async function detectSite(url, opts = {}) {
50166
51776
  return { ...detectPlatform(r.text, { "content-type": r.contentType }), url: r.url || base };
50167
51777
  }
50168
51778
  async function importSite(opts) {
51779
+ return phoneReady(await importAnySite(opts));
51780
+ }
51781
+ async function importAnySite(opts) {
50169
51782
  let detection;
50170
51783
  let platform;
50171
51784
  if (opts.repoDir) return importGitHub(opts);
@@ -50228,7 +51841,7 @@ async function renderAgentsMd(root, siteTitle) {
50228
51841
  } catch {
50229
51842
  }
50230
51843
  }
50231
- const { generateAgentsMd: generateAgentsMd2 } = await import("./agents-md-63S3T7CX.js");
51844
+ const { generateAgentsMd: generateAgentsMd2 } = await import("./agents-md-OWDSHBGF.js");
50232
51845
  return generateAgentsMd2(title ? { siteTitle: title } : {});
50233
51846
  }
50234
51847
  async function writeAgentsMd(root, siteTitle) {
@@ -50507,7 +52120,7 @@ function compareFidelityTool(opts) {
50507
52120
  [],
50508
52121
  "Install Playwright next to the CLI (npm i -D playwright && npx playwright install chromium) and restart the MCP server."
50509
52122
  );
50510
- const widths = input.device === "desktop" ? [DESKTOP] : input.device === "mobile" ? [PHONE] : [DESKTOP, PHONE];
52123
+ const widths = input.device === "desktop" ? [DESKTOP] : input.device === "mobile" ? [PHONE2] : [DESKTOP, PHONE2];
50511
52124
  const run2 = opts.compare ?? runCompare;
50512
52125
  const r = await run2(u.toString(), opts.siteRoot, {
50513
52126
  ...input.pages?.length ? { pages: input.pages } : {},
@@ -50710,8 +52323,8 @@ var DEFAULTS = {
50710
52323
  products: []
50711
52324
  };
50712
52325
  var FILE = {
50713
- rss: { file: "rss.xml", label: "RSS", mime: "application/rss+xml" },
50714
- atom: { file: "atom.xml", label: "Atom", mime: "application/atom+xml" },
52326
+ rss: { file: "rss.xml", label: "RSS feed", mime: "application/rss+xml" },
52327
+ atom: { file: "atom.xml", label: "Atom feed", mime: "application/atom+xml" },
50715
52328
  ical: { file: "calendar.ics", label: "Add to calendar (iCal)", mime: "text/calendar" },
50716
52329
  podcast: { file: "podcast.xml", label: "Podcast feed", mime: "application/rss+xml" }
50717
52330
  };
@@ -51526,7 +53139,11 @@ async function startPreviewServer(root, load, opts = {}) {
51526
53139
  page = Object.values(view.pages).find((p) => p.slug === slug);
51527
53140
  }
51528
53141
  if (page) {
51529
- const html = renderPreviewHtml(view, page.id, { behaviorsUrl: "/_os/behaviors" });
53142
+ const html = renderPreviewHtml(view, page.id, {
53143
+ behaviorsUrl: "/_os/behaviors",
53144
+ // `?os_mode=production`: exactly as published (the Phone check measures this).
53145
+ production: url.searchParams.get("os_mode") === "production"
53146
+ });
51530
53147
  res.writeHead(200, {
51531
53148
  "content-type": "text/html; charset=utf-8",
51532
53149
  "cache-control": "no-store"
@@ -51590,6 +53207,7 @@ var FileSiteStore = class _FileSiteStore {
51590
53207
  canPublish: store.hasGit,
51591
53208
  canRollback: store.hasGit,
51592
53209
  canScreenshot: Boolean(options.screenshotter),
53210
+ canMeasurePhone: Boolean(options.screenshotter),
51593
53211
  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
53212
  };
51595
53213
  return store;
@@ -51898,6 +53516,16 @@ Unpublished work saved as ${tag}`,
51898
53516
  const url = await this.previewUrl(siteId, path2);
51899
53517
  return this.options.screenshotter.capture(url, request);
51900
53518
  }
53519
+ async measurePhone(siteId, request) {
53520
+ this.check(siteId);
53521
+ if (!this.options.screenshotter)
53522
+ throw new ToolError("The browser phone check is not available.", "unsupported");
53523
+ const doc = await loadSiteDocument(this.root);
53524
+ const page = doc.pages[request.pageId];
53525
+ const path2 = page?.slug ? `/${page.slug}/` : "/";
53526
+ const url = await this.previewUrl(siteId, `${path2}?os_mode=production`);
53527
+ return this.options.screenshotter.measure(url, request.width);
53528
+ }
51901
53529
  async agentsMd() {
51902
53530
  try {
51903
53531
  return await readFile9(join11(this.root, "AGENTS.md"), "utf8");
@@ -52013,6 +53641,32 @@ function createScreenshotter(chromium) {
52013
53641
  await page.close().catch(() => void 0);
52014
53642
  }
52015
53643
  },
53644
+ async measure(url, width) {
53645
+ browser ??= launch2();
53646
+ let b;
53647
+ try {
53648
+ b = await browser;
53649
+ } catch (e) {
53650
+ browser = void 0;
53651
+ throw e;
53652
+ }
53653
+ const context = await b.newContext({
53654
+ viewport: { width, height: PHONE_HEIGHT },
53655
+ deviceScaleFactor: 2,
53656
+ isMobile: true,
53657
+ hasTouch: true,
53658
+ reducedMotion: "reduce"
53659
+ });
53660
+ const page = await context.newPage();
53661
+ try {
53662
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
53663
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => void 0);
53664
+ await page.evaluate(PHONE_PREPARE_SCRIPT).catch(() => void 0);
53665
+ return await page.evaluate(PHONE_PROBE_SCRIPT);
53666
+ } finally {
53667
+ await context.close().catch(() => void 0);
53668
+ }
53669
+ },
52016
53670
  async close() {
52017
53671
  if (!browser) return;
52018
53672
  const b = await browser.catch(() => void 0);
@@ -52232,6 +53886,147 @@ function rebrand(value, from, to) {
52232
53886
  return changed ? out : null;
52233
53887
  }
52234
53888
 
53889
+ // src/phone.ts
53890
+ var pathOf = (p) => p.slug ? `/${p.slug}/` : "/";
53891
+ async function measureSite(root, load, doc, shooter, pages, widths, now) {
53892
+ const server = await startPreviewServer(root, load);
53893
+ const out = /* @__PURE__ */ new Map();
53894
+ try {
53895
+ for (const page of pages) {
53896
+ const results = [];
53897
+ for (const w of widths)
53898
+ results.push(await shooter.measure(`${server.url}${pathOf(page)}?os_mode=production`, w));
53899
+ out.set(page.id, phoneIssuesFromProbe(doc, page.id, results, { now, actor: "phone-check" }));
53900
+ }
53901
+ } finally {
53902
+ await server.close();
53903
+ }
53904
+ return out;
53905
+ }
53906
+ async function runPhoneCheck(root, opts = {}) {
53907
+ const now = opts.now ?? Date.now();
53908
+ const widths = opts.widths ?? PHONE_WIDTHS;
53909
+ let doc = await loadSiteDocument(root);
53910
+ const pick = (d) => Object.values(d.pages).filter(
53911
+ (p) => !opts.pages?.length || opts.pages.includes(p.id) || opts.pages.includes(p.slug)
53912
+ );
53913
+ let shooter = opts.screenshotter;
53914
+ let note;
53915
+ let owned = false;
53916
+ if (!shooter && opts.rendered !== false) {
53917
+ const pw = await loadPlaywright();
53918
+ if ("error" in pw)
53919
+ 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.";
53920
+ else {
53921
+ shooter = createScreenshotter(pw.chromium);
53922
+ owned = true;
53923
+ }
53924
+ }
53925
+ let current2 = doc;
53926
+ const load = async () => current2;
53927
+ const measure2 = async () => shooter && opts.rendered !== false ? measureSite(root, load, current2, shooter, pick(current2), widths, now).catch((e) => {
53928
+ note = `The browser check couldn't run: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`;
53929
+ return /* @__PURE__ */ new Map();
53930
+ }) : /* @__PURE__ */ new Map();
53931
+ try {
53932
+ let rendered2 = await measure2();
53933
+ const fixed = [];
53934
+ const written = [];
53935
+ if (opts.fix) {
53936
+ const r = fixForPhones(doc, {
53937
+ now,
53938
+ actor: "phone-check",
53939
+ rendered: [...rendered2.values()].flat()
53940
+ });
53941
+ if (r.ops.length) {
53942
+ doc = r.doc;
53943
+ current2 = doc;
53944
+ const w = await writeSiteDocument(root, doc);
53945
+ written.push(...w.written);
53946
+ fixed.push(...r.fixed);
53947
+ rendered2 = await measure2();
53948
+ }
53949
+ }
53950
+ const statics = phoneCheck(doc, void 0, { now, actor: "phone-check" });
53951
+ const pages = pick(doc).map((p) => ({
53952
+ page: p.id,
53953
+ title: p.title,
53954
+ path: pathOf(p),
53955
+ issues: sortIssues([
53956
+ ...statics.filter((i) => i.page === p.id),
53957
+ ...(rendered2.get(p.id) ?? []).filter((i) => i.page === p.id)
53958
+ ])
53959
+ }));
53960
+ const elsewhere = [
53961
+ ...statics.filter((i) => !i.page || !doc.pages[i.page]),
53962
+ // Footer findings repeat on every page: report each once.
53963
+ ...dedupe([...rendered2.values()].flat().filter((i) => i.page === "footer"))
53964
+ ];
53965
+ const issues = sortIssues([...elsewhere, ...pages.flatMap((p) => p.issues)]);
53966
+ const counts2 = { error: 0, warning: 0, info: 0 };
53967
+ for (const i of issues) counts2[i.severity]++;
53968
+ return {
53969
+ rendered: !!shooter && opts.rendered !== false && !note,
53970
+ ...note ? { note } : {},
53971
+ issues,
53972
+ pages,
53973
+ counts: counts2,
53974
+ fixed,
53975
+ written
53976
+ };
53977
+ } finally {
53978
+ if (owned) await shooter?.close();
53979
+ }
53980
+ }
53981
+ function dedupe(issues) {
53982
+ const seen = /* @__PURE__ */ new Set();
53983
+ return issues.filter((i) => {
53984
+ const key2 = `${i.rule}|${i.message}`;
53985
+ if (seen.has(key2)) return false;
53986
+ seen.add(key2);
53987
+ return true;
53988
+ });
53989
+ }
53990
+ function formatPhoneReport(r, paint2) {
53991
+ const lines = [];
53992
+ lines.push(paint2(1, r.rendered ? "Phone check (measured at 360 and 390 px)" : "Phone check"));
53993
+ if (r.note) lines.push(paint2(33, r.note));
53994
+ if (r.fixed.length) {
53995
+ lines.push(
53996
+ paint2(32, `Fixed for phones: ${r.fixed.length} change${r.fixed.length === 1 ? "" : "s"}.`)
53997
+ );
53998
+ for (const f of uniqueLabels(r.fixed)) lines.push(` \u2713 ${f}`);
53999
+ }
54000
+ const groups = /* @__PURE__ */ new Map();
54001
+ for (const i of r.issues) {
54002
+ const page = r.pages.find((p) => p.page === i.page);
54003
+ const key2 = page ? `${page.title} (${page.path})` : i.page === "footer" ? "Footer" : "Whole site";
54004
+ groups.set(key2, [...groups.get(key2) ?? [], i]);
54005
+ }
54006
+ for (const [where2, list5] of groups) {
54007
+ lines.push("", paint2(1, where2));
54008
+ for (const i of list5) {
54009
+ const sev = i.severity === "error" ? paint2(31, "error ") : i.severity === "warning" ? paint2(33, "warning") : "info ";
54010
+ lines.push(` ${sev} ${i.message}`);
54011
+ lines.push(` ${i.autofix ? `Fix for phones: ${i.autofix.label}.` : i.fix}`);
54012
+ }
54013
+ }
54014
+ const { error: error2, warning, info } = r.counts;
54015
+ lines.push("");
54016
+ lines.push(
54017
+ error2 + warning === 0 ? paint2(
54018
+ 32,
54019
+ `Looks great on phones${info ? ` (${info} suggestion${info === 1 ? "" : "s"})` : ""}.`
54020
+ ) : `${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." : ""}`
54021
+ );
54022
+ return lines.join("\n");
54023
+ }
54024
+ function uniqueLabels(fixed) {
54025
+ const counts2 = /* @__PURE__ */ new Map();
54026
+ for (const f of fixed) counts2.set(f.label, (counts2.get(f.label) ?? 0) + 1);
54027
+ return [...counts2].map(([l, n]) => n > 1 ? `${l} (${n}\xD7)` : l);
54028
+ }
54029
+
52235
54030
  // src/wow-first.ts
52236
54031
  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
54032
  function assertGitHubPagesStart(target) {
@@ -52410,6 +54205,7 @@ async function runImportSite(io, url, dirArg, opts = {}) {
52410
54205
  await writeShell(dir, existing.deploy?.target ?? "github-pages", slug);
52411
54206
  await writeImport(dir, result);
52412
54207
  await writeAgentsMd(dir, result.doc.site.title);
54208
+ await phoneFixRendered(dir, opts, io);
52413
54209
  const check2 = await runCheck(dir);
52414
54210
  const r2 = { dir, result, check: check2 };
52415
54211
  if (opts.summary) await writeSummary(resolve6(io.cwd, opts.summary), r2);
@@ -52427,11 +54223,21 @@ async function runImportSite(io, url, dirArg, opts = {}) {
52427
54223
  await rm6(join15(dir, entry), { recursive: true, force: true });
52428
54224
  throw e;
52429
54225
  }
54226
+ await phoneFixRendered(dir, opts, io);
52430
54227
  const check = await runCheck(dir);
52431
54228
  const r = { dir, result, check };
52432
54229
  if (opts.summary) await writeSummary(resolve6(io.cwd, opts.summary), r);
52433
54230
  return r;
52434
54231
  }
54232
+ async function phoneFixRendered(dir, opts, io) {
54233
+ if (!opts.render) return;
54234
+ try {
54235
+ const r = await runPhoneCheck(dir, { fix: true });
54236
+ if (r.fixed.length) io.err(` Fixed ${r.fixed.length} thing(s) for phones.`);
54237
+ } catch (e) {
54238
+ io.err(` Couldn't check the pages on a phone: ${e.message.split("\n")[0]}`);
54239
+ }
54240
+ }
52435
54241
  function importCounts(r) {
52436
54242
  const { result, check } = r;
52437
54243
  return {
@@ -56698,8 +58504,8 @@ var escapeText3 = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replac
56698
58504
  var escapeAttr2 = (s2) => s2.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\{\{/g, "&#123;&#123;");
56699
58505
  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
58506
  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]+/, "");
58507
+ const words4 = s2.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
58508
+ const out = words4.map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)).join("").replace(/^[^a-z]+/, "");
56703
58509
  return out.slice(0, 32);
56704
58510
  }
56705
58511
  function semanticName(el) {
@@ -56742,9 +58548,34 @@ function nameFor(el, fallback2 = "text") {
56742
58548
  if (!el) return fallback2;
56743
58549
  return semanticName(el) ?? TAG_NAME[tagOf(el)] ?? fallback2;
56744
58550
  }
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);
58551
+ var PLAIN_WORDS = {
58552
+ cta: "button",
58553
+ btn: "button",
58554
+ img: "picture",
58555
+ image: "picture",
58556
+ pic: "picture",
58557
+ photo: "picture",
58558
+ desc: "description",
58559
+ txt: "text",
58560
+ href: "link",
58561
+ url: "link",
58562
+ kicker: "small label",
58563
+ eyebrow: "small label",
58564
+ overline: "small label",
58565
+ lede: "intro",
58566
+ lead: "intro",
58567
+ subhead: "subheading",
58568
+ bg: "background",
58569
+ nav: "menu"
58570
+ };
58571
+ function humanize2(key2, kind) {
58572
+ 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);
58573
+ const linky = words4.some((w) => w === "button" || w === "link");
58574
+ if (linky && words4.length > 1 && words4[words4.length - 1] === "label")
58575
+ words4[words4.length - 1] = "text";
58576
+ if (kind === "link" && words4[words4.length - 1] !== "link") words4.push("goes to");
58577
+ const out = words4.join(" ") || "text";
58578
+ return (out.charAt(0).toUpperCase() + out.slice(1)).slice(0, 60);
56748
58579
  }
56749
58580
  var RESERVED2 = /* @__PURE__ */ new Set([
56750
58581
  "rich",
@@ -56786,7 +58617,7 @@ var FieldScope = class {
56786
58617
  }
56787
58618
  add(base, spec, value) {
56788
58619
  const key2 = this.key(base);
56789
- const label = humanize2(key2);
58620
+ const label = humanize2(key2, spec.kind);
56790
58621
  this.fields[key2] = { ...spec, label };
56791
58622
  this.values[key2] = value;
56792
58623
  return key2;
@@ -57061,10 +58892,10 @@ var SectionConverter = class {
57061
58892
  async shapes(els, w) {
57062
58893
  const map2 = /* @__PURE__ */ new Map();
57063
58894
  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);
58895
+ const counts2 = /* @__PURE__ */ new Map();
58896
+ for (const e of els) counts2.set(sig(e), (counts2.get(sig(e)) ?? 0) + 1);
57066
58897
  for (const e of els) {
57067
- if ((counts.get(sig(e)) ?? 0) < 2) continue;
58898
+ if ((counts2.get(sig(e)) ?? 0) < 2) continue;
57068
58899
  const scope = new FieldScope();
57069
58900
  const tpl = await this.gen(e, scope, { ...w, listDepth: w.listDepth + 1 });
57070
58901
  const kinds = Object.entries(scope.fields).map(([k, f]) => `${k}:${f.kind}`).join(",");
@@ -58028,7 +59859,11 @@ function tokensFromPage(css, fontLinks) {
58028
59859
  const btn = step(/class:(?:btn|button)\b/i);
58029
59860
  if (btn) type.button = btn;
58030
59861
  if (Object.keys(type).length) tokens.type = type;
58031
- return { tokens, notes: read?.notes ?? [], families };
59862
+ const nColors = Object.keys(tokens.colors ?? {}).length;
59863
+ const nType = Object.keys(tokens.type ?? {}).length;
59864
+ const pl = (k, w) => `${k} ${w}${k === 1 ? "" : "s"}`;
59865
+ 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")}` : ""}.`;
59866
+ return { tokens, notes: [note], families };
58032
59867
  }
58033
59868
  function linkFamilies(hrefs) {
58034
59869
  const out = [];
@@ -58074,6 +59909,9 @@ function siteHref(bundle, fromPath, href, pagePaths) {
58074
59909
  return resolved;
58075
59910
  }
58076
59911
  async function importHtmlBundle(bundle, opts) {
59912
+ return phoneReady(await importBundle(bundle, opts));
59913
+ }
59914
+ async function importBundle(bundle, opts) {
58077
59915
  const progress = opts.onProgress ?? (() => void 0);
58078
59916
  const now = opts.now ?? /* @__PURE__ */ new Date();
58079
59917
  const notices = [];
@@ -58234,7 +60072,7 @@ ${await r.text()}`;
58234
60072
  }
58235
60073
  const docTitle = text(find(home.root, (e) => e.tagName === "title"));
58236
60074
  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);
60075
+ 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
60076
  const logoImg = brand ? find(brand, (e) => e.tagName === "img") : void 0;
58239
60077
  const logoRef = logoImg ? resolveRef(bundle, attr(logoImg, "src") ?? "", home.path) : void 0;
58240
60078
  const segsByPage = /* @__PURE__ */ new Map();
@@ -58298,6 +60136,7 @@ ${await r.text()}`;
58298
60136
  const placeholder = email ? attr(email, "placeholder") : void 0;
58299
60137
  blocks2.push({
58300
60138
  kind: "newsletter",
60139
+ ...signupAsksName(f) ? { askName: true } : {},
58301
60140
  ...label3 ? { buttonLabel: label3.slice(0, 40) } : {},
58302
60141
  ...placeholder ? { placeholder } : {}
58303
60142
  });
@@ -58661,8 +60500,8 @@ function componentNameFor(el, pagePath2) {
58661
60500
  )
58662
60501
  );
58663
60502
  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);
60503
+ const words4 = text(heading2).split(/\s+/).slice(0, 3).join(" ");
60504
+ const raw2 = (id && id.length > 2 && !/^(?:top|main|content|section\d*)$/i.test(id) ? id : void 0) ?? cls ?? (words4 || el.tagName);
58666
60505
  const slug = raw2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^(\d)/, "s-$1").slice(0, 30).replace(/-+$/, "");
58667
60506
  const prefix = pagePath2 === "/" ? "" : `${pagePath2.split("/").filter(Boolean).pop()}-`;
58668
60507
  return `${prefix}${slug || "section"}`.replace(/^[^a-z]+/, "s-").slice(0, 36).replace(/-+$/, "");
@@ -59302,7 +61141,7 @@ async function runMcp(io, dir, opts = {}) {
59302
61141
  // src/fidelity.ts
59303
61142
  var DESKTOP = 1440;
59304
61143
  var COMPONENT_RETRY_BELOW = 75;
59305
- var PHONE = 390;
61144
+ var PHONE2 = 390;
59306
61145
  async function launch() {
59307
61146
  const pw = await loadPlaywright();
59308
61147
  if ("error" in pw) throw new CliError(pw.error);
@@ -59345,7 +61184,7 @@ async function lookProber() {
59345
61184
  const out = {};
59346
61185
  for (const [key2, width] of [
59347
61186
  ["desktop", DESKTOP],
59348
- ["mobile", PHONE]
61187
+ ["mobile", PHONE2]
59349
61188
  ]) {
59350
61189
  const page = await open(browser, url, width);
59351
61190
  try {
@@ -59494,7 +61333,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59494
61333
  await mkdir8(out, { recursive: true });
59495
61334
  if (!opts.out) await writeFile10(join18(out, ".gitignore"), "*\n");
59496
61335
  const pages = (opts.pages?.length ? opts.pages : defaultPages(doc)).map((p) => `/${slugOf2(p)}`);
59497
- const widths = opts.widths ?? [DESKTOP, PHONE];
61336
+ const widths = opts.widths ?? [DESKTOP, PHONE2];
59498
61337
  const server = await startPreviewServer(root, () => loadSiteDocument(root));
59499
61338
  const browser = await launch();
59500
61339
  const oldPath = /* @__PURE__ */ new Map();
@@ -59514,7 +61353,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59514
61353
  await page.close().catch(() => void 0);
59515
61354
  }
59516
61355
  };
59517
- const measure = async () => {
61356
+ const measure2 = async () => {
59518
61357
  const comparisons = [];
59519
61358
  const measured = [];
59520
61359
  const shots = /* @__PURE__ */ new Map();
@@ -59559,7 +61398,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59559
61398
  let best;
59560
61399
  let tuning;
59561
61400
  try {
59562
- best = await measure();
61401
+ best = await measure2();
59563
61402
  const first2 = fidelityReport(best.comparisons);
59564
61403
  const rounds = Math.max(0, Math.min(5, opts.tune ?? 0));
59565
61404
  if (rounds && best.measured.length) {
@@ -59610,7 +61449,7 @@ async function runCompare(original, dir, opts = {}, log = () => void 0) {
59610
61449
  await writeSiteDocument(root, bestDoc);
59611
61450
  continue;
59612
61451
  }
59613
- const next = await measure();
61452
+ const next = await measure2();
59614
61453
  const score = fidelityReport(next.comparisons).exact ?? 0;
59615
61454
  const better = score > bestScore + 0.1;
59616
61455
  log(
@@ -59728,10 +61567,17 @@ function fidelitySummary(report, tuning, thumbs) {
59728
61567
  const before = new Map(
59729
61568
  (tuning?.before.pages ?? []).map((p) => [`${p.path}@${p.viewport}`, p.score])
59730
61569
  );
61570
+ const strictBefore = new Map(
61571
+ (tuning?.before.pages ?? []).map((p) => [`${p.path}@${p.viewport}`, p.strict?.score])
61572
+ );
59731
61573
  return {
59732
61574
  version: 1,
59733
61575
  score: report.score,
61576
+ // The same pages by the strict v3 scorer, side by side (additive: older readers ignore it).
61577
+ ...report.strict ? { strict: report.strict.score } : {},
61578
+ ...report.scorer ? { scorer: report.scorer } : {},
59734
61579
  ...tuning ? { before: tuning.before.score, changes: tuning.changes.slice(0, 40) } : {},
61580
+ ...tuning?.before.strict ? { strictBefore: tuning.before.strict.score } : {},
59735
61581
  ...tuning?.withoutComponents !== void 0 ? {
59736
61582
  withoutComponents: tuning.withoutComponents,
59737
61583
  ...tuning.withComponents !== void 0 ? { withComponents: tuning.withComponents } : {}
@@ -59740,11 +61586,15 @@ function fidelitySummary(report, tuning, thumbs) {
59740
61586
  const key2 = `${p.path}@${p.viewport}`;
59741
61587
  const b = before.get(key2);
59742
61588
  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);
61589
+ const sb = strictBefore.get(key2);
59743
61590
  return {
59744
61591
  path: p.path,
59745
61592
  device: p.viewport < 700 ? "phone" : "desktop",
59746
61593
  score: p.score,
61594
+ ...p.strict ? { strict: p.strict.score } : {},
61595
+ ...p.corrections?.length ? { corrections: p.corrections.slice(0, 6) } : {},
59747
61596
  ...b !== void 0 ? { before: b } : {},
61597
+ ...sb !== void 0 ? { strictBefore: sb } : {},
59748
61598
  ...thumbs[key2] ? { image: thumbs[key2] } : {},
59749
61599
  ...worst.length ? { differs: worst } : {}
59750
61600
  };
@@ -59788,6 +61638,7 @@ async function addFidelityToSummary(file, r) {
59788
61638
  const summary = JSON.parse(await readFile14(file, "utf8"));
59789
61639
  summary.fidelity = {
59790
61640
  score: r.report.score,
61641
+ ...r.report.strict ? { strict: r.report.strict.score } : {},
59791
61642
  ...r.tuning ? { before: r.tuning.before.score } : {}
59792
61643
  };
59793
61644
  await writeFile10(file, `${JSON.stringify(summary, null, 2)}
@@ -59796,8 +61647,9 @@ async function addFidelityToSummary(file, r) {
59796
61647
  }
59797
61648
  }
59798
61649
  function compareSummary(r) {
61650
+ 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
61651
  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.`
61652
+ 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
61653
  ];
59802
61654
  for (const p of r.report.pages) {
59803
61655
  const off = [
@@ -59805,7 +61657,7 @@ function compareSummary(r) {
59805
61657
  ...p.sections.filter((s2) => s2.score < 80).map((s2) => `section ${s2.index + 1}${s2.heading ? ` "${s2.heading.slice(0, 30)}"` : ""}`)
59806
61658
  ];
59807
61659
  lines.push(
59808
- ` ${p.path} ${p.viewport}px: ${p.score}/100${off.length ? ` \u2014 differs: ${off.slice(0, 6).join(", ")}${off.length > 6 ? "\u2026" : ""}` : ""}`
61660
+ ` ${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
61661
  );
59810
61662
  }
59811
61663
  lines.push(` Report and side-by-side images: ${join18(r.out, "FIDELITY-REPORT.md")}`);
@@ -60025,14 +61877,23 @@ async function runMigrate(root, opts = {}) {
60025
61877
  var VERSION = "0.1.0";
60026
61878
  function createProgram(io, setExit, deps = {}) {
60027
61879
  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(
61880
+ 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
61881
  async (dir, opts) => {
61882
+ let phone;
61883
+ if (opts.phone || opts.fix) {
61884
+ phone = await runPhoneCheck(resolve10(io.cwd, dir ?? "."), { fix: opts.fix });
61885
+ if (!opts.json) {
61886
+ io.out(formatPhoneReport(phone, (code3, s2) => paint(colorEnabled(io), code3, s2)));
61887
+ io.out("");
61888
+ }
61889
+ if (phone.counts.error) setExit(1);
61890
+ }
60030
61891
  const budgets = {};
60031
61892
  if (opts.mediaWarnMb !== void 0) budgets.mediaWarnBytes = opts.mediaWarnMb * 1024 * 1024;
60032
61893
  if (opts.mediaFailMb !== void 0) budgets.mediaFailBytes = opts.mediaFailMb * 1024 * 1024;
60033
61894
  const report = await runCheck(resolve10(io.cwd, dir ?? "."), { budgets });
60034
61895
  if (opts.json) {
60035
- io.out(JSON.stringify(report, null, 2));
61896
+ io.out(JSON.stringify(phone ? { ...report, phone } : report, null, 2));
60036
61897
  } else {
60037
61898
  if (opts.ci) for (const line of githubAnnotations(report)) io.out(line);
60038
61899
  const color = colorEnabled(io);
@@ -60388,4 +62249,4 @@ export {
60388
62249
  createProgram,
60389
62250
  run
60390
62251
  };
60391
- //# sourceMappingURL=chunk-TDLUJHWB.js.map
62252
+ //# sourceMappingURL=chunk-JDVCOOGD.js.map