@konvert7/promoot 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,15 @@ Whatever the sponsor bought, without you choosing:
27
27
  letter is shown instead — never both, so a transparent icon can't show the letter
28
28
  through it.
29
29
 
30
+ And when nobody has bought it yet, the slot sells itself. If the owner composed an empty
31
+ state in their dashboard, it renders as a billboard: the lines they chose — their own
32
+ words, the price, the view count, their domain — over a faint chart of the last 30 days
33
+ of traffic, with a preview of the visitor's own ad fading in on hover. Owners who never
34
+ composed one get the plain pitch instead: headline, audience, proof and terms.
35
+
36
+ The empty state needs no configuration here. It arrives resolved from the server, so the
37
+ words are already the words, and this block only sets them.
38
+
30
39
  ## Why inline
31
40
 
32
41
  An iframe fails closed. If a content filter ever blocks the frame, your sponsor's ad
@@ -76,10 +85,19 @@ visitor's browser fetches regardless of how that HTML was produced.
76
85
  `className` and `style` land on the container, and the internal structure carries
77
86
  `data-promoot-*` attributes plus `promoot-*` class names you can target.
78
87
 
88
+ **It inherits your page rather than painting over it.** Unless the owner picked a
89
+ typeface in their dashboard, the block sets `font: inherit` and renders in whatever face
90
+ your page already uses, webfont included — something the iframe version can never do,
91
+ because nothing crosses an iframe boundary. Corner radius and outline width come from the
92
+ owner's settings too, and each falls back to the shipped value if your Promoot server is
93
+ older than those controls.
94
+
79
95
  The block **never declares a colour scheme of its own**. Its palette uses `light-dark()`,
80
96
  which resolves against the scheme it inherits from your page — so a light-only site keeps
81
97
  a light panel even for a visitor whose OS prefers dark, and a dark site gets a dark one.
82
- All of its CSS is scoped to the block; nothing is defined on `:root`.
98
+ All of its CSS is scoped to the block; nothing is defined on `:root`. The only name it
99
+ declares globally is the keyframes the empty state breathes with on touch devices, and
100
+ that name carries the slot's own id, so two blocks on one page never collide.
83
101
 
84
102
  The one thing you cannot restyle is the "Sponsored" label, which renders from inline
85
103
  styles. Disclosure of a paid placement is not the site owner's to remove.
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { PromootBlock, type PromootBlockProps } from "./promoot-block.js";
2
- export type { BlockPayload, BlockRender, Creative } from "./types.js";
2
+ export type { BlockPayload, BlockRender, Creative, PitchLine } from "./types.js";
@@ -13,6 +13,38 @@ async function loadBlock(embedUrl) {
13
13
  return null;
14
14
  }
15
15
  }
