Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d8f6674
A format can name the viewport widths at which the page asks for it
jwrosewell Oct 1, 2026
c836d66
Merge branch 'final/28-device-signals' into final/29-viewport-sizes
Jamesr51d Oct 12, 2026
9c2e6e8
The refresh auction offers only the sizes of the viewport's band
Jamesr51d Oct 12, 2026
dcffa78
Merge branch 'final/28-device-signals' into final/29-viewport-sizes
Jamesr51d Oct 12, 2026
fc048f6
Merge 28-device-signals into 29-viewport-sizes
Jamesr51d Oct 12, 2026
600c4ac
Merge 28-device-signals into 29-viewport-sizes
jwrosewell Oct 13, 2026
e7b922c
Merge 28-device-signals into 29-viewport-sizes
jwrosewell Oct 13, 2026
d1f89ef
Merge split/28-device-signals into split/29-viewport-sizes
Jamesr51d Oct 13, 2026
4ac815f
Merge split/28-device-signals into split/29-viewport-sizes
Jamesr51d Oct 13, 2026
5707c89
Merge 28-device-signals into 29-viewport-sizes
jwrosewell Oct 13, 2026
b0dfc42
Merge 28-device-signals into 29-viewport-sizes
jwrosewell Oct 13, 2026
054f3a2
Merge 28-device-signals into 29-viewport-sizes
jwrosewell Oct 13, 2026
1346263
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
ccea158
A slot whose viewport band asks for no size is not defined
jwrosewell Oct 16, 2026
2a582ca
A winning bid with no size of its own takes the size of the viewport …
jwrosewell Oct 16, 2026
0a39bae
The refresh auction narrows only a slot defined with the viewport bands
jwrosewell Oct 16, 2026
bd1c912
Tests cover the viewport bands at both claim-expiry fallback sites
jwrosewell Oct 16, 2026
e02424d
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
27bef56
A size mapping that throws leaves the slot with every format
jwrosewell Oct 16, 2026
429f101
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
668664b
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
2cea39e
The ad templates details name each format's viewport bounds
jwrosewell Oct 16, 2026
4978ef5
The changelog and the guide describe the viewport band fixes
jwrosewell Oct 16, 2026
1b47297
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
caaf776
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
932b564
Merge the review fixes into split/29-viewport-sizes
jwrosewell Oct 16, 2026
5f3e167
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
6f738fc
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
25c84cc
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
dd07c1c
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
8453850
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
21d5bdf
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 16, 2026
621d4aa
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 18, 2026
d38a606
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 18, 2026
feae1fb
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 18, 2026
111d5d9
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 18, 2026
8fc3261
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 19, 2026
061f01b
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 19, 2026
9be8541
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 19, 2026
d7ff53b
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 19, 2026
b7c91bf
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
2af338c
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
fdb9998
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
aa96b93
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
0b018e5
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
bc09e0a
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
7047c07
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
4404500
Merge split/28-device-signals into split/29-viewport-sizes
jwrosewell Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, define no slot where the band asks for no size, and keep every format when the mapping fails. The bundle's refresh auction offers a bidder only the sizes of the band the viewport is in for a slot defined with that mapping, and offers a slot the publisher defined its formats as before. A winning bid with no size of its own takes the band's first size, and `ts config ad-templates --details` prints each format's bounds. A slot whose formats name no bound behaves as before.
- A device module may answer the confidence that a person is looking at the page, `DeviceSignals::human_confidence`, which nothing reads yet, which is not stored in the identity graph with the other signals, and which stays `Unknown` for every module that does not score the request, and may tell a bidder what the device is through `DeviceModule::advertising_attributes`, which reaches the bid request's device object as its type, make, model, operating system and screen. That call carries the permissions resolved for the request, a backend the module registers during it waits no longer than the auction's timeout, and core drops a device type outside the AdCOM 1.0 device types list, a screen size that is not positive and a blank string. A module that resolves nothing adds no device object.
- A prefix module, selected by `[prefix] module`, gives the start of every name the browser holds for a publisher and of every key the publisher's entries take in a shared store. Core's own module gives `ts` and `ts.<publisher.domain>` and runs when nothing is selected, so a default deployment's names are unchanged. The page's bundle tag tells the client script the public prefix as `data-ts-public-prefix`, and a template stored under one prefix is never served under another.
- `[shared.<vendor>]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table whose name is not lower case letters, digits, `_` or `-` is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared, resolved and checked at push to name a key as a module's own are. A shared table, or a module's own, that cannot be read or fails its validation is refused naming the setting at fault and never its value.
Expand Down
74 changes: 64 additions & 10 deletions crates/ad-tag/google/src/fixtures/page-change.recorded.html
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,68 @@
);
}

// The sizes the slot's viewport band asks for at the current viewport width,
// an empty list where that band asks for none, or null when the slot names
// no band. Bands come widest first, so the first one the width reaches holds.
function viewportBandSizes(slot) {
var bands = slot.viewport_sizes;
if (!bands || !bands.length) return null;
var width = window.innerWidth;
var band = bands.find(function (candidate) {
return width >= candidate[0][0];
});
return band ? band[1] : [];
}

// Defines the slot and applies its viewport bands as a size mapping, recording
// the slot in ts.viewportMappedSlots once the mapping is applied. Defines
// nothing where the band at the current viewport width asks for no size, as
// no ad can serve there and GPT would otherwise size or collapse the slot's
// container after the page has painted. A mapping that throws leaves the slot
// defined with every format, so the caller still adds it to the ad service.
function defineWithViewportSizes(api, slot, elementId) {
var bandSizes = viewportBandSizes(slot);
if (bandSizes && !bandSizes.length) {
if (ts.log && typeof ts.log.debug === "function") {
ts.log.debug("GPT slot asks for no size at this viewport width", {
divId: slot.div_id,
});
}
return null;
}
var definedSlot = api.defineSlot(
slot.gam_unit_path,
slot.formats,
elementId,
);
var bands = slot.viewport_sizes;
if (
definedSlot &&
bands &&
bands.length &&
api.sizeMapping &&
definedSlot.defineSizeMapping
) {
try {
var builder = api.sizeMapping();
bands.forEach(function (band) {
builder.addSize(band[0], band[1]);
});
definedSlot.defineSizeMapping(builder.build());
ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet();
ts.viewportMappedSlots.add(definedSlot);
} catch (err) {
if (ts.log && typeof ts.log.warn === "function") {
ts.log.warn("GPT size mapping failed; slot keeps every format", {
divId: slot.div_id,
error: err,
});
}
}
}
return definedSlot;
}

function normalizedGptFormats(formats) {
return formats.length === 2 &&
formats.every(function (format) {
Expand Down Expand Up @@ -682,11 +744,7 @@
var tsOwned = false;
if (!gptSlot) {
gptSlot = runHandoffInternal(function () {
return window.googletag.defineSlot(
slot.gam_unit_path,
slot.formats,
element.id,
);
return defineWithViewportSizes(window.googletag, slot, element.id);
});
if (!gptSlot) {
releaseTrustedServerFirstImpressionClaim(element, fallbackClaim);
Expand Down Expand Up @@ -834,11 +892,7 @@
// Define TS's fallback on the publisher's actual div. The scoped
// handoff wrapper returns this slot if the publisher defines it later.
s = runHandoffInternal(function () {
return googletag.defineSlot(
slot.gam_unit_path,
slot.formats,
actualDivId,
);
return defineWithViewportSizes(googletag, slot, actualDivId);
});
if (!s) {
releaseTrustedServerFirstImpressionClaim(el, tsClaim);
Expand Down
74 changes: 64 additions & 10 deletions crates/ad-tag/google/src/gpt_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,68 @@
);
}

// The sizes the slot's viewport band asks for at the current viewport width,
// an empty list where that band asks for none, or null when the slot names
// no band. Bands come widest first, so the first one the width reaches holds.
function viewportBandSizes(slot) {
var bands = slot.viewport_sizes;
if (!bands || !bands.length) return null;
var width = window.innerWidth;
var band = bands.find(function (candidate) {
return width >= candidate[0][0];
});
return band ? band[1] : [];
}

// Defines the slot and applies its viewport bands as a size mapping, recording
// the slot in ts.viewportMappedSlots once the mapping is applied. Defines
// nothing where the band at the current viewport width asks for no size, as
// no ad can serve there and GPT would otherwise size or collapse the slot's
// container after the page has painted. A mapping that throws leaves the slot
// defined with every format, so the caller still adds it to the ad service.
function defineWithViewportSizes(api, slot, elementId) {
var bandSizes = viewportBandSizes(slot);
if (bandSizes && !bandSizes.length) {
if (ts.log && typeof ts.log.debug === "function") {
ts.log.debug("GPT slot asks for no size at this viewport width", {
divId: slot.div_id,
});
}
return null;
}
var definedSlot = api.defineSlot(
slot.gam_unit_path,
slot.formats,
elementId,
);
var bands = slot.viewport_sizes;
if (
definedSlot &&
bands &&
bands.length &&
api.sizeMapping &&
definedSlot.defineSizeMapping
) {
try {
var builder = api.sizeMapping();
bands.forEach(function (band) {
builder.addSize(band[0], band[1]);
});
definedSlot.defineSizeMapping(builder.build());
ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet();
ts.viewportMappedSlots.add(definedSlot);
} catch (err) {
if (ts.log && typeof ts.log.warn === "function") {
ts.log.warn("GPT size mapping failed; slot keeps every format", {
divId: slot.div_id,
error: err,
});
}
}
}
return definedSlot;
}

function normalizedGptFormats(formats) {
return formats.length === 2 &&
formats.every(function (format) {
Expand Down Expand Up @@ -680,11 +742,7 @@
var tsOwned = false;
if (!gptSlot) {
gptSlot = runHandoffInternal(function () {
return window.googletag.defineSlot(
slot.gam_unit_path,
slot.formats,
element.id,
);
return defineWithViewportSizes(window.googletag, slot, element.id);
});
if (!gptSlot) {
releaseTrustedServerFirstImpressionClaim(element, fallbackClaim);
Expand Down Expand Up @@ -832,11 +890,7 @@
// Define TS's fallback on the publisher's actual div. The scoped
// handoff wrapper returns this slot if the publisher defines it later.
s = runHandoffInternal(function () {
return googletag.defineSlot(
slot.gam_unit_path,
slot.formats,
actualDivId,
);
return defineWithViewportSizes(googletag, slot, actualDivId);
});
if (!s) {
releaseTrustedServerFirstImpressionClaim(el, tsClaim);
Expand Down
2 changes: 1 addition & 1 deletion crates/ad-tag/google/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1375,7 +1375,7 @@ mod tests {
"bootstrap should install idempotent GPT handoff wrappers"
);
assert!(
combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"),
combined.contains("return defineWithViewportSizes(googletag, slot, actualDivId)"),
"bootstrap should define the TS fallback on the actual inner div"
);
assert!(
Expand Down
53 changes: 52 additions & 1 deletion crates/trusted-server-cli/src/commands/config/ad_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,13 +487,26 @@ fn format_slot(slot: &CreativeOpportunitySlot, gam_network_id: &str, section: &s
)
}

/// Formats one format for `--details`, with any viewport bound under the name
/// the configuration gives it.
fn format_format(format: &CreativeOpportunityFormat) -> String {
let media_type = match format.media_type {
MediaType::Banner => "banner",
MediaType::Video => "video",
MediaType::Native => "native",
};
format!("{}x{} {media_type}", format.width, format.height)
let bounds: String = [
format
.min_viewport_width
.map(|min| format!(" min_viewport_width={min}")),
format
.max_viewport_width
.map(|max| format!(" max_viewport_width={max}")),
]
.into_iter()
.flatten()
.collect();
format!("{}x{} {media_type}{bounds}", format.width, format.height)
}

fn format_providers(slot: &CreativeOpportunitySlot) -> String {
Expand Down Expand Up @@ -615,6 +628,44 @@ mod tests {
);
}

#[test]
fn match_details_include_each_formats_viewport_bounds() {
let config_text = format!(
"{}\n\
[[creative_opportunities.slot]]\n\
id = \"billboard\"\n\
page_patterns = [\"/video/*\"]\n\
formats = [\n\
{{ width = 970, height = 250, min_viewport_width = 1040 }},\n\
{{ width = 728, height = 90, min_viewport_width = 800, max_viewport_width = 1039 }},\n\
{{ width = 320, height = 50, max_viewport_width = 599 }},\n\
]\n",
config_with_slots()
);
let (_temp, config) = project_with_config(&config_text);
let mut out = Vec::new();

run_ad_templates_with_writer(
&AdTemplatesCommand::Match(AdTemplatesMatchArgs {
config,
path_or_url: "/video/clip".to_string(),
details: true,
}),
&mut out,
)
.expect("should match slots");

let output = String::from_utf8(out).expect("should be utf8");
assert!(
output.contains(
"formats=[970x250 banner min_viewport_width=1040, \
728x90 banner min_viewport_width=800 max_viewport_width=1039, \
320x50 banner max_viewport_width=599]"
),
"should print each format's viewport bounds: {output}"
);
}

#[test]
fn check_rejects_unexpected_extra_slots_by_default() {
let (_temp, config) = project_with_config(&config_with_slots());
Expand Down
Loading
Loading