---
name: daylight-ads
description: >-
  Install a Daylight ad unit on a website — the glass-box ad network served from
  plainsun.com. Use this whenever someone wants to put an ad, an ad unit, an ad slot, a
  sponsor card, a side rail or a skyscraper on a site and the network is Daylight; when they
  say "add the ads to <site>", "put a unit on this page", "install the tag", "wire up
  daylight", "add the ad script"; when they ask which placement or size to use; or when an
  installed unit is not filling, is filling twice, is blocked by a CSP, or has lost its
  disclosure line. It carries the serving contract, the placement sizes, the rules a unit
  must not break, and — the part that matters — how to prove the install is actually
  counting before you tell anybody it worked.
---

# Adding a Daylight ad unit to a site

Daylight is an ad network with the numbers in public. A publisher pastes two tags and their
own fallback content; the network fills the placeholder with one real listing, counts the
impression, and publishes every click it counted **and every one it refused, with the
reason**, on a page needing no account.

Installing it is two tags and a placeholder. This document is the whole procedure. Follow it
in order, and **do not stop after Step 4** — Step 5 is the one that tells you whether any of
it worked.

Everything below is at `https://daylight.ad` (the network) and `https://plainsun.com` (where ads are served from).
Those are two different hosts on purpose and they are not interchangeable. See Step 4.

---

## Read this document raw before you follow it

If you reached this through a tool that fetches and summarises a page, **you are holding a
summary and it is not enough to install from.** Get the text itself:

```bash
curl -s https://daylight.ad/SKILL.md
```

Roughly a third of what is below is a rule about what must *not* happen, and those are
exactly the lines a summary drops. Read the whole thing, then start.

---

## Working with the person

Three of the steps here are theirs rather than yours, and each one is a stop:

1. **Making the account and handing you the site key.** You cannot do this part; there is no
   API for it. They click through a website while you wait.
2. **Where the unit goes.** It is going on their page, in their design, and you are guessing
   if you do not ask.
3. **The fallback content.** It is the publisher's own markup and it stays on the page for
   ever if the script is ever blocked, so it has to be something they are happy for a reader
   to see. Write it with them, not for them.

Say what you are doing as you go, and say plainly when it works. Nothing here needs their
password, and nothing here needs anything from them but one sign-up and two decisions.

They may never have heard of Daylight. One sentence covers it: it is an ad network that
publishes every number it counts, the unit sets no cookies and stores no addresses, and every
click it refuses is published with the reason on a page anybody can read.

---

## Step 1, Get the three values

You need three things and you can only invent one of them.

| | what | where it comes from |
|---|---|---|
| serving base | `https://plainsun.com` | given, right here |
| site key | `dl_` + 24 hex characters | the person, from their dashboard |
| registered domain | the host the ads will run on | the person, from the same page |

**Send them to `https://daylight.ad/join?want=site`.** That query ticks **`Show ads on my page`** and
nothing else, so the page asks for their link, an `Email` and a `Password` of at least 8
characters. The button reads **`Create my account and get my tag`**.

Three things to tell them before they press it:

- **Put the site's domain in the box labelled `Your link`.** With it there, pressing the button
  makes the site and lands on the site's own page — skip the next paragraph.
- **Leave the box reading *"It's also a board — it ranks or lists other people's products"*
  unticked,** and leave **`Run my page as an ad`** unticked too. The first registers the site
  as a board and sends them somewhere else entirely; the second sends them to a preview of an
  ad instead of to the site.
- If they land on a dashboard instead of a form, they already have an account and are signed
  in. That is fine — go straight to the next paragraph.

**If the link was left blank, then on `https://daylight.ad/dash`:** the tile reading **`+ Add a site`**,
then the bare domain (no `https://`, no `www.`, no path), then **`Preview`**, then
**`Add the site`**. That lands on the site's own page.

**The key is on that page**, on the line reading **"Your site key"**, directly under the
domain. It is one click to select the whole thing — there is no copy button on the key
itself. The copy button on the `Install it` section's first snippet is easier and gives you
the key already inside `data-site`.

**The site key is public by design.** It is in the HTML of every page that runs a unit, it
names the site and never the person, and it belongs in a config file rather than a secrets
store. Do not put it in a `.env` that the site treats as secret, do not hide it behind a
server-side proxy, and do not apologise for committing it. What protects a publisher's
numbers is not the key being hidden — it is Step 5.

**Do not construct the site's slug.** If a domain was already taken the slug gets a `-2`
suffix you cannot guess. Read it from the address bar, or from `site.slug` in any unit
response.

---

## Step 2, Check the registered domain before you install anything

**This is the step that is cheap now and invisible later.** The network checks the browser's
`Origin` against the domain the site was registered under. A unit running anywhere else is
**still filled** — the reader sees a completely normal ad — and its impressions are not
counted and its clicks are published as refused.

So an install on the wrong domain looks exactly like a working one. There is nothing in the
network tab, no error, no empty box.

Ask: **is the domain on the dashboard the domain the ads will actually run on?**

- `www.` is covered. A site registered as `example.com` counts on `www.example.com`.
- **Subdomains are not.** `blog.example.com` is a different site needing its own registration
  and its own key. Registering `blog.example.com` does *not* cover the apex either.
- A staging or preview host (`*.vercel.app`, a netlify preview) is a different domain and
  counts nothing. That is correct and is not worth working around.

---

## Step 3, Choose the placement, and write the fallback content

The unit's shape is set per site in the dashboard, arrives as `site.layout`, and is one of
three:

| layout | shape | natural height | fits |
|---|---|---|---|
| `card` | image left, text right | ~120px, width-flexible | in-content, end of an article, a footer strip |
| `stack` | image above, text below | ~254px | a rail, a sidebar, a square |
| `compact` | text only | ~87px | inline, a footer line |

**A 300px-wide `stack` unit is about 300×254** — a standard medium rectangle. A 336px rail
gives the large rectangle. That coincidence is usually the answer for a side rail.

**Before promising a side rail, check it fits.** With a centred content lane the page needs
`lane + 2 × (rail + gap + 16)` pixels of viewport, so a 990px lane with a 300px rail wants
about 1662px and will not appear on most laptops. Shifting the lane instead of centring it
drops that to about 1346px. If neither works, put the unit in the flow instead of inventing
a cramped rail — and if you do shift a lane, shift it with `margin-left` and never with
`transform`, which silently breaks `position: fixed` elsewhere on the page.

**Then write the fallback content**, inside the placeholder, with the person. It renders
first and is replaced only when a real ad is in hand, so if the script is ever blocked this
is what stays on their page permanently. A house link, a related post, their own pitch.
**Never `Loading…`, never a spinner, never an empty div.**

One naming note that costs nothing and saves a support thread: **do not call the wrapper
`#ads`, `.ad-slot` or `.advert`.** Cosmetic filter lists hide those by name, and the wrapper
holds the *publisher's own* fallback — so an ad-farm class name makes their markup defeat
their own fallback. The unit is still labelled: the disclosure line says so in visible text.

---

## Step 4, Install

The default path is two tags and a placeholder:

```html
<script async src="https://plainsun.com/f.js" data-site="dl_YOUR_KEY_HERE"></script>
<link rel="stylesheet" href="https://plainsun.com/f.css">      <!-- optional -->

<div data-daylight>
  <a href="/something-of-yours">Your fallback content — publisher-owned.</a>
</div>
```

**Every URL a publisher's browser loads is `https://plainsun.com`** — script, stylesheet, image, click
link, beacon. Never `daylight.ad`. A snippet naming the network's own domain works perfectly
in every test and then points the filter lists at the domain the dashboard is on. The single
exception is the disclosure mark's own link, which the payload sets for you and which never
appears in your source.

Three things that fail silently if you get them wrong:

- **`data-site` goes on the `<script>` element itself.** The widget reads
  `document.currentScript`. On a wrapper it is a no-op — the widget returns without a word,
  no error anywhere.
- **The API base is derived from the script's own `src` minus `/f.js`**, so a wrong host
  breaks the ad fetch too, not just the script tag.
- **Gate it on the key.** An empty key must render *nothing at all* — no placeholder, no
  empty box. That is what lets a site ship this dormant and what keeps a staging copy out of
  the live numbers. On a hand-written HTML file with no config layer, the dormant state is
  simply that the markup is not in the file yet; do not invent a config object to satisfy
  this.

### By platform, only what differs

- **Static HTML / a generator** — paste it. Keep the key in whatever the site already
  gitignores, if it has one.
- **Server-rendered PHP / Python / Rails** — same two tags. Read the key from config, and
  render the whole block only when it is non-empty.
- **React, or any client-rendered SPA** — **the widget scans the DOM exactly once**, on
  `DOMContentLoaded`, and exposes no global, no callback and no re-scan. A placeholder React
  mounts after that moment is never filled, and neither is one on the next client-side route.
  Either re-append the script element on route change (the IIFE runs again and skips units
  that already carry `data-dl-loaded`, so it is idempotent), or fetch the JSON yourself and
  render it — in which case you owe the disclosure line by hand.