16
+ // A keyframe name cannot be scoped to a selector, so it carries the slot's own
17
+ // id instead, stripped to the characters a css identifier may hold.
18
+ function breatheName(slotId, face) {
19
+ return `promoot-breathe-${face}-${slotId.replaceAll(/[^A-Za-z0-9_-]/g, "")}`;
20
+ }
21
+ const mono = "ui-monospace, SFMono-Regular, Menlo, monospace";
22
+ // The face the owner composed, and the ghost that shows a visitor what their own
23
+ // ad would look like in its place. Both are stacked, and hover swaps them.
24
+ function billboardCss(payload, scope) {
25
+ const rest = breatheName(payload.slot.id, "rest");
26
+ const ghost = breatheName(payload.slot.id, "ghost");
27
+ return `${scope} .promoot-bb { position: relative; display: block; width: 100%; height: 100%; box-sizing: border-box; border: var(--border, 1px) solid var(--edge); border-radius: var(--radius, 10px); background: var(--surface); overflow: hidden; font-family: var(--font, inherit); }
28
+ ${scope} .promoot-bb:hover { border-color: var(--edge-hover); }
29
+ ${scope} .promoot-face { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 10px 16px; box-sizing: border-box; text-align: center; transition: opacity .3s ease; }
30
+ ${scope} .promoot-bb:hover .promoot-rest { opacity: 0; }
31
+ ${scope} .promoot-ghost { flex-direction: row; gap: 12px; text-align: left; opacity: 0; }
32
+ ${scope} .promoot-bb:hover .promoot-ghost { opacity: 1; }
33
+ ${scope} .promoot-spark { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 34%; pointer-events: none; }
34
+ ${scope} .promoot-spark polygon { fill: var(--surface-hover); }
35
+ ${scope} .promoot-spark polyline { fill: none; stroke: var(--edge); stroke-width: 1.5; }
36
+ ${scope} .promoot-line { position: relative; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
37
+ ${scope} .promoot-line-text { font-size: 15px; font-weight: 600; color: var(--ink); }
38
+ ${scope} .promoot-line-price { font-family: ${mono}; font-size: 12.5px; font-weight: 600; color: var(--ink); }
39
+ ${scope} .promoot-line-proof, ${scope} .promoot-line-domain { font-family: ${mono}; font-size: 11px; color: var(--muted); }
40
+ ${scope} .promoot-plus { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 9px; border: 1.5px dashed var(--muted); color: var(--muted); font-size: 18px; }
41
+ ${scope} .promoot-ghost-title { display: block; font-size: 13.5px; font-weight: 600; color: var(--ink); }
42
+ ${scope} .promoot-ghost-body { display: block; font-size: 12px; color: var(--muted); }
43
+ @media (hover: none) { ${scope} .promoot-rest { animation: ${rest} 12s ease-in-out 2s infinite; } ${scope} .promoot-ghost { animation: ${ghost} 12s ease-in-out 2s infinite; } }
44
+ @media (prefers-reduced-motion: reduce) { ${scope} .promoot-rest, ${scope} .promoot-ghost { animation: none; } ${scope} .promoot-face { transition: none; } }
45
+ @keyframes ${rest} { 0%, 55%, 100% { opacity: 1; } 70%, 85% { opacity: 0; } }
46
+ @keyframes ${ghost} { 0%, 55%, 100% { opacity: 0; } 70%, 85% { opacity: 1; } }`;
47
+ }
16
48
  // Every rule is scoped to this one block, and it never declares a colour scheme
17
49
  // of its own: the palette uses light-dark(), which resolves against the scheme
18
50
  // inherited from the host page, so a light-only site keeps a light panel.
@@ -22,7 +54,7 @@ export function scopedCss(payload) {
22
54
  ${scope} a { text-decoration: none; }
23
55
  ${scope} .promoot-ad { display: block; position: relative; height: 100%; width: 100%; }
24
56
  ${scope} .promoot-ad > img { display: block; height: 100%; width: 100%; object-fit: cover; }
25
- ${scope} .promoot-text { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 100%; height: 100%; box-sizing: border-box; padding: 6px 12px; border: 1px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; text-align: center; font-family: system-ui, -apple-system, sans-serif; }
57
+ ${scope} .promoot-text { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 100%; height: 100%; box-sizing: border-box; padding: 6px 12px; border: var(--border, 1px) solid var(--edge); border-radius: var(--radius, 10px); background: var(--surface); overflow: hidden; text-align: center; font-family: var(--font, inherit); }
26
58
  ${scope} .promoot-ad:hover .promoot-text { border-color: var(--edge-hover); background: var(--surface-hover); }
27
59
  ${scope} .promoot-mark { position: relative; flex: none; display: grid; place-items: center; width: 24px; height: 24px; margin-bottom: 1px; border-radius: 6px; background: var(--edge); color: var(--surface); font-weight: 700; font-size: 12px; overflow: hidden; }
28
60
  ${scope} .promoot-mark img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
@@ -30,13 +62,14 @@ ${scope} .promoot-text > span:not(.promoot-mark) { max-width: 100%; white-space:
30
62
  ${scope} .promoot-text .promoot-headline { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.25; }
31
63
  ${scope} .promoot-text .promoot-desc { font-size: 12px; color: var(--muted); line-height: 1.25; }
32
64
  ${scope} .promoot-text .promoot-host { font-size: 11px; color: var(--muted); line-height: 1.25; }
33
- ${scope} .promoot-cta { display: flex; flex-direction: column; align-items: center; gap: 3px; width: 100%; height: 100%; justify-content: center; padding: 0 10px; text-align: center; border: 1px dashed var(--edge); border-radius: 10px; box-sizing: border-box; color: var(--muted); background: var(--surface); font-family: system-ui, -apple-system, sans-serif; }
65
+ ${scope} .promoot-cta { display: flex; flex-direction: column; align-items: center; gap: 3px; width: 100%; height: 100%; justify-content: center; padding: 0 10px; text-align: center; border: var(--border, 1px) dashed var(--edge); border-radius: var(--radius, 10px); box-sizing: border-box; color: var(--muted); background: var(--surface); font-family: var(--font, inherit); }
34
66
  ${scope} .promoot-cta:hover { border-color: var(--edge-hover); background: var(--surface-hover); }
35
67
  ${scope} .promoot-cta strong { font-size: 14px; color: var(--ink); }
36
68
  ${scope} .promoot-cta .promoot-pitch { font-size: 13px; color: var(--ink); }
37
69
  ${scope} .promoot-cta .promoot-proof { font-size: 11px; color: var(--muted); }
38
70
  ${scope} .promoot-cta .promoot-terms { font-size: 12px; }
39
- ${scope} .promoot-beacon { position: absolute; bottom: 0; right: 0; width: 1px; height: 1px; }`;
71
+ ${scope} .promoot-beacon { position: absolute; bottom: 0; right: 0; width: 1px; height: 1px; }
72
+ ${billboardCss(payload, scope)}`;
40
73
  }
41
74
  // Inline, not a class: a host can restyle everything else, but the disclosure
42
75
  // is not theirs to remove.
@@ -63,10 +96,40 @@ function AdCreative({ creative }) {
63
96
  }
64
97
  return (_jsxs("span", { className: "promoot-text", children: [_jsx(Mark, { creative: creative }), creative.headline && _jsx("span", { className: "promoot-headline", children: creative.headline }), creative.body && _jsx("span", { className: "promoot-desc", children: creative.body }), _jsx("span", { className: "promoot-host", children: creative.host })] }));
65
98
  }
