Release v1.10.0: banner colours + urgent, and 3 UI fixes #183

Merged
developer merged 8 commits from development into master 2026-07-05 14:10:25 +00:00
32 changed files with 1297 additions and 152 deletions
Showing only changes of commit d61ba68e9b - Show all commits
@@ -193,3 +193,24 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
.replay-log li.cur { color: var(--ink); font-weight: 600; }
/* Banner colour override editor + live preview (banner_detail). The override
fieldsets group the enable toggle with the native colour swatches; the preview
renders a sample strip on both themes from those inputs (see the inline script). */
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
.ovr .note { margin: 0.2rem 0 0.4rem; }
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
.banner-preview .bp { flex: 1 1 18rem; }
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
.ad-frame.light { background: #f4f6f9; }
.ad-frame.dark { background: #0f1420; }
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ad-sample .ad-link { text-decoration: underline; }
@@ -11,10 +11,72 @@
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
<fieldset class="ovr">
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
{{end}}
<div><button type="submit">Save</button></div>
</form>
{{if not .IsDefault}}
<div class="banner-preview">
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
</div>
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
<script>
(function(){
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
function byName(n){return document.querySelector('[name="'+n+'"]');}
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
if(!allOn||!darkOn){return;}
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
function paint(el,c){
el.style.background=c.bg; el.style.color=c.fg;
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
}
function resolve(){
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
paint(lightEl,light); paint(darkEl,dark);
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
if(inp&&hx){hx.textContent=inp.value;}
});
}
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
resolve();
})();
</script>
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
<button type="submit" class="danger">Delete campaign</button>
</form>
+23 -8
View File
@@ -498,15 +498,30 @@ type BannerCampaignRow struct {
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
//
// The colour-override and urgent fields drive the non-default campaign's editor:
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
// when a set is on, otherwise the neutral theme token so the picker starts from a
// sensible colour and the live preview shows the real fallback.
type BannerDetailView struct {
ID string
Name string
Weight int
IsDefault bool
Enabled bool
StartsAt string
EndsAt string
Messages []BannerMessageRow
ID string
Name string
Weight int
IsDefault bool
Enabled bool
StartsAt string
EndsAt string
Urgent bool
OverrideAllOn bool
AllBg string
AllFg string
AllLink string
OverrideDarkOn bool
DarkBg string
DarkFg string
DarkLink string
Messages []BannerMessageRow
}
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
+77 -13
View File
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
// window or message body).
var ErrValidation = errors.New("ads: validation")
// ColorSet is an optional per-campaign colour override for the banner strip:
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
// are set together or the whole set is absent (a nil *ColorSet).
type ColorSet struct {
Bg string
Fg string
Link string
}
// Campaign is one advertising placement order with its messages.
type Campaign struct {
ID uuid.UUID // uuid.Nil on create
@@ -40,6 +49,16 @@ type Campaign struct {
StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
// light ← all ?? token). Both are nil for the default campaign and for a
// campaign that keeps the neutral theme tokens. Non-default only.
OverrideAll *ColorSet
OverrideDark *ColorSet
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
// any urgent campaign is active, suppresses every non-urgent campaign and the
// default remainder. Non-default only; always false for the default campaign.
Urgent bool
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -70,10 +89,15 @@ type Timings struct {
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order.
// language and in display (round-robin) order, plus the optional colour overrides
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
// carried here: it is resolved server-side into the set's membership and the
// eligibility bypass, so the client only ever renders what it is sent.
type ActiveCampaign struct {
Weight int
Messages []string
Weight int
Messages []string
OverrideAll *ColorSet
OverrideDark *ColorSet
}
// Eligible reports whether an account should be shown the advertising banner: a
@@ -85,14 +109,20 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
}
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang. Campaigns outside their validity window, and
// campaigns with no messages, are dropped. The default campaign's effective
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
// weights) — so it fills unsold inventory and is dropped entirely when timed
// campaigns already reach 100%. Weights are then reduced by their GCD so the
// fair rotation cycle stays short. The input is expected to be the enabled
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
// at time now, in language lang, and reports whether the feed is an urgent one.
// Campaigns outside their validity window, and campaigns with no messages, are
// dropped.
//
// While any active campaign is urgent, the feed is the urgent campaigns alone —
// every non-urgent timed campaign and the default remainder are suppressed for
// the duration (and the caller shows the feed to every viewer, bypassing
// eligibility). Otherwise the default campaign's effective weight is the
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
// unsold inventory and is dropped entirely when timed campaigns already reach
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
// disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
var timed []Campaign
var def *Campaign
for i := range campaigns {
@@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
timed = append(timed, c)
}
}
if urgent := filterUrgent(timed); len(urgent) > 0 {
out := make([]ActiveCampaign, 0, len(urgent))
for _, c := range urgent {
out = append(out, activeFrom(c, lang))
}
reduceByGCD(out)
return out, true
}
sumTimed := 0
for _, c := range timed {
sumTimed += c.Weight
}
out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed {
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
out = append(out, activeFrom(c, lang))
}
if def != nil {
if dw := 100 - sumTimed; dw > 0 {
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
a := activeFrom(*def, lang)
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
out = append(out, a)
}
}
reduceByGCD(out)
return out, false
}
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
// language-resolved messages and its colour overrides (carried through by
// reference, they are read-only).
func activeFrom(c Campaign, lang string) ActiveCampaign {
return ActiveCampaign{
Weight: c.Weight,
Messages: resolveBodies(c.Messages, lang),
OverrideAll: c.OverrideAll,
OverrideDark: c.OverrideDark,
}
}
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
// preempt selector: a non-empty result makes the whole feed urgent.
func filterUrgent(cs []Campaign) []Campaign {
var out []Campaign
for _, c := range cs {
if c.Urgent {
out = append(out, c)
}
}
return out
}
+63 -7
View File
@@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) {
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
}
urgent := func(name string, weight int, msgs []Message) Campaign {
c := timed(name, weight, nil, nil, msgs)
c.Urgent = true
return c
}
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
tests := []struct {
name string
campaigns []Campaign
lang string
want []ActiveCampaign
name string
campaigns []Campaign
lang string
want []ActiveCampaign
wantUrgent bool
}{
{
name: "default only reduces to weight 1",
@@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) {
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
},
{
name: "urgent preempts default and normal timed",
campaigns: []Campaign{
def(msg("house")),
timed("promo", 40, nil, nil, msg("promo")),
urgent("alert", 50, msg("alert")),
},
lang: "en",
// only the urgent campaign survives; a lone weight reduces to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
wantUrgent: true,
},
{
name: "multiple urgent share the feed by gcd",
campaigns: []Campaign{
def(msg("house")),
urgent("a", 60, msg("a")),
urgent("b", 40, msg("b")),
},
lang: "en",
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 2, Messages: []string{"b-en"}},
},
wantUrgent: true,
},
{
name: "out-of-window urgent does not preempt",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
},
lang: "en",
// the urgent campaign is not yet live, so the normal default feed stands.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "colour overrides ride the active campaign",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeActiveSet(tt.campaigns, now, tt.lang)
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
}
if gotUrgent != tt.wantUrgent {
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
}
})
}
}
func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
}
}
+62 -5
View File
@@ -3,6 +3,7 @@ package ads
import (
"context"
"fmt"
"regexp"
"strings"
"time"
@@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} }
// ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin.
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
// lang, ready for the client's weighted round-robin. The bool result reports
// whether the feed is urgent — an urgent feed is shown to every viewer
// regardless of eligibility (the caller skips the eligibility gate for it).
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
campaigns, err := s.store.ActiveCampaigns(ctx)
if err != nil {
return nil, Timings{}, err
return nil, Timings{}, false, err
}
timings, err := s.store.Settings(ctx)
if err != nil {
return nil, Timings{}, err
return nil, Timings{}, false, err
}
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
return set, timings, urgent, nil
}
// ListCampaigns returns every campaign with its messages, for the admin console.
@@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return uuid.Nil, err
}
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return uuid.Nil, err
}
return s.store.CreateCampaign(ctx, Campaign{
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
})
}
@@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
upd.Enabled = true
upd.StartsAt = nil
upd.EndsAt = nil
// The default (house) campaign stays plain: no colour overrides, never urgent.
} else {
if err := validWeight(c.Weight); err != nil {
return err
@@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return err
}
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return err
}
upd.Weight = c.Weight
upd.Enabled = c.Enabled
upd.StartsAt = c.StartsAt
upd.EndsAt = c.EndsAt
upd.OverrideAll = all
upd.OverrideDark = dark
upd.Urgent = c.Urgent
}
return s.store.UpdateCampaign(ctx, upd)
}
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
return nil
}
// hexColor matches a "#rrggbb" colour — the format the console's native colour
// input emits and the wire carries.
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// validOverrides validates the two optional colour sets together and returns
// their normalised copies (nil when a set is absent), so a caller can store them
// directly.
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
va, err := validColorSet(all)
if err != nil {
return nil, nil, err
}
vd, err := validColorSet(dark)
if err != nil {
return nil, nil, err
}
return va, vd, nil
}
// validColorSet validates one optional colour override: a nil set passes (no
// override); otherwise all three colours must be present and "#rrggbb". It
// returns a trimmed, lower-cased copy.
func validColorSet(cs *ColorSet) (*ColorSet, error) {
if cs == nil {
return nil, nil
}
bg := strings.TrimSpace(cs.Bg)
fg := strings.TrimSpace(cs.Fg)
link := strings.TrimSpace(cs.Link)
if bg == "" || fg == "" || link == "" {
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
}
for _, h := range []string{bg, fg, link} {
if !hexColor.MatchString(h) {
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
}
}
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
}
// validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) {
en := strings.TrimSpace(bodyEn)
+49 -11
View File
@@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
id := uuid.New()
now := time.Now().UTC()
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.INSERT(
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent,
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
).VALUES(
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
postgres.Bool(false), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent),
postgres.TimestampzT(now), postgres.TimestampzT(now),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
@@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
// window. The default flag is never touched here. Returns ErrNotFound when no
// campaign matches.
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.UPDATE(
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
).SET(
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
return execOne(ctx, s.db, stmt, "update campaign")
}
@@ -257,18 +273,40 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh
func modelToCampaign(r model.AdCampaigns) Campaign {
return Campaign{
ID: r.CampaignID,
Name: r.Name,
Weight: int(r.Weight),
IsDefault: r.IsDefault,
Enabled: r.Enabled,
StartsAt: r.StartsAt,
EndsAt: r.EndsAt,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ID: r.CampaignID,
Name: r.Name,
Weight: int(r.Weight),
IsDefault: r.IsDefault,
Enabled: r.Enabled,
StartsAt: r.StartsAt,
EndsAt: r.EndsAt,
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
Urgent: r.Urgent,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
// the set is absent.
func colorSetFrom(bg, fg, link *string) *ColorSet {
if bg == nil || fg == nil || link == nil {
return nil
}
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
}
// colorCols renders a *ColorSet as its three column value-expressions in
// bg, fg, link order — NULL for each when the set is absent.
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
if cs == nil {
return postgres.NULL, postgres.NULL, postgres.NULL
}
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
}
func modelToMessage(r model.AdMessages) Message {
return Message{
ID: r.MessageID,
+142 -2
View File
@@ -192,8 +192,9 @@ func TestBannerMessageOwnership(t *testing.T) {
type profileBanner struct {
Banner *struct {
Campaigns []struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
Weight int `json:"weight"`
Messages []string `json:"messages"`
OverrideBg string `json:"override_bg"`
} `json:"campaigns"`
Timings struct {
HoldMs int `json:"hold_ms"`
@@ -274,3 +275,142 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
}
}
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
// full two-set override + urgent round-trips through the store (exercising the new
// columns and CHECK constraints), clearing the sets removes the override, and the
// default campaign stays plain (colours + urgent are ignored for it).
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
h := srv.Handler()
base := "http://admin.test/_gm"
const origin = "http://admin.test"
name := "Brand-" + uuid.NewString()[:8]
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
}
id := findCampaign(t, adsSvc, name)
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
detail := base + "/banners/" + id.String()
// A partial colour set (a missing colour) is refused by validation.
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// A malformed hex is refused.
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// Both colour sets + urgent persist and round-trip through the store.
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
}
cm, err := adsSvc.Campaign(ctx, id)
if err != nil {
t.Fatalf("read campaign: %v", err)
}
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
t.Fatalf("override all = %+v", cm.OverrideAll)
}
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
t.Fatalf("override dark = %+v", cm.OverrideDark)
}
if !cm.Urgent {
t.Fatal("urgent not persisted")
}
// Clearing the sets (both checkboxes off, urgent off) removes the override.
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
t.Fatalf("clear = %d", code)
}
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
}
// The default campaign stays plain: submitted colours + urgent are ignored for it.
def := defaultCampaign(t, adsSvc)
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
t.Fatalf("update default = %d", code)
}
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
}
}
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
// every viewer — including the no_banner role and a non-empty hint wallet that
// otherwise suppress the banner.
func TestBannerUrgentBypassesEligibility(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
})
if err != nil {
t.Fatalf("create urgent: %v", err)
}
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
t.Fatalf("add urgent message: %v", err)
}
get := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode: %v", err)
}
return p
}
assertUrgentOnly := func(what string, p profileBanner) {
t.Helper()
if p.Banner == nil {
t.Fatalf("%s: no banner", what)
}
if len(p.Banner.Campaigns) != 1 {
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
}
c := p.Banner.Campaigns[0]
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
}
if c.OverrideBg != "#aa0000" {
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
}
}
// A normal viewer sees only the urgent campaign (the default is preempted).
assertUrgentOnly("eligible", get())
// The no_banner role no longer suppresses it — urgent reaches everyone.
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
assertUrgentOnly("no_banner", get())
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// A non-empty hint wallet no longer suppresses it either.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err)
}
assertUrgentOnly("hints", get())
}
@@ -13,13 +13,20 @@ import (
)
type AdCampaigns struct {
CampaignID uuid.UUID `sql:"primary_key"`
Name string
Weight int32
IsDefault bool
Enabled bool
StartsAt *time.Time
EndsAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
CampaignID uuid.UUID `sql:"primary_key"`
Name string
Weight int32
IsDefault bool
Enabled bool
StartsAt *time.Time
EndsAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
OverrideBg *string
OverrideFg *string
OverrideLink *string
OverrideBgDark *string
OverrideFgDark *string
OverrideLinkDark *string
Urgent bool
}
@@ -17,15 +17,22 @@ type adCampaignsTable struct {
postgres.Table
// Columns
CampaignID postgres.ColumnString
Name postgres.ColumnString
Weight postgres.ColumnInteger
IsDefault postgres.ColumnBool
Enabled postgres.ColumnBool
StartsAt postgres.ColumnTimestampz
EndsAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
CampaignID postgres.ColumnString
Name postgres.ColumnString
Weight postgres.ColumnInteger
IsDefault postgres.ColumnBool
Enabled postgres.ColumnBool
StartsAt postgres.ColumnTimestampz
EndsAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
OverrideBg postgres.ColumnString
OverrideFg postgres.ColumnString
OverrideLink postgres.ColumnString
OverrideBgDark postgres.ColumnString
OverrideFgDark postgres.ColumnString
OverrideLinkDark postgres.ColumnString
Urgent postgres.ColumnBool
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -67,33 +74,47 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
var (
CampaignIDColumn = postgres.StringColumn("campaign_id")
NameColumn = postgres.StringColumn("name")
WeightColumn = postgres.IntegerColumn("weight")
IsDefaultColumn = postgres.BoolColumn("is_default")
EnabledColumn = postgres.BoolColumn("enabled")
StartsAtColumn = postgres.TimestampzColumn("starts_at")
EndsAtColumn = postgres.TimestampzColumn("ends_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
CampaignIDColumn = postgres.StringColumn("campaign_id")
NameColumn = postgres.StringColumn("name")
WeightColumn = postgres.IntegerColumn("weight")
IsDefaultColumn = postgres.BoolColumn("is_default")
EnabledColumn = postgres.BoolColumn("enabled")
StartsAtColumn = postgres.TimestampzColumn("starts_at")
EndsAtColumn = postgres.TimestampzColumn("ends_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
OverrideBgColumn = postgres.StringColumn("override_bg")
OverrideFgColumn = postgres.StringColumn("override_fg")
OverrideLinkColumn = postgres.StringColumn("override_link")
OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark")
OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark")
OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark")
UrgentColumn = postgres.BoolColumn("urgent")
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn}
)
return adCampaignsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
CampaignID: CampaignIDColumn,
Name: NameColumn,
Weight: WeightColumn,
IsDefault: IsDefaultColumn,
Enabled: EnabledColumn,
StartsAt: StartsAtColumn,
EndsAt: EndsAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
CampaignID: CampaignIDColumn,
Name: NameColumn,
Weight: WeightColumn,
IsDefault: IsDefaultColumn,
Enabled: EnabledColumn,
StartsAt: StartsAtColumn,
EndsAt: EndsAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
OverrideBg: OverrideBgColumn,
OverrideFg: OverrideFgColumn,
OverrideLink: OverrideLinkColumn,
OverrideBgDark: OverrideBgDarkColumn,
OverrideFgDark: OverrideFgDarkColumn,
OverrideLinkDark: OverrideLinkDarkColumn,
Urgent: UrgentColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,60 @@
-- Per-campaign banner colour overrides and the urgent (always-show) flag.
--
-- ad_campaigns gains two optional colour sets and an urgent flag, all for
-- non-default campaigns only:
-- override_bg/fg/link — paints the strip on every theme
-- override_bg/fg/link_dark — further overrides the dark theme
-- urgent — force the banner on every viewer (bypassing
-- eligibility) and suppress all non-urgent
-- campaigns while any urgent one is active
--
-- Each colour set is all-or-nothing (bg+fg+link together, or absent) and every
-- colour is a "#rrggbb" hex string. The default (house) campaign stays plain: no
-- colours, never urgent.
--
-- Expand-contract: additive (nullable columns plus one NOT NULL boolean with a
-- default), so a backend image rollback stays DB-safe — older code ignores them.
-- The ad_campaigns table shape changes, so its generated go-jet model is
-- regenerated (by hand here, to keep the diff to this one table).
-- +goose Up
ALTER TABLE backend.ad_campaigns
ADD COLUMN override_bg text,
ADD COLUMN override_fg text,
ADD COLUMN override_link text,
ADD COLUMN override_bg_dark text,
ADD COLUMN override_fg_dark text,
ADD COLUMN override_link_dark text,
ADD COLUMN urgent boolean DEFAULT false NOT NULL,
ADD CONSTRAINT ad_campaigns_override_all_chk CHECK (
(override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL)
OR (override_bg IS NOT NULL AND override_fg IS NOT NULL AND override_link IS NOT NULL)
),
ADD CONSTRAINT ad_campaigns_override_dark_chk CHECK (
(override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL)
OR (override_bg_dark IS NOT NULL AND override_fg_dark IS NOT NULL AND override_link_dark IS NOT NULL)
),
ADD CONSTRAINT ad_campaigns_override_hex_chk CHECK (
(override_bg IS NULL OR override_bg ~ '^#[0-9a-fA-F]{6}$')
AND (override_fg IS NULL OR override_fg ~ '^#[0-9a-fA-F]{6}$')
AND (override_link IS NULL OR override_link ~ '^#[0-9a-fA-F]{6}$')
AND (override_bg_dark IS NULL OR override_bg_dark ~ '^#[0-9a-fA-F]{6}$')
AND (override_fg_dark IS NULL OR override_fg_dark ~ '^#[0-9a-fA-F]{6}$')
AND (override_link_dark IS NULL OR override_link_dark ~ '^#[0-9a-fA-F]{6}$')
),
ADD CONSTRAINT ad_campaigns_default_plain_chk CHECK (
(NOT is_default)
OR (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL
AND override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL
AND NOT urgent)
);
-- +goose Down
ALTER TABLE backend.ad_campaigns
DROP COLUMN override_bg,
DROP COLUMN override_fg,
DROP COLUMN override_link,
DROP COLUMN override_bg_dark,
DROP COLUMN override_fg_dark,
DROP COLUMN override_link_dark,
DROP COLUMN urgent;
+42 -13
View File
@@ -22,10 +22,20 @@ type bannerDTO struct {
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
// weight (for the client's smooth weighted round-robin) and its messages, in
// display order, already resolved to one language.
// display order, already resolved to one language. The override_* colours are
// present only for a campaign that carries a colour override: override_bg/fg/link
// paint every theme, and the *_dark trio further overrides the dark theme (the
// client resolves the cascade). They are absent for a plain campaign, which keeps
// the neutral theme tokens.
type bannerCampaignDTO struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
Weight int `json:"weight"`
Messages []string `json:"messages"`
OverrideBg string `json:"override_bg,omitempty"`
OverrideFg string `json:"override_fg,omitempty"`
OverrideLink string `json:"override_link,omitempty"`
OverrideBgDark string `json:"override_bg_dark,omitempty"`
OverrideFgDark string `json:"override_fg_dark,omitempty"`
OverrideLinkDark string `json:"override_link_dark,omitempty"`
}
// bannerTimingsDTO mirrors ads.Timings on the wire.
@@ -83,22 +93,27 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
if s.ads == nil {
return nil
}
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
if err != nil {
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
return nil
}
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
if err != nil {
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
// hides the banner).
if !urgent {
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
if err != nil {
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
return nil
}
}
campaigns := make([]bannerCampaignDTO, 0, len(set))
for _, c := range set {
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
campaigns = append(campaigns, bannerCampaignFromActive(c))
}
return &bannerDTO{
Campaigns: campaigns,
@@ -113,6 +128,20 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
}
}
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
// projecting each optional colour set into its three "#rrggbb" fields (empty when
// the set is absent, so JSON omitempty drops them).
func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO {
d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}
if c.OverrideAll != nil {
d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link
}
if c.OverrideDark != nil {
d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link
}
return d
}
// bannerLang resolves the message language for a viewer: their interface language
// (Russian, or English by default).
func bannerLang(acc account.Account) string {
@@ -19,6 +19,15 @@ const bannersBack = "/_gm/banners"
// bound; the operator's entry is interpreted as UTC.
const localTimeLayout = "2006-01-02T15:04"
// Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg,
// --text-muted, --accent for the light and dark themes). They seed the console's
// colour pickers and the preview fallback when a campaign has no override, so the
// operator always edits against the real neutral colours.
const (
tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6"
tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff"
)
// consoleBanners lists the advertising campaigns (default first), each with its
// weight, validity window, enabled flag, message count and live-now status.
func (s *Server) consoleBanners(c *gin.Context) {
@@ -56,14 +65,19 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
return
}
view := adminconsole.BannerDetailView{
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
StartsAt: localInput(cm.StartsAt),
EndsAt: localInput(cm.EndsAt),
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
StartsAt: localInput(cm.StartsAt),
EndsAt: localInput(cm.EndsAt),
Urgent: cm.Urgent,
OverrideAllOn: cm.OverrideAll != nil,
OverrideDarkOn: cm.OverrideDark != nil,
}
view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink)
view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink)
for i, m := range cm.Messages {
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
ID: m.ID.String(),
@@ -111,12 +125,15 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
return
}
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
ID: id,
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
ID: id,
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
Urgent: c.PostForm("urgent") != "",
OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"),
OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"),
})
if err != nil {
s.bannerError(c, err, back)
@@ -321,3 +338,29 @@ func atoiForm(c *gin.Context, name string) int {
n, _ := strconv.Atoi(trimForm(c, name))
return n
}
// seedColors returns the three colour-input seed values for a campaign's optional
// override: the stored colours when the set is present, otherwise the given
// neutral theme tokens (so the native picker starts sensibly and the preview
// shows the real fallback).
func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) {
if cs == nil {
return tokenBg, tokenFg, tokenLink
}
return cs.Bg, cs.Fg, cs.Link
}
// colorSetForm reads an optional colour override from the form: the on flag gates
// it (an unchecked set is absent, nil), otherwise the three posted colours are
// returned for the ads service to validate. The colour inputs are disabled in the
// browser while the set is off, so they are not posted then.
func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet {
if c.PostForm(on) == "" {
return nil
}
return &ads.ColorSet{
Bg: trimForm(c, bg),
Fg: trimForm(c, fg),
Link: trimForm(c, link),
}
}
+13 -1
View File
@@ -980,7 +980,19 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. A
non-default campaign may also carry an optional **colour override** (background, text, link) in two
sets — one for every theme, one for the dark theme only — plus an **urgent** flag. The colours ride
the same block (six optional strings appended to each `BannerCampaign` on the wire — trailing,
backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives
the strip's border from the background in JS (no CSS `color-mix`, for the old-WebView floor).
**Urgent is resolved entirely server-side, with no wire field**: while any enabled, in-window urgent
campaign exists, `computeActiveSet` returns *only* the urgent campaigns (preempting the timed set and
the default remainder) and `bannerFor` **skips the eligibility gate** for that feed, so an urgent
notice reaches every viewer — paid, hint-holding or `no_banner` included. There is no instant
broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each
viewer's next `profile.get`. The default campaign stays plain (no colours, never urgent), enforced by
both the service and a DB CHECK. The same
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
+15 -4
View File
@@ -485,8 +485,19 @@ through**, regardless of their interface language. The client rotates the eligib
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
and a campaign with several messages shows them in turn.
A non-default campaign can carry its own **colours** — background, text and link — so a sponsored or
operational message stands out from the neutral strip. Colours come in two sets: one that applies on
**every theme**, and an optional second that overrides the **dark theme** only, so a campaign reads
well on both; the strip's border is derived from the background automatically. A campaign can also be
marked **urgent**: an urgent campaign is shown to **everyone** — even paid players, hint holders and
`no_banner` users — and, while it is live, it is the **only** thing the strip shows (ordinary
campaigns and the house default step aside). Urgent is for genuine system notices (maintenance,
outages), not advertising.
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
keeps at least one message.
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
holds, the scroll of an over-long message, and the fade-out → gap → fade-in transition between
messages). The default campaign cannot be deleted, keeps at least one message, and stays plain (no
colours, never urgent).
+14 -3
View File
@@ -496,8 +496,19 @@ high-rate флага. С карточки пользователя операт
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
кампания с несколькими сообщениями показывает их по очереди.
Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
системных уведомлений (тех.работы, сбои), а не для рекламы.
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
всегда остаётся хотя бы одно сообщение.
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
остаётся простой (без цветов, никогда не срочная).
+13
View File
@@ -315,6 +315,19 @@ left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measu
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
and a long message does not scroll.
**Per-campaign colours.** A campaign may override the strip's colours (background, text, link). The
engine carries the current message's campaign colours to the host, and `AdBanner` resolves them for
the **rendered theme** (`lib/bannerColors`: dark ← dark-set ?? all-set, light ← all-set) and applies
them as inline CSS variables scoped to `.ad` (`--ad-bg`, `--text-muted`, `--accent`, and a derived
`--ad-border`) — so an override never leaks past the strip, and a campaign with no override keeps the
neutral tokens. The border is computed from the background in JS (a luminance-aware nudge toward
black/white — no CSS `color-mix`, which the old Android WebView floor lacks), matching the admin
console's live preview. The resolution re-runs when the theme flips (a `[data-theme]` /
`prefers-color-scheme` observer), so switching light↔dark repaints an overridden strip at once. An
**urgent** campaign has no client-specific styling — it simply arrives (with its colours) as the only
campaign in the feed; the preempt-and-show-everyone behaviour is entirely server-side (ARCHITECTURE
§10).
## Result / status iconography (`lib/result.ts`)
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
+11 -2
View File
@@ -53,9 +53,18 @@ type BannerResp struct {
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
// weight and its messages, in display order, already resolved to one language.
// The override_* colours are the optional per-campaign palette (empty when the
// campaign keeps the neutral theme tokens); they ride through to the client
// verbatim, mirroring the backend DTO.
type BannerCampaignResp struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
Weight int `json:"weight"`
Messages []string `json:"messages"`
OverrideBg string `json:"override_bg,omitempty"`
OverrideFg string `json:"override_fg,omitempty"`
OverrideLink string `json:"override_link,omitempty"`
OverrideBgDark string `json:"override_bg_dark,omitempty"`
OverrideFgDark string `json:"override_fg_dark,omitempty"`
OverrideLinkDark string `json:"override_link_dark,omitempty"`
}
// BannerTimingsResp mirrors the backend's global display timings.
+31
View File
@@ -118,6 +118,23 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
return b.FinishedBytes()
}
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
// optional field (the client then reads it as absent).
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
if s == "" {
return 0
}
return b.CreateString(s)
}
// addOptString adds an optional string slot only when it was created (a 0 offset
// leaves the field absent). add is the generated BannerCampaignAdd* setter.
func addOptString(b *flatbuffers.Builder, add func(*flatbuffers.Builder, flatbuffers.UOffsetT), off flatbuffers.UOffsetT) {
if off != 0 {
add(b, off)
}
}
// encodeBanner builds a BannerInfo table from the resolved banner block and
// returns its offset. It is bottom-up: each campaign's messages vector and table
// are built first, then the campaigns vector, then the BannerInfo table. The
@@ -134,9 +151,23 @@ func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatb
b.PrependUOffsetT(msgOffsets[j])
}
msgs := b.EndVector(len(msgOffsets))
// The optional colour strings must be created before the table starts; an
// empty colour is omitted (offset 0), so the client reads it as absent.
obg := optString(b, c.OverrideBg)
ofg := optString(b, c.OverrideFg)
olink := optString(b, c.OverrideLink)
obgD := optString(b, c.OverrideBgDark)
ofgD := optString(b, c.OverrideFgDark)
olinkD := optString(b, c.OverrideLinkDark)
fb.BannerCampaignStart(b)
fb.BannerCampaignAddWeight(b, int32(c.Weight))
fb.BannerCampaignAddMessages(b, msgs)
addOptString(b, fb.BannerCampaignAddOverrideBg, obg)
addOptString(b, fb.BannerCampaignAddOverrideFg, ofg)
addOptString(b, fb.BannerCampaignAddOverrideLink, olink)
addOptString(b, fb.BannerCampaignAddOverrideBgDark, obgD)
addOptString(b, fb.BannerCampaignAddOverrideFgDark, ofgD)
addOptString(b, fb.BannerCampaignAddOverrideLinkDark, olinkD)
campOffsets[i] = fb.BannerCampaignEnd(b)
}
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
@@ -61,6 +61,54 @@ func TestProfileGetEncodesBanner(t *testing.T) {
}
}
// TestProfileGetEncodesBannerColors verifies the gateway carries a campaign's
// optional colour overrides into the FlatBuffers payload, and leaves an absent
// colour (or an absent set) empty so the client falls back to the theme tokens.
func TestProfileGetEncodesBannerColors(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
`"banner":{"campaigns":[` +
`{"weight":1,"messages":["promo-en"],` +
`"override_bg":"#aa0000","override_fg":"#ffffff","override_link":"#ffdd00",` +
`"override_bg_dark":"#330000","override_fg_dark":"#eeeeee","override_link_dark":"#ffcc00"},` +
`{"weight":1,"messages":["plain-en"]}],` +
`"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` +
`"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgProfileGet)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
banner := fb.GetRootAsProfile(payload, 0).Banner(nil)
if banner == nil {
t.Fatal("profile carries no banner block")
}
var c fb.BannerCampaign
banner.Campaigns(&c, 0)
if got := string(c.OverrideBg()); got != "#aa0000" {
t.Errorf("override_bg = %q, want #aa0000", got)
}
if got := string(c.OverrideLink()); got != "#ffdd00" {
t.Errorf("override_link = %q, want #ffdd00", got)
}
if got := string(c.OverrideBgDark()); got != "#330000" {
t.Errorf("override_bg_dark = %q, want #330000", got)
}
if got := string(c.OverrideLinkDark()); got != "#ffcc00" {
t.Errorf("override_link_dark = %q, want #ffcc00", got)
}
// The second campaign has no override: every colour field must be absent (nil).
banner.Campaigns(&c, 1)
if c.OverrideBg() != nil || c.OverrideFg() != nil || c.OverrideLink() != nil ||
c.OverrideBgDark() != nil || c.OverrideFgDark() != nil || c.OverrideLinkDark() != nil {
t.Error("plain campaign unexpectedly carries a colour override")
}
}
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
// banner block (the backend omits it).
func TestProfileGetNoBanner(t *testing.T) {
+11 -1
View File
@@ -180,10 +180,20 @@ table Ack {
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
// (the client runs a smooth weighted round-robin over campaigns by this weight)
// and its messages in display order, each already resolved to the viewer's bot
// language (the stored en/ru pair is picked server-side).
// language (the stored en/ru pair is picked server-side). The override_* colours
// are an optional per-campaign palette: override_bg/fg/link paint the strip on
// every theme, and the *_dark trio further overrides the dark theme (the client
// resolves dark ← dark ?? all ?? token, light ← all ?? token). Empty means the
// campaign keeps the neutral theme tokens (all added trailing — backward-compatible).
table BannerCampaign {
weight:int;
messages:[string];
override_bg:string;
override_fg:string;
override_link:string;
override_bg_dark:string;
override_fg_dark:string;
override_link_dark:string;
}
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
+67 -1
View File
@@ -70,8 +70,56 @@ func (rcv *BannerCampaign) MessagesLength() int {
return 0
}
func (rcv *BannerCampaign) OverrideBg() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *BannerCampaign) OverrideFg() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *BannerCampaign) OverrideLink() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *BannerCampaign) OverrideBgDark() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *BannerCampaign) OverrideFgDark() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *BannerCampaign) OverrideLinkDark() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func BannerCampaignStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
builder.StartObject(8)
}
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
builder.PrependInt32Slot(0, weight, 0)
@@ -82,6 +130,24 @@ func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffer
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func BannerCampaignAddOverrideBg(builder *flatbuffers.Builder, overrideBg flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(overrideBg), 0)
}
func BannerCampaignAddOverrideFg(builder *flatbuffers.Builder, overrideFg flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(overrideFg), 0)
}
func BannerCampaignAddOverrideLink(builder *flatbuffers.Builder, overrideLink flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(overrideLink), 0)
}
func BannerCampaignAddOverrideBgDark(builder *flatbuffers.Builder, overrideBgDark flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(overrideBgDark), 0)
}
func BannerCampaignAddOverrideFgDark(builder *flatbuffers.Builder, overrideFgDark flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(overrideFgDark), 0)
}
func BannerCampaignAddOverrideLinkDark(builder *flatbuffers.Builder, overrideLinkDark flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(overrideLinkDark), 0)
}
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+43 -5
View File
@@ -3,12 +3,14 @@
import {
attachBannerHost,
bannerCurrent,
bannerCurrentColors,
configureBanner,
detachBannerHost,
remeasureBanner,
} from '../lib/bannerEngine';
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model';
import { resolveBannerColors, type ThemeMode } from '../lib/bannerColors';
import type { BannerCampaign, BannerColors, BannerTimings } from '../lib/model';
import { onExternalLinkClick } from '../lib/links';
let {
@@ -21,12 +23,33 @@
// current message and is visible at once (no fade — see inFade), so a screen change does not
// replay the fade. Empty on the very first mount (engine not yet started).
let current = $state(bannerCurrent());
let currentColors = $state<BannerColors>(bannerCurrentColors());
let visible = $state(bannerCurrent() !== '');
let tx = $state(0);
let txDur = $state(0);
let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>();
// The rendered theme (light/dark), tracked so a campaign's colour override resolves against the
// theme actually on screen and re-resolves live when the operator/OS flips it. It follows the
// [data-theme] attribute (theme.ts), or the OS preference when unset ('auto').
let themeMode = $state<ThemeMode>(resolveThemeMode());
function resolveThemeMode(): ThemeMode {
if (typeof document === 'undefined') return 'light';
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark' || attr === 'light') return attr;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// The strip's colours for the current campaign + theme (null = the neutral tokens). Applied as
// inline CSS variables scoped to .ad, so an override never leaks to the rest of the page.
const adColors = $derived(resolveBannerColors(currentColors, themeMode));
const adStyle = $derived(
adColors
? `--ad-bg:${adColors.bg};--text-muted:${adColors.fg};--accent:${adColors.link};--ad-border:${adColors.border}`
: '',
);
// The first appearance after mounting onto an already-running cycle is instant; every later
// message change fades. (Consumed by the first in:fade.)
let instantOnce = bannerCurrent() !== '';
@@ -50,8 +73,9 @@
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
const host: BannerHost = {
show(md) {
show(md, colors) {
current = md;
currentColors = colors;
tx = 0;
txDur = 0;
visible = true;
@@ -93,6 +117,20 @@
$effect(() => {
configureBanner(campaigns, eff);
});
// Track the rendered theme so a colour override re-resolves when the theme flips: watch the
// [data-theme] attribute (Settings toggle / Telegram) and, for 'auto', the OS colour scheme.
$effect(() => {
const update = () => (themeMode = resolveThemeMode());
update();
const obs = new MutationObserver(update);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
mq?.addEventListener('change', update);
return () => {
obs.disconnect();
mq?.removeEventListener('change', update);
};
});
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
$effect(() => {
@@ -116,7 +154,7 @@
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
<div class="ad" bind:this={viewport} style={adStyle} onclick={onExternalLinkClick}>
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
collapses while the message layer is absent during the fade gap (the message is overlaid
absolutely, so its presence/absence does not change the strip height). -->
@@ -145,8 +183,8 @@
font-size: 0.85rem;
line-height: 1.2;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
border-top: 1px solid var(--ad-border, var(--border));
border-bottom: 1px solid var(--ad-border, var(--border));
user-select: none;
}
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
+74 -2
View File
@@ -37,8 +37,50 @@ messagesLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
overrideBg():string|null
overrideBg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideBg(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideFg():string|null
overrideFg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideFg(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideLink():string|null
overrideLink(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideLink(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideBgDark():string|null
overrideBgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideBgDark(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideFgDark():string|null
overrideFgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideFgDark(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideLinkDark():string|null
overrideLinkDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideLinkDark(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startBannerCampaign(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(8);
}
static addWeight(builder:flatbuffers.Builder, weight:number) {
@@ -61,15 +103,45 @@ static startMessagesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addOverrideBg(builder:flatbuffers.Builder, overrideBgOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, overrideBgOffset, 0);
}
static addOverrideFg(builder:flatbuffers.Builder, overrideFgOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, overrideFgOffset, 0);
}
static addOverrideLink(builder:flatbuffers.Builder, overrideLinkOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, overrideLinkOffset, 0);
}
static addOverrideBgDark(builder:flatbuffers.Builder, overrideBgDarkOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, overrideBgDarkOffset, 0);
}
static addOverrideFgDark(builder:flatbuffers.Builder, overrideFgDarkOffset:flatbuffers.Offset) {
builder.addFieldOffset(6, overrideFgDarkOffset, 0);
}
static addOverrideLinkDark(builder:flatbuffers.Builder, overrideLinkDarkOffset:flatbuffers.Offset) {
builder.addFieldOffset(7, overrideLinkDarkOffset, 0);
}
static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset):flatbuffers.Offset {
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset, overrideBgOffset:flatbuffers.Offset, overrideFgOffset:flatbuffers.Offset, overrideLinkOffset:flatbuffers.Offset, overrideBgDarkOffset:flatbuffers.Offset, overrideFgDarkOffset:flatbuffers.Offset, overrideLinkDarkOffset:flatbuffers.Offset):flatbuffers.Offset {
BannerCampaign.startBannerCampaign(builder);
BannerCampaign.addWeight(builder, weight);
BannerCampaign.addMessages(builder, messagesOffset);
BannerCampaign.addOverrideBg(builder, overrideBgOffset);
BannerCampaign.addOverrideFg(builder, overrideFgOffset);
BannerCampaign.addOverrideLink(builder, overrideLinkOffset);
BannerCampaign.addOverrideBgDark(builder, overrideBgDarkOffset);
BannerCampaign.addOverrideFgDark(builder, overrideFgDarkOffset);
BannerCampaign.addOverrideLinkDark(builder, overrideLinkDarkOffset);
return BannerCampaign.endBannerCampaign(builder);
}
}
+18 -4
View File
@@ -29,7 +29,7 @@ describe('createScheduler', () => {
{ weight: 7, messages: ['b'] },
]);
expect(sched.total).toBe(2);
const cycle = Array.from({ length: 10 }, () => sched.next());
const cycle = Array.from({ length: 10 }, () => sched.next().md);
const a = cycle.filter((m) => m === 'a').length;
const b = cycle.filter((m) => m === 'b').length;
expect(a).toBe(3);
@@ -42,7 +42,7 @@ describe('createScheduler', () => {
it('round-robins messages within a campaign', () => {
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
expect(sched.total).toBe(3);
expect([sched.next(), sched.next(), sched.next(), sched.next()]).toEqual(['m0', 'm1', 'm2', 'm0']);
expect([sched.next().md, sched.next().md, sched.next().md, sched.next().md]).toEqual(['m0', 'm1', 'm2', 'm0']);
});
it('drops empty and non-positive-weight campaigns', () => {
@@ -52,13 +52,27 @@ describe('createScheduler', () => {
{ weight: 2, messages: ['keep'] },
]);
expect(sched.total).toBe(1);
expect(sched.next()).toBe('keep');
expect(sched.next().md).toBe('keep');
});
it('is empty for no campaigns', () => {
const sched = createScheduler([]);
expect(sched.total).toBe(0);
expect(sched.next()).toBe('');
expect(sched.next().md).toBe('');
});
it('carries each campaign colour override with its message', () => {
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
const sched = createScheduler([
{ weight: 1, messages: ['plain'] },
{ weight: 1, messages: ['branded'], overrideAll: red, overrideDark: darkRed },
]);
const two = [sched.next(), sched.next()];
const branded = two.find((it) => it.md === 'branded');
const plain = two.find((it) => it.md === 'plain');
expect(branded?.colors).toEqual({ all: red, dark: darkRed });
expect(plain?.colors).toEqual({ all: null, dark: null });
});
});
+21 -12
View File
@@ -4,7 +4,7 @@
// unit-testable with fake timers. The campaigns + timings come from the profile's
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
import type { BannerCampaign, BannerTimings } from './model';
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
/** Fallback display timings, matching the seeded ad_settings defaults. */
export const defaultBannerTimings: BannerTimings = {
@@ -16,9 +16,15 @@ export const defaultBannerTimings: BannerTimings = {
fadeInMs: 1_000,
};
/** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */
export interface BannerItem {
md: string;
colors: BannerColors;
}
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
export interface Scheduler {
next(): string;
next(): BannerItem;
/** Total messages across all (non-empty, positive-weight) campaigns. */
readonly total: number;
}
@@ -33,15 +39,17 @@ export interface Scheduler {
*/
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
const colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null }));
const current = cs.map(() => 0); // SWRR running weights
const cursor = cs.map(() => 0); // per-campaign round-robin position
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
const empty: BannerColors = { all: null, dark: null };
return {
total,
next(): string {
if (cs.length === 0) return '';
next(): BannerItem {
if (cs.length === 0) return { md: '', colors: empty };
let best = 0;
for (let i = 0; i < cs.length; i++) {
current[i] += cs[i].weight;
@@ -51,15 +59,16 @@ export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
const c = cs[best];
const md = c.messages[cursor[best] % c.messages.length];
cursor[best]++;
return md;
return { md, colors: colors[best] };
},
};
}
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
export interface BannerHost {
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
show(md: string): void;
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the
* source campaign's colour override, applied to the strip (its neutral tokens when unset). */
show(md: string, colors: BannerColors): void;
/** Reset the scroll to the start without re-fading (to loop a long message). */
resetScroll(): void;
/** Fade the current message out over durationMs. */
@@ -98,7 +107,7 @@ export function createBannerRotator(
const sched = createScheduler(campaigns);
let running = false;
let cycleStart = 0;
let lastShown = ''; // the message currently presented, for restart() (re-measure)
let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure)
const timers: ReturnType<typeof setTimeout>[] = [];
const at = (ms: number, fn: () => void) => {
@@ -109,11 +118,11 @@ export function createBannerRotator(
timers.length = 0;
};
function present(md: string) {
function present(item: BannerItem) {
if (!running) return;
clear();
lastShown = md;
host.show(md);
lastShown = item;
host.show(item.md, item.colors);
at(config.fadeInMs, measure);
}
@@ -143,7 +152,7 @@ export function createBannerRotator(
// the same fade as a message change, not a hard jump.
host.hide(config.fadeOutMs);
at(config.fadeOutMs + config.gapMs, () => {
host.show(lastShown);
if (lastShown) host.show(lastShown.md, lastShown.colors);
at(config.fadeInMs, () => scrollCycle(over));
});
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { derivedBorder, resolveBannerColors } from './bannerColors';
import type { BannerColors } from './model';
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
describe('resolveBannerColors', () => {
it('returns null when the campaign has no override (neutral tokens)', () => {
const none: BannerColors = { all: null, dark: null };
expect(resolveBannerColors(none, 'light')).toBeNull();
expect(resolveBannerColors(none, 'dark')).toBeNull();
});
it('applies the all-themes set on both themes when no dark override', () => {
const c: BannerColors = { all: red, dark: null };
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
});
it('prefers the dark override on the dark theme, and the all set on light', () => {
const c: BannerColors = { all: red, dark: darkRed };
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg });
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
});
it('supports a dark-only override (light keeps the neutral tokens)', () => {
const c: BannerColors = { all: null, dark: darkRed };
expect(resolveBannerColors(c, 'light')).toBeNull();
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
});
it('derives the border from the resolved background', () => {
const c: BannerColors = { all: red, dark: null };
expect(resolveBannerColors(c, 'light')?.border).toBe(derivedBorder(red.bg));
});
});
describe('derivedBorder', () => {
it('darkens a light background and lightens a dark one', () => {
expect(derivedBorder('#ffffff')).toBe('#dbdbdb');
expect(derivedBorder('#000000')).toBe('#242424');
});
it('returns a malformed colour unchanged', () => {
expect(derivedBorder('nope')).toBe('nope');
});
});
+69
View File
@@ -0,0 +1,69 @@
// Banner colour resolution. A campaign may carry two optional colour sets (see
// lib/model BannerColors): one for every theme and one that further overrides the
// dark theme. resolveBannerColors collapses them to the colours the strip paints on
// the currently-rendered theme, or null when the campaign keeps the neutral
// --ad-bg / --text-muted / --accent tokens. Pure and DOM-free, so it is unit-tested
// directly; AdBanner.svelte applies the result as inline CSS variables.
import type { BannerColors } from './model';
export type ThemeMode = 'light' | 'dark';
/** ResolvedBannerColors is the strip's colours for one theme, border already derived. */
export interface ResolvedBannerColors {
bg: string;
fg: string;
link: string;
border: string;
}
/**
* resolveBannerColors picks the colour set for the rendered theme — dark ← dark ?? all,
* light ← all — and returns null when there is no override for that theme (the strip then
* keeps the neutral tokens). The border is derived from the background so it stays subtle
* on any colour.
*/
export function resolveBannerColors(colors: BannerColors, mode: ThemeMode): ResolvedBannerColors | null {
const set = mode === 'dark' ? (colors.dark ?? colors.all) : colors.all;
if (!set) return null;
return { bg: set.bg, fg: set.fg, link: set.link, border: derivedBorder(set.bg) };
}
/**
* derivedBorder nudges the background 14% toward black on a light background and toward
* white on a dark one — a subtle edge that suits any override colour. It is computed in
* JS (no CSS color-mix) so it works on the old Android WebView floor (Chrome 67), and
* mirrors the admin console preview (banner_detail.gohtml). A malformed colour is
* returned unchanged.
*/
export function derivedBorder(bgHex: string): string {
const rgb = hexToRgb(bgHex);
if (!rgb) return bgHex;
const target: RGB = luminance(rgb) > 0.5 ? [0, 0, 0] : [255, 255, 255];
return rgbToHex(mix(rgb, target, 0.14));
}
type RGB = [number, number, number];
function hexToRgb(h: string): RGB | null {
const m = /^#([0-9a-fA-F]{6})$/.exec(h.trim());
if (!m) return null;
const n = m[1];
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function luminance(r: RGB): number {
return (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]) / 255;
}
function pad(x: number): string {
return Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0');
}
function rgbToHex(r: RGB): string {
return '#' + pad(r[0]) + pad(r[1]) + pad(r[2]);
}
+17 -3
View File
@@ -4,11 +4,14 @@
// independent of route transitions (the cycle is not restarted on navigation).
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
import type { BannerCampaign, BannerTimings } from './model';
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
const noColors: BannerColors = { all: null, dark: null };
let rotator: Rotator | null = null;
let mounted: BannerHost | null = null;
let current = '';
let currentColors: BannerColors = noColors;
let key = '';
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
@@ -17,10 +20,11 @@ let activeScroll: { toPx: number; dur: number; start: number } | null = null;
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
const proxy: BannerHost = {
show(md) {
show(md, colors) {
current = md;
currentColors = colors;
activeScroll = null;
mounted?.show(md);
mounted?.show(md, colors);
},
resetScroll() {
activeScroll = null;
@@ -58,6 +62,7 @@ export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimi
key = k;
rotator?.stop();
current = '';
currentColors = noColors;
rotator = createBannerRotator(campaigns, proxy, timings);
rotator.start();
}
@@ -71,6 +76,15 @@ export function bannerCurrent(): string {
return current;
}
/**
* bannerCurrentColors returns the colour override of the campaign whose message is currently
* displayed (neutral tokens — both sets null — before the first message). A freshly-mounted view
* reads it alongside bannerCurrent so the strip paints the right colours immediately on mount.
*/
export function bannerCurrentColors(): BannerColors {
return currentColors;
}
/**
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
+26 -2
View File
@@ -647,7 +647,10 @@ describe('codec', () => {
const uid = b.createString('u-1');
const m0 = b.createString('promo-en');
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
const camp = fb.BannerCampaign.createBannerCampaign(b, 3, msgs);
fb.BannerCampaign.startBannerCampaign(b);
fb.BannerCampaign.addWeight(b, 3);
fb.BannerCampaign.addMessages(b, msgs);
const camp = fb.BannerCampaign.endBannerCampaign(b);
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
fb.Profile.startProfile(b);
@@ -656,7 +659,8 @@ describe('codec', () => {
b.finish(fb.Profile.endProfile(b));
const p = decodeProfile(b.asUint8Array());
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'] }]);
// A plain campaign carries no override (both sets null → the neutral theme tokens).
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'], overrideAll: null, overrideDark: null }]);
expect(p.banner?.timings).toEqual({
holdMs: 60000,
edgePauseMs: 5000,
@@ -667,6 +671,26 @@ describe('codec', () => {
});
});
it('decodes a campaign colour override (both sets)', () => {
const b = new Builder(256);
const uid = b.createString('u-1');
const m0 = b.createString('branded');
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
const s = (v: string) => b.createString(v);
const [obg, ofg, ol, obgd, ofgd, old] = ['#aa0000', '#ffffff', '#ffdd00', '#330000', '#eeeeee', '#ffcc00'].map(s);
const camp = fb.BannerCampaign.createBannerCampaign(b, 1, msgs, obg, ofg, ol, obgd, ofgd, old);
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addBanner(b, banner);
b.finish(fb.Profile.endProfile(b));
const p = decodeProfile(b.asUint8Array());
expect(p.banner?.campaigns[0].overrideAll).toEqual({ bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' });
expect(p.banner?.campaigns[0].overrideDark).toEqual({ bg: '#330000', fg: '#eeeeee', link: '#ffcc00' });
});
it('leaves the profile banner undefined when absent', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
+13 -1
View File
@@ -19,6 +19,7 @@ import type {
BestMoveTile,
BlockStatus,
ChatMessage,
ColorTriple,
EvalResult,
FeedbackState,
FriendCode,
@@ -368,6 +369,12 @@ function decodeVariantPreferences(p: fb.Profile): Variant[] {
return out;
}
// bannerTriple builds a colour override from the three wire fields, or null when the
// set is absent (any field empty/missing) — the strip then keeps the neutral tokens.
function bannerTriple(bg: string | null, fg: string | null, link: string | null): ColorTriple | null {
return bg && fg && link ? { bg, fg, link } : null;
}
// decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined {
@@ -379,7 +386,12 @@ function decodeBanner(p: fb.Profile): Banner | undefined {
if (!c) continue;
const messages: string[] = [];
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
campaigns.push({ weight: c.weight(), messages });
campaigns.push({
weight: c.weight(),
messages,
overrideAll: bannerTriple(c.overrideBg(), c.overrideFg(), c.overrideLink()),
overrideDark: bannerTriple(c.overrideBgDark(), c.overrideFgDark(), c.overrideLinkDark()),
});
}
return {
campaigns,
+21
View File
@@ -169,12 +169,33 @@ export interface Banner {
timings: BannerTimings;
}
/** ColorTriple is a banner colour override's three "#rrggbb" colours. */
export interface ColorTriple {
bg: string;
fg: string;
link: string;
}
/**
* BannerColors is a campaign's optional palette: `all` overrides every theme, `dark`
* further overrides the dark theme (either may be null — the neutral tokens then apply).
* resolveBannerColors (lib/bannerColors) collapses it to the rendered theme's colours.
*/
export interface BannerColors {
all: ColorTriple | null;
dark: ColorTriple | null;
}
/** BannerCampaign is one campaign in the rotation feed. */
export interface BannerCampaign {
/** GCD-reduced show weight for the client's smooth weighted round-robin. */
weight: number;
/** Messages in display order, already resolved to the viewer's bot language. */
messages: string[];
/** Colour override for every theme; null (or absent) keeps the neutral tokens. */
overrideAll?: ColorTriple | null;
/** Colour override for the dark theme only; null (or absent) falls back to overrideAll/tokens. */
overrideDark?: ColorTriple | null;
}
/** BannerTimings are the global banner display timings (ms, except scrollPxPerSec). */