feat: robots.txt editor and tester screen

- golem15.seo.robots singleton (UI-SPEC C10): staging callout and the
  served file (P4); the plugin's own extension adds the editor, the
  tester and the AI bots tab, saved to the robots row with history
- field type robotstxt (E4, live lint, size meter; Decode refuses over
  500 KB) and widget golem15-seo-robotstester (E5) on robots_test, which
  tests the unsaved text and switches through grobotstxt
- saving rules that block a noindex address answers robots.blocks_noindex
This commit is contained in:
2026-10-27 11:51:41 +01:00
parent 3dd936202f
commit 51413fbda4
21 changed files with 1336 additions and 26 deletions

View File

@@ -1,23 +1,39 @@
import './styles.css'
import { IMAGE_TAG, SeoImageElement } from './image'
import { ROBOTS_TAG, SeoRobotsElement } from './robots'
import { ROBOTSTESTER_TAG, SeoRobotsTesterElement } from './robotstester'
import { ROBOTSTXT_TAG, SeoRobotsTxtElement } from './robotstxt'
import { SERP_TAG, SeoSerpElement } from './serp'
import { SeoTextElement, TEXT_TAG } from './text'
// The admin SPA loads this script once on every admin page (the SEO field
// types list it in Scripts); defining a tag twice would throw, so a second
// evaluation is a no-op.
if (!customElements.get(TEXT_TAG)) {
customElements.define(TEXT_TAG, SeoTextElement)
}
if (!customElements.get(ROBOTS_TAG)) {
customElements.define(ROBOTS_TAG, SeoRobotsElement)
}
if (!customElements.get(SERP_TAG)) {
customElements.define(SERP_TAG, SeoSerpElement)
}
if (!customElements.get(IMAGE_TAG)) {
customElements.define(IMAGE_TAG, SeoImageElement)
const elements: Array<[string, CustomElementConstructor]> = [
[TEXT_TAG, SeoTextElement],
[ROBOTS_TAG, SeoRobotsElement],
[SERP_TAG, SeoSerpElement],
[IMAGE_TAG, SeoImageElement],
[ROBOTSTXT_TAG, SeoRobotsTxtElement],
[ROBOTSTESTER_TAG, SeoRobotsTesterElement],
]
for (const [tag, ctor] of elements) {
if (!customElements.get(tag)) {
customElements.define(tag, ctor)
}
}
export { IMAGE_TAG, ROBOTS_TAG, SERP_TAG, SeoImageElement, SeoRobotsElement, SeoSerpElement, SeoTextElement, TEXT_TAG }
export {
IMAGE_TAG,
ROBOTS_TAG,
ROBOTSTESTER_TAG,
ROBOTSTXT_TAG,
SERP_TAG,
SeoImageElement,
SeoRobotsElement,
SeoRobotsTesterElement,
SeoRobotsTxtElement,
SeoSerpElement,
SeoTextElement,
TEXT_TAG,
}

View File

@@ -1,5 +1,6 @@
// Element copy of the SEO field elements (UI-SPEC copywriting E1, E2, E3,
// plus E7 for the social image), chosen by the ui-locale attribute. Plural
// E4 and E5, plus E7 for the social image), chosen by the ui-locale
// attribute. Plural
// texts are CLDR maps; placeholders use :name.
/** A CLDR plural map: one/other in English, one/few/many/other in Polish. */
@@ -77,6 +78,29 @@ export interface Labels {
imageUploading: string
imageFailed: string
imageAlt: string
// E4
size: string
clean: string
unknown: string
unknownSuggest: string
noGroup: string
badPath: string
noColon: string
ownSitemap: string
crawlDelay: string
moreFindings: string
tooLarge: string
// E5
url: string
urlPlaceholder: string
agent: string
allowedRule: string
allowedNone: string
blocked: string
blockedBot: string
unsaved: string
stagingNote: string
urlRequired: string
}
const en: Labels = {
@@ -142,6 +166,27 @@ const en: Labels = {
imageUploading: 'Uploading…',
imageFailed: 'The image could not be uploaded.',
imageAlt: 'Social image preview',
size: ':size of 500 KB',
clean: 'No problems found.',
unknown: 'Line :line: “:word” is not a robots.txt directive.',
unknownSuggest: 'Line :line: “:word” is not a robots.txt directive. Did you mean “:suggestion”?',
noGroup: 'Line :line: this rule has no User-agent line above it, so crawlers ignore it.',
badPath: 'Line :line: a path must start with / or *.',
noColon: 'Line :line: write the directive as “Name: value”.',
ownSitemap: 'Line :line: Sitemap lines are added automatically. This one is kept as written.',
crawlDelay: 'Line :line: Google ignores Crawl-delay. Bing and Yandex use it.',
moreFindings: 'and :count more',
tooLarge: 'Over 500 KB: the file cannot be saved.',
url: 'Address',
urlPlaceholder: '/konto/ustawienia',
agent: 'Crawler',
allowedRule: 'Allowed for :agent. Rule: line :line “:rule”.',
allowedNone: 'Allowed for :agent: no rule matches this address.',
blocked: 'Blocked for :agent by line :line “:rule”.',
blockedBot: 'Blocked for :agent by its switch on the AI bots tab.',
unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.',
stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.',
urlRequired: 'Enter an address starting with /.',
}
const pl: Labels = {
@@ -217,6 +262,27 @@ const pl: Labels = {
imageUploading: 'Wgrywanie…',
imageFailed: 'Nie udało się wgrać obrazu.',
imageAlt: 'Podgląd obrazu w mediach społecznościowych',
size: ':size z 500 KB',
clean: 'Nie znaleziono problemów.',
unknown: 'Wiersz :line: „:word” nie jest dyrektywą robots.txt.',
unknownSuggest: 'Wiersz :line: „:word” nie jest dyrektywą robots.txt. Czy chodziło o „:suggestion”?',
noGroup: 'Wiersz :line: nad tą regułą nie ma wiersza User-agent, więc roboty ją ignorują.',
badPath: 'Wiersz :line: ścieżka musi zaczynać się od / lub *.',
noColon: 'Wiersz :line: zapisz dyrektywę jako „Nazwa: wartość”.',
ownSitemap: 'Wiersz :line: wiersze Sitemap są dodawane automatycznie. Ten zostaje bez zmian.',
crawlDelay: 'Wiersz :line: Google ignoruje Crawl-delay. Bing i Yandex go stosują.',
moreFindings: 'i :count więcej',
tooLarge: 'Ponad 500 KB: nie można zapisać pliku.',
url: 'Adres',
urlPlaceholder: '/konto/ustawienia',
agent: 'Robot',
allowedRule: 'Dozwolone dla :agent. Reguła: wiersz :line „:rule”.',
allowedNone: 'Dozwolone dla :agent: żadna reguła nie pasuje do tego adresu.',
blocked: 'Zablokowane dla :agent przez wiersz :line „:rule”.',
blockedBot: 'Zablokowane dla :agent przełącznikiem w zakładce Boty AI.',
unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.',
stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.',
urlRequired: 'Wpisz adres zaczynający się od /.',
}
const dictionaries: Record<string, Labels> = { en, pl }

View File

@@ -0,0 +1,244 @@
import { el, emit, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester'
/** The tester's context answer: the crawler list and the environment. */
interface ContextData {
op: 'context'
agents: string[]
bots: string[]
staging: boolean
}
/** The tester's test answer. */
interface TestData {
op: 'test'
url: string
allowed: boolean
line: number
rule: string
bot: boolean
agent: string
staging: boolean
}
type Payload = { op: 'context' } | { op: 'test'; url: string; agent: string }
/** The crawler list before the context answer arrives. */
const DEFAULT_AGENTS = ['Googlebot', 'Googlebot-Image', 'Bingbot', '*']
/** The result line of a test answer in the element's language. */
export function resultText(labels: Labels, r: TestData): string {
const params = { agent: r.agent, line: r.line, rule: r.rule }
if (r.allowed) {
return r.line > 0 ? fmt(labels.allowedRule, params) : fmt(labels.allowedNone, params)
}
return r.bot ? fmt(labels.blockedBot, params) : fmt(labels.blocked, params)
}
/**
* E5 golem15-seo-robotstester (UI-SPEC C10): an address input, a crawler
* select and the test button (labelled from the action), and the result
* line. It tests the editor's text and the AI bot switches the form holds,
* including unsaved changes (the widget's fill), through the robots_test
* action only.
*/
export class SeoRobotsTesterElement extends HTMLElement {
static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled', 'label', 'busy-label']
private context: ContextData | null = null
private result: TestData | null = null
private error = ''
private inflight: Payload | null = null
private queued: Payload | null = null
private connected = false
private input: HTMLInputElement | null = null
private select: HTMLSelectElement | null = null
private button: HTMLButtonElement | null = null
private line: HTMLDivElement | null = null
private notes: HTMLDivElement | null = null
private builtFor = ''
private readonly onResult = (ev: Event): void => {
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
const data = detail?.data as ContextData | TestData | undefined
this.inflight = null
if (data && data.op === 'context') {
this.context = data
this.build(true)
} else if (data && data.op === 'test') {
this.result = data
this.error = ''
this.renderResult()
}
this.flush()
}
connectedCallback(): void {
this.connected = true
this.addEventListener('summer-result', this.onResult)
this.build(false)
this.request({ op: 'context' })
}
disconnectedCallback(): void {
this.connected = false
this.removeEventListener('summer-result', this.onResult)
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
if (!this.connected) {
return
}
if (name === 'busy' && value === null) {
this.flush()
}
if (name === 'state' && value === 'error') {
this.inflight = null
}
if (name === 'ui-locale') {
this.build(true)
return
}
this.syncControls()
}
private labels(): Labels {
return labelsFor(uiLocaleOf(this))
}
private request(payload: Payload): void {
if (this.hasAttribute('busy') || this.inflight) {
this.queued = payload
return
}
this.inflight = payload
emit(this, 'summer-action', { payload })
}
private flush(): void {
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
return
}
const next = this.queued
this.queued = null
this.request(next)
}
private build(force: boolean): void {
const key = uiLocaleOf(this) + '|' + (this.context?.agents ?? DEFAULT_AGENTS).join(',')
if (!force && this.builtFor === key && this.input) {
this.syncControls()
return
}
this.builtFor = key
const labels = this.labels()
const keepUrl = this.input?.value ?? ''
const keepAgent = this.select?.value ?? 'Googlebot'
this.replaceChildren()
const row = el('div', 'seo-robotstester__row')
const urlId = uid('seo-robotstester-url')
const urlWrap = el('div', 'seo-robotstester__field seo-robotstester__field--url')
const urlLabel = el('label', 'seo-robotstester__label', labels.url)
urlLabel.htmlFor = urlId
const input = el('input', 'seo-robotstester__input')
input.id = urlId
input.type = 'text'
input.placeholder = labels.urlPlaceholder
input.value = keepUrl
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
this.test()
}
})
urlWrap.append(urlLabel, input)
const agentId = uid('seo-robotstester-agent')
const agentWrap = el('div', 'seo-robotstester__field seo-robotstester__field--agent')
const agentLabel = el('label', 'seo-robotstester__label', labels.agent)
agentLabel.htmlFor = agentId
const select = el('select', 'seo-robotstester__select')
select.id = agentId
for (const agent of this.context?.agents ?? DEFAULT_AGENTS) {
const option = el('option', '', agent)
option.value = agent
select.append(option)
}
select.value = keepAgent
if (select.value !== keepAgent) {
select.value = 'Googlebot'
}
agentWrap.append(agentLabel, select)
const button = el('button', 'seo-robotstester__button')
button.type = 'button'
button.addEventListener('click', () => this.test())
row.append(urlWrap, agentWrap, button)
const line = el('div', 'seo-robotstester__result')
line.setAttribute('role', 'status')
const notes = el('div', 'seo-robotstester__notes')
this.input = input
this.select = select
this.button = button
this.line = line
this.notes = notes
this.append(row, line, notes)
this.syncControls()
this.renderResult()
}
private syncControls(): void {
if (!this.button || !this.input || !this.select) {
return
}
const disabled = this.hasAttribute('disabled')
const busy = this.hasAttribute('busy')
this.input.readOnly = disabled
this.select.disabled = disabled
this.button.hidden = disabled
this.button.disabled = busy
this.button.textContent = busy ? this.getAttribute('busy-label') || this.getAttribute('label') || '' : this.getAttribute('label') || ''
}
private test(): void {
if (this.hasAttribute('disabled') || !this.input || !this.select) {
return
}
const url = this.input.value.trim()
if (!url.startsWith('/')) {
this.error = this.labels().urlRequired
this.result = null
this.renderResult()
return
}
this.error = ''
this.request({ op: 'test', url, agent: this.select.value })
}
private renderResult(): void {
if (!this.line || !this.notes) {
return
}
const labels = this.labels()
this.line.className = 'seo-robotstester__result'
if (this.error) {
this.line.classList.add('seo-robotstester__result--error')
this.line.replaceChildren(el('span', '', this.error))
} else if (this.result) {
const allowed = this.result.allowed
this.line.classList.add(allowed ? 'seo-robotstester__result--allowed' : 'seo-robotstester__result--blocked')
this.line.replaceChildren(icon(allowed ? 'circle-check' : 'triangle-alert'), el('span', '', resultText(labels, this.result)))
} else {
this.line.replaceChildren()
}
const notes: HTMLElement[] = []
if (this.context?.staging || jsonAttr<{ staging?: boolean }>(this, 'data', {}).staging) {
notes.push(el('p', 'seo-robotstester__note', labels.stagingNote))
}
notes.push(el('p', 'seo-robotstester__note', labels.unsaved))
this.notes.replaceChildren(...notes)
}
}

View File

@@ -0,0 +1,239 @@
import { el, emit, fmt, icon, labelsFor, num, uid, uiLocaleOf, type Labels } from './labels'
export const ROBOTSTXT_TAG = 'golem15-seo-robotstxt'
/** The largest robots.txt the server stores: Google reads 500 KiB. */
export const MAX_ROBOTS_BYTES = 500 * 1024
/** The findings shown before "and :count more". */
export const MAX_FINDINGS = 10
/** The directives the lint knows, as written in the suggestion. */
const DIRECTIVES = ['User-agent', 'Allow', 'Disallow', 'Sitemap', 'Crawl-delay', 'Host', 'Clean-param']
export type FindingKind = 'unknown' | 'unknownSuggest' | 'noGroup' | 'badPath' | 'noColon' | 'ownSitemap' | 'crawlDelay'
export interface Finding {
kind: FindingKind
line: number
word?: string
suggestion?: string
}
/** Levenshtein distance of two short strings. */
function distance(a: string, b: string): number {
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
for (let i = 1; i <= a.length; i++) {
let prev = row[0]
row[0] = i
for (let j = 1; j <= b.length; j++) {
const tmp = row[j]
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
prev = tmp
}
}
return row[b.length]
}
/** The closest known directive within two edits, or ''. */
function suggest(word: string): string {
const lower = word.toLowerCase()
let best = ''
let bestDistance = 3
for (const d of DIRECTIVES) {
const dist = distance(lower, d.toLowerCase())
if (dist < bestDistance) {
best = d
bestDistance = dist
}
}
return best
}
/**
* Lints a robots.txt (UI-SPEC E4): every finding is a warning that never
* blocks a save. Lines are 1-based; comments (#) and blank lines are
* skipped.
*/
export function lintRobots(text: string): Finding[] {
const out: Finding[] = []
let group = false
text.split(/\r?\n/).forEach((raw, index) => {
const line = index + 1
const hash = raw.indexOf('#')
const body = (hash >= 0 ? raw.slice(0, hash) : raw).trim()
if (body === '') {
return
}
const colon = body.indexOf(':')
if (colon < 0) {
out.push({ kind: 'noColon', line })
return
}
const word = body.slice(0, colon).trim()
const value = body.slice(colon + 1).trim()
switch (word.toLowerCase()) {
case 'user-agent':
group = true
return
case 'allow':
case 'disallow':
if (!group) {
out.push({ kind: 'noGroup', line })
} else if (value !== '' && !value.startsWith('/') && !value.startsWith('*')) {
out.push({ kind: 'badPath', line })
}
return
case 'sitemap':
out.push({ kind: 'ownSitemap', line })
return
case 'crawl-delay':
out.push({ kind: 'crawlDelay', line })
return
case 'host':
case 'clean-param':
return
}
const suggestion = suggest(word)
out.push(suggestion ? { kind: 'unknownSuggest', line, word, suggestion } : { kind: 'unknown', line, word })
})
return out
}
/** The UTF-8 size of text in bytes. */
export function byteSize(text: string): number {
return new TextEncoder().encode(text).length
}
/** The text of a finding in the element's language. */
export function findingText(labels: Labels, f: Finding): string {
return fmt(labels[f.kind], { line: f.line, word: f.word ?? '', suggestion: f.suggestion ?? '' })
}
/**
* E4 golem15-seo-robotstxt (UI-SPEC C10): the robots.txt editor, a
* monospaced textarea with a live lint panel (at most ten findings) and a
* size meter. Findings never block a save; only a file over 500 KB is
* refused, by the field type's Decode. The value is a plain string; the
* element emits summer-change on every input.
*/
export class SeoRobotsTxtElement extends HTMLElement {
static observedAttributes = ['disabled', 'invalid', 'ui-locale']
private current = ''
private control: HTMLTextAreaElement | null = null
private panel: HTMLDivElement | null = null
private meter: HTMLDivElement | null = null
private builtFor = ''
get value(): string {
return this.current
}
set value(v: unknown) {
this.current = typeof v === 'string' ? v : v == null ? '' : String(v)
if (this.control && this.control.value !== this.current) {
this.control.value = this.current
}
this.update()
}
connectedCallback(): void {
this.build()
}
attributeChangedCallback(name: string): void {
if (!this.isConnected) {
return
}
if (name === 'ui-locale') {
this.build()
return
}
this.sync()
}
private labels(): Labels {
return labelsFor(uiLocaleOf(this))
}
private build(): void {
const key = uiLocaleOf(this)
if (this.builtFor === key && this.control) {
this.sync()
return
}
this.builtFor = key
this.replaceChildren()
const panelId = uid('seo-robotstxt-lint')
const control = el('textarea', 'seo-robotstxt__control')
control.spellcheck = false
control.setAttribute('wrap', 'off')
control.value = this.current
control.setAttribute('aria-describedby', panelId)
const label = this.getAttribute('field-name')
if (label) {
control.setAttribute('aria-label', label)
}
control.addEventListener('input', () => {
if (this.hasAttribute('disabled')) {
return
}
this.current = control.value
this.update()
emit(this, 'summer-change', { value: this.current })
})
const panel = el('div', 'seo-robotstxt__lint')
panel.id = panelId
panel.setAttribute('role', 'status')
const meter = el('div', 'seo-robotstxt__size')
this.control = control
this.panel = panel
this.meter = meter
this.append(control, panel, meter)
this.sync()
}
private sync(): void {
if (!this.control) {
return
}
this.control.readOnly = this.hasAttribute('disabled')
this.classList.toggle('seo-robotstxt--invalid', this.hasAttribute('invalid'))
this.control.setAttribute('aria-invalid', this.hasAttribute('invalid') ? 'true' : 'false')
this.update()
}
private update(): void {
if (!this.panel || !this.meter) {
return
}
const labels = this.labels()
const locale = uiLocaleOf(this)
const findings = lintRobots(this.current)
const items: HTMLElement[] = []
if (findings.length === 0) {
const item = el('div', 'seo-robotstxt__finding seo-robotstxt__finding--clean')
item.append(icon('circle-check'), el('span', '', labels.clean))
items.push(item)
} else {
for (const f of findings.slice(0, MAX_FINDINGS)) {
const item = el('div', 'seo-robotstxt__finding')
item.append(icon('triangle-alert'), el('span', '', findingText(labels, f)))
items.push(item)
}
if (findings.length > MAX_FINDINGS) {
items.push(el('div', 'seo-robotstxt__more', fmt(labels.moreFindings, { count: num(findings.length - MAX_FINDINGS, locale) })))
}
}
this.panel.replaceChildren(...items)
const bytes = byteSize(this.current)
const kb = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(bytes / 1024) + ' KB'
this.meter.replaceChildren(el('span', '', fmt(labels.size, { size: kb })))
const over = bytes > MAX_ROBOTS_BYTES
this.classList.toggle('seo-robotstxt--over', over)
if (over) {
this.meter.append(el('span', 'seo-robotstxt__over', labels.tooLarge))
}
}
}