- **Next.js** — script in the root layout, placeholder in a server component: the layout's
  script is injected after hydration, by which point the server-rendered placeholder is in
  the DOM and the single scan finds it. Do not let the fetch cache freeze a unit onto a page.
- **WordPress** — the script path only, and here that is a feature: page caches are
  near-universal and the script path is the only one a page cache cannot corrupt, because
  what gets cached is a placeholder and a tag. `wp_enqueue_script` cannot add
  arbitrary attributes, so `data-site` needs the `script_loader_tag` filter. Never
  server-render a unit in PHP on a cached page.

### If the page sends a Content-Security-Policy

Four directives, and `style-src` is the one people forget:

```
script-src  https://plainsun.com;
style-src   https://plainsun.com;
img-src     https://plainsun.com;
connect-src https://plainsun.com;
```

`style-src` only if you link `f.css`. You do **not** need `'unsafe-inline'`: the widget
applies theme values through the CSSOM, never as a `style` attribute built from a string.
You do not need `frame-src`: there is no iframe anywhere in this network, which is also why
the publisher's own CSS can restyle the unit.

---

## Step 5, Prove it counts. Do not skip this.

Pasting the tag is not the job. **Confirming the unit counts on this domain is**, and it is
the one thing you cannot see by looking at the page.

### 5a. Does this key count on this domain?

Two calls. The first mints a unit while telling the network which page it is for; the second
redeems that unit's token and reports back the verdict the network sealed into it.

```bash
SERVE=https://plainsun.com
KEY=dl_the_site_key
SITE=https://the-domain-the-ads-run-on

TOKEN=$(curl -s -H "Origin: $SITE" "$SERVE/api/v1/unit?site=$KEY&nonce=chk$$" \
        | python3 -c 'import json,sys; print(json.load(sys.stdin).get("impression_token",""))')

curl -s -H "Origin: $SITE" -X POST "$SERVE/api/v1/e" \
     -d "{\"impression_token\":\"$TOKEN\"}"
```

- `{"ok":true,"counted":true}` — **this key counts on that domain.** The install is
  attributed correctly.
- `{"ok":true,"counted":false}` — it fills and the numbers go nowhere. The site is registered
  under a different domain. Go back to Step 2; do not debug the markup, there is nothing
  wrong with it.

Two things about that check, both of which will otherwise mislead you:

- **The `Origin` that decides it is the one on the first call.** The verdict is sealed into
  the token when the unit is served. The second call only reads it back.
- **`counted:false` also means "this token was already redeemed."** Mint a fresh one and
  redeem it exactly once. On a first redemption it can only mean the origin did not match.

### 5b. Is the library actually filling?

```bash
for i in 1 2; do
  curl -s "$SERVE/api/v1/unit?site=$KEY&nonce=n$i$$" \
    | python3 -c 'import json,sys; a=json.load(sys.stdin)["ad"]; print(a["product_domain"] if a else "null")'
done
```

Two `null`s means the library has nothing for this site yet. **A domain then `null` is
correct behaviour, not a fault** — the network deduplicates products across one page, so a
page with three slots and one eligible listing correctly fills one and leaves two showing
their fallback. Do not go looking for a bug in the markup.

### 5c. Is the page itself right?

```bash
# how many placeholders — must be 8 or fewer
curl -s "$PAGE_URL" | grep -cE 'data-daylight'
# the network's own host must not appear at all
curl -s "$PAGE_URL" | grep -n 'daylight.ad'
# the script tag, carrying the key
curl -s "$PAGE_URL" | grep -nE 'f\.js|data-site'
```

### 5d. The two checks no script can run

Open the page in a real browser.

- **A real ad renders, with its disclosure line intact** — both halves, readable, and the
  card link and the credit link separately clickable.
- **Block the serving host and reload.** The publisher's fallback content must be sitting
  there looking intentional. This is the release gate and nothing can automate it.

In the console, if you want the count: `document.querySelectorAll('[data-dl-loaded]').length`
is how many filled, and each filled placeholder carries `data-dl-ad` with the ad's id. There
is deliberately no global to inspect.

### What the answers mean

| what you see | what it is | what to do |
|---|---|---|
| `404 {"error":"unknown site key"}` | the key is wrong **or** the site is `pending` — someone else registered that domain first, and the lookup only returns active sites, so these are the same reply | check `https://daylight.ad/s/{slug}`: if that page renders, the key is right and the site is pending. The tags are correct and will not fill. Say so rather than debugging markup |
| `400 {"error":"missing site parameter"}` | no `site=` on the request | — |
| `429 {"error":"rate limited"}` | 60 per minute per address on the unit endpoint and on the beacon, 30 on clicks | wait for the next minute. If you are server-rendering, note the whole site shares one bucket |
| `{"ad":null,...}` | nothing to serve this page right now | not an install fault. See 5b |
| `counted:false` on a fresh token | the origin did not match | Step 2 |

