bingo: build the card from chosen providers
Let visitors tick providers (Yandex, IXcellerate, RTK-DC, Sber, …) and get the usual 5×5 card made of only those providers' datacenters, first 25 in ranking order, with lines scored as normal. A selection with fewer than 25 sites leaves the rest of the card empty, so the card is always 5×5. Each provider chip shows crossed/total for the current view. Datacenters get a provider slug, backfilled from the free-text operator, guessed for new rows and editable in the admin. Filtered URLs are noindex with canonical to the plain card. Assisted-by: Claude:opus-5
This commit is contained in:
parent
9ba108b6b0
commit
c64098813b
9 changed files with 155 additions and 8 deletions
|
|
@ -131,6 +131,7 @@ A second public site served from the **same process** on `BINGO_HOST` (`datacent
|
|||
- Admin: `/admin/bingo` on asxp.io (`lib/bingo_admin.rb`, registered into `AsxpioWeb`, so HTTP Basic + CSRF apply). Record/delete incidents; add, edit, delete DCs (delete cascades to incidents); upload a photo or import it from a URL; bulk import missing / re-import all photos from `photo_source_url`; "Sort by MW".
|
||||
- Status: operational | building | planned. `mw_type` (it | connected | total | design | mva, NULL = not stated) tags the MW figure in the dialog; mva swaps the unit.
|
||||
- Views: the bare locale URL is the **operational** card (default, indexed); `?view=all` adds construction/planned projects in the same ranking with lines recomputed, and is noindex with canonical to the bare URL. The admin dashboard tile shows the operational card too.
|
||||
- Provider filter: `bingo_datacenters.provider` (a `Bingo::PROVIDERS` slug; migration 016 backfilled it from `operator`, `Bingo.guess_provider` fills new rows, editable in admin). `?p[]=yandex&p[]=rtk` (or `?p=yandex,rtk`) builds the usual 5×5 card from only those providers' datacenters, first 25 by position; a smaller selection leaves empty cells. Filtered URLs are noindex with canonical to the bare page; language and view links keep the selection.
|
||||
- Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts, `renumber!` after deletes). The card shows positions 1–25; anything beyond is reserve. Slug is fixed at creation (it names photo objects).
|
||||
- Photos: stored in the existing bucket under `bingo/<slug>-<hex>.<ext>` (no storage-repo change), proxied by `BingoWeb` at `/p/:file` with immutable caching (new upload = new key; old objects are kept). Type sniffed from magic bytes (JPEG/PNG/WebP), 8 MB cap, metadata stripped (GPS!). URL import is https-only, admin-only. On store, ImageMagick (`convert`, installed in the Dockerfile) derives `-480.webp` (card cells), `-1200.webp` (dialog, JSON-LD) and `-og.jpg` (1200×630 centre crop, og:image); `photo_variants`/`photo_og` record success (rows without `photo_og` use the 1200 WebP as og:image) and the page falls back to the original without them. "Rebuild resized photos" in admin re-derives from stored originals.
|
||||
- SEO: `BingoWeb` has `static` off (public/ is asxp.io's), its own `/robots.txt`, `/sitemap.xml` (hreflang alternates, `lastmod` = max `updated_at`), and favicon/apple-touch-icon routes serving `assets/bingo/`. The h1 carries a per-locale `tagline`; `/en/` 301s to `/en`. Per-locale `seo_title`/`seo_desc` with the live score, OG/Twitter tags (og:image = latest hit's photo, alt = its name and city), JSON-LD WebSite + ItemList of Places (`info_url` as `subjectOf`, incidents as `event`). HSTS via Rack::Protection. Canonical/hreflang always use `BINGO_HOST`, so `BINGO_ALT_HOST` doesn't compete.
|
||||
|
|
|
|||
18
bingo_web.rb
18
bingo_web.rb
|
|
@ -25,8 +25,17 @@ class BingoWeb < Sinatra::Base
|
|||
def photo_path(dc, width = nil) = dc.photo_file(width) && "/p/#{dc.photo_file(width)}"
|
||||
# Path for a locale/view combination; the default view (operational)
|
||||
# keeps a bare URL, the full list is ?view=all.
|
||||
def card_path(locale = @locale, view = @view)
|
||||
view == 'all' ? "/#{locale}?view=all" : "/#{locale}"
|
||||
# Path for a locale/view/provider selection; the default (operational,
|
||||
# every provider) keeps a bare URL.
|
||||
def card_path(locale = @locale, view = @view, providers = @providers)
|
||||
q = []
|
||||
q << 'view=all' if view == 'all'
|
||||
Array(providers).each { |p| q << "p[]=#{Rack::Utils.escape(p)}" }
|
||||
q.empty? ? "/#{locale}" : "/#{locale}?#{q.join('&')}"
|
||||
end
|
||||
|
||||
def selected_providers
|
||||
Array(params[:p]).flat_map { |v| v.to_s.split(',') }.select { |p| Bingo::PROVIDERS.key?(p) }.uniq
|
||||
end
|
||||
|
||||
def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}"
|
||||
|
|
@ -167,10 +176,11 @@ class BingoWeb < Sinatra::Base
|
|||
get %r{/(ru|en|uk)} do |locale|
|
||||
@locale = locale
|
||||
@view = params[:view] == 'all' ? 'all' : Bingo::VIEWS.first
|
||||
@board = Bingo::Board.load(@view)
|
||||
@providers = selected_providers
|
||||
@board = Bingo::Board.load(@view, providers: @providers)
|
||||
@title = t(:seo_title)
|
||||
@desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size)
|
||||
@noindex = !@board.operational_only? # ?view=all is a variant of the bare page
|
||||
@noindex = !@board.operational_only? || @board.filtered? # variants of the bare page
|
||||
@path_for = ->(l) { "/#{l}" }
|
||||
@x_default = '/'
|
||||
@og = og_dc(@board)
|
||||
|
|
|
|||
23
db/migrations/016_bingo_provider.rb
Normal file
23
db/migrations/016_bingo_provider.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
Sequel.migration do
|
||||
up do
|
||||
# Company grouping for the per-provider cards (Bingo::PROVIDERS slugs).
|
||||
# Backfilled from the free-text operator; Datacenter#before_create keeps
|
||||
# new rows filled the same way (Bingo.guess_provider).
|
||||
add_column :bingo_datacenters, :provider, String
|
||||
{
|
||||
'yandex' => %w[Яндекс%], 'ixcellerate' => %w[IXcellerate%], 'rtk' => %w[РТК-ЦОД%],
|
||||
'atomdata' => %w[Атомдата% Росатом%], 'datapro' => %w[DataPro%], 'selectel' => %w[Selectel%],
|
||||
'mws' => %w[MWS% МТС%], 'keypoint' => ['Key Point%'], '3data' => %w[3data%], 'sber' => %w[Сбер%],
|
||||
'megafon' => %w[МегаФон%], 'datahouse' => %w[DataHouse%], 'oxygen' => %w[OXYGEN%], 'linx' => %w[Linx%],
|
||||
'cloudx' => ['Cloud X%'], 'nkgroup' => ['NK Group%'], 'goznak' => %w[Гознак%]
|
||||
}.each do |slug, patterns|
|
||||
patterns.each do |pat|
|
||||
self[:bingo_datacenters].where(provider: nil).where(Sequel.ilike(:operator, pat)).update(provider: slug)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
down do
|
||||
drop_column :bingo_datacenters, :provider
|
||||
end
|
||||
end
|
||||
45
lib/bingo.rb
45
lib/bingo.rb
|
|
@ -15,6 +15,26 @@ module Bingo
|
|||
STATUSES = %w[operational building planned].freeze
|
||||
MW_TYPES = %w[it connected total design mva].freeze
|
||||
|
||||
# Providers (companies) for the per-provider cards: slug => name per locale.
|
||||
PROVIDERS = {
|
||||
'yandex' => %w[Яндекс Yandex Яндекс], 'ixcellerate' => %w[IXcellerate] * 3,
|
||||
'rtk' => %w[РТК-ЦОД RTK-DC РТК-ЦОД], 'atomdata' => ['Атомдата (Росатом)', 'Atomdata (Rosatom)', 'Атомдата (Росатом)'],
|
||||
'datapro' => %w[DataPro] * 3, 'selectel' => %w[Selectel] * 3, 'mws' => ['MWS / МТС', 'MWS / MTS', 'MWS / МТС'],
|
||||
'keypoint' => ['Key Point'] * 3, '3data' => %w[3data] * 3, 'sber' => %w[Сбер Sber Сбер],
|
||||
'megafon' => %w[МегаФон MegaFon МегаФон], 'datahouse' => %w[DataHouse] * 3, 'oxygen' => %w[OXYGEN] * 3,
|
||||
'linx' => %w[Linx] * 3, 'cloudx' => ['Cloud X'] * 3, 'nkgroup' => ['NK Group'] * 3, 'goznak' => %w[Гознак Goznak Гознак]
|
||||
}.transform_values { |names| %w[ru en uk].zip(names).to_h }.freeze
|
||||
|
||||
PROVIDER_GUESS = [
|
||||
[/\AЯндекс/i, 'yandex'], [/\AIXcellerate/i, 'ixcellerate'], [/\AРТК-ЦОД/i, 'rtk'], [/\A(Атомдата|Росатом)/i, 'atomdata'],
|
||||
[/\ADataPro/i, 'datapro'], [/\ASelectel/i, 'selectel'], [/\A(MWS|МТС)/i, 'mws'], [/\AKey Point/i, 'keypoint'],
|
||||
[/\A3data/i, '3data'], [/\AСбер/i, 'sber'], [/\AМегаФон/i, 'megafon'], [/\ADataHouse/i, 'datahouse'],
|
||||
[/\AOXYGEN/i, 'oxygen'], [/\ALinx/i, 'linx'], [/\ACloud X/i, 'cloudx'], [/\ANK Group/i, 'nkgroup'], [/\AГознак/i, 'goznak']
|
||||
].freeze
|
||||
|
||||
def self.provider_name(slug, locale) = PROVIDERS.dig(slug, locale) || slug
|
||||
def self.guess_provider(operator) = PROVIDER_GUESS.find { |re, _| operator.to_s.match?(re) }&.last
|
||||
|
||||
# Every row, column and both diagonals, as 0-based cell indices.
|
||||
LINES = (
|
||||
(0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } +
|
||||
|
|
@ -40,6 +60,7 @@ module Bingo
|
|||
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
|
||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
|
||||
letters: %w[Б И Н Г О],
|
||||
providers_label: 'Собрать карточку из провайдеров', providers_apply: 'Показать', providers_reset: 'Все провайдеры',
|
||||
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать',
|
||||
dc_title: '%{name} (%{city}) — инциденты ЦОДа | Бинго ЦОДов',
|
||||
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бинго ЦОДов',
|
||||
|
|
@ -67,6 +88,7 @@ module Bingo
|
|||
footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.',
|
||||
kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' },
|
||||
letters: %w[B I N G O],
|
||||
providers_label: 'Build a card from providers', providers_apply: 'Show', providers_reset: 'All providers',
|
||||
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show',
|
||||
dc_title: '%{name} (%{city}) — datacenter incidents | Datacenter Bingo',
|
||||
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Datacenter Bingo',
|
||||
|
|
@ -94,6 +116,7 @@ module Bingo
|
|||
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
|
||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
|
||||
letters: %w[Б І Н Г О],
|
||||
providers_label: 'Зібрати картку з провайдерів', providers_apply: 'Показати', providers_reset: 'Усі провайдери',
|
||||
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати',
|
||||
dc_title: '%{name} (%{city}) — інциденти ЦОДу | Бінго ЦОДів',
|
||||
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бінго ЦОДів',
|
||||
|
|
@ -120,6 +143,7 @@ module Bingo
|
|||
|
||||
def before_create
|
||||
self.created_at ||= Time.now.utc
|
||||
self.provider ||= Bingo.guess_provider(operator)
|
||||
super
|
||||
end
|
||||
|
||||
|
|
@ -182,16 +206,31 @@ module Bingo
|
|||
|
||||
# The card: the first SIZE*SIZE datacenters by position, optionally only
|
||||
# operational ones (same ranking, construction projects skipped).
|
||||
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on, :view) do
|
||||
def self.load(view = 'all')
|
||||
# The card is always 5×5: the first SIZE*SIZE datacenters by position,
|
||||
# optionally only operational ones and/or only the chosen providers. A
|
||||
# selection with fewer datacenters leaves the remaining cells empty.
|
||||
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on, :view, :providers) do
|
||||
def self.load(view = 'all', providers: nil)
|
||||
scope = Datacenter.order(:position, :id)
|
||||
scope = scope.where(status: 'operational') if view == 'operational'
|
||||
scope = scope.where(provider: providers) if providers&.any?
|
||||
cells = scope.limit(SIZE * SIZE).eager(:incidents).all
|
||||
hit = cells.map { |dc| dc.incidents.any? }
|
||||
wins = LINES.select { |line| line.all? { |i| hit[i] } }
|
||||
incidents = cells.flat_map(&:incidents).sort_by { |i| [i.occurred_on, i.id] }.reverse
|
||||
updated = [Datacenter.max(:updated_at), Incident.max(:updated_at)].compact.max
|
||||
new(cells, hit, wins, incidents, updated, view)
|
||||
new(cells, hit, wins, incidents, updated, view, providers)
|
||||
end
|
||||
|
||||
def filtered? = providers&.any? || false
|
||||
|
||||
# [[slug, total, crossed]] for every provider with datacenters in this view.
|
||||
def self.providers(view)
|
||||
scope = Datacenter.exclude(provider: nil)
|
||||
scope = scope.where(status: 'operational') if view == 'operational'
|
||||
scope.eager(:incidents).all.group_by(&:provider)
|
||||
.map { |slug, dcs| [slug, dcs.size, dcs.count { |d| d.incidents.any? }] }
|
||||
.sort_by { |slug, n, x| [-x, -n, slug] }
|
||||
end
|
||||
|
||||
def operational_only? = view == 'operational'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ require 'uri'
|
|||
# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb.
|
||||
# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply.
|
||||
module BingoAdmin
|
||||
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw mw_type status
|
||||
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator provider mw mw_type status
|
||||
photo_credit photo_source_url info_url about_ru about_en about_uk].freeze
|
||||
|
||||
module Helpers
|
||||
|
|
@ -25,6 +25,7 @@ module BingoAdmin
|
|||
dc.year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil
|
||||
dc.status = 'operational' unless Bingo::STATUSES.include?(dc.status)
|
||||
dc.mw_type = nil unless Bingo::MW_TYPES.include?(dc.mw_type)
|
||||
dc.provider = nil unless Bingo::PROVIDERS.key?(dc.provider)
|
||||
errs = []
|
||||
errs << 'Name (ru), city (ru) and operator are required' if [dc.name_ru, dc.city_ru, dc.operator].any?(&:nil?)
|
||||
errs << 'URLs must be http(s)' unless [dc.photo_source_url, dc.info_url].all? { |u| http_url?(u.to_s) }
|
||||
|
|
|
|||
|
|
@ -174,6 +174,47 @@ class BingoTest < Minitest::Test
|
|||
assert_nil @dcs[0].refresh.mw_type
|
||||
end
|
||||
|
||||
def test_provider_guessed_on_create
|
||||
dc = Bingo::Datacenter.create(slug: 'ya-x', position: 30, name_ru: 'Я', city_ru: 'М', operator: 'Яндекс')
|
||||
assert_equal 'yandex', dc.provider
|
||||
assert_nil Bingo::Datacenter.create(slug: 'zz', position: 31, name_ru: 'Z', city_ru: 'М', operator: 'Op').provider
|
||||
end
|
||||
|
||||
def test_provider_filter_keeps_a_5x5_card
|
||||
@dcs.first(3).each { |d| d.update(provider: 'yandex') }
|
||||
@dcs[3].update(provider: 'rtk')
|
||||
hit!(0)
|
||||
board = Bingo::Board.load('operational', providers: %w[yandex rtk])
|
||||
assert_equal %w[dc-1 dc-2 dc-3 dc-4], board.cells.map(&:slug)
|
||||
get '/ru?p[]=yandex&p[]=rtk&p[]=bogus'
|
||||
assert last_response.ok?
|
||||
body = last_response.body
|
||||
assert_equal 4, body.scan(/class="cell(?! empty)[ "]/).size # the four selected datacenters
|
||||
assert_equal 21, body.scan('class="cell empty"').size
|
||||
assert_includes body, 'noindex, follow'
|
||||
assert_includes body, 'rel="canonical" href="https://example.org/ru"'
|
||||
assert_includes body, 'href="/en?p[]=yandex&p[]=rtk"' # language links keep the selection
|
||||
assert_includes body, 'name="p[]" value="yandex" checked'
|
||||
refute_includes body, 'ЦОД 5<' # unselected provider
|
||||
get '/ru?p=yandex,rtk' # comma form works too
|
||||
assert_equal 21, last_response.body.scan('class="cell empty"').size
|
||||
end
|
||||
|
||||
def test_filtered_card_still_scores_lines
|
||||
@dcs.first(5).each { |d| d.update(provider: 'ixcellerate') }
|
||||
(0..4).each { |i| hit!(i) }
|
||||
assert_equal 1, Bingo::Board.load('operational', providers: ['ixcellerate']).wins.size
|
||||
end
|
||||
|
||||
def test_admin_saves_provider
|
||||
@app = AsxpioWeb
|
||||
token = admin_token("/admin/bingo/dc/#{@dcs[0].id}")
|
||||
post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'operational', provider: 'selectel'), admin_env
|
||||
assert_equal 'selectel', @dcs[0].refresh.provider
|
||||
post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'operational', provider: 'nope'), admin_env
|
||||
assert_nil @dcs[0].refresh.provider
|
||||
end
|
||||
|
||||
def test_robots_and_sitemap_are_the_bingos_own
|
||||
get '/robots.txt'
|
||||
assert last_response.ok?
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@
|
|||
<div class="field"><label for="city_<%= l %>">City (<%= l %>)</label><input type="text" id="city_<%= l %>" name="city_<%= l %>" value="<%= @dc[:"city_#{l}"] %>" <%= 'required' if l == 'ru' %> /></div>
|
||||
<% end %>
|
||||
<div class="field"><label for="operator">Operator</label><input type="text" id="operator" name="operator" value="<%= @dc.operator %>" required /></div>
|
||||
<div class="field">
|
||||
<label for="provider">Provider <span class="optional">(groups the per-provider card)</span></label>
|
||||
<select id="provider" name="provider">
|
||||
<option value="" <%= 'selected' unless @dc.provider %>>none</option>
|
||||
<% Bingo::PROVIDERS.each do |slug, names| %><option value="<%= slug %>" <%= 'selected' if @dc.provider == slug %>><%= names['ru'] %></option><% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for="mw">MW <span class="optional">(number or range)</span></label><input type="text" id="mw" name="mw" value="<%= @dc.mw %>" /></div>
|
||||
<div class="field">
|
||||
<label for="mw_type">Capacity type</label>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,20 @@
|
|||
</header>
|
||||
|
||||
<p class="lead"><%= t(:desc) %></p>
|
||||
<% provs = Bingo::Board.providers(@view) %>
|
||||
<form class="providers" method="get" action="/<%= @locale %>">
|
||||
<% if @view == 'all' %><input type="hidden" name="view" value="all"><% end %>
|
||||
<fieldset>
|
||||
<legend><%= t(:providers_label) %></legend>
|
||||
<% provs.each do |slug, total, crossed| %>
|
||||
<label><input type="checkbox" name="p[]" value="<%= slug %>" <%= 'checked' if @providers.include?(slug) %>> <%= Bingo.provider_name(slug, @locale) %> <span><%= crossed %>/<%= total %></span></label>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<div class="provider-actions">
|
||||
<button type="submit"><%= t(:providers_apply) %></button>
|
||||
<% if @board.filtered? %><a href="<%= card_path(@locale, @view, []) %>"><%= t(:providers_reset) %></a><% end %>
|
||||
</div>
|
||||
</form>
|
||||
<p class="meta">
|
||||
<%= t(:crossed) %> <b><%= b.hit.count(true) %></b>/<%= b.cells.size %>
|
||||
· <%= t(:lines) %> <b><%= b.wins.size %></b>
|
||||
|
|
@ -48,6 +62,7 @@
|
|||
<% if b.hit[idx] %><div class="stamp"><%= kind_icon(dc.incidents.first.kind) %></div><% end %>
|
||||
</a>
|
||||
<% end %>
|
||||
<% (b.cells.size...Bingo::SIZE**2).each do %><div class="cell empty" aria-hidden="true"></div><% end %>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,16 @@
|
|||
.banner { margin: 0 0 16px; padding: 12px 16px; border-radius: 10px; background: var(--win); color: var(--on-win); font-weight: 700; font-size: 18px; }
|
||||
|
||||
.letters, .grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
|
||||
.cell.empty { background: transparent; border-style: dashed; cursor: default; }
|
||||
.providers { margin: 0 0 16px; }
|
||||
.providers fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.providers legend { font-size: 13px; color: var(--muted); margin-bottom: 6px; padding: 0; }
|
||||
.providers label { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||||
.providers label:has(input:checked) { border-color: var(--ink); }
|
||||
.providers input { accent-color: var(--hit); margin: 0; }
|
||||
.providers label span { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.provider-actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; font-size: 13px; }
|
||||
.provider-actions button { font: inherit; padding: 3px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; }
|
||||
.letters div { text-align: center; font-weight: 900; font-size: clamp(22px, 5vw, 36px); color: var(--hit); }
|
||||
.grid { margin-top: 6px; }
|
||||
.cell {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue