srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'web/src')
-rw-r--r--web/src/adSlot.js30
-rw-r--r--web/src/app.js4
-rw-r--r--web/src/screens/contributeScreen.js44
-rw-r--r--web/src/screens/multiplayerScreen.js17
-rw-r--r--web/src/screens/storeScreen.js81
-rw-r--r--web/src/styles.css243
6 files changed, 394 insertions, 25 deletions
diff --git a/web/src/adSlot.js b/web/src/adSlot.js
index 6dfb5b5..b87ae4f 100644
--- a/web/src/adSlot.js
+++ b/web/src/adSlot.js
@@ -31,6 +31,8 @@ import { getUser, onAuthChange } from './auth.js';
const SIZES = {
// Roughly a leaderboard unit, and a mobile banner below the breakpoint.
banner: { w: 728, h: 90, mobileW: 320, mobileH: 100 },
+ // A wide skyscraper, the usual unit for a page margin.
+ skyscraper: { w: 160, h: 600, mobileW: 160, mobileH: 600 },
};
export function shouldShowAds() {
@@ -65,6 +67,34 @@ export function renderAdSlot(root, { size = 'banner', label = 'Advertisement' }
}
+// The two page margins. They appear only where there is genuinely room for
+// them beside the content, which the stylesheet decides, and they are the
+// first thing dropped on a narrow window.
+export function mountSideAdRails() {
+ const rails = ['left', 'right'].map(side => {
+ const rail = document.createElement('div');
+ rail.className = `ad-rail ad-rail-${side}`;
+ document.body.appendChild(rail);
+ return { rail, cleanup: renderAdSlot(rail, { size: 'skyscraper' }) };
+ });
+
+ let screenName = 'menu';
+ function paint() {
+ const show = shouldShowAds() && !HIDDEN_ON.has(screenName);
+ for (const { rail } of rails) rail.hidden = !show;
+ }
+ paint();
+ const unsubscribe = onAuthChange(paint);
+
+ return {
+ setScreen(name) { screenName = name; paint(); },
+ cleanup() {
+ unsubscribe();
+ for (const { rail, cleanup } of rails) { cleanup(); rail.remove(); }
+ },
+ };
+}
+
// Screens the banner stays off. Typing and passive reading are both somebody
// working through a text, and the race lobby leads straight into typing.
const HIDDEN_ON = new Set(['typing', 'passive', 'multiplayer']);
diff --git a/web/src/app.js b/web/src/app.js
index 4ef6c2e..b306c66 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -18,7 +18,7 @@ import { saveDocument, setPosition, getDocument, listDocuments, removeDocument }
import { getSettings } from './settings.js';
import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js';
import { getWeakChars } from './keyStats.js';
-import { mountTopAdBanner } from './adSlot.js';
+import { mountTopAdBanner, mountSideAdRails } from './adSlot.js';
const FALLBACK_TEXT = { category: 'general', content: 'The quick brown fox jumps over the lazy dog.', attribution: 'Traditional pangram' };
@@ -98,6 +98,7 @@ export function startApp(root, localTexts) {
let game = null;
let cleanupScreen = null;
const adBanner = mountTopAdBanner();
+ const adRails = mountSideAdRails();
// Approved community submissions, merged on top of the bundled dataset.
// Best-effort: the app is fully usable on the packs it ships with, so a
@@ -146,6 +147,7 @@ export function startApp(root, localTexts) {
currentScreenName = name;
escapeIsCostly = costly;
adBanner.setScreen(name);
+ adRails.setScreen(name);
}
function handleGlobalEscape(e) {
diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js
index b84ee40..b5eb262 100644
--- a/web/src/screens/contributeScreen.js
+++ b/web/src/screens/contributeScreen.js
@@ -28,6 +28,7 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
let isAdmin = false;
let roleHolders = [];
let userSearch = [];
+ let orders = [];
let cleanupInner = null;
function statusLabel(s) {
@@ -47,6 +48,14 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
// ordinary case for almost everyone.
try { roleHolders = await api.get('/api/admin/users'); isAdmin = true; }
catch { roleHolders = []; isAdmin = false; }
+ // Paid orders waiting to be posted. Only an administrator can see
+ // them, and they carry a postal address, so this is the one screen in
+ // the app that shows one.
+ if (isAdmin) {
+ try { orders = await api.get('/api/admin/orders'); } catch { orders = []; }
+ } else {
+ orders = [];
+ }
}
function submissionRow(s, moderating) {
@@ -116,6 +125,30 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
</div>
` : ''}
+ ${isAdmin ? `
+ <h3>Orders to post${orders.length ? ` (${orders.length})` : ''}</h3>
+ <div class="settings-hint">Paid and not yet posted. Marking one sent does not notify the buyer.</div>
+ ${orders.length ? `
+ <div class="submission-list">
+ ${orders.map(o => `
+ <div class="submission-row order-row" data-order="${escapeHtml(o.id)}">
+ <div class="submission-head">
+ <span class="submission-category">${escapeHtml(o.item)}${o.variant ? ` &middot; ${escapeHtml(o.variant)}` : ''}</span>
+ <span class="submission-by">for ${escapeHtml(o.username)}</span>
+ <span class="submission-status">$${(o.amount_cents / 100).toFixed(2)}</span>
+ </div>
+ <div class="order-address">${o.shipping_address
+ ? escapeHtml(o.shipping_address).replace(/\n/g, '<br>')
+ : 'No address recorded. Check the payment processor before posting.'}</div>
+ <div class="submission-actions">
+ <button class="menu-button small primary" data-action="mark-shipped" data-order="${escapeHtml(o.id)}">Mark posted</button>
+ </div>
+ </div>
+ `).join('')}
+ </div>
+ ` : '<div class="stats-empty">Nothing waiting to go out.</div>'}
+ ` : ''}
+
${isModerator ? `
<h3>Review queue${queue.length ? ` (${queue.length})` : ''}</h3>
${queue.length
@@ -185,7 +218,16 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
});
});
- root.querySelectorAll('.submission-actions button').forEach(btn => {
+ root.querySelectorAll('[data-action="mark-shipped"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ try { await api.post(`/api/admin/orders/${encodeURIComponent(btn.dataset.order)}/shipped`); }
+ catch { /* reloading below shows what actually happened */ }
+ await load();
+ render();
+ });
+ });
+
+ root.querySelectorAll('.submission-actions button:not([data-action="mark-shipped"])').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.submission-row');
const decision = btn.dataset.action === 'approve' ? 'approve' : 'reject';
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index 878f659..053a461 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -50,6 +50,12 @@ function applyInlineStyles(container) {
container.querySelectorAll('[data-percent]').forEach(el => {
el.style.width = `${el.dataset.percent}%`;
});
+ // How far along the track a sprite has travelled, as a fraction. The
+ // stylesheet turns it into a position; a percentage on its own would put
+ // the sprite's left edge at the finish rather than the sprite itself.
+ container.querySelectorAll('[data-progress]').forEach(el => {
+ el.style.setProperty('--p', String(Math.max(0, Math.min(1, Number(el.dataset.progress) / 100)) || 0));
+ });
}
// No account required - a quick race with friends shouldn't need signing
@@ -281,11 +287,18 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
opponents.innerHTML = players.map(p => {
const me = p.id === myId;
const prog = progressById[p.id] || {};
+ const percent = Math.round(prog.percent || 0);
+ // The sprite rides the track rather than sitting beside it.
+ // A static icon next to a thin bar made the one thing a
+ // player owns and can see the least visible part of the race.
return `
<div class="mp-racer-row${me ? ' me' : ''}" data-racer-color="${colors[p.id]}">
- <span class="mp-racer-sprite">${RACER_SPRITES[sprites[p.id]]}</span>
<span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span>
- <div class="mp-racer-bar"><div class="mp-racer-bar-fill" data-percent="${prog.percent || 0}"></div></div>
+ <div class="mp-racer-track">
+ <div class="mp-racer-trail" data-percent="${percent}"></div>
+ <span class="mp-racer-sprite" data-progress="${percent}">${RACER_SPRITES[sprites[p.id]]}</span>
+ </div>
+ <span class="mp-racer-percent">${percent}%</span>
<span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span>
</div>`;
}).join('');
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 5299582..26cbade 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -15,6 +15,13 @@ function formatPrice(cents) {
// far taller than the others beside it.
const MAX_PREVIEWS = 10;
+function merchKindLabel(kind) {
+ if (kind === 'shirt') return 'Shirts';
+ if (kind === 'mug') return 'Mugs';
+ if (kind === 'deskmat') return 'Deskmats';
+ return 'Other';
+}
+
function categoryLabel(category) {
if (category === 'caret') return 'Caret Colours';
if (category === 'sprite') return 'Race Sprites';
@@ -28,6 +35,9 @@ function categoryLabel(category) {
export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) {
let catalog = [];
let bundles = [];
+ let merch = [];
+ // Size picked per item, so a shirt cannot be bought without one.
+ const chosenVariant = {};
let mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false };
let status = 'loading';
let message = '';
@@ -106,6 +116,35 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
</div>`;
}
+ function merchMarkup(item) {
+ const signedOut = status === 'signed-out';
+ const needsVariant = (item.variants || []).length > 0;
+ const picked = chosenVariant[item.id];
+ return `
+ <div class="merch-card">
+ <div class="merch-head">
+ <div class="merch-name">${escapeHtml(item.name)}</div>
+ <div class="merch-price">${formatPrice(item.price_cents)}</div>
+ </div>
+ ${item.description ? `<div class="merch-description">${escapeHtml(item.description)}</div>` : ''}
+ ${needsVariant ? `
+ <div class="merch-variants">
+ ${item.variants.map(v => `
+ <button class="merch-variant${picked === v ? ' active' : ''}" data-action="pick-variant"
+ data-id="${escapeHtml(item.id)}" data-variant="${escapeHtml(v)}">${escapeHtml(v)}</button>
+ `).join('')}
+ </div>` : ''}
+ <div class="merch-foot">
+ <span class="merch-shipping">${item.shipping_cents ? `plus ${formatPrice(item.shipping_cents)} postage` : 'postage included'}</span>
+ <button class="menu-button small${signedOut ? ' quiet' : ' primary'}"
+ data-action="${signedOut ? 'go-account' : 'buy-merch'}"
+ data-id="${escapeHtml(item.id)}"
+ ${signedOut ? ' data-tooltip="Sign in to order"' : ''}>Order</button>
+ </div>
+ <div class="merch-error" data-error-for="${escapeHtml(item.id)}"></div>
+ </div>`;
+ }
+
function supporterMarkup() {
if (status === 'signed-out') return '';
if (mine.is_supporter) {
@@ -139,6 +178,18 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
? `<h3>Bundles</h3><div class="bundle-grid">${bundles.map(bundleMarkup).join('')}</div>`
: '';
+ const merchKinds = ['shirt', 'mug', 'deskmat'];
+ const merchSections = merch.length
+ ? `<h3>Merch</h3>
+ <div class="settings-hint merch-note">Posted to 21 countries. Your address is entered on the payment page and never on this site.</div>`
+ + merchKinds.map(kind => {
+ const items = merch.filter(m => m.kind === kind);
+ if (!items.length) return '';
+ return `<h4 class="merch-kind">${merchKindLabel(kind)}</h4>
+ <div class="merch-grid">${items.map(merchMarkup).join('')}</div>`;
+ }).join('')
+ : '';
+
const categories = ['caret', 'flair', 'sprite'];
const itemSections = categories.map(cat => {
const items = catalog.filter(i => i.category === cat);
@@ -146,16 +197,16 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
}).join('');
- return banner + supporterMarkup() + bundleSection + itemSections
+ return banner + supporterMarkup() + bundleSection + itemSections + merchSections
+ `<div class="custom-error store-error"></div>`;
}
/// Starts a checkout and follows the redirect the server returns. A server
/// with no processor keys answers 501, which is reported rather than
/// leaving the button looking broken.
- async function startCheckout(path, errorEl) {
+ async function startCheckout(path, errorEl, body) {
try {
- const data = await api.post(path);
+ const data = await api.post(path, body);
if (data && data.url) window.location.assign(data.url);
} catch (err) {
if (!errorEl) return;
@@ -196,6 +247,29 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
root.querySelector('[data-action="buy-supporter"]')
?.addEventListener('click', () => startCheckout('/api/billing/supporter', errorEl));
+ root.querySelectorAll('[data-action="pick-variant"]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ chosenVariant[btn.dataset.id] = btn.dataset.variant;
+ rerender();
+ });
+ });
+ root.querySelectorAll('[data-action="buy-merch"]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = btn.dataset.id;
+ const item = merch.find(m => m.id === id);
+ const cardError = root.querySelector(`[data-error-for="${CSS.escape(id)}"]`);
+ // Checked here as well as on the server, so choosing a size is
+ // a click rather than a failed checkout.
+ if (item && (item.variants || []).length && !chosenVariant[id]) {
+ if (cardError) cardError.textContent = 'Choose a size first.';
+ return;
+ }
+ if (cardError) cardError.textContent = '';
+ startCheckout(`/api/billing/merch/${encodeURIComponent(id)}`, cardError || errorEl,
+ { variant: chosenVariant[id] || null });
+ });
+ });
+
root.querySelectorAll('[data-action="equip"]').forEach(btn => {
btn.addEventListener('click', async () => {
await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {});
@@ -233,6 +307,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
// A store with no bundles is still a store, so this failing is not an
// error worth replacing the page with.
try { bundles = await api.get('/api/cosmetics/bundles'); } catch { bundles = []; }
+ try { merch = await api.get('/api/merch'); } catch { merch = []; }
}
async function loadMine() {
diff --git a/web/src/styles.css b/web/src/styles.css
index f51bb65..ee132d9 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -8,6 +8,9 @@
--neutral-color: #646669;
--caret-color: #00ff9d;
--sub-color: #8b8e92;
+ /* Panel and control outlines. Derived from the neutral so it tracks each
+ theme, rather than being a sixth colour to keep in step by hand. */
+ --border-color: color-mix(in srgb, var(--neutral-color) 55%, transparent);
/* Unified top offset below fixed header/toggle */
/* Reserved height for the top advertising banner, and the amount the
header moves down when one is shown. --ad-banner-h is set on the root
@@ -1945,21 +1948,23 @@ body {
left out the one bar you most need to see: your own. */
.mp-racers {
position: fixed;
- top: 58%;
+ top: 56%;
left: 50%;
transform: translateX(-50%);
z-index: 90;
display: flex;
flex-direction: column;
- gap: 0.5rem;
- width: min(680px, 80vw);
+ gap: 0.35rem;
+ /* Wider than it was. The field is the thing you look up at between
+ glances at the passage, so it gets the room. */
+ width: min(1040px, 94vw);
}
.mp-racer-row {
display: flex;
align-items: center;
- gap: 0.75rem;
- font-size: 0.8rem;
+ gap: 0.85rem;
+ font-size: 0.9rem;
color: var(--racer-color, var(--sub-color));
}
@@ -1970,32 +1975,61 @@ body {
}
.mp-racer-name {
- min-width: 7rem;
- max-width: 7rem;
+ min-width: 9rem;
+ max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
-.mp-racer-bar {
+/* The track the sprite travels along. Tall enough to hold the sprite, with
+ the trail drawn behind it. */
+.mp-racer-track {
+ position: relative;
flex: 1 1 auto;
- height: 0.4rem;
- background: color-mix(in srgb, var(--neutral-color) 45%, transparent);
- border-radius: var(--radius-pill);
- overflow: hidden;
+ height: 2rem;
+ border-bottom: var(--border-width) dashed color-mix(in srgb, var(--neutral-color) 45%, transparent);
}
-.mp-racer-bar-fill {
- height: 100%;
+/* How far this racer has come. Sits at the foot of the track, on the dashed
+ line, so the line reads as road ahead and the trail as road behind. */
+.mp-racer-trail {
+ position: absolute;
+ left: 0;
+ bottom: -1px;
+ height: 2px;
background: var(--racer-color, var(--primary-color));
transition: width 0.15s ease;
}
+/* The sprite rides the track. The position is the fraction of the way along,
+ scaled by the room left after the sprite's own width, so at 100 percent the
+ sprite sits at the finish rather than overhanging it. */
+.mp-racer-sprite {
+ position: absolute;
+ bottom: 0.15rem;
+ left: calc(var(--p, 0) * (100% - 1.9rem));
+ width: 1.9rem;
+ height: 1.9rem;
+ transition: left 0.15s ease;
+}
+
+.mp-racer-row.me .mp-racer-sprite {
+ filter: drop-shadow(0 0 4px var(--racer-color, var(--primary-color)));
+}
+
+.mp-racer-percent,
.mp-racer-wpm {
- min-width: 2.5rem;
- text-align: left;
+ min-width: 3rem;
+ text-align: right;
font-family: 'JetBrains Mono', monospace;
+ font-variant-numeric: tabular-nums;
+}
+
+.mp-racer-percent {
+ color: var(--sub-color);
+ font-size: 0.8rem;
}
/* The one genuinely time-critical state in the app: seconds left to get your
@@ -3050,6 +3084,138 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
min-width: 5.5rem;
}
+/* A postal address, shown to an administrator so an order can be packed. */
+.order-address {
+ margin: 0.4rem 0;
+ padding: 0.5rem 0.75rem;
+ border-left: 2px solid var(--border-color);
+ color: var(--text-color);
+ font-size: 0.8rem;
+ line-height: 1.5;
+ white-space: normal;
+}
+
+/* Physical goods. A card, like a bundle, because each one has a description,
+ possibly a size to pick, and a postage line. */
+.merch-note {
+ text-align: left;
+ width: 100%;
+ max-width: none;
+ margin-bottom: 0.75rem;
+}
+
+.merch-kind {
+ width: 100%;
+ text-align: left;
+ color: var(--sub-color);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin: 0.75rem 0 0.4rem 0;
+}
+
+.merch-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+ gap: 0.75rem;
+ width: 100%;
+ margin-bottom: 0.75rem;
+ box-sizing: border-box;
+}
+
+.merch-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.9rem 1rem;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-panel);
+ text-align: left;
+}
+
+.merch-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.merch-name {
+ color: var(--text-color);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.merch-price {
+ color: var(--text-color);
+ font-size: 1rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.merch-description {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ line-height: 1.45;
+}
+
+.merch-variants {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.merch-variant {
+ padding: 0.3rem 0.6rem;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius);
+ background: none;
+ color: var(--sub-color);
+ font-family: inherit;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.merch-variant:hover {
+ border-color: var(--primary-color);
+ color: var(--text-color);
+}
+
+.merch-variant.active {
+ border-color: var(--primary-color);
+ background: var(--primary-color);
+ color: var(--background-color);
+}
+
+.merch-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: auto;
+ padding-top: 0.25rem;
+}
+
+.merch-foot .menu-button.small {
+ margin: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
+}
+
+.merch-shipping {
+ color: var(--sub-color);
+ font-size: 0.72rem;
+}
+
+.merch-error:empty {
+ display: none;
+}
+
+.merch-error {
+ color: var(--error-color, var(--incorrect-color));
+ font-size: 0.75rem;
+}
+
/* The supporter offer, above the catalogue. It is the one thing in the store
that is not cosmetic, so it does not sit in a category with the rest. */
.store-supporter {
@@ -3673,12 +3839,18 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
display: inline-flex;
align-items: center;
justify-content: center;
- width: 1.15rem;
- height: 1.15rem;
flex-shrink: 0;
color: var(--racer-color, var(--primary-color));
}
+/* Beside a name in a list, rather than on a track. */
+.leaderboard-name .mp-racer-sprite,
+.mp-standings-name .mp-racer-sprite {
+ position: static;
+ width: 1.15rem;
+ height: 1.15rem;
+}
+
.mp-racer-sprite svg {
width: 100%;
height: 100%;
@@ -3754,6 +3926,41 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
box-sizing: border-box;
}
+/* The page margins. Hidden by default and shown only on a window wide enough
+ to hold them beside the content without crowding it: the widest content
+ column is 1040px, and two 160px rails with gaps need 1424px before they fit
+ without pushing anything. Below that they simply do not exist. */
+.ad-rail {
+ display: none;
+}
+
+.ad-rail[hidden] {
+ display: none;
+}
+
+@media (min-width: 1424px) {
+ .ad-rail:not([hidden]) {
+ display: flex;
+ position: fixed;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 80;
+ align-items: center;
+ }
+
+ .ad-rail-left:not([hidden]) {
+ left: 1.5rem;
+ }
+
+ .ad-rail-right:not([hidden]) {
+ right: 1.5rem;
+ }
+
+ .ad-rail .ad-slot {
+ margin: 0;
+ }
+}
+
/* Dashed and unfilled, which is the one place that convention is right: it is
a placeholder, and it should not be mistaken for content. */
.ad-slot-label {