Compare commits

...

13 Commits

Author SHA1 Message Date
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer d61ba68e9b Merge pull request 'feat(banner): per-campaign colour overrides and urgent alerts' (#182) from feature/banner-colors-urgent into development
CI / integration (push) Successful in 15s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / ui (pull_request) Successful in 1m8s
2026-07-05 13:57:32 +00:00
Ilia Denisov 6db9178449 feat(banner): per-campaign colour overrides and urgent alerts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.

- Colours ride profile.get as six trailing FlatBuffers strings on
  BannerCampaign (backward-compatible). The client resolves the cascade
  (dark <- dark ?? all, light <- all) per rendered theme and derives the strip
  border from the background in JS (no CSS color-mix, for the old Android
  WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
  campaign exists, computeActiveSet returns only the urgent campaigns and
  bannerFor skips the eligibility gate — so a system notice reaches every viewer
  (paid / hint-holding / no_banner included) and preempts the ordinary feed. No
  wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
  preview of the strip, and an urgent toggle. The default campaign stays plain,
  enforced by the service and a DB CHECK.

Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.

Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
2026-07-05 15:36:35 +02:00
developer ac383880b7 Merge pull request 'fix(ui): hold info toast ~1s before it rises and fades' (#181) from feature/toast-hold-before-fade into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m7s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-05 12:24:08 +00:00
developer 16349f5ad9 Merge pull request 'fix(ui): poll for out-of-band email confirmation' (#180) from feature/email-confirm-poll-fallback into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m45s
2026-07-05 12:20:38 +00:00
developer 101a3f118c Merge pull request 'feat(ui): hide current-host sign-in row in profile' (#179) from feature/hide-current-host-signin-row into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m33s
2026-07-05 12:16:22 +00:00
Ilia Denisov 400b6ac2a5 fix(ui): hold info toast ~1s before it rises and fades
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The info toast began drifting up and fading the instant it finished
appearing (the CSS keyframes went straight from the 12% appeared-stop to
the 100% risen-and-faded stop), so a glanced message was already leaving.

Insert a ~1s hold at full opacity/rest before the rise-and-fade: extend the
animation 2s → 3s with keyframe stops at 8% (appeared, ~240ms) and 41% (end
of the ~1s hold), keeping the original rise-and-fade pace for the tail. The
reduced-motion variant gets the same appear/hold/fade timing (fade only, no
travel). The showToast dismissal timer is bumped 2000 → 3000ms to stay in
lockstep with the animation (the error toast's 4s dwell is unchanged).
2026-07-05 14:10:34 +02:00
Ilia Denisov fb7490f1df fix(ui): poll for out-of-band email confirmation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
An email link/code can be confirmed out of band: the recipient taps the
one-tap link in the email, which confirms in another browser/session. The
backend already publishes a `notify` `profile` re-fetch signal for this
(handlers_auth.go handleEmailConfirmLink), and the client re-fetches on it.

But the live stream is single-shot with no replay: a Mini App backgrounded
while the user is in their mail app tapping the link drops the stream and
misses the event (the gateway hub has no subscriber to deliver to), and the
reconnect on foreground does not re-sync — so the open code form stayed
until a manual reload. On Telegram Desktop the app is never backgrounded,
so the push works and there was no bug.

Add a client-side fallback: while an add-email confirmation is pending
(a code was sent, no email yet), poll `profile.get` on a 4s interval and on
foreground regain until the address lands; the effect stops as soon as the
email appears. The live push still updates instantly when foregrounded —
this only covers the backgrounded-miss gap.

Tests: a mock e2e attaches the email WITHOUT emitting a live event (new
window.__mock.clearEmail / confirmEmailOutOfBand seams), so it exercises the
poll, not the push, and asserts the code form collapses into the email row.
Docs: ARCHITECTURE.md §10 notes the single-shot gap + the poll fallback.
2026-07-05 13:58:39 +02:00
Ilia Denisov c1805e5b7c feat(ui): hide current-host sign-in row in profile
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Inside a Telegram/VK Mini App the host provider is auto-linked. Once the
player also linked an email, `canUnlink` turned true and the "Unlink"
control appeared on the host platform's own row — letting them unlink the
very platform they are signed in through, which is meaningless.

Gate the Telegram row on `!insideTelegram()` and the VK row on
`!insideVK()`, reusing the runtime host detectors that already gate the
"link" buttons. Symmetric: inside TG only the TG row is hidden (the VK row
still shows, since VK is not the current host), and vice versa. The web and
native builds are unchanged (both detectors are false there); the backend
is untouched — this is a UI display gate, and `linkUnlink` still refuses to
remove the last identity.

Docs: FUNCTIONAL.md (+_ru mirror).
2026-07-05 13:10:40 +02:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer fcedadcb5b Merge pull request 'feat(gateway): unsupported-engine telemetry beacon + Grafana counter' (#177) from feature/unsupported-engine-telemetry into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / deploy (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
2026-07-04 21:16:23 +00:00
Ilia Denisov 2feb638329 feat(gateway): unsupported-engine telemetry beacon + Grafana counter
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The index.html boot guard now fires a fire-and-forget beacon (POST /telemetry/unsupported)
when it turns a client away on the unsupported-engine screen, so the owner can see — on the
Users dashboard beside "app opens" — how many real clients hit it and on which engines.

- Client: navigator.sendBeacon (fetch fallback) with a localStorage dedup keyed by app version
  + reason + Chromium, so a user reopening the app is one report, not ten.
- Gateway: a new unauthenticated POST /telemetry/unsupported handler (the client never booted,
  so it carries no session), per-IP public-limited and body-capped, mirroring the export-download
  route. It folds the beacon into the OTel counter unsupported_engine_total {reason =
  no_bigint/no_proxy/boot_error/other, chromium}, with reason allow-listed and the Chromium major
  reduced to a bounded range (normalizeUnsupported) so a spoofed beacon cannot inflate the metric
  cardinality; the full user agent is logged, not labelled.
- Caddy: /telemetry/* added to the @gateway matcher (else it falls to the landing catch-all).
- Grafana: two panels on the Scrabble — Users dashboard (by reason, by Chromium major).
- Docs: ARCHITECTURE.md §11.

Tests: recordUnsupportedEngine (metric split), normalizeUnsupported (cardinality bounding), and
the handler route end to end (204 / 405 / counter). go build+vet+test and gofmt clean; ui
check/build/e2e green; the client beacon + dedup verified against a forced hard-gate (BigInt
removed) — one POST, deduped on reopen, correct reason/chromium/version labels.
2026-07-04 23:03:47 +02:00
developer 4dfedd02a3 feat(ui): support old Android in-app WebViews (es2019 + core-js + engine screen)
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
Old Android System WebViews (the Telegram/VK in-app browser; a real device with Google
Play auto-updates the WebView, but emulators / no-Play / restricted devices stay frozen)
showed a white screen. Two causes, fixed in order:

- build.target es2022 shipped ?./?? verbatim, which the old engine cannot parse. Lowered
  to es2019 so esbuild down-levels the syntax.
- The es2020+ runtime globals the bundle and its deps call (globalThis, structuredClone,
  Array.at, ...) are missing on those engines. A conditional core-js loader (emitPolyfills
  writes dist/polyfills.js, document.write'd by an index.html gate only when needed) covers
  them; modern engines download nothing, so the bundle-size budget is untouched.

BigInt (the 64-bit FlatBuffers timestamp decode) and Proxy (Svelte 5 runes) cannot be
polyfilled, so the effective floor is Chrome 67. Below it, a permanent ES5 boot guard in
index.html shows a friendly "this device's OS or browser can't run the app" screen (with
the web-version link inside a Mini App) and a "Diagnostic information" view with a Copy
button, instead of a white screen. A reactive net raises the same screen on an uncaught
boot error that never signals window.__booted.

Board tile glyphs used container-query units (cqw, Chrome 105+) under .cell font-size:0,
so they collapsed to 0px on Chrome 74 (invisible letters); added vmin fallbacks.

The unsupported-engine telemetry beacon is a separate follow-up PR.
2026-07-04 20:36:05 +00:00
51 changed files with 1978 additions and 178 deletions
@@ -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),
}
}
+1 -1
View File
@@ -86,7 +86,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/*
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
handle @gateway {
reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot
+16
View File
@@ -56,6 +56,22 @@
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
},
{
"type": "timeseries",
"title": "Unsupported-engine screens (cumulative by reason)",
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
},
{
"type": "timeseries",
"title": "Unsupported engines by Chromium (rate)",
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
}
]
}
+28 -2
View File
@@ -980,10 +980,25 @@ 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.
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the
link — misses the event; while an add-email confirmation is pending the client therefore also
**polls `profile.get`** (and on foreground regain) as a fallback until the address lands.
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
@@ -1070,6 +1085,17 @@ browser), so an open in-app session reflects it at once.
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
a game input.
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported`
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
report. The gateway folds it into `unsupported_engine_total` (`reason` =
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
on the **Scrabble — Users** dashboard beside app opens.
- **Rate-limit observability:** every limiter rejection increments the gateway
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
+18 -5
View File
@@ -109,7 +109,9 @@ two accounts share a game still in progress.
The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
back); inside a Mini App the host platform is already linked. Linking a provider that
back); inside a Mini App the host platform is already linked, and its own sign-in-method
row is hidden — the platform the player is currently signed in through cannot be unlinked
from there, only the other provider's row is shown. Linking a provider that
already belongs to another account offers the same irreversible **merge** as email
linking. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is
@@ -483,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).
+17 -4
View File
@@ -113,7 +113,9 @@ Telegram держит **единого бота**: все игроки поль
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
показывается только плашка другого провайдера. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
@@ -494,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.
+14
View File
@@ -32,6 +32,8 @@ type serverMetrics struct {
localColdStart metric.Int64Counter
localDictLoad metric.Int64Counter
localPreview metric.Int64Counter
// Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler).
unsupportedEngine metric.Int64Counter
}
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
@@ -63,6 +65,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
unsupportedEngine: counterOf(meter, "unsupported_engine_total",
"Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."),
}
gauge, err := meter.Int64ObservableGauge("active_users",
@@ -108,6 +112,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) {
m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
attribute.String("chromium", chromium),
))
}
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
// dictionaries vs on-device previews.
@@ -128,3 +128,70 @@ func TestBannedMetric(t *testing.T) {
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
}
}
// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts
// unsupported_engine_total splits by reason and Chromium major.
func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "boot_error", "74")
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
type key struct{ reason, chromium string }
counts := map[key]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
counts[key{reason.AsString(), chromium.AsString()}] += dp.Value
}
}
}
if got := counts[key{"no_bigint", "66"}]; got != 2 {
t.Errorf("unsupported no_bigint/66 = %d, want 2", got)
}
if got := counts[key{"boot_error", "74"}]; got != 1 {
t.Errorf("unsupported boot_error/74 = %d, want 1", got)
}
}
// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label
// values, so a spoofed beacon cannot explode the metric cardinality.
func TestNormalizeUnsupported(t *testing.T) {
cases := []struct {
reason, chromium string
wantReason, wantChromium string
}{
{"no_bigint", "66", "no_bigint", "66"},
{"no_proxy", " 74 ", "no_proxy", "74"},
{"boot_error", "105", "boot_error", "105"},
{"garbage", "66", "other", "66"},
{"", "", "other", "other"},
{"no_bigint", "not-a-number", "no_bigint", "other"},
{"no_bigint", "9999", "no_bigint", "other"},
{"no_bigint", "0", "no_bigint", "other"},
{"no_bigint", "-5", "no_bigint", "other"},
}
for _, c := range cases {
gotR, gotC := normalizeUnsupported(c.reason, c.chromium)
if gotR != c.wantReason || gotC != c.wantChromium {
t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium)
}
}
}
+74
View File
@@ -15,6 +15,7 @@ import (
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle("/dl/", s.exportDownloadHandler())
// The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
@@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) {
clamp(&r.PreviewNetwork)
}
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
// error). It is deduped client-side (one per device / app version / reason).
type unsupportedEngineBeacon struct {
Reason string `json:"reason"`
Chromium string `json:"chromium"`
Version string `json:"version"`
UA string `json:"ua"`
}
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
// user agent is logged, not labelled. Only POST; the reply is always 204.
func (s *Server) unsupportedEngineHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
var b unsupportedEngineBeacon
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
s.log.Info("unsupported engine",
zap.String("reason", reason),
zap.String("chromium", chromium),
zap.String("app_version", truncate(b.Version, 40)),
zap.String("user_agent", truncate(b.UA, 400)),
)
w.WriteHeader(http.StatusNoContent)
})
}
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
// parsed as a major version kept only within a plausible range, otherwise "other".
func normalizeUnsupported(reason, chromium string) (string, string) {
switch reason {
case "no_bigint", "no_proxy", "boot_error":
// a recognised reason — keep as-is
default:
reason = "other"
}
major := "other"
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
major = strconv.Itoa(n)
}
return reason, major
}
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
// trusted to be small).
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
@@ -0,0 +1,81 @@
package connectsrv_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/ratelimit"
)
// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST
// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405.
func TestUnsupportedEngineHandler(t *testing.T) {
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
edge := connectsrv.NewServer(connectsrv.Deps{
Limiter: ratelimit.New(),
RateLimit: config.DefaultRateLimit(),
Meter: meter,
})
srv := httptest.NewServer(edge.HTTPHandler())
defer srv.Close()
url := srv.URL + "/telemetry/unsupported"
// A GET is rejected.
getResp, err := http.Get(url)
if err != nil {
t.Fatalf("get: %v", err)
}
getResp.Body.Close()
if getResp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
}
// A POST is accepted (204) and folded into the counter with normalised labels.
body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}`
resp, err := http.Post(url, "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("POST status = %d, want 204", resp.StatusCode)
}
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
var total int64
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
if reason.AsString() != "no_bigint" || chromium.AsString() != "66" {
t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString())
}
total += dp.Value
}
}
}
if total != 1 {
t.Errorf("unsupported_engine_total = %d, want 1", total)
}
}
+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()
}
+30
View File
@@ -327,6 +327,36 @@ test('change email: a free address replaces the current one', async ({ page }) =
await expect(page.getByText('you@example.com')).toHaveCount(0);
});
// The add-email confirmation can complete out of band: the recipient taps the one-tap link in the
// email, which confirms in another browser/session. A backgrounded Mini App misses the live
// 'profile' event (the stream is single-shot, no replay), so the open code form falls back to
// polling the profile until the address lands. The mock attaches the email WITHOUT emitting an
// event, so only the poll can surface it.
test('add email: an out-of-band confirmation surfaces on the open form via polling', async ({ page }) => {
await loginLobby(page);
await openProfile(page);
// Drop the seeded email so the sign-in section offers the add-email flow.
await page.evaluate(() => (window as unknown as { __mock: { clearEmail(): void } }).__mock.clearEmail());
const emailInput = page.locator('.accounts input[type="email"]');
await expect(emailInput).toBeVisible();
await emailInput.fill('linked@example.com');
await page.getByRole('button', { name: 'Send code' }).click();
await expect(page.locator('.accounts .codein')).toBeVisible();
// Confirmed elsewhere via the one-tap link, with no live event delivered: only the poll surfaces it.
await page.evaluate(() =>
(
window as unknown as { __mock: { confirmEmailOutOfBand(email: string): void } }
).__mock.confirmEmailOutOfBand('linked@example.com'),
);
// The confirmed address surfaces as the email row, and the code form is gone.
await expect(page.locator('.acctrow').filter({ hasText: 'linked@example.com' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.accounts .codein')).toHaveCount(0);
});
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
await loginLobby(page);
await openProfile(page);
+234
View File
@@ -2,6 +2,240 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<!-- Boot capability guard. Runs before the deferred ES module, in plain ES5 so it survives even
on an engine too old to parse the bundle. Three jobs, as early as possible:
1. Hard gate — if an UNPOLYFILLABLE essential is missing (BigInt for the 64-bit
FlatBuffers wire decode, Proxy for Svelte 5 runes) the app cannot run at all: show the
unsupported-engine screen (an old Android System WebView, e.g. Chromium 66, is the case
this guards) instead of a white screen.
2. Soft gate — if only polyfillable es2020+ globals are missing (globalThis,
structuredClone, Array.at, …) pull core-js (emitted as polyfills.js) BEFORE the module
runs. document.write is deliberate: the only way to inject a parser-blocking <script>
guaranteed to run ahead of a deferred module; its argument is a static literal, so no
injection surface.
3. Reactive net — record any uncaught error/rejection during boot; if the app has not
signalled window.__booted within a grace period AND an error fired, show the same
screen with the captured cause (covers a bundle that fails to parse, or an unforeseen
incompatibility). __booted is set in App.svelte once bootstrap resolves.
The screen has a "Diagnostic information" view (engine + feature table + reason + version)
with a Copy button. On a capable engine nothing here renders, so neither the bundle-size
budget nor the mock e2e is affected. -->
<script>
(function () {
'use strict';
var nav = navigator;
var ua = nav.userAgent || '';
var VERSION = '__BOOT_VERSION__'; // replaced at build (vite.config injectBootVersion)
var RU = (nav.language || '').toLowerCase().indexOf('ru') === 0;
var MINIAPP = /\/(?:telegram|vk)\//.test(location.pathname);
var WEBURL = location.protocol + '//' + location.host + '/app/';
// [label, test, Chrome version it landed in, hard?] — hard = required and unpolyfillable.
function has(fn) {
try {
return !!fn();
} catch (e) {
return false;
}
}
var probes = [
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67, true],
['Proxy', function () { return typeof Proxy !== 'undefined'; }, 49, true],
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71, false],
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98, false],
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92, false],
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97, false],
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93, false],
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73, false],
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76, false],
['Promise.any', function () { return !!Promise.any; }, 85, false],
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84, false],
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71, false]
];
var results = [];
var hardMissing = [];
var softMissing = false;
for (var i = 0; i < probes.length; i++) {
var ok = has(probes[i][1]);
results.push({ name: probes[i][0], ok: ok, chrome: probes[i][2], hard: probes[i][3] });
if (!ok) {
if (probes[i][3]) hardMissing.push(probes[i][0]);
else softMissing = true;
}
}
// The diagnostic report (built on demand, behind the button).
function diag(reason) {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var wv = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
var out = [];
out.push('reason : ' + reason);
out.push('app : ' + VERSION);
out.push('chromium : ' + (cm ? cm[1] : 'n/a') + (wv ? ' (Android WebView)' : ''));
out.push('userAgent : ' + ua);
out.push('url : ' + location.href);
out.push('viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @' + (window.devicePixelRatio || 1));
out.push('lang : ' + (nav.language || '?') + ' online: ' + nav.onLine);
out.push('');
out.push('features:');
for (var k = 0; k < results.length; k++) {
var r = results[k];
out.push(' ' + (r.ok ? 'OK' : 'NO') + ' ' + r.name + ' (' + (r.hard ? 'required' : 'polyfilled') + ', Chrome ' + r.chrome + ')');
}
return out.join('\n');
}
function el(tag, style, text) {
var e = document.createElement(tag);
if (style) e.setAttribute('style', style);
if (text != null) e.appendChild(document.createTextNode(text));
return e;
}
function btn(bg, fg) {
return 'display:inline-block;margin:0 10px 10px 0;padding:11px 18px;border:0;border-radius:8px;' +
'font:600 15px/1 inherit;cursor:pointer;color:' + fg + ';background:' + bg + ';';
}
var shown = false;
// Fire-and-forget beacon so the gateway can count who hits this screen (Grafana). Deduped
// in localStorage by app version + reason + Chromium, so a user reopening the app ten times
// is one report. sendBeacon (Chrome 39+, present on the engines that reach here) with a
// fetch fallback; both are best-effort and never block or throw.
function beacon(code) {
try {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var chromium = cm ? cm[1] : '';
var sig = VERSION + '|' + code + '|' + chromium;
try {
if (localStorage.getItem('scrabble_unsupp') === sig) return;
} catch (e) {}
var payload = JSON.stringify({ reason: code, chromium: chromium, version: VERSION, ua: ua });
var sent = false;
try {
if (nav.sendBeacon) sent = nav.sendBeacon('/telemetry/unsupported', new Blob([payload], { type: 'application/json' }));
} catch (e) {}
if (!sent && typeof fetch === 'function') {
try {
fetch('/telemetry/unsupported', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(function () {});
sent = true;
} catch (e) {}
}
if (sent) {
try {
localStorage.setItem('scrabble_unsupp', sig);
} catch (e) {}
}
} catch (e) {}
}
function show(reason, code) {
if (shown) return;
shown = true;
beacon(code);
var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
'-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' +
'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;');
var wrap = el('div', 'max-width:520px;margin:0 auto;');
root.appendChild(wrap);
var msg = el('div', null);
msg.appendChild(el('div', 'font-size:22px;font-weight:700;margin-bottom:14px;', 'Эрудит'));
msg.appendChild(el('p', 'margin:0 0 14px;', RU
? 'На вашей версии операционной системы или браузера приложение не сможет работать.'
: "This app can't run on your device's operating system or browser version."));
if (MINIAPP) {
msg.appendChild(el('p', 'margin:0 0 6px;', RU
? 'Откройте веб-версию в обычном браузере (Chrome, Firefox):'
: 'Open the web version in a regular browser (Chrome, Firefox):'));
var a = el('a', 'color:#8ab4ff;word-break:break-all;', WEBURL);
a.setAttribute('href', WEBURL);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
var lp = el('p', 'margin:0 0 18px;');
lp.appendChild(a);
msg.appendChild(lp);
}
var infoBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Диагностическая информация' : 'Diagnostic information');
msg.appendChild(infoBtn);
wrap.appendChild(msg);
var dv = el('div', 'display:none;');
var pre = el('pre', 'white-space:pre-wrap;word-break:break-word;background:#0b0d11;border-radius:8px;padding:12px;' +
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;overflow:auto;', diag(reason));
dv.appendChild(pre);
var copyBtn = el('button', btn('#8ab4ff', '#0b0d11'), RU ? 'Копировать' : 'Copy');
var backBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Назад' : 'Back');
var row = el('div', 'margin-top:12px;');
row.appendChild(copyBtn);
row.appendChild(backBtn);
dv.appendChild(row);
wrap.appendChild(dv);
infoBtn.onclick = function () {
msg.style.display = 'none';
dv.style.display = 'block';
};
backBtn.onclick = function () {
dv.style.display = 'none';
msg.style.display = 'block';
};
copyBtn.onclick = function () {
var txt = diag(reason);
try {
if (nav.clipboard && nav.clipboard.writeText) {
nav.clipboard.writeText(txt);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
return;
}
} catch (e) {}
try {
var ta = document.createElement('textarea');
ta.value = txt;
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
} catch (e2) {
copyBtn.firstChild.nodeValue = RU ? 'Выделите и скопируйте текст' : 'Select and copy the text';
}
};
function mount() {
document.body.insertBefore(root, document.body.firstChild);
}
if (document.body) mount();
else document.addEventListener('DOMContentLoaded', mount);
}
// 1 + 2: the gate.
if (hardMissing.length) {
show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '), hardMissing.indexOf('BigInt') >= 0 ? 'no_bigint' : 'no_proxy');
} else if (softMissing) {
document.write('<script src="polyfills.js"><\/script>');
}
// 3: the reactive net for an unforeseen boot failure.
var bootErr = null;
function note(m) {
if (!bootErr) bootErr = m;
}
window.onerror = function (m, s, l, c, e) {
note(String(m) + (e && e.stack ? '\n' + e.stack : s ? ' @ ' + s + ':' + l : ''));
return false;
};
window.addEventListener('error', function (e) {
if (e && e.message) note(e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
}, true);
window.addEventListener('unhandledrejection', function (e) {
var r = e && e.reason;
note(r && (r.stack || r.message) ? r.stack || r.message : String(r));
});
setTimeout(function () {
if (!window.__booted && bootErr && !shown) show((RU ? 'ошибка запуска: ' : 'boot error: ') + bootErr, 'boot_error');
}, 8000);
})();
</script>
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
reach this tag). -->
+1
View File
@@ -27,6 +27,7 @@
"@playwright/test": "^1.49.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
+8
View File
@@ -36,6 +36,9 @@ importers:
'@types/node':
specifier: ^22.10.0
version: 22.19.19
core-js-bundle:
specifier: ^3.49.0
version: 3.49.0
svelte:
specifier: ^5.15.0
version: 5.56.0
@@ -508,6 +511,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
core-js-bundle@3.49.0:
resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1132,6 +1138,8 @@ snapshots:
clsx@2.1.1: {}
core-js-bundle@3.49.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
+4
View File
@@ -1,4 +1,8 @@
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
# its CLI shim; the platform binary itself ships as an optional dependency.
# core-js-bundle ships a prebuilt minified.js (we only read that file at build time,
# via vite.config emitPolyfills) and its install script is just a funding banner, so
# it is denied — nothing to build.
allowBuilds:
core-js-bundle: false
esbuild: true
+6 -1
View File
@@ -26,7 +26,12 @@
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => {
void bootstrap();
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
// unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred
// AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility).
void bootstrap().then(() => {
(window as unknown as { __booted?: boolean }).__booted = true;
});
});
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
+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
+13 -10
View File
@@ -3,8 +3,9 @@
import { app, dismissToast } from '../lib/app.svelte';
const dur = $derived(app.reduceMotion ? 0 : 260);
// An info bubble owns its whole 2s life through a CSS rise-and-fade animation, so it takes
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour.
// An info bubble owns its whole 3s life through a CSS animation — appear, a ~1s hold at rest,
// then rise-and-fade — so it takes no Svelte enter/leave transition; an error keeps the fly-in /
// fade-out dwell behaviour.
const isInfo = $derived(app.toast?.kind !== 'error');
</script>
@@ -50,17 +51,19 @@
border-color: var(--danger);
color: var(--danger);
}
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out,
all within 2s; the X centring is preserved across the rise. */
/* An info bubble fades in, holds for ~1s at rest, then drifts up by roughly the tab-bar height
while fading out, all within 3s; the X centring is preserved across the rise. The 8%/41%
stops are the appear (~240ms) and the end of the ~1s hold. */
.toast.rise {
animation: toast-rise 2s ease-out forwards;
animation: toast-rise 3s ease-out forwards;
}
@keyframes toast-rise {
0% {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
12% {
8%,
41% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@@ -69,16 +72,16 @@
transform: translateX(-50%) translateY(-56px);
}
}
/* Reduced motion: the same 2s life, fade only — no upward travel. */
/* Reduced motion: the same 3s life (appear, ~1s hold, fade), fade only — no upward travel. */
.toast.rise-reduced {
animation: toast-rise-reduced 2s ease-out forwards;
animation: toast-rise-reduced 3s ease-out forwards;
}
@keyframes toast-rise-reduced {
0% {
opacity: 0;
}
12%,
70% {
8%,
41% {
opacity: 1;
}
100% {
+11
View File
@@ -479,6 +479,11 @@
position: absolute;
top: 5%;
left: 8%;
/* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell
`font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks
the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System
WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */
font-size: calc(4.2vmin * var(--z, 1));
font-size: 4.2cqw;
font-weight: 700;
line-height: 1;
@@ -487,12 +492,14 @@
position: absolute;
right: 5%;
bottom: 3%;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw;
font-weight: 600;
}
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark {
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.8cqw;
bottom: 0;
}
@@ -501,6 +508,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 3.6cqw;
opacity: 0.7;
}
@@ -509,6 +517,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.7cqw;
font-weight: 600;
opacity: 0.9;
@@ -526,10 +535,12 @@
padding: 0 1px;
}
.bt {
font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
+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);
}
}
+3 -3
View File
@@ -214,9 +214,9 @@ function goForeground(): void {
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text, seq: ++toastSeq };
if (toastTimer) clearTimeout(toastTimer);
// An info bubble lives exactly as long as its rise-and-fade animation (2s); an error
// dwells longer (4s) so it is not missed.
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000);
// An info bubble lives exactly as long as its appear + ~1s hold + rise-and-fade animation (3s);
// an error dwells longer (4s) so it is not missed.
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 3000);
}
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
+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,
+4
View File
@@ -39,6 +39,8 @@ if (isMock && typeof window !== 'undefined') {
joinOpponentSilently(): void;
adminReply(): void;
setGameLimit(v: boolean): void;
clearEmail(): void;
confirmEmailOutOfBand(email: string): void;
};
}
).__mock = {
@@ -46,5 +48,7 @@ if (isMock && typeof window !== 'undefined') {
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
adminReply: () => (gateway as MockGateway).mockAdminReply(),
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
};
}
+11
View File
@@ -659,6 +659,17 @@ export class MockGateway implements GatewayClient {
this.profile.email = email;
return emptyLinked();
}
// e2e hooks (window.__mock, see lib/gateway.ts): mockClearEmail drops the seeded email so the
// sign-in section offers the add-email flow; mockConfirmEmailOutOfBand attaches the address
// WITHOUT a live event — standing in for the one-tap confirm link opened in another browser,
// so the open code form must reflect it by polling the profile, not by a push.
mockClearEmail(): void {
this.profile.email = '';
this.emit({ kind: 'notify', sub: 'profile' });
}
mockConfirmEmailOutOfBand(email: string): void {
this.profile.email = email;
}
async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.email = email;
+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). */
+35 -5
View File
@@ -7,13 +7,15 @@
confirmDeleteAccount,
handleError,
logout,
refreshProfile,
requestDeleteAccount,
showToast,
} from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte';
import {
@@ -58,6 +60,11 @@
let deletePhrase = $state('');
const telegramLinkable = loginWidgetAvailable();
const vkLinkable = vkWebLinkAvailable();
// Inside a host Mini App the current platform's own identity must not be managed from the
// profile — unlinking the very platform you are signed in through is meaningless — so that
// provider's linked-account row is hidden here (the web / native builds still show both).
const hostTelegram = insideTelegram();
const hostVK = insideVK();
function defaultTz(): string {
const b = browserOffset();
@@ -106,6 +113,27 @@
);
const canUnlink = $derived(linkedCount >= 2);
// While an add-email confirmation is pending, the address may be confirmed out of band — the
// one-tap link in the email confirms in another browser/session. A backgrounded Mini App drops
// the single-shot live stream and misses the 'profile' event (there is no replay), so poll the
// profile as a fallback until the address lands; the live push still updates it instantly while
// the app stays foregrounded. Scoped to the add-email wait (a code sent, no email yet).
const awaitingEmailConfirm = $derived(emailSent && !app.profile?.email);
$effect(() => {
if (!awaitingEmailConfirm) return;
const poll = (): void => void refreshProfile();
const id = setInterval(poll, 4000);
// Re-check the moment the Mini App returns to the foreground, where the missed event landed.
const onVisible = (): void => {
if (document.visibilityState === 'visible') poll();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVisible);
};
});
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
// the player must allow themselves at least one variant.
function toggleVariant(id: Variant) {
@@ -392,8 +420,10 @@
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
host provider is already linked. Email is never unlinked — it is changed. Unlink is
offered only when another identity remains (the backend also refuses the last one). -->
host provider is already linked, so its own row is hidden — the platform you are signed
in through cannot be unlinked from here (the other provider's row still shows). Email is
never unlinked — it is changed. Unlink is offered only when another identity remains
(the backend also refuses the last one). -->
<section class="accounts">
<h3>{t('profile.accountsTitle')}</h3>
@@ -440,7 +470,7 @@
</div>
{/if}
{#if p.telegramLinked}
{#if p.telegramLinked && !hostTelegram}
<div class="acctrow">
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
{#if canUnlink}
@@ -453,7 +483,7 @@
</button>
{/if}
{#if p.vkLinked}
{#if p.vkLinked && !hostVK}
<div class="acctrow">
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
{#if canUnlink}
+47 -3
View File
@@ -1,7 +1,42 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
/**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
* __BOOT_VERSION__ placeholder with the same VITE_APP_VERSION build-arg that feeds __APP_VERSION__.
* The guard runs before the bundle, so it cannot read the bundle's version; this lets its on-demand
* diagnostic report name the client version anyway. Falls back to "dev" for a local build.
*/
function injectBootVersion(): Plugin {
const version = process.env.VITE_APP_VERSION || 'dev';
return {
name: 'inject-boot-version',
transformIndexHtml(html) {
return html.replace(/__BOOT_VERSION__/g, version);
},
};
}
/**
* emitPolyfills writes the prebuilt core-js bundle to `dist/polyfills.js`. The index.html gate
* script loads it via document.write only on an old engine that lacks the es2020+ runtime APIs the
* app uses (globalThis, structuredClone, Array.at, ) e.g. the Chromium 66 Android System WebView
* behind the Telegram/VK in-app browser before the deferred module runs. A modern engine never
* requests it, so it adds nothing to that payload and stays out of the module graph the bundle-size
* gate measures.
*/
function emitPolyfills(): Plugin {
return {
name: 'emit-polyfills',
generateBundle() {
const src = readFileSync(resolve(import.meta.dirname, 'node_modules/core-js-bundle/minified.js'), 'utf8');
this.emitFile({ type: 'asset', fileName: 'polyfills.js', source: src });
},
};
}
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -19,7 +54,9 @@ export default defineConfig(({ mode }) => ({
// so a missing build-arg never breaks the build.
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
},
plugins: [svelte()],
// emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
plugins: [svelte(), emitPolyfills(), injectBootVersion()],
server: {
port: 5173,
proxy:
@@ -33,7 +70,14 @@ export default defineConfig(({ mode }) => ({
},
},
build: {
target: 'es2022',
// Down-level to es2019 so an old Android System WebView (seen: Telegram/VK in-app WebView on
// Chromium 66, Android 9) can parse the bundle — esbuild lowers es2020+ syntax (?., ??, private
// fields, static blocks, …) that Chrome 66 rejects with "Unexpected token ?", which left the SPA
// on a white screen. esbuild lowers syntax only, not runtime APIs — the es2020+ globals the
// bundle/deps call at runtime (globalThis, structuredClone, Array.at, …) are covered separately
// by the conditional core-js loader (emitPolyfills + the index.html gate), loaded only on an
// engine that actually lacks them.
target: 'es2019',
// Emit sourcemaps everywhere except the production build. A shipped `.map`
// carries full `sourcesContent` — the entire TypeScript/Svelte source — and the
// gateway/landing images serve `dist/` verbatim, so production maps would expose