@wix/web5-core 1.63.36 → 1.63.38

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.
@@ -15,6 +15,14 @@ const DEFAULT_POWERED_BY_HREF = exports.DEFAULT_POWERED_BY_HREF = 'https://www.n
15
15
  * Rendering is the caller's decision — this component says nothing about plans.
16
16
  * The AI page mounts it only when the site is on the Free tier (see
17
17
  * `shouldShowPoweredBy`); any paid package removes it.
18
+ *
19
+ * NOTE: unlike every other component here, its CSS is NOT in
20
+ * `styles/components.css`. That file ships in the client bundle, and this
21
+ * component is mounted from the app bundle — the two are deployed
22
+ * independently, and splitting it across them shipped a naked, unstyled credit
23
+ * to a live storefront. Its stylesheet lives with the shell that mounts it, in
24
+ * `web50-server-ui/src/components/App.css`. A future client that mounts this
25
+ * itself has to bring its own copy, or move the rules somewhere both can see.
18
26
  */
19
27
  const PoweredByBadge = ({
20
28
  label = 'Powered by',
@@ -30,7 +38,7 @@ const PoweredByBadge = ({
30
38
  __self: void 0,
31
39
  __source: {
32
40
  fileName: _jsxFileName,
33
- lineNumber: 39,
41
+ lineNumber: 47,
34
42
  columnNumber: 5
35
43
  }
36
44
  }, /*#__PURE__*/_react.default.createElement("a", {
@@ -47,7 +55,7 @@ const PoweredByBadge = ({
47
55
  __self: void 0,
48
56
  __source: {
49
57
  fileName: _jsxFileName,
50
- lineNumber: 40,
58
+ lineNumber: 48,
51
59
  columnNumber: 7
52
60
  }
53
61
  }, /*#__PURE__*/_react.default.createElement("span", {
@@ -55,7 +63,7 @@ const PoweredByBadge = ({
55
63
  __self: void 0,
56
64
  __source: {
57
65
  fileName: _jsxFileName,
58
- lineNumber: 53,
66
+ lineNumber: 61,
59
67
  columnNumber: 9
60
68
  }
61
69
  }, label), ' ', /*#__PURE__*/_react.default.createElement("span", {
@@ -63,7 +71,7 @@ const PoweredByBadge = ({
63
71
  __self: void 0,
64
72
  __source: {
65
73
  fileName: _jsxFileName,
66
- lineNumber: 54,
74
+ lineNumber: 62,
67
75
  columnNumber: 9
68
76
  }
69
77
  }, brand)));
@@ -1 +1 @@
1
- {"version":3,"names":["_react","_interopRequireDefault","require","_utils","_jsxFileName","DEFAULT_POWERED_BY_HREF","exports","PoweredByBadge","label","brand","href","target","onClick","className","rel","undefined","default","createElement","cn","__self","__source","fileName","lineNumber","columnNumber"],"sources":["../../../../src/components/ui/PoweredByBadge.tsx"],"sourcesContent":["import React, { FC } from 'react';\nimport { cn } from '../../lib/utils';\n\nexport interface PoweredByBadgeProps {\n /** Muted lead-in. Default: \"Powered by\". */\n label?: string;\n /** The emphasised brand name, and the link's visible anchor. Default: \"Niso\". */\n brand?: string;\n /** URL the badge opens. Default: the Niso marketing site. */\n href?: string;\n /** Override the link target. Default: `_blank` (opens in a new tab). */\n target?: '_blank' | '_self' | '_parent' | '_top';\n /** Click handler invoked before navigation. Use to fire analytics. */\n onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;\n className?: string;\n}\n\nexport const DEFAULT_POWERED_BY_HREF = 'https://www.nisoweb.ai';\n\n/**\n * The free-tier credit bar (B2B-4574): a full-width strip at the very bottom of\n * the AI page reading \"Powered by Niso\", with the brand name linking out.\n *\n * Rendering is the caller's decision — this component says nothing about plans.\n * The AI page mounts it only when the site is on the Free tier (see\n * `shouldShowPoweredBy`); any paid package removes it.\n */\nexport const PoweredByBadge: FC<PoweredByBadgeProps> = ({\n label = 'Powered by',\n brand = 'Niso',\n href = DEFAULT_POWERED_BY_HREF,\n target = '_blank',\n onClick,\n className,\n}) => {\n const rel = target === '_blank' ? 'noopener noreferrer' : undefined;\n\n return (\n <div className={cn('web5-powered-by', className)}>\n <a\n className=\"web5-powered-by__link\"\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n // The visible text splits across two spans for the two-tone treatment,\n // so spell the whole thing out once for assistive tech — including the\n // new tab, which is otherwise unannounced.\n aria-label={`${label} ${brand}${\n target === '_blank' ? ' (opens in a new tab)' : ''\n }`}\n >\n <span className=\"web5-powered-by__label\">{label}</span>{' '}\n <span className=\"web5-powered-by__brand\">{brand}</span>\n </a>\n </div>\n );\n};\n"],"mappings":";;;;;AAAA,IAAAA,MAAA,GAAAC,sBAAA,CAAAC,OAAA;AACA,IAAAC,MAAA,GAAAD,OAAA;AAAqC,IAAAE,YAAA;AAgB9B,MAAMC,uBAAuB,GAAAC,OAAA,CAAAD,uBAAA,GAAG,wBAAwB;;AAE/D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,MAAME,cAAuC,GAAGA,CAAC;EACtDC,KAAK,GAAG,YAAY;EACpBC,KAAK,GAAG,MAAM;EACdC,IAAI,GAAGL,uBAAuB;EAC9BM,MAAM,GAAG,QAAQ;EACjBC,OAAO;EACPC;AACF,CAAC,KAAK;EACJ,MAAMC,GAAG,GAAGH,MAAM,KAAK,QAAQ,GAAG,qBAAqB,GAAGI,SAAS;EAEnE,oBACEf,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IAAKJ,SAAS,EAAE,IAAAK,SAAE,EAAC,iBAAiB,EAAEL,SAAS,CAAE;IAAAM,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,gBAC/CvB,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IACEJ,SAAS,EAAC,uBAAuB;IACjCH,IAAI,EAAEA,IAAK;IACXC,MAAM,EAAEA,MAAO;IACfG,GAAG,EAAEA,GAAI;IACTF,OAAO,EAAEA;IACT;IACA;IACA;IAAA;IACA,cAAY,GAAGJ,KAAK,IAAIC,KAAK,GAC3BE,MAAM,KAAK,QAAQ,GAAG,uBAAuB,GAAG,EAAE,EACjD;IAAAQ,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,gBAEHvB,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IAAMJ,SAAS,EAAC,wBAAwB;IAAAM,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,GAAEf,KAAY,CAAC,EAAC,GAAG,eAC3DR,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IAAMJ,SAAS,EAAC,wBAAwB;IAAAM,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,GAAEd,KAAY,CACrD,CACA,CAAC;AAEV,CAAC;AAACH,OAAA,CAAAC,cAAA,GAAAA,cAAA","ignoreList":[]}
1
+ {"version":3,"names":["_react","_interopRequireDefault","require","_utils","_jsxFileName","DEFAULT_POWERED_BY_HREF","exports","PoweredByBadge","label","brand","href","target","onClick","className","rel","undefined","default","createElement","cn","__self","__source","fileName","lineNumber","columnNumber"],"sources":["../../../../src/components/ui/PoweredByBadge.tsx"],"sourcesContent":["import React, { FC } from 'react';\nimport { cn } from '../../lib/utils';\n\nexport interface PoweredByBadgeProps {\n /** Muted lead-in. Default: \"Powered by\". */\n label?: string;\n /** The emphasised brand name, and the link's visible anchor. Default: \"Niso\". */\n brand?: string;\n /** URL the badge opens. Default: the Niso marketing site. */\n href?: string;\n /** Override the link target. Default: `_blank` (opens in a new tab). */\n target?: '_blank' | '_self' | '_parent' | '_top';\n /** Click handler invoked before navigation. Use to fire analytics. */\n onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;\n className?: string;\n}\n\nexport const DEFAULT_POWERED_BY_HREF = 'https://www.nisoweb.ai';\n\n/**\n * The free-tier credit bar (B2B-4574): a full-width strip at the very bottom of\n * the AI page reading \"Powered by Niso\", with the brand name linking out.\n *\n * Rendering is the caller's decision — this component says nothing about plans.\n * The AI page mounts it only when the site is on the Free tier (see\n * `shouldShowPoweredBy`); any paid package removes it.\n *\n * NOTE: unlike every other component here, its CSS is NOT in\n * `styles/components.css`. That file ships in the client bundle, and this\n * component is mounted from the app bundle — the two are deployed\n * independently, and splitting it across them shipped a naked, unstyled credit\n * to a live storefront. Its stylesheet lives with the shell that mounts it, in\n * `web50-server-ui/src/components/App.css`. A future client that mounts this\n * itself has to bring its own copy, or move the rules somewhere both can see.\n */\nexport const PoweredByBadge: FC<PoweredByBadgeProps> = ({\n label = 'Powered by',\n brand = 'Niso',\n href = DEFAULT_POWERED_BY_HREF,\n target = '_blank',\n onClick,\n className,\n}) => {\n const rel = target === '_blank' ? 'noopener noreferrer' : undefined;\n\n return (\n <div className={cn('web5-powered-by', className)}>\n <a\n className=\"web5-powered-by__link\"\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n // The visible text splits across two spans for the two-tone treatment,\n // so spell the whole thing out once for assistive tech — including the\n // new tab, which is otherwise unannounced.\n aria-label={`${label} ${brand}${\n target === '_blank' ? ' (opens in a new tab)' : ''\n }`}\n >\n <span className=\"web5-powered-by__label\">{label}</span>{' '}\n <span className=\"web5-powered-by__brand\">{brand}</span>\n </a>\n </div>\n );\n};\n"],"mappings":";;;;;AAAA,IAAAA,MAAA,GAAAC,sBAAA,CAAAC,OAAA;AACA,IAAAC,MAAA,GAAAD,OAAA;AAAqC,IAAAE,YAAA;AAgB9B,MAAMC,uBAAuB,GAAAC,OAAA,CAAAD,uBAAA,GAAG,wBAAwB;;AAE/D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,MAAME,cAAuC,GAAGA,CAAC;EACtDC,KAAK,GAAG,YAAY;EACpBC,KAAK,GAAG,MAAM;EACdC,IAAI,GAAGL,uBAAuB;EAC9BM,MAAM,GAAG,QAAQ;EACjBC,OAAO;EACPC;AACF,CAAC,KAAK;EACJ,MAAMC,GAAG,GAAGH,MAAM,KAAK,QAAQ,GAAG,qBAAqB,GAAGI,SAAS;EAEnE,oBACEf,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IAAKJ,SAAS,EAAE,IAAAK,SAAE,EAAC,iBAAiB,EAAEL,SAAS,CAAE;IAAAM,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,gBAC/CvB,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IACEJ,SAAS,EAAC,uBAAuB;IACjCH,IAAI,EAAEA,IAAK;IACXC,MAAM,EAAEA,MAAO;IACfG,GAAG,EAAEA,GAAI;IACTF,OAAO,EAAEA;IACT;IACA;IACA;IAAA;IACA,cAAY,GAAGJ,KAAK,IAAIC,KAAK,GAC3BE,MAAM,KAAK,QAAQ,GAAG,uBAAuB,GAAG,EAAE,EACjD;IAAAQ,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,gBAEHvB,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IAAMJ,SAAS,EAAC,wBAAwB;IAAAM,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,GAAEf,KAAY,CAAC,EAAC,GAAG,eAC3DR,MAAA,CAAAgB,OAAA,CAAAC,aAAA;IAAMJ,SAAS,EAAC,wBAAwB;IAAAM,MAAA;IAAAC,QAAA;MAAAC,QAAA,EAAAjB,YAAA;MAAAkB,UAAA;MAAAC,YAAA;IAAA;EAAA,GAAEd,KAAY,CACrD,CACA,CAAC;AAEV,CAAC;AAACH,OAAA,CAAAC,cAAA,GAAAA,cAAA","ignoreList":[]}
@@ -22,9 +22,55 @@ exports.composeSemantic = composeSemantic;
22
22
  * CDN build externalises core, so a core change could not be seen through
23
23
  * `?clientBundleUrl=` until it had been published. 0223 always assigned it
24
24
  * here, and this is that move.
25
+ *
26
+ * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)
27
+ * ------------------------------------------------------
28
+ * It buys **recall, and only recall**. In media-manager an editorial slot
29
+ * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates
30
+ * (`CandidateGather.planFor`), scoped by a filter that is just "this site's
31
+ * images" (`buildFilter`) — so the phrase ranks the corpus rather than gating
32
+ * it. Which picture then WINS is decided by `SlotScorer`, whose every term is
33
+ * geometric (crop fit, background, subject, text safety, resolution). Vespa's
34
+ * relevance score is not one of them, and the pool's order is discarded by the
35
+ * assignment solve. The phrase's entire job is choosing which 30 images reach
36
+ * the scorer.
37
+ *
38
+ * That is why it is budgeted in WORDS. The documents it is matched against are
39
+ * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,
40
+ * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with
41
+ * vector closeness. A headline plus a clause of marketing prose is the wrong
42
+ * shape against that on both halves: the vector drifts toward the mean of
43
+ * several topics instead of sitting on the subject, and BM25 spends term weight
44
+ * on filler that pulls in whatever else on the site happens to carry those
45
+ * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)
46
+ * happen on the document side only.
47
+ *
48
+ * A phrase that is too specific costs a worse 30, never a blank section: the
49
+ * site-wide widening in `CandidateGather.poolFor` is entity-only because an
50
+ * editorial pool empties only when the site has no images at all.
51
+ *
52
+ * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text
53
+ * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —
54
+ * every text-derived slot in the product gets a new id. Not a place for
55
+ * cosmetic edits.
56
+ */
57
+
58
+ /**
59
+ * The phrase's real budget, in whole words — the unit that matters, because
60
+ * what it is matched against is a short bag of nouns and not a paragraph.
61
+ */
62
+ const WORD_BUDGET = 10;
63
+
64
+ /**
65
+ * A title of this many words already names its own subject, so the lead adds
66
+ * dilution rather than signal and is dropped. Below it the title may be as thin
67
+ * as "Our picks" or "Why it matters", which names nothing retrievable on its
68
+ * own — that is the case the lead exists for.
25
69
  */
70
+ const TITLE_ENOUGH = 5;
26
71
 
27
- /** Longer than this and the tail stops helping retrieval and starts diluting it. */
72
+ /** A character backstop under the word budget: ten words is normally well
73
+ * inside it, but one pathological unbroken "word" is not. */
28
74
  const MAX_LENGTH = 120;
29
75
 
30
76
  /** Below this there is no query worth sending — better to render no picture
@@ -39,9 +85,18 @@ const stripMarkdown = raw => raw
39
85
  .replace(/^\s*#{1,6}\s*/gm, '').replace(/^\s*>\s?/gm, '')
40
86
  // html tags an author or the parser may have left behind
41
87
  .replace(/<[^>]+>/g, ' ');
42
- const tidy = raw => stripMarkdown(raw).replace(/\s+/g, ' ').trim()
43
- // trailing punctuation is noise in a retrieval phrase
44
- .replace(/[\s.,;:!?—–-]+$/g, '').trim();
88
+ const trimTail = raw => raw.replace(/[\s.,;:!?—–-]+$/g, '').trim();
89
+ const tidy = raw => trimTail(stripMarkdown(raw).replace(/\s+/g, ' ').trim());
90
+ const wordsOf = raw => raw.split(' ').filter(word => word.length > 0);
91
+
92
+ /** Keep at most `max` whole words. The unit the budget is actually stated in. */
93
+ const clampToWords = (raw, max) => {
94
+ const words = wordsOf(raw);
95
+ if (words.length <= max) {
96
+ return raw;
97
+ }
98
+ return trimTail(words.slice(0, max).join(' '));
99
+ };
45
100
 
46
101
  /**
47
102
  * Clamp without cutting a word in half. Slicing at a fixed length leaves
@@ -58,9 +113,12 @@ const clampToWord = (raw, max) => {
58
113
  const cut = raw.slice(0, max + 1);
59
114
  const lastBreak = cut.lastIndexOf(' ');
60
115
  const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);
61
- return out.replace(/[\s.,;:!?—–-]+$/g, '').trim();
116
+ return trimTail(out);
62
117
  };
63
118
 
119
+ /** The word budget first, then the character backstop under it. */
120
+ const budgeted = raw => clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);
121
+
64
122
  /** The first clause carries the subject; what follows is usually qualification. */
65
123
  const firstClause = raw => {
66
124
  const m = raw.match(/^[^.!?;]+/);
@@ -78,17 +136,23 @@ function composeSemantic({
78
136
  // A mission, when there is one, is the whole answer.
79
137
  const mission = tidy(sectionSemantic ?? '');
80
138
  if (mission.length >= MIN_LENGTH) {
81
- return clampToWord(mission, MAX_LENGTH);
139
+ return budgeted(mission);
82
140
  }
83
141
  const head = tidy(title ?? '');
84
- const body = firstClause(tidy(lead ?? ''));
142
+ const headWords = wordsOf(head).length;
143
+
144
+ // The title is the stronger signal, so it is spent first and never truncated
145
+ // to make room for the lead. Once it names a subject on its own, it IS the
146
+ // phrase.
147
+ if (headWords >= TITLE_ENOUGH) {
148
+ const out = budgeted(head);
149
+ return out.length < MIN_LENGTH ? '' : out;
150
+ }
151
+
152
+ // A thin title spends what it did not use on the lead's first clause.
153
+ const body = clampToWords(firstClause(tidy(lead ?? '')), WORD_BUDGET - headWords);
85
154
  const joined = [head, body].filter(Boolean).join('. ');
86
155
  if (joined.length < MIN_LENGTH) return '';
87
- if (joined.length <= MAX_LENGTH) return joined;
88
-
89
- // Over budget: keep the title whole if it fits, since it is the stronger
90
- // signal, and spend whatever is left on the lead.
91
- if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);
92
156
  return clampToWord(joined, MAX_LENGTH);
93
157
  }
94
158
  //# sourceMappingURL=composeSemantic.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","tidy","trim","clampToWord","max","length","cut","slice","lastBreak","lastIndexOf","out","firstClause","m","match","composeSemantic","title","lead","sectionSemantic","mission","head","body","joined","filter","Boolean","join"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n */\n\n/** Longer than this and the tail stops helping retrieval and starts diluting it. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst tidy = (raw: string): string =>\n stripMarkdown(raw)\n .replace(/\\s+/g, ' ')\n .trim()\n // trailing punctuation is noise in a retrieval phrase\n .replace(/[\\s.,;:!?—–-]+$/g, '')\n .trim();\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return out.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n};\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return clampToWord(mission, MAX_LENGTH);\n }\n\n const head = tidy(title ?? '');\n const body = firstClause(tidy(lead ?? ''));\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n if (joined.length <= MAX_LENGTH) return joined;\n\n // Over budget: keep the title whole if it fits, since it is the stronger\n // signal, and spend whatever is left on the lead.\n if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":";;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA,MAAMA,UAAU,GAAG,GAAG;;AAEtB;AACA;AACA,MAAMC,UAAU,GAAG,CAAC;AAEpB,MAAMC,aAAa,GAAIC,GAAW,IAChCA;AACE;AAAA,CACCC,OAAO,CAAC,yBAAyB,EAAE,IAAI,CAAC,CACxCA,OAAO,CAAC,wBAAwB,EAAE,IAAI;AACvC;AAAA,CACCA,OAAO,CAAC,iCAAiC,EAAE,EAAE;AAC9C;AAAA,CACCA,OAAO,CAAC,iBAAiB,EAAE,EAAE,CAAC,CAC9BA,OAAO,CAAC,YAAY,EAAE,EAAE;AACzB;AAAA,CACCA,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC;AAE7B,MAAMC,IAAI,GAAIF,GAAW,IACvBD,aAAa,CAACC,GAAG,CAAC,CACfC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CACpBE,IAAI,CAAC;AACN;AAAA,CACCF,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAC/BE,IAAI,CAAC,CAAC;;AAEX;AACA;AACA;AACA;AACA;AACA;AACA;AACA,MAAMC,WAAW,GAAGA,CAACJ,GAAW,EAAEK,GAAW,KAAa;EACxD,IAAIL,GAAG,CAACM,MAAM,IAAID,GAAG,EAAE;IACrB,OAAOL,GAAG;EACZ;EACA;EACA,MAAMO,GAAG,GAAGP,GAAG,CAACQ,KAAK,CAAC,CAAC,EAAEH,GAAG,GAAG,CAAC,CAAC;EACjC,MAAMI,SAAS,GAAGF,GAAG,CAACG,WAAW,CAAC,GAAG,CAAC;EACtC,MAAMC,GAAG,GAAGF,SAAS,GAAG,CAAC,GAAGF,GAAG,CAACC,KAAK,CAAC,CAAC,EAAEC,SAAS,CAAC,GAAGT,GAAG,CAACQ,KAAK,CAAC,CAAC,EAAEH,GAAG,CAAC;EACvE,OAAOM,GAAG,CAACV,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAACE,IAAI,CAAC,CAAC;AACnD,CAAC;;AAED;AACA,MAAMS,WAAW,GAAIZ,GAAW,IAAa;EAC3C,MAAMa,CAAC,GAAGb,GAAG,CAACc,KAAK,CAAC,WAAW,CAAC;EAChC,OAAO,CAACD,CAAC,GAAGA,CAAC,CAAC,CAAC,CAAC,GAAGb,GAAG,EAAEG,IAAI,CAAC,CAAC;AAChC,CAAC;AAaD;AACA;AACA;AACA;AACO,SAASY,eAAeA,CAAC;EAC9BC,KAAK;EACLC,IAAI;EACJC;AACoB,CAAC,EAAU;EAC/B;EACA,MAAMC,OAAO,GAAGjB,IAAI,CAACgB,eAAe,IAAI,EAAE,CAAC;EAC3C,IAAIC,OAAO,CAACb,MAAM,IAAIR,UAAU,EAAE;IAChC,OAAOM,WAAW,CAACe,OAAO,EAAEtB,UAAU,CAAC;EACzC;EAEA,MAAMuB,IAAI,GAAGlB,IAAI,CAACc,KAAK,IAAI,EAAE,CAAC;EAC9B,MAAMK,IAAI,GAAGT,WAAW,CAACV,IAAI,CAACe,IAAI,IAAI,EAAE,CAAC,CAAC;EAE1C,MAAMK,MAAM,GAAG,CAACF,IAAI,EAAEC,IAAI,CAAC,CAACE,MAAM,CAACC,OAAO,CAAC,CAACC,IAAI,CAAC,IAAI,CAAC;EACtD,IAAIH,MAAM,CAAChB,MAAM,GAAGR,UAAU,EAAE,OAAO,EAAE;EAEzC,IAAIwB,MAAM,CAAChB,MAAM,IAAIT,UAAU,EAAE,OAAOyB,MAAM;;EAE9C;EACA;EACA,IAAIF,IAAI,CAACd,MAAM,IAAIT,UAAU,EAAE,OAAOO,WAAW,CAACgB,IAAI,EAAEvB,UAAU,CAAC;EACnE,OAAOO,WAAW,CAACkB,MAAM,EAAEzB,UAAU,CAAC;AACxC","ignoreList":[]}
1
+ {"version":3,"names":["WORD_BUDGET","TITLE_ENOUGH","MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","trimTail","trim","tidy","wordsOf","split","filter","word","length","clampToWords","max","words","slice","join","clampToWord","cut","lastBreak","lastIndexOf","out","budgeted","firstClause","m","match","composeSemantic","title","lead","sectionSemantic","mission","head","headWords","body","joined","Boolean"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n *\n * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)\n * ------------------------------------------------------\n * It buys **recall, and only recall**. In media-manager an editorial slot\n * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates\n * (`CandidateGather.planFor`), scoped by a filter that is just \"this site's\n * images\" (`buildFilter`) — so the phrase ranks the corpus rather than gating\n * it. Which picture then WINS is decided by `SlotScorer`, whose every term is\n * geometric (crop fit, background, subject, text safety, resolution). Vespa's\n * relevance score is not one of them, and the pool's order is discarded by the\n * assignment solve. The phrase's entire job is choosing which 30 images reach\n * the scorer.\n *\n * That is why it is budgeted in WORDS. The documents it is matched against are\n * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,\n * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with\n * vector closeness. A headline plus a clause of marketing prose is the wrong\n * shape against that on both halves: the vector drifts toward the mean of\n * several topics instead of sitting on the subject, and BM25 spends term weight\n * on filler that pulls in whatever else on the site happens to carry those\n * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)\n * happen on the document side only.\n *\n * A phrase that is too specific costs a worse 30, never a blank section: the\n * site-wide widening in `CandidateGather.poolFor` is entity-only because an\n * editorial pool empties only when the site has no images at all.\n *\n * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text\n * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —\n * every text-derived slot in the product gets a new id. Not a place for\n * cosmetic edits.\n */\n\n/**\n * The phrase's real budget, in whole words — the unit that matters, because\n * what it is matched against is a short bag of nouns and not a paragraph.\n */\nconst WORD_BUDGET = 10;\n\n/**\n * A title of this many words already names its own subject, so the lead adds\n * dilution rather than signal and is dropped. Below it the title may be as thin\n * as \"Our picks\" or \"Why it matters\", which names nothing retrievable on its\n * own — that is the case the lead exists for.\n */\nconst TITLE_ENOUGH = 5;\n\n/** A character backstop under the word budget: ten words is normally well\n * inside it, but one pathological unbroken \"word\" is not. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst trimTail = (raw: string): string =>\n raw.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n\nconst tidy = (raw: string): string =>\n trimTail(stripMarkdown(raw).replace(/\\s+/g, ' ').trim());\n\nconst wordsOf = (raw: string): string[] =>\n raw.split(' ').filter((word) => word.length > 0);\n\n/** Keep at most `max` whole words. The unit the budget is actually stated in. */\nconst clampToWords = (raw: string, max: number): string => {\n const words = wordsOf(raw);\n if (words.length <= max) {\n return raw;\n }\n return trimTail(words.slice(0, max).join(' '));\n};\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return trimTail(out);\n};\n\n/** The word budget first, then the character backstop under it. */\nconst budgeted = (raw: string): string =>\n clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return budgeted(mission);\n }\n\n const head = tidy(title ?? '');\n const headWords = wordsOf(head).length;\n\n // The title is the stronger signal, so it is spent first and never truncated\n // to make room for the lead. Once it names a subject on its own, it IS the\n // phrase.\n if (headWords >= TITLE_ENOUGH) {\n const out = budgeted(head);\n return out.length < MIN_LENGTH ? '' : out;\n }\n\n // A thin title spends what it did not use on the lead's first clause.\n const body = clampToWords(\n firstClause(tidy(lead ?? '')),\n WORD_BUDGET - headWords,\n );\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":"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","ignoreList":[]}
@@ -2,7 +2,6 @@
2
2
  @import '../components/ui/PromptEntryEmptyState.css';
3
3
  @import '../components/ui/FeedbackBar.css';
4
4
  @import '../components/ui/AiDisclosure.css';
5
- @import '../components/ui/PoweredByBadge.css';
6
5
  @import '../components/ui/UserQuery.css';
7
6
  @import '../components/ui/PlacementLoader.css';
8
7
  @import '../components/placement/PlacementResponseRenderer.css';
@@ -9,6 +9,14 @@ export const DEFAULT_POWERED_BY_HREF = 'https://www.nisoweb.ai';
9
9
  * Rendering is the caller's decision — this component says nothing about plans.
10
10
  * The AI page mounts it only when the site is on the Free tier (see
11
11
  * `shouldShowPoweredBy`); any paid package removes it.
12
+ *
13
+ * NOTE: unlike every other component here, its CSS is NOT in
14
+ * `styles/components.css`. That file ships in the client bundle, and this
15
+ * component is mounted from the app bundle — the two are deployed
16
+ * independently, and splitting it across them shipped a naked, unstyled credit
17
+ * to a live storefront. Its stylesheet lives with the shell that mounts it, in
18
+ * `web50-server-ui/src/components/App.css`. A future client that mounts this
19
+ * itself has to bring its own copy, or move the rules somewhere both can see.
12
20
  */
13
21
  export const PoweredByBadge = _ref => {
14
22
  let {
@@ -1 +1 @@
1
- {"version":3,"names":["React","cn","DEFAULT_POWERED_BY_HREF","PoweredByBadge","_ref","label","brand","href","target","onClick","className","rel","undefined","createElement"],"sources":["../../../../src/components/ui/PoweredByBadge.tsx"],"sourcesContent":["import React, { FC } from 'react';\nimport { cn } from '../../lib/utils';\n\nexport interface PoweredByBadgeProps {\n /** Muted lead-in. Default: \"Powered by\". */\n label?: string;\n /** The emphasised brand name, and the link's visible anchor. Default: \"Niso\". */\n brand?: string;\n /** URL the badge opens. Default: the Niso marketing site. */\n href?: string;\n /** Override the link target. Default: `_blank` (opens in a new tab). */\n target?: '_blank' | '_self' | '_parent' | '_top';\n /** Click handler invoked before navigation. Use to fire analytics. */\n onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;\n className?: string;\n}\n\nexport const DEFAULT_POWERED_BY_HREF = 'https://www.nisoweb.ai';\n\n/**\n * The free-tier credit bar (B2B-4574): a full-width strip at the very bottom of\n * the AI page reading \"Powered by Niso\", with the brand name linking out.\n *\n * Rendering is the caller's decision — this component says nothing about plans.\n * The AI page mounts it only when the site is on the Free tier (see\n * `shouldShowPoweredBy`); any paid package removes it.\n */\nexport const PoweredByBadge: FC<PoweredByBadgeProps> = ({\n label = 'Powered by',\n brand = 'Niso',\n href = DEFAULT_POWERED_BY_HREF,\n target = '_blank',\n onClick,\n className,\n}) => {\n const rel = target === '_blank' ? 'noopener noreferrer' : undefined;\n\n return (\n <div className={cn('web5-powered-by', className)}>\n <a\n className=\"web5-powered-by__link\"\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n // The visible text splits across two spans for the two-tone treatment,\n // so spell the whole thing out once for assistive tech — including the\n // new tab, which is otherwise unannounced.\n aria-label={`${label} ${brand}${\n target === '_blank' ? ' (opens in a new tab)' : ''\n }`}\n >\n <span className=\"web5-powered-by__label\">{label}</span>{' '}\n <span className=\"web5-powered-by__brand\">{brand}</span>\n </a>\n </div>\n );\n};\n"],"mappings":"AAAA,OAAOA,KAAK,MAAc,OAAO;AACjC,SAASC,EAAE,QAAQ,iBAAiB;AAgBpC,OAAO,MAAMC,uBAAuB,GAAG,wBAAwB;;AAE/D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMC,cAAuC,GAAGC,IAAA,IAOjD;EAAA,IAPkD;IACtDC,KAAK,GAAG,YAAY;IACpBC,KAAK,GAAG,MAAM;IACdC,IAAI,GAAGL,uBAAuB;IAC9BM,MAAM,GAAG,QAAQ;IACjBC,OAAO;IACPC;EACF,CAAC,GAAAN,IAAA;EACC,MAAMO,GAAG,GAAGH,MAAM,KAAK,QAAQ,GAAG,qBAAqB,GAAGI,SAAS;EAEnE,oBACEZ,KAAA,CAAAa,aAAA;IAAKH,SAAS,EAAET,EAAE,CAAC,iBAAiB,EAAES,SAAS;EAAE,gBAC/CV,KAAA,CAAAa,aAAA;IACEH,SAAS,EAAC,uBAAuB;IACjCH,IAAI,EAAEA,IAAK;IACXC,MAAM,EAAEA,MAAO;IACfG,GAAG,EAAEA,GAAI;IACTF,OAAO,EAAEA;IACT;IACA;IACA;IAAA;IACA,cAAY,GAAGJ,KAAK,IAAIC,KAAK,GAC3BE,MAAM,KAAK,QAAQ,GAAG,uBAAuB,GAAG,EAAE;EACjD,gBAEHR,KAAA,CAAAa,aAAA;IAAMH,SAAS,EAAC;EAAwB,GAAEL,KAAY,CAAC,EAAC,GAAG,eAC3DL,KAAA,CAAAa,aAAA;IAAMH,SAAS,EAAC;EAAwB,GAAEJ,KAAY,CACrD,CACA,CAAC;AAEV,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["React","cn","DEFAULT_POWERED_BY_HREF","PoweredByBadge","_ref","label","brand","href","target","onClick","className","rel","undefined","createElement"],"sources":["../../../../src/components/ui/PoweredByBadge.tsx"],"sourcesContent":["import React, { FC } from 'react';\nimport { cn } from '../../lib/utils';\n\nexport interface PoweredByBadgeProps {\n /** Muted lead-in. Default: \"Powered by\". */\n label?: string;\n /** The emphasised brand name, and the link's visible anchor. Default: \"Niso\". */\n brand?: string;\n /** URL the badge opens. Default: the Niso marketing site. */\n href?: string;\n /** Override the link target. Default: `_blank` (opens in a new tab). */\n target?: '_blank' | '_self' | '_parent' | '_top';\n /** Click handler invoked before navigation. Use to fire analytics. */\n onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;\n className?: string;\n}\n\nexport const DEFAULT_POWERED_BY_HREF = 'https://www.nisoweb.ai';\n\n/**\n * The free-tier credit bar (B2B-4574): a full-width strip at the very bottom of\n * the AI page reading \"Powered by Niso\", with the brand name linking out.\n *\n * Rendering is the caller's decision — this component says nothing about plans.\n * The AI page mounts it only when the site is on the Free tier (see\n * `shouldShowPoweredBy`); any paid package removes it.\n *\n * NOTE: unlike every other component here, its CSS is NOT in\n * `styles/components.css`. That file ships in the client bundle, and this\n * component is mounted from the app bundle — the two are deployed\n * independently, and splitting it across them shipped a naked, unstyled credit\n * to a live storefront. Its stylesheet lives with the shell that mounts it, in\n * `web50-server-ui/src/components/App.css`. A future client that mounts this\n * itself has to bring its own copy, or move the rules somewhere both can see.\n */\nexport const PoweredByBadge: FC<PoweredByBadgeProps> = ({\n label = 'Powered by',\n brand = 'Niso',\n href = DEFAULT_POWERED_BY_HREF,\n target = '_blank',\n onClick,\n className,\n}) => {\n const rel = target === '_blank' ? 'noopener noreferrer' : undefined;\n\n return (\n <div className={cn('web5-powered-by', className)}>\n <a\n className=\"web5-powered-by__link\"\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n // The visible text splits across two spans for the two-tone treatment,\n // so spell the whole thing out once for assistive tech — including the\n // new tab, which is otherwise unannounced.\n aria-label={`${label} ${brand}${\n target === '_blank' ? ' (opens in a new tab)' : ''\n }`}\n >\n <span className=\"web5-powered-by__label\">{label}</span>{' '}\n <span className=\"web5-powered-by__brand\">{brand}</span>\n </a>\n </div>\n );\n};\n"],"mappings":"AAAA,OAAOA,KAAK,MAAc,OAAO;AACjC,SAASC,EAAE,QAAQ,iBAAiB;AAgBpC,OAAO,MAAMC,uBAAuB,GAAG,wBAAwB;;AAE/D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMC,cAAuC,GAAGC,IAAA,IAOjD;EAAA,IAPkD;IACtDC,KAAK,GAAG,YAAY;IACpBC,KAAK,GAAG,MAAM;IACdC,IAAI,GAAGL,uBAAuB;IAC9BM,MAAM,GAAG,QAAQ;IACjBC,OAAO;IACPC;EACF,CAAC,GAAAN,IAAA;EACC,MAAMO,GAAG,GAAGH,MAAM,KAAK,QAAQ,GAAG,qBAAqB,GAAGI,SAAS;EAEnE,oBACEZ,KAAA,CAAAa,aAAA;IAAKH,SAAS,EAAET,EAAE,CAAC,iBAAiB,EAAES,SAAS;EAAE,gBAC/CV,KAAA,CAAAa,aAAA;IACEH,SAAS,EAAC,uBAAuB;IACjCH,IAAI,EAAEA,IAAK;IACXC,MAAM,EAAEA,MAAO;IACfG,GAAG,EAAEA,GAAI;IACTF,OAAO,EAAEA;IACT;IACA;IACA;IAAA;IACA,cAAY,GAAGJ,KAAK,IAAIC,KAAK,GAC3BE,MAAM,KAAK,QAAQ,GAAG,uBAAuB,GAAG,EAAE;EACjD,gBAEHR,KAAA,CAAAa,aAAA;IAAMH,SAAS,EAAC;EAAwB,GAAEL,KAAY,CAAC,EAAC,GAAG,eAC3DL,KAAA,CAAAa,aAAA;IAAMH,SAAS,EAAC;EAAwB,GAAEJ,KAAY,CACrD,CACA,CAAC;AAEV,CAAC","ignoreList":[]}
@@ -18,9 +18,55 @@
18
18
  * CDN build externalises core, so a core change could not be seen through
19
19
  * `?clientBundleUrl=` until it had been published. 0223 always assigned it
20
20
  * here, and this is that move.
21
+ *
22
+ * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)
23
+ * ------------------------------------------------------
24
+ * It buys **recall, and only recall**. In media-manager an editorial slot
25
+ * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates
26
+ * (`CandidateGather.planFor`), scoped by a filter that is just "this site's
27
+ * images" (`buildFilter`) — so the phrase ranks the corpus rather than gating
28
+ * it. Which picture then WINS is decided by `SlotScorer`, whose every term is
29
+ * geometric (crop fit, background, subject, text safety, resolution). Vespa's
30
+ * relevance score is not one of them, and the pool's order is discarded by the
31
+ * assignment solve. The phrase's entire job is choosing which 30 images reach
32
+ * the scorer.
33
+ *
34
+ * That is why it is budgeted in WORDS. The documents it is matched against are
35
+ * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,
36
+ * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with
37
+ * vector closeness. A headline plus a clause of marketing prose is the wrong
38
+ * shape against that on both halves: the vector drifts toward the mean of
39
+ * several topics instead of sitting on the subject, and BM25 spends term weight
40
+ * on filler that pulls in whatever else on the site happens to carry those
41
+ * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)
42
+ * happen on the document side only.
43
+ *
44
+ * A phrase that is too specific costs a worse 30, never a blank section: the
45
+ * site-wide widening in `CandidateGather.poolFor` is entity-only because an
46
+ * editorial pool empties only when the site has no images at all.
47
+ *
48
+ * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text
49
+ * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —
50
+ * every text-derived slot in the product gets a new id. Not a place for
51
+ * cosmetic edits.
52
+ */
53
+
54
+ /**
55
+ * The phrase's real budget, in whole words — the unit that matters, because
56
+ * what it is matched against is a short bag of nouns and not a paragraph.
57
+ */
58
+ const WORD_BUDGET = 10;
59
+
60
+ /**
61
+ * A title of this many words already names its own subject, so the lead adds
62
+ * dilution rather than signal and is dropped. Below it the title may be as thin
63
+ * as "Our picks" or "Why it matters", which names nothing retrievable on its
64
+ * own — that is the case the lead exists for.
21
65
  */
66
+ const TITLE_ENOUGH = 5;
22
67
 
23
- /** Longer than this and the tail stops helping retrieval and starts diluting it. */
68
+ /** A character backstop under the word budget: ten words is normally well
69
+ * inside it, but one pathological unbroken "word" is not. */
24
70
  const MAX_LENGTH = 120;
25
71
 
26
72
  /** Below this there is no query worth sending — better to render no picture
@@ -35,9 +81,18 @@ const stripMarkdown = raw => raw
35
81
  .replace(/^\s*#{1,6}\s*/gm, '').replace(/^\s*>\s?/gm, '')
36
82
  // html tags an author or the parser may have left behind
37
83
  .replace(/<[^>]+>/g, ' ');
38
- const tidy = raw => stripMarkdown(raw).replace(/\s+/g, ' ').trim()
39
- // trailing punctuation is noise in a retrieval phrase
40
- .replace(/[\s.,;:!?—–-]+$/g, '').trim();
84
+ const trimTail = raw => raw.replace(/[\s.,;:!?—–-]+$/g, '').trim();
85
+ const tidy = raw => trimTail(stripMarkdown(raw).replace(/\s+/g, ' ').trim());
86
+ const wordsOf = raw => raw.split(' ').filter(word => word.length > 0);
87
+
88
+ /** Keep at most `max` whole words. The unit the budget is actually stated in. */
89
+ const clampToWords = (raw, max) => {
90
+ const words = wordsOf(raw);
91
+ if (words.length <= max) {
92
+ return raw;
93
+ }
94
+ return trimTail(words.slice(0, max).join(' '));
95
+ };
41
96
 
42
97
  /**
43
98
  * Clamp without cutting a word in half. Slicing at a fixed length leaves
@@ -54,9 +109,12 @@ const clampToWord = (raw, max) => {
54
109
  const cut = raw.slice(0, max + 1);
55
110
  const lastBreak = cut.lastIndexOf(' ');
56
111
  const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);
57
- return out.replace(/[\s.,;:!?—–-]+$/g, '').trim();
112
+ return trimTail(out);
58
113
  };
59
114
 
115
+ /** The word budget first, then the character backstop under it. */
116
+ const budgeted = raw => clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);
117
+
60
118
  /** The first clause carries the subject; what follows is usually qualification. */
61
119
  const firstClause = raw => {
62
120
  const m = raw.match(/^[^.!?;]+/);
@@ -75,17 +133,23 @@ export function composeSemantic(_ref) {
75
133
  // A mission, when there is one, is the whole answer.
76
134
  const mission = tidy(sectionSemantic ?? '');
77
135
  if (mission.length >= MIN_LENGTH) {
78
- return clampToWord(mission, MAX_LENGTH);
136
+ return budgeted(mission);
79
137
  }
80
138
  const head = tidy(title ?? '');
81
- const body = firstClause(tidy(lead ?? ''));
139
+ const headWords = wordsOf(head).length;
140
+
141
+ // The title is the stronger signal, so it is spent first and never truncated
142
+ // to make room for the lead. Once it names a subject on its own, it IS the
143
+ // phrase.
144
+ if (headWords >= TITLE_ENOUGH) {
145
+ const out = budgeted(head);
146
+ return out.length < MIN_LENGTH ? '' : out;
147
+ }
148
+
149
+ // A thin title spends what it did not use on the lead's first clause.
150
+ const body = clampToWords(firstClause(tidy(lead ?? '')), WORD_BUDGET - headWords);
82
151
  const joined = [head, body].filter(Boolean).join('. ');
83
152
  if (joined.length < MIN_LENGTH) return '';
84
- if (joined.length <= MAX_LENGTH) return joined;
85
-
86
- // Over budget: keep the title whole if it fits, since it is the stronger
87
- // signal, and spend whatever is left on the lead.
88
- if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);
89
153
  return clampToWord(joined, MAX_LENGTH);
90
154
  }
91
155
  //# sourceMappingURL=composeSemantic.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","tidy","trim","clampToWord","max","length","cut","slice","lastBreak","lastIndexOf","out","firstClause","m","match","composeSemantic","_ref","title","lead","sectionSemantic","mission","head","body","joined","filter","Boolean","join"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n */\n\n/** Longer than this and the tail stops helping retrieval and starts diluting it. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst tidy = (raw: string): string =>\n stripMarkdown(raw)\n .replace(/\\s+/g, ' ')\n .trim()\n // trailing punctuation is noise in a retrieval phrase\n .replace(/[\\s.,;:!?—–-]+$/g, '')\n .trim();\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return out.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n};\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return clampToWord(mission, MAX_LENGTH);\n }\n\n const head = tidy(title ?? '');\n const body = firstClause(tidy(lead ?? ''));\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n if (joined.length <= MAX_LENGTH) return joined;\n\n // Over budget: keep the title whole if it fits, since it is the stronger\n // signal, and spend whatever is left on the lead.\n if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["WORD_BUDGET","TITLE_ENOUGH","MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","trimTail","trim","tidy","wordsOf","split","filter","word","length","clampToWords","max","words","slice","join","clampToWord","cut","lastBreak","lastIndexOf","out","budgeted","firstClause","m","match","composeSemantic","_ref","title","lead","sectionSemantic","mission","head","headWords","body","joined","Boolean"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n *\n * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)\n * ------------------------------------------------------\n * It buys **recall, and only recall**. In media-manager an editorial slot\n * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates\n * (`CandidateGather.planFor`), scoped by a filter that is just \"this site's\n * images\" (`buildFilter`) — so the phrase ranks the corpus rather than gating\n * it. Which picture then WINS is decided by `SlotScorer`, whose every term is\n * geometric (crop fit, background, subject, text safety, resolution). Vespa's\n * relevance score is not one of them, and the pool's order is discarded by the\n * assignment solve. The phrase's entire job is choosing which 30 images reach\n * the scorer.\n *\n * That is why it is budgeted in WORDS. The documents it is matched against are\n * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,\n * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with\n * vector closeness. A headline plus a clause of marketing prose is the wrong\n * shape against that on both halves: the vector drifts toward the mean of\n * several topics instead of sitting on the subject, and BM25 spends term weight\n * on filler that pulls in whatever else on the site happens to carry those\n * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)\n * happen on the document side only.\n *\n * A phrase that is too specific costs a worse 30, never a blank section: the\n * site-wide widening in `CandidateGather.poolFor` is entity-only because an\n * editorial pool empties only when the site has no images at all.\n *\n * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text\n * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —\n * every text-derived slot in the product gets a new id. Not a place for\n * cosmetic edits.\n */\n\n/**\n * The phrase's real budget, in whole words — the unit that matters, because\n * what it is matched against is a short bag of nouns and not a paragraph.\n */\nconst WORD_BUDGET = 10;\n\n/**\n * A title of this many words already names its own subject, so the lead adds\n * dilution rather than signal and is dropped. Below it the title may be as thin\n * as \"Our picks\" or \"Why it matters\", which names nothing retrievable on its\n * own — that is the case the lead exists for.\n */\nconst TITLE_ENOUGH = 5;\n\n/** A character backstop under the word budget: ten words is normally well\n * inside it, but one pathological unbroken \"word\" is not. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst trimTail = (raw: string): string =>\n raw.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n\nconst tidy = (raw: string): string =>\n trimTail(stripMarkdown(raw).replace(/\\s+/g, ' ').trim());\n\nconst wordsOf = (raw: string): string[] =>\n raw.split(' ').filter((word) => word.length > 0);\n\n/** Keep at most `max` whole words. The unit the budget is actually stated in. */\nconst clampToWords = (raw: string, max: number): string => {\n const words = wordsOf(raw);\n if (words.length <= max) {\n return raw;\n }\n return trimTail(words.slice(0, max).join(' '));\n};\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return trimTail(out);\n};\n\n/** The word budget first, then the character backstop under it. */\nconst budgeted = (raw: string): string =>\n clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return budgeted(mission);\n }\n\n const head = tidy(title ?? '');\n const headWords = wordsOf(head).length;\n\n // The title is the stronger signal, so it is spent first and never truncated\n // to make room for the lead. Once it names a subject on its own, it IS the\n // phrase.\n if (headWords >= TITLE_ENOUGH) {\n const out = budgeted(head);\n return out.length < MIN_LENGTH ? '' : out;\n }\n\n // A thin title spends what it did not use on the lead's first clause.\n const body = clampToWords(\n firstClause(tidy(lead ?? '')),\n WORD_BUDGET - headWords,\n );\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":"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","ignoreList":[]}
@@ -2,7 +2,6 @@
2
2
  @import '../components/ui/PromptEntryEmptyState.css';
3
3
  @import '../components/ui/FeedbackBar.css';
4
4
  @import '../components/ui/AiDisclosure.css';
5
- @import '../components/ui/PoweredByBadge.css';
6
5
  @import '../components/ui/UserQuery.css';
7
6
  @import '../components/ui/PlacementLoader.css';
8
7
  @import '../components/placement/PlacementResponseRenderer.css';
@@ -20,6 +20,14 @@ export declare const DEFAULT_POWERED_BY_HREF = "https://www.nisoweb.ai";
20
20
  * Rendering is the caller's decision — this component says nothing about plans.
21
21
  * The AI page mounts it only when the site is on the Free tier (see
22
22
  * `shouldShowPoweredBy`); any paid package removes it.
23
+ *
24
+ * NOTE: unlike every other component here, its CSS is NOT in
25
+ * `styles/components.css`. That file ships in the client bundle, and this
26
+ * component is mounted from the app bundle — the two are deployed
27
+ * independently, and splitting it across them shipped a naked, unstyled credit
28
+ * to a live storefront. Its stylesheet lives with the shell that mounts it, in
29
+ * `web50-server-ui/src/components/App.css`. A future client that mounts this
30
+ * itself has to bring its own copy, or move the rules somewhere both can see.
23
31
  */
24
32
  export declare const PoweredByBadge: FC<PoweredByBadgeProps>;
25
33
  //# sourceMappingURL=PoweredByBadge.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PoweredByBadge.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/PoweredByBadge.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC;AAGlC,MAAM,WAAW,mBAAmB;IAClC,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;IACjD,sEAAsE;IACtE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAC;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,uBAAuB,2BAA2B,CAAC;AAEhE;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,EAAE,EAAE,CAAC,mBAAmB,CA8BlD,CAAC"}
1
+ {"version":3,"file":"PoweredByBadge.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/PoweredByBadge.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC;AAGlC,MAAM,WAAW,mBAAmB;IAClC,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;IACjD,sEAAsE;IACtE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAC;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,uBAAuB,2BAA2B,CAAC;AAEhE;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,cAAc,EAAE,EAAE,CAAC,mBAAmB,CA8BlD,CAAC"}
@@ -18,6 +18,37 @@
18
18
  * CDN build externalises core, so a core change could not be seen through
19
19
  * `?clientBundleUrl=` until it had been published. 0223 always assigned it
20
20
  * here, and this is that move.
21
+ *
22
+ * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)
23
+ * ------------------------------------------------------
24
+ * It buys **recall, and only recall**. In media-manager an editorial slot
25
+ * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates
26
+ * (`CandidateGather.planFor`), scoped by a filter that is just "this site's
27
+ * images" (`buildFilter`) — so the phrase ranks the corpus rather than gating
28
+ * it. Which picture then WINS is decided by `SlotScorer`, whose every term is
29
+ * geometric (crop fit, background, subject, text safety, resolution). Vespa's
30
+ * relevance score is not one of them, and the pool's order is discarded by the
31
+ * assignment solve. The phrase's entire job is choosing which 30 images reach
32
+ * the scorer.
33
+ *
34
+ * That is why it is budgeted in WORDS. The documents it is matched against are
35
+ * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,
36
+ * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with
37
+ * vector closeness. A headline plus a clause of marketing prose is the wrong
38
+ * shape against that on both halves: the vector drifts toward the mean of
39
+ * several topics instead of sitting on the subject, and BM25 spends term weight
40
+ * on filler that pulls in whatever else on the site happens to carry those
41
+ * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)
42
+ * happen on the document side only.
43
+ *
44
+ * A phrase that is too specific costs a worse 30, never a blank section: the
45
+ * site-wide widening in `CandidateGather.poolFor` is entity-only because an
46
+ * editorial pool empties only when the site has no images at all.
47
+ *
48
+ * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text
49
+ * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —
50
+ * every text-derived slot in the product gets a new id. Not a place for
51
+ * cosmetic edits.
21
52
  */
22
53
  export interface ComposeSemanticInput {
23
54
  /** The section's headline. */
@@ -1 +1 @@
1
- {"version":3,"file":"composeSemantic.d.ts","sourceRoot":"","sources":["../../../src/image/composeSemantic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAsDH,MAAM,WAAW,oBAAoB;IACnC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;yEAEqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,IAAI,EACJ,eAAe,GAChB,EAAE,oBAAoB,GAAG,MAAM,CAmB/B"}
1
+ {"version":3,"file":"composeSemantic.d.ts","sourceRoot":"","sources":["../../../src/image/composeSemantic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAmFH,MAAM,WAAW,oBAAoB;IACnC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;yEAEqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,IAAI,EACJ,eAAe,GAChB,EAAE,oBAAoB,GAAG,MAAM,CA4B/B"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wix/web5-core",
3
3
  "license": "MIT",
4
- "version": "1.63.36",
4
+ "version": "1.63.38",
5
5
  "author": {
6
6
  "name": "tsachis",
7
7
  "email": "tsachis@wix.com"
@@ -100,5 +100,5 @@
100
100
  "wallaby": {
101
101
  "autoDetect": true
102
102
  },
103
- "falconPackageHash": "fd995f5e05dc3354be22d05cd45791b67be39f102c79617aa4ab65c7"
103
+ "falconPackageHash": "527f52ff447d5d61666f8af72e2f79ab513eea188fe4f1a640a38cb2"
104
104
  }
@@ -2,7 +2,6 @@
2
2
  @import '../components/ui/PromptEntryEmptyState.css';
3
3
  @import '../components/ui/FeedbackBar.css';
4
4
  @import '../components/ui/AiDisclosure.css';
5
- @import '../components/ui/PoweredByBadge.css';
6
5
  @import '../components/ui/UserQuery.css';
7
6
  @import '../components/ui/PlacementLoader.css';
8
7
  @import '../components/placement/PlacementResponseRenderer.css';
@@ -1,68 +0,0 @@
1
- /* "Powered by Niso" free-tier credit bar (B2B-4574).
2
-
3
- A full-width strip pinned under the prompt dock at the very bottom of the AI
4
- page. Colour and type are vars with fallbacks so a client can re-skin the bar
5
- without forking the component — but see the two values below that are
6
- deliberately fixed, and `App.tsx` on why the bar itself is not a UI slot. */
7
-
8
- .web5-powered-by {
9
- /*
10
- * The bar's lift, straight from the design (Figma 751-2729): a soft drop
11
- * plus a wider ambient glow, the latter being what actually reads above a
12
- * bar pinned to the bottom edge.
13
- *
14
- * Its own value rather than one of the `--shadow-*` steps in
15
- * `base-tokens.css`: that is a general-purpose scale clients legitimately
16
- * re-map — feature.com flattens `--shadow-sm` to `none` — and the bar needs
17
- * a lift that survives it. Declared here rather than in `base-tokens.css`
18
- * because that file ships in the *client* bundle, which is versioned apart
19
- * from this stylesheet; a token defined there would resolve to nothing on
20
- * any client built before this change.
21
- */
22
- --web5-credit-bar-shadow:
23
- 0px 6px 6px hsl(230 100% 7% / 0.03), 0px 0px 18px hsl(230 100% 7% / 0.07);
24
-
25
- display: flex;
26
- align-items: center;
27
- justify-content: center;
28
- width: 100%;
29
- /*
30
- * Fixed, not a var. The host docks this bar `position: fixed` and lifts the
31
- * prompt input by the same 30px (`--web5-credit-bar-height` in `App.css`);
32
- * a client able to resize it could only ever push the two out of step. The
33
- * colour and type vars below are safe precisely because they cannot.
34
- */
35
- height: 30px;
36
- padding-inline: var(--web5-powered-by-padding-inline, 16px);
37
- background: var(--web5-powered-by-background, hsl(var(--background)));
38
- box-shadow: var(--web5-powered-by-shadow, var(--web5-credit-bar-shadow));
39
- font-size: var(--web5-powered-by-font-size, 14px);
40
- line-height: var(--web5-powered-by-line-height, 1);
41
- font-weight: 400;
42
- text-align: center;
43
- }
44
-
45
- .web5-powered-by__link {
46
- display: inline-flex;
47
- align-items: center;
48
- gap: 0.25em;
49
- /* WCAG 2.1 AA target size — the 30px bar is taller than the text alone. */
50
- min-height: 24px;
51
- color: inherit;
52
- text-decoration: none;
53
- cursor: pointer;
54
- }
55
-
56
- .web5-powered-by__label {
57
- color: var(--web5-powered-by-label-color, hsl(var(--muted-foreground)));
58
- }
59
-
60
- .web5-powered-by__brand {
61
- color: var(--web5-powered-by-brand-color, hsl(var(--foreground)));
62
- }
63
-
64
- .web5-powered-by__link:hover .web5-powered-by__brand,
65
- .web5-powered-by__link:focus-visible .web5-powered-by__brand {
66
- text-decoration: underline;
67
- text-decoration-skip-ink: none;
68
- }
@@ -1,68 +0,0 @@
1
- /* "Powered by Niso" free-tier credit bar (B2B-4574).
2
-
3
- A full-width strip pinned under the prompt dock at the very bottom of the AI
4
- page. Colour and type are vars with fallbacks so a client can re-skin the bar
5
- without forking the component — but see the two values below that are
6
- deliberately fixed, and `App.tsx` on why the bar itself is not a UI slot. */
7
-
8
- .web5-powered-by {
9
- /*
10
- * The bar's lift, straight from the design (Figma 751-2729): a soft drop
11
- * plus a wider ambient glow, the latter being what actually reads above a
12
- * bar pinned to the bottom edge.
13
- *
14
- * Its own value rather than one of the `--shadow-*` steps in
15
- * `base-tokens.css`: that is a general-purpose scale clients legitimately
16
- * re-map — feature.com flattens `--shadow-sm` to `none` — and the bar needs
17
- * a lift that survives it. Declared here rather than in `base-tokens.css`
18
- * because that file ships in the *client* bundle, which is versioned apart
19
- * from this stylesheet; a token defined there would resolve to nothing on
20
- * any client built before this change.
21
- */
22
- --web5-credit-bar-shadow:
23
- 0px 6px 6px hsl(230 100% 7% / 0.03), 0px 0px 18px hsl(230 100% 7% / 0.07);
24
-
25
- display: flex;
26
- align-items: center;
27
- justify-content: center;
28
- width: 100%;
29
- /*
30
- * Fixed, not a var. The host docks this bar `position: fixed` and lifts the
31
- * prompt input by the same 30px (`--web5-credit-bar-height` in `App.css`);
32
- * a client able to resize it could only ever push the two out of step. The
33
- * colour and type vars below are safe precisely because they cannot.
34
- */
35
- height: 30px;
36
- padding-inline: var(--web5-powered-by-padding-inline, 16px);
37
- background: var(--web5-powered-by-background, hsl(var(--background)));
38
- box-shadow: var(--web5-powered-by-shadow, var(--web5-credit-bar-shadow));
39
- font-size: var(--web5-powered-by-font-size, 14px);
40
- line-height: var(--web5-powered-by-line-height, 1);
41
- font-weight: 400;
42
- text-align: center;
43
- }
44
-
45
- .web5-powered-by__link {
46
- display: inline-flex;
47
- align-items: center;
48
- gap: 0.25em;
49
- /* WCAG 2.1 AA target size — the 30px bar is taller than the text alone. */
50
- min-height: 24px;
51
- color: inherit;
52
- text-decoration: none;
53
- cursor: pointer;
54
- }
55
-
56
- .web5-powered-by__label {
57
- color: var(--web5-powered-by-label-color, hsl(var(--muted-foreground)));
58
- }
59
-
60
- .web5-powered-by__brand {
61
- color: var(--web5-powered-by-brand-color, hsl(var(--foreground)));
62
- }
63
-
64
- .web5-powered-by__link:hover .web5-powered-by__brand,
65
- .web5-powered-by__link:focus-visible .web5-powered-by__brand {
66
- text-decoration: underline;
67
- text-decoration-skip-ink: none;
68
- }
@@ -1,68 +0,0 @@
1
- /* "Powered by Niso" free-tier credit bar (B2B-4574).
2
-
3
- A full-width strip pinned under the prompt dock at the very bottom of the AI
4
- page. Colour and type are vars with fallbacks so a client can re-skin the bar
5
- without forking the component — but see the two values below that are
6
- deliberately fixed, and `App.tsx` on why the bar itself is not a UI slot. */
7
-
8
- .web5-powered-by {
9
- /*
10
- * The bar's lift, straight from the design (Figma 751-2729): a soft drop
11
- * plus a wider ambient glow, the latter being what actually reads above a
12
- * bar pinned to the bottom edge.
13
- *
14
- * Its own value rather than one of the `--shadow-*` steps in
15
- * `base-tokens.css`: that is a general-purpose scale clients legitimately
16
- * re-map — feature.com flattens `--shadow-sm` to `none` — and the bar needs
17
- * a lift that survives it. Declared here rather than in `base-tokens.css`
18
- * because that file ships in the *client* bundle, which is versioned apart
19
- * from this stylesheet; a token defined there would resolve to nothing on
20
- * any client built before this change.
21
- */
22
- --web5-credit-bar-shadow:
23
- 0px 6px 6px hsl(230 100% 7% / 0.03), 0px 0px 18px hsl(230 100% 7% / 0.07);
24
-
25
- display: flex;
26
- align-items: center;
27
- justify-content: center;
28
- width: 100%;
29
- /*
30
- * Fixed, not a var. The host docks this bar `position: fixed` and lifts the
31
- * prompt input by the same 30px (`--web5-credit-bar-height` in `App.css`);
32
- * a client able to resize it could only ever push the two out of step. The
33
- * colour and type vars below are safe precisely because they cannot.
34
- */
35
- height: 30px;
36
- padding-inline: var(--web5-powered-by-padding-inline, 16px);
37
- background: var(--web5-powered-by-background, hsl(var(--background)));
38
- box-shadow: var(--web5-powered-by-shadow, var(--web5-credit-bar-shadow));
39
- font-size: var(--web5-powered-by-font-size, 14px);
40
- line-height: var(--web5-powered-by-line-height, 1);
41
- font-weight: 400;
42
- text-align: center;
43
- }
44
-
45
- .web5-powered-by__link {
46
- display: inline-flex;
47
- align-items: center;
48
- gap: 0.25em;
49
- /* WCAG 2.1 AA target size — the 30px bar is taller than the text alone. */
50
- min-height: 24px;
51
- color: inherit;
52
- text-decoration: none;
53
- cursor: pointer;
54
- }
55
-
56
- .web5-powered-by__label {
57
- color: var(--web5-powered-by-label-color, hsl(var(--muted-foreground)));
58
- }
59
-
60
- .web5-powered-by__brand {
61
- color: var(--web5-powered-by-brand-color, hsl(var(--foreground)));
62
- }
63
-
64
- .web5-powered-by__link:hover .web5-powered-by__brand,
65
- .web5-powered-by__link:focus-visible .web5-powered-by__brand {
66
- text-decoration: underline;
67
- text-decoration-skip-ink: none;
68
- }