@konvert7/promoot 0.3.0 → 0.4.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
@@ -79,7 +88,9 @@ visitor's browser fetches regardless of how that HTML was produced.
79
88
  The block **never declares a colour scheme of its own**. Its palette uses `light-dark()`,
80
89
  which resolves against the scheme it inherits from your page — so a light-only site keeps
81
90
  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`.
91
+ All of its CSS is scoped to the block; nothing is defined on `:root`. The only name it
92
+ declares globally is the keyframes the empty state breathes with on touch devices, and
93
+ that name carries the slot's own id, so two blocks on one page never collide.
83
94
 
84
95
  The one thing you cannot restyle is the "Sponsored" label, which renders from inline
85
96
  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";
@@ -16,6 +16,38 @@ async function loadBlock(embedUrl) {
16
16
  // Every rule is scoped to this one block, and it never declares a colour scheme
17
17
  // of its own: the palette uses light-dark(), which resolves against the scheme
18
18
  // inherited from the host page, so a light-only site keeps a light panel.
19
+ // A keyframe name cannot be scoped to a selector, so it carries the slot's own
20
+ // id instead, stripped to the characters a css identifier may hold.
21
+ function breatheName(slotId, face) {
22
+ return `promoot-breathe-${face}-${slotId.replaceAll(/[^A-Za-z0-9_-]/g, "")}`;
23
+ }
24
+ const mono = "ui-monospace, SFMono-Regular, Menlo, monospace";
25
+ // The face the owner composed, and the ghost that shows a visitor what their own
26
+ // ad would look like in its place. Both are stacked, and hover swaps them.
27
+ function billboardCss(payload, scope) {
28
+ const rest = breatheName(payload.slot.id, "rest");
29
+ const ghost = breatheName(payload.slot.id, "ghost");
30
+ return `${scope} .promoot-bb { position: relative; display: block; width: 100%; height: 100%; box-sizing: border-box; border: 1px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; font-family: system-ui, -apple-system, sans-serif; }
31
+ ${scope} .promoot-bb:hover { border-color: var(--edge-hover); }
32
+ ${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; }
33
+ ${scope} .promoot-bb:hover .promoot-rest { opacity: 0; }
34
+ ${scope} .promoot-ghost { flex-direction: row; gap: 12px; text-align: left; opacity: 0; }
35
+ ${scope} .promoot-bb:hover .promoot-ghost { opacity: 1; }
36
+ ${scope} .promoot-spark { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 34%; pointer-events: none; }
37
+ ${scope} .promoot-spark polygon { fill: var(--surface-hover); }
38
+ ${scope} .promoot-spark polyline { fill: none; stroke: var(--edge); stroke-width: 1.5; }
39
+ ${scope} .promoot-line { position: relative; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
40
+ ${scope} .promoot-line-text { font-size: 15px; font-weight: 600; color: var(--ink); }
41
+ ${scope} .promoot-line-price { font-family: ${mono}; font-size: 12.5px; font-weight: 600; color: var(--ink); }
42
+ ${scope} .promoot-line-proof, ${scope} .promoot-line-domain { font-family: ${mono}; font-size: 11px; color: var(--muted); }
43
+ ${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; }
44
+ ${scope} .promoot-ghost-title { display: block; font-size: 13.5px; font-weight: 600; color: var(--ink); }
45
+ ${scope} .promoot-ghost-body { display: block; font-size: 12px; color: var(--muted); }
46
+ @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; } }
47
+ @media (prefers-reduced-motion: reduce) { ${scope} .promoot-rest, ${scope} .promoot-ghost { animation: none; } ${scope} .promoot-face { transition: none; } }
48
+ @keyframes ${rest} { 0%, 55%, 100% { opacity: 1; } 70%, 85% { opacity: 0; } }
49
+ @keyframes ${ghost} { 0%, 55%, 100% { opacity: 0; } 70%, 85% { opacity: 1; } }`;
50
+ }
19
51
  export function scopedCss(payload) {
20
52
  const scope = `[data-promoot-slot="${payload.slot.id}"]`;
21
53
  return `${scope} { ${payload.paletteVars}; position: relative; display: block; width: 100%; max-width: ${payload.slot.widthPx}px; height: auto; max-height: ${payload.slot.heightPx}px; aspect-ratio: ${payload.slot.widthPx} / ${payload.slot.heightPx}; }
@@ -36,7 +68,8 @@ ${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.4.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",