View File

@@ -1,11 +1,13 @@
/* SEO field elements (UI-SPEC C4, C5, C6, C14). Light DOM, every rule
/* SEO field elements (UI-SPEC C4, C5, C6, C10, C14). Light DOM, every rule
scoped under its element tag, colours from the public --c-* variables
only (except the E-1 mimic palette inside golem15-seo-serp .serp-card). */
golem15-seo-text,
golem15-seo-robots,
golem15-seo-serp,
golem15-seo-image {
golem15-seo-image,
golem15-seo-robotstxt,
golem15-seo-robotstester {
display: block;
color: var(--c-text);
font-size: 14px;
@@ -440,6 +442,187 @@ golem15-seo-image.seo-image--invalid .seo-image__img {
border-color: var(--c-danger);
}
/* E4 golem15-seo-robotstxt */
golem15-seo-robotstxt .seo-robotstxt__control {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 320px;
padding: 12px 14px;
border: 1px solid var(--c-border-strong);
border-radius: 10px;
background: var(--c-surface);
color: var(--c-text);
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
resize: vertical;
transition: border-color 150ms, box-shadow 150ms;
}
golem15-seo-robotstxt .seo-robotstxt__control:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px var(--c-ring);
}
golem15-seo-robotstxt.seo-robotstxt--invalid .seo-robotstxt__control,
golem15-seo-robotstxt.seo-robotstxt--over .seo-robotstxt__control {
border-color: var(--c-danger);
}
golem15-seo-robotstxt .seo-robotstxt__lint {
margin-top: 8px;
padding: 12px 14px;
border: 1px solid var(--c-border);
border-radius: 10px;
background: var(--c-subtle);
font-size: 13px;
}
golem15-seo-robotstxt .seo-robotstxt__finding {
display: flex;
align-items: flex-start;
gap: 6px;
overflow-wrap: anywhere;
}
golem15-seo-robotstxt .seo-robotstxt__finding + .seo-robotstxt__finding {
margin-top: 4px;
}
golem15-seo-robotstxt .seo-robotstxt__finding .seo-icon {
flex: none;
margin-top: 3px;
}
golem15-seo-robotstxt .seo-robotstxt__finding--clean {
color: var(--c-ok-text, var(--c-text));
}
golem15-seo-robotstxt .seo-robotstxt__more {
margin-top: 4px;
color: var(--c-muted);
}
golem15-seo-robotstxt .seo-robotstxt__size {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 6px;
color: var(--c-muted);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
golem15-seo-robotstxt .seo-robotstxt__over {
color: var(--c-danger);
}
/* E5 golem15-seo-robotstester */
golem15-seo-robotstester .seo-robotstester__row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
}
golem15-seo-robotstester .seo-robotstester__field {
display: flex;
flex-direction: column;
gap: 4px;
}
golem15-seo-robotstester .seo-robotstester__field--url {
flex: 1 1 240px;
}
golem15-seo-robotstester .seo-robotstester__field--agent {
min-width: 200px;
}
golem15-seo-robotstester .seo-robotstester__label {
color: var(--c-muted);
font-size: 13px;
}
golem15-seo-robotstester .seo-robotstester__input,
golem15-seo-robotstester .seo-robotstester__select {
box-sizing: border-box;
height: 44px;
padding: 0 14px;
border: 1px solid var(--c-border-strong);
border-radius: 10px;
background: var(--c-surface);
color: var(--c-text);
font: inherit;
font-size: 14px;
transition: border-color 150ms, box-shadow 150ms;
}
golem15-seo-robotstester .seo-robotstester__input:focus,
golem15-seo-robotstester .seo-robotstester__select:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px var(--c-ring);
}
golem15-seo-robotstester .seo-robotstester__button {
height: 42px;
padding: 0 16px;
border: 1px solid var(--c-border-strong);
border-radius: 10px;
background: var(--c-surface);
color: var(--c-text);
font: inherit;
font-size: 14px;
cursor: pointer;
}
golem15-seo-robotstester .seo-robotstester__button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--c-ring);
}
golem15-seo-robotstester .seo-robotstester__button:disabled {
cursor: default;
opacity: 0.6;
}
golem15-seo-robotstester .seo-robotstester__result {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
font-size: 14px;
overflow-wrap: anywhere;
}
golem15-seo-robotstester .seo-robotstester__result .seo-icon {
flex: none;
margin-top: 3px;
}
golem15-seo-robotstester .seo-robotstester__result--allowed {
color: var(--c-ok-text, var(--c-text));
}
golem15-seo-robotstester .seo-robotstester__result--blocked {
color: var(--c-text);
}
golem15-seo-robotstester .seo-robotstester__result--error {
color: var(--c-danger);
}
golem15-seo-robotstester .seo-robotstester__note {
margin: 6px 0 0;
color: var(--c-muted);
font-size: 13px;
}
@media (max-width: 600px) {
golem15-seo-robots .seo-robots__panel {
grid-template-columns: 1fr;
@@ -451,6 +634,9 @@ golem15-seo-image.seo-image--invalid .seo-image__img {
}
@media (prefers-reduced-motion: reduce) {
golem15-seo-robotstxt .seo-robotstxt__control,
golem15-seo-robotstester .seo-robotstester__input,
golem15-seo-robotstester .seo-robotstester__select,
golem15-seo-text .seo-text__control,
golem15-seo-robots .seo-robots__segment,
golem15-seo-serp .seo-serp__segment {