**Two cautions.** A plain `curl` of the unit endpoint with no `Origin` header is treated as a
server-side render and **counts a real impression** on that site's public numbers — so keep
verification to a handful of calls and tell the publisher the first few impressions are
yours. And the site's own dashboard page shows a *live preview* card that is drawn by our
server: it looks identical whether or not the tag is on their page, so it is the one thing
on the site most likely to be mistaken for proof. It is not proof. 5a is.

---

## The rules a unit must never break

Hard constraints from the network's own spec, not preferences. Breaking one is a bug even
when the page looks right.

- **The disclosure line stays visible.** Both halves — the source credit and the Daylight mark. Never `display:none`, never zero-height, never behind a hover. It is what makes the ad honest rather than merely labelled.
- **The disclosure is a sibling of the card link, never a child.** Anchors cannot nest; a nested one silently wins and the credit link becomes unclickable.
- **Fallback content is sacred.** It is only removed once a real replacement is in hand. Nothing in an install may pre-empt it — no clearing the div on load, no `visibility:hidden` while waiting, no spinner.
- **Never proxy `/f.js`, or any serving path, through the publisher's own domain.** That is block evasion and the network bans it outright. If a filter list blocks the unit, the correct outcome is the publisher's fallback content, and that is the system working rather than failing.
- **All ad text renders as text nodes.** The widget does this for you. If you render server-side, escape every field — title, description, domain, everything. Never `innerHTML` the payload.
- **Never cache a unit response.** The impression token is single-use and the response says `no-store`. A cached unit double-counts, and behind a full-page cache one fetch is served to every reader.
- **No cookies, no fingerprinting, no raw IP.** The unit sets none and sends `credentials: 'omit'`. An install must not add any of them back — no wrapper that attaches a session, no "ad id" written to `localStorage`.
- **Eight units per page, maximum.** The widget fills the first eight placeholders and the server enforces the same ceiling per page nonce. A ninth placeholder shows its fallback for ever.
- **`rel="noopener noreferrer sponsored nofollow"` on the click link, `target="_blank"`.** `sponsored` and `nofollow` are the honesty half, `noopener` and `noreferrer` the security half. The widget writes all four; a hand-rolled unit owes them.
- **A unit only counts on the domain its site key is registered to.** A site key is public by design — it is in the HTML of every page that runs a unit — so the browser's `Origin` is what ties a unit to a publisher. Never install a key on a domain it was not registered for: it will fill perfectly and count nothing. Never try to make a key work elsewhere by proxying or spoofing, which is the block-evasion rule above wearing a different hat. A publisher who genuinely runs two domains registers two sites and gets two keys.

---

## When it is working

Step 5a has said `counted:true`. Tell them, plainly — it is the sentence they have been
waiting for — and then four things:

- **Their public page: `https://daylight.ad/s/{slug}`.** No account needed. Every click counted and every
  one refused, with the reason. It is theirs to share and it is the whole point of the
  network.
- **Where the unit went**, so they can move it if it is not where they had pictured.
- **That the disclosure line is not theirs to hide.** If they ask to restyle it, that is
  fine — colours, size, spacing, their own stylesheet always wins. Removing it is not.
- **That their own visits count.** The first few impressions on that page are theirs and
  yours, and nothing is wrong.

Then stop. There is nothing else to configure: no second script, no events to define, no
consent banner to add — the unit sets no cookies, so there is nothing for one to ask about.

---

## Tell us how it went

This document is the only thing standing between a stranger and a working install, so what
you had to work out is worth more to us than it is to you.

**Hand the person this, with your four lines already in it**, and let them decide whether to
send it:

    mailto:hello@daylight.ad?subject=Daylight%20install%20report

Four lines is plenty: what kind of site it was, which path you used, how many slots, and the
one thing you had to work out that this document did not tell you.

---

## Limits

- **8 units per page.** The widget fills the first 3 placeholders; a page that
  draws units from the JSON API can show up to 8. The server enforces that ceiling.
- **One site per domain**, and a subdomain is a different domain.
- **60 unit requests and 60 beacons per minute per address**, 30 clicks. From a browser that
  is per reader and generous; from a server it is the whole site.
- **No money anywhere.** The network runs free. Nothing here pays out and nothing here
  charges, so do not promise a publisher revenue.