99
+ // Oldest day first across a fixed box, which the slot's own aspect ratio then
100
+ // stretches: one path serves every size.
101
+ function sparkPoints(series) {
102
+ const max = Math.max(...series, 1);
103
+ const step = series.length > 1 ? 600 / (series.length - 1) : 600;
104
+ return series
105
+ .map((value, index) => {
106
+ const x = Math.round(index * step * 10) / 10;
107
+ const y = Math.round((44 - (value / max) * 38) * 10) / 10;
108
+ return `${x},${y}`;
109
+ })
110
+ .join(" ");
111
+ }
112
+ function Spark({ series }) {
113
+ const points = sparkPoints(series);
114
+ return (_jsxs("svg", { className: "promoot-spark", viewBox: "0 0 600 48", preserveAspectRatio: "none", "aria-hidden": "true", children: [_jsx("polygon", { points: `0,48 ${points} 600,48` }), _jsx("polyline", { points: points })] }));
115
+ }
116
+ // What the owner wrote, with a preview of somebody else's ad underneath it: the
117
+ // empty slot sells itself to the visitor who could fill it.
118
+ function Billboard({ pitch, lines }) {
119
+ return (_jsxs("a", { className: "promoot-bb", href: pitch.purchaseUrl, target: "_blank", rel: "noopener", children: [_jsxs("span", { className: "promoot-face promoot-rest", children: [pitch.spark?.length ? _jsx(Spark, { series: pitch.spark }) : null, lines.map((line, index) => (_jsx("span", { className: `promoot-line promoot-line-${line.kind}`, children: line.text }, `${line.kind}-${index}`)))] }), _jsxs("span", { className: "promoot-face promoot-ghost", children: [_jsx("span", { className: "promoot-plus", children: "+" }), _jsxs("span", { children: [_jsx("span", { className: "promoot-ghost-title", children: "Your product \u00B7 one line your buyers read" }), _jsxs("span", { className: "promoot-ghost-body", children: ["This is how it would look.", pitch.price ? ` ${pitch.price}.` : ""] })] })] })] }));
120
+ }
121
+ function ClassicPitch({ pitch }) {
122
+ return (_jsxs("a", { className: "promoot-cta", href: pitch.purchaseUrl, target: "_blank", rel: "noopener", children: [_jsx("strong", { children: pitch.headline }), pitch.pitch && _jsx("span", { className: "promoot-pitch", children: pitch.pitch }), pitch.proof && _jsx("span", { className: "promoot-proof", children: pitch.proof }), _jsx("span", { className: "promoot-terms", children: pitch.terms })] }));
123
+ }
124
+ // A server that has not learned to compose an empty state sends no lines, and
125
+ // the pitch it does send is rendered exactly as it always was.
126
+ function EmptySlot({ pitch }) {
127
+ return pitch.lines?.length ? (_jsx(Billboard, { pitch: pitch, lines: pitch.lines })) : (_jsx(ClassicPitch, { pitch: pitch }));
128
+ }
66
129
  export async function PromootBlock({ url, className, style, fallback = null, }) {
67
130
  const block = await loadBlock(url);
68
131
  if (!block || block.render === "blank") {
69
132
  return _jsx(_Fragment, { children: fallback });
70
133
  }
71
- return (_jsxs("div", { "data-promoot-slot": block.slot.id, className: className, style: style, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: scopedCss(block) } }), block.ad && (_jsxs("a", { className: "promoot-ad", href: block.ad.clickUrl, target: "_blank", rel: "noopener nofollow sponsored", children: [_jsx(AdCreative, { creative: block.ad }), block.slot.sponsoredLabel && (_jsx("span", { style: labelStyle, children: block.slot.sponsoredLabel }))] })), block.pitch && (_jsxs("a", { className: "promoot-cta", href: block.pitch.purchaseUrl, target: "_blank", rel: "noopener", children: [_jsx("strong", { children: block.pitch.headline }), block.pitch.pitch && _jsx("span", { className: "promoot-pitch", children: block.pitch.pitch }), block.pitch.proof && _jsx("span", { className: "promoot-proof", children: block.pitch.proof }), _jsx("span", { className: "promoot-terms", children: block.pitch.terms })] })), _jsx("img", { className: "promoot-beacon", src: block.beaconUrl, width: 1, height: 1, alt: "", "aria-hidden": "true" })] }));
134
+ return (_jsxs("div", { "data-promoot-slot": block.slot.id, className: className, style: style, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: scopedCss(block) } }), block.ad && (_jsxs("a", { className: "promoot-ad", href: block.ad.clickUrl, target: "_blank", rel: "noopener nofollow sponsored", children: [_jsx(AdCreative, { creative: block.ad }), block.slot.sponsoredLabel && (_jsx("span", { style: labelStyle, children: block.slot.sponsoredLabel }))] })), block.pitch && _jsx(EmptySlot, { pitch: block.pitch }), _jsx("img", { className: "promoot-beacon", src: block.beaconUrl, width: 1, height: 1, alt: "", "aria-hidden": "true" })] }));
72
135
  }
