diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 3cda4a0..0b6c35f 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -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; } diff --git a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml index aeeb1dc..38c028c 100644 --- a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml @@ -11,10 +11,72 @@ +
+ +

Shows to everyone, 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.

+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
{{end}}
{{if not .IsDefault}} + +

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.

+
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index deddfe2..5abfeff 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -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 diff --git a/backend/internal/ads/ads.go b/backend/internal/ads/ads.go index 91c3d80..ef38c91 100644 --- a/backend/internal/ads/ads.go +++ b/backend/internal/ads/ads.go @@ -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 } diff --git a/backend/internal/ads/ads_test.go b/backend/internal/ads/ads_test.go index 9cb1efe..5f9f31b 100644 --- a/backend/internal/ads/ads_test.go +++ b/backend/internal/ads/ads_test.go @@ -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) } } diff --git a/backend/internal/ads/service.go b/backend/internal/ads/service.go index 939771d..5bd1a2c 100644 --- a/backend/internal/ads/service.go +++ b/backend/internal/ads/service.go @@ -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) diff --git a/backend/internal/ads/store.go b/backend/internal/ads/store.go index 4074418..247d5d4 100644 --- a/backend/internal/ads/store.go +++ b/backend/internal/ads/store.go @@ -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, diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go index d858213..4db5042 100644 --- a/backend/internal/inttest/banner_console_test.go +++ b/backend/internal/inttest/banner_console_test.go @@ -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()) +} diff --git a/backend/internal/postgres/jet/backend/model/ad_campaigns.go b/backend/internal/postgres/jet/backend/model/ad_campaigns.go index 718bab8..e266a9e 100644 --- a/backend/internal/postgres/jet/backend/model/ad_campaigns.go +++ b/backend/internal/postgres/jet/backend/model/ad_campaigns.go @@ -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 } diff --git a/backend/internal/postgres/jet/backend/table/ad_campaigns.go b/backend/internal/postgres/jet/backend/table/ad_campaigns.go index 891586b..088d461 100644 --- a/backend/internal/postgres/jet/backend/table/ad_campaigns.go +++ b/backend/internal/postgres/jet/backend/table/ad_campaigns.go @@ -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, diff --git a/backend/internal/postgres/migrations/00009_banner_colors_urgent.sql b/backend/internal/postgres/migrations/00009_banner_colors_urgent.sql new file mode 100644 index 0000000..96fb959 --- /dev/null +++ b/backend/internal/postgres/migrations/00009_banner_colors_urgent.sql @@ -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; diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index f3f5892..f86088d 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -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 { diff --git a/backend/internal/server/handlers_admin_banners.go b/backend/internal/server/handlers_admin_banners.go index 0a8ecc6..5ca8033 100644 --- a/backend/internal/server/handlers_admin_banners.go +++ b/backend/internal/server/handlers_admin_banners.go @@ -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), + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c92b9e4..99e47b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 196ad0a..806e3a8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ff23181..333ff76 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -496,8 +496,19 @@ high-rate флага. С карточки пользователя операт **честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а кампания с несколькими сообщениями показывает их по очереди. +Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или +служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один +применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы +кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также +пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам, +владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает +**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих +системных уведомлений (тех.работы, сбои), а не для рекламы. + Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует, включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет -их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и -переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней -всегда остаётся хотя бы одно сообщение. +их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты +на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько +держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между +сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она +остаётся простой (без цветов, никогда не срочная). diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 5078d9f..cf904b0 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 2b4d019..ef5ce5c 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index afc5434..3258ba4 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -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)) diff --git a/gateway/internal/transcode/transcode_banner_test.go b/gateway/internal/transcode/transcode_banner_test.go index 9c27dcf..6d986b2 100644 --- a/gateway/internal/transcode/transcode_banner_test.go +++ b/gateway/internal/transcode/transcode_banner_test.go @@ -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) { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 570b98c..0afd743 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -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 diff --git a/pkg/fbs/scrabblefb/BannerCampaign.go b/pkg/fbs/scrabblefb/BannerCampaign.go index 2a01ac7..c9219b9 100644 --- a/pkg/fbs/scrabblefb/BannerCampaign.go +++ b/pkg/fbs/scrabblefb/BannerCampaign.go @@ -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() } diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index 28b1e10..6a2613e 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -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(bannerCurrentColors()); let visible = $state(bannerCurrent() !== ''); let tx = $state(0); let txDur = $state(0); let track = $state(); let viewport = $state(); + // 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(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. --> -
+
@@ -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 diff --git a/ui/src/gen/fbs/scrabblefb/banner-campaign.ts b/ui/src/gen/fbs/scrabblefb/banner-campaign.ts index a8ccfc8..904edb1 100644 --- a/ui/src/gen/fbs/scrabblefb/banner-campaign.ts +++ b/ui/src/gen/fbs/scrabblefb/banner-campaign.ts @@ -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); } } diff --git a/ui/src/lib/banner.test.ts b/ui/src/lib/banner.test.ts index 78c0afa..6bb8f1e 100644 --- a/ui/src/lib/banner.test.ts +++ b/ui/src/lib/banner.test.ts @@ -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 }); }); }); diff --git a/ui/src/lib/banner.ts b/ui/src/lib/banner.ts index 3a09ac8..369ae2d 100644 --- a/ui/src/lib/banner.ts +++ b/ui/src/lib/banner.ts @@ -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[] = []; 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)); }); } diff --git a/ui/src/lib/bannerColors.test.ts b/ui/src/lib/bannerColors.test.ts new file mode 100644 index 0000000..8592b57 --- /dev/null +++ b/ui/src/lib/bannerColors.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/bannerColors.ts b/ui/src/lib/bannerColors.ts new file mode 100644 index 0000000..e3a44c5 --- /dev/null +++ b/ui/src/lib/bannerColors.ts @@ -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]); +} diff --git a/ui/src/lib/bannerEngine.ts b/ui/src/lib/bannerEngine.ts index 4fcd0b2..b3ebd60 100644 --- a/ui/src/lib/bannerEngine.ts +++ b/ui/src/lib/bannerEngine.ts @@ -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 diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 9f6d397..45fac3f 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index ebd60f8..be6474a 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 678e7c7..a9f978e 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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). */