package/dist/types.d.ts CHANGED
@@ -14,6 +14,14 @@ export type Creative = {
14
14
  host: string;
15
15
  monogram: string;
16
16
  };
17
+ /**
18
+ * One line of the empty state the slot's owner composed, already resolved into
19
+ * the words it prints. The kind only decides how it is set.
20
+ */
21
+ export type PitchLine = {
22
+ kind: "text" | "price" | "proof" | "domain";
23
+ text: string;
24
+ };
17
25
  /** What GET /api/block/:slotId answers with. */
18
26
  export type BlockPayload = {
19
27
  slot: {
@@ -33,6 +41,15 @@ export type BlockPayload = {
33
41
  proof: string | null;
34
42
  terms: string;
35
43
  purchaseUrl: string;
44
+ /**
45
+ * The owner's composed empty state. Present from Promoot 0.4 onwards; an
46
+ * older server omits it and the classic pitch is rendered instead.
47
+ */
48
+ lines?: PitchLine[] | null;
49
+ /** Daily views behind the lines, already gated by the owner's traffic floor. */
50
+ spark?: number[] | null;
51
+ /** The price on its own, formatted. */
52
+ price?: string;
36
53
  } | null;
37
54
  beaconUrl: string;
38
55
  paletteVars: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/promoot",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Render a Promoot sponsor slot inline as a React server component, with views counted by the visitor's own browser.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",