feat(admin): present OCR-only analysis UI, hide vision surfaces
The analysis admin now reflects the OCR backend instead of the vision LLM. Backend response types are unchanged (vision fields ride along, ignored) so this is fully reversible. - Settings → Analysis: keep only the OCR-relevant knobs (workers, job timeout, max image bytes) plus a read-only "Backend: OCR (ocrs)" line; drop the endpoint/model/API-key, sampling, slicing, and prompt fieldsets. - Analysis tab: reword the lede to OCR text extraction; the page-detail modal drops the NSFW/content-warning, scene, and auto-tag sections and shows only OCR text (the per-line kind label renders only when set, since ocrs leaves it blank). - History table: remove the NSFW-only filter and the per-row NSFW tag, and stop sending the `nsfw` query param. - Metrics panel: remove the by-model table (OCR has a single engine); keep the tiles and trend charts. Add component tests asserting the history table omits the NSFW filter and never tags a row, and the metrics panel renders tiles without the by-model table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,6 @@
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let statusFilter = $state<'' | 'done' | 'failed'>('');
|
||||
let nsfwOnly = $state(false);
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -34,7 +33,6 @@
|
||||
try {
|
||||
const resp = await listAnalysisHistory({
|
||||
status: statusFilter || undefined,
|
||||
nsfw: nsfwOnly,
|
||||
search: search.trim() || undefined,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
@@ -86,15 +84,6 @@
|
||||
<option value="done">done</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
<label class="nsfw">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={nsfwOnly}
|
||||
onchange={applyFilters}
|
||||
data-testid="analysis-history-nsfw"
|
||||
/>
|
||||
NSFW only
|
||||
</label>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
@@ -136,7 +125,6 @@
|
||||
<td><span class="status-pill {r.status}">{r.status}</span></td>
|
||||
<td>
|
||||
{r.manga_title} · Ch {r.chapter_number} · p{r.page_number}
|
||||
{#if r.is_nsfw}<span class="nsfw-tag">⚠ NSFW</span>{/if}
|
||||
</td>
|
||||
<td class="muted">{r.model ?? '—'}</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
@@ -169,13 +157,6 @@
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.nsfw {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -218,11 +199,6 @@
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.nsfw-tag {
|
||||
font-size: var(--font-xs);
|
||||
color: #b85e1a;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/svelte';
|
||||
|
||||
// Mock the admin API so the component's onMount load() is deterministic.
|
||||
const listAnalysisHistory = vi.fn();
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
listAnalysisHistory: (...args: unknown[]) => listAnalysisHistory(...args)
|
||||
}));
|
||||
|
||||
import AnalysisHistoryTable from './AnalysisHistoryTable.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
listAnalysisHistory.mockReset();
|
||||
});
|
||||
|
||||
function row(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
page_id: 'p1',
|
||||
page_number: 1,
|
||||
chapter_id: 'c1',
|
||||
chapter_number: 5,
|
||||
manga_id: 'm1',
|
||||
manga_title: 'Berserk',
|
||||
status: 'done',
|
||||
is_nsfw: true, // even when the backend reports nsfw, the OCR UI must not surface it
|
||||
model: 'ocrs',
|
||||
error: null,
|
||||
analyzed_at: '2026-01-02T00:00:00Z',
|
||||
duration_ms: 1234,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOnce(rows: ReturnType<typeof row>[]) {
|
||||
listAnalysisHistory.mockResolvedValue({
|
||||
items: rows,
|
||||
page: { total: rows.length }
|
||||
});
|
||||
}
|
||||
|
||||
describe('AnalysisHistoryTable (OCR)', () => {
|
||||
it('does not render the NSFW-only filter', async () => {
|
||||
resolveOnce([row()]);
|
||||
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||
await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('analysis-history-nsfw')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not pass an nsfw filter to the history query', async () => {
|
||||
resolveOnce([row()]);
|
||||
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||
await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled());
|
||||
const arg = listAnalysisHistory.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect('nsfw' in arg).toBe(false);
|
||||
});
|
||||
|
||||
it('never shows an NSFW tag on a row, even if the row is flagged', async () => {
|
||||
resolveOnce([row({ is_nsfw: true })]);
|
||||
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||
await waitFor(() => expect(screen.getByText(/Berserk/)).toBeTruthy());
|
||||
expect(screen.queryByText(/NSFW/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -105,30 +105,6 @@
|
||||
<span class="value">{metrics.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>By model</h2>
|
||||
{#if metrics.by_model.length === 0}
|
||||
<p class="muted">No completed analyses in this window.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th class="num">Avg</th>
|
||||
<th class="num">N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metrics.by_model as m (m.model)}
|
||||
<tr>
|
||||
<td>{m.model ?? '(unknown)'}</td>
|
||||
<td class="num">{fmtDuration(m.avg_ms)}</td>
|
||||
<td class="num">{m.n}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -186,29 +162,6 @@
|
||||
font-weight: var(--weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 32rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const getAnalysisMetrics = vi.fn();
|
||||
const getAnalysisMetricsSeries = vi.fn();
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
getAnalysisMetrics: (...a: unknown[]) => getAnalysisMetrics(...a),
|
||||
getAnalysisMetricsSeries: (...a: unknown[]) => getAnalysisMetricsSeries(...a)
|
||||
}));
|
||||
|
||||
import AnalysisMetricsPanel from './AnalysisMetricsPanel.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
getAnalysisMetrics.mockReset();
|
||||
getAnalysisMetricsSeries.mockReset();
|
||||
});
|
||||
|
||||
describe('AnalysisMetricsPanel (OCR)', () => {
|
||||
it('renders the aggregate tiles but not the by-model table', async () => {
|
||||
getAnalysisMetrics.mockResolvedValue({
|
||||
n: 10,
|
||||
ok: 9,
|
||||
failed: 1,
|
||||
avg_ms: 1200,
|
||||
// Even when the API returns a by-model breakdown, OCR has a single
|
||||
// engine so the panel must not surface the table.
|
||||
by_model: [{ model: 'ocrs', avg_ms: 1200, n: 10 }]
|
||||
});
|
||||
getAnalysisMetricsSeries.mockResolvedValue({ buckets: [] });
|
||||
|
||||
render(AnalysisMetricsPanel);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('analysis-metrics-n').textContent).toContain('10')
|
||||
);
|
||||
expect(screen.queryByText(/By model/i)).toBeNull();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -533,9 +533,9 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
|
||||
NSFW moderation). Browse what's analyzed, queue more, and inspect any
|
||||
page's result. Updates stream live as pages are queued and processed.
|
||||
Coverage of the OCR text-extraction worker. Browse which pages have had
|
||||
their text extracted, queue more, and inspect any page's OCR result.
|
||||
Updates stream live as pages are queued and processed.
|
||||
</p>
|
||||
|
||||
<div class="viewtabs">
|
||||
@@ -818,37 +818,12 @@
|
||||
<p class="error">{detail.error ?? 'Analysis failed.'}</p>
|
||||
{/if}
|
||||
|
||||
{#if detail.is_nsfw || detail.content_warnings.length > 0}
|
||||
<div class="warnings" data-testid="admin-analysis-detail-warnings">
|
||||
<span class="cw-label">⚠ NSFW</span>
|
||||
{#each detail.content_warnings as w (w)}
|
||||
<span class="cw-chip">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if detail.scene_description}
|
||||
<section class="block">
|
||||
<h3>Scene</h3>
|
||||
<p>{detail.scene_description}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.tags.length > 0}
|
||||
<section class="block">
|
||||
<h3>Tags</h3>
|
||||
<div class="tag-row">
|
||||
{#each detail.tags as t (t)}<span class="tag">{t}</span>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.ocr.length > 0}
|
||||
<section class="block">
|
||||
<h3>OCR text</h3>
|
||||
<ul class="ocr">
|
||||
{#each detail.ocr as line, i (i)}
|
||||
<li><span class="ocr-kind">{line.kind}</span> {line.text}</li>
|
||||
<li>{#if line.kind}<span class="ocr-kind">{line.kind}</span> {/if}{line.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -1186,28 +1161,6 @@
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.warnings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, #d4762a 12%, transparent);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.cw-label {
|
||||
font-weight: 600;
|
||||
color: #b85e1a;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.cw-chip {
|
||||
font-size: var(--font-sm);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, #d4762a 22%, transparent);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.block {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
@@ -1218,17 +1171,6 @@
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.tag {
|
||||
font-size: var(--font-sm);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.ocr {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
updateAnalysisSettings,
|
||||
type CrawlerSettings,
|
||||
type CrawlerEnvOnly,
|
||||
type AnalysisSettings,
|
||||
type PromptDefaults
|
||||
type AnalysisSettings
|
||||
} from '$lib/api/admin';
|
||||
|
||||
type Tab = 'crawler' | 'analysis';
|
||||
@@ -21,10 +20,11 @@
|
||||
let crawlerEnv = $state<CrawlerEnvOnly | null>(null);
|
||||
let allowlistText = $state(''); // textarea mirror of download_allowlist
|
||||
|
||||
// Analysis state
|
||||
// Analysis state. The full settings object is loaded and saved as-is;
|
||||
// vision-only fields (endpoint, model, prompts, slicing, sampling) ride
|
||||
// along unchanged while the OCR backend is active — only the OCR-relevant
|
||||
// knobs are surfaced below.
|
||||
let analysis = $state<AnalysisSettings | null>(null);
|
||||
let promptDefaults = $state<PromptDefaults | null>(null);
|
||||
let apiKeyConfigured = $state(false);
|
||||
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
@@ -46,8 +46,6 @@
|
||||
crawlerEnv = c.env_only;
|
||||
allowlistText = c.editable.download_allowlist.join('\n');
|
||||
analysis = a.editable;
|
||||
promptDefaults = a.prompt_defaults;
|
||||
apiKeyConfigured = a.env_only.api_key_configured;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : 'Failed to load settings.';
|
||||
} finally {
|
||||
@@ -96,8 +94,6 @@
|
||||
} else if (tab === 'analysis' && analysis) {
|
||||
const r = await updateAnalysisSettings(analysis);
|
||||
analysis = r.editable;
|
||||
promptDefaults = r.prompt_defaults;
|
||||
apiKeyConfigured = r.env_only.api_key_configured;
|
||||
}
|
||||
success = 'Saved and applied. The subsystem was restarted with the new settings.';
|
||||
} catch (e) {
|
||||
@@ -113,9 +109,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetPrompt(which: 'system_prompt' | 'ocr_prompt' | 'grounding_prompt') {
|
||||
if (analysis) analysis[which] = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Settings</h1>
|
||||
@@ -322,145 +315,29 @@
|
||||
{:else if tab === 'analysis' && analysis}
|
||||
<div class="form">
|
||||
<fieldset>
|
||||
<legend>Endpoint & model</legend>
|
||||
<label class="field">
|
||||
<span>Vision endpoint URL</span>
|
||||
<input type="text" bind:value={analysis.endpoint} />
|
||||
{#if fieldErrors.endpoint}<small class="fe">{fieldErrors.endpoint}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Model</span>
|
||||
<input type="text" bind:value={analysis.model} />
|
||||
{#if fieldErrors.model}<small class="fe">{fieldErrors.model}</small>{/if}
|
||||
</label>
|
||||
<legend>Engine</legend>
|
||||
<p class="env-note muted">
|
||||
API key: {apiKeyConfigured
|
||||
? 'configured via environment'
|
||||
: 'not set'} (managed via environment).
|
||||
Backend: OCR (ocrs) — in-process text extraction. Selected via
|
||||
environment; vision is temporarily disabled.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Workers & timeouts</legend>
|
||||
<legend>Worker</legend>
|
||||
<label class="field">
|
||||
<span>Workers</span>
|
||||
<input type="number" min="1" bind:value={analysis.workers} />
|
||||
{#if fieldErrors.workers}<small class="fe">{fieldErrors.workers}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Request timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.request_timeout_secs} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Job timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.job_timeout_secs} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Sampling & output</legend>
|
||||
<label class="field">
|
||||
<span>Temperature</span>
|
||||
<input type="number" min="0" step="0.05" bind:value={analysis.temperature} />
|
||||
{#if fieldErrors.temperature}<small class="fe"
|
||||
>{fieldErrors.temperature}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Frequency penalty</span>
|
||||
<input type="number" step="0.05" bind:value={analysis.frequency_penalty} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max output tokens</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_tokens} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Response format</span>
|
||||
<select bind:value={analysis.response_format}>
|
||||
<option value="json_schema">json_schema</option>
|
||||
<option value="json_object">json_object</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Image slicing</legend>
|
||||
<label class="field">
|
||||
<span>Max pixels per slice</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_pixels} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Min slice height (px)</span>
|
||||
<input type="number" min="1" bind:value={analysis.min_slice_height} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slice overlap (0–0.9)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="0.9"
|
||||
step="0.01"
|
||||
bind:value={analysis.slice_overlap}
|
||||
/>
|
||||
{#if fieldErrors.slice_overlap}<small class="fe"
|
||||
>{fieldErrors.slice_overlap}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Tall aspect threshold (≥1.0)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
bind:value={analysis.tall_aspect_threshold}
|
||||
/>
|
||||
{#if fieldErrors.tall_aspect_threshold}<small class="fe"
|
||||
>{fieldErrors.tall_aspect_threshold}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max slices</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_slices} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max image bytes</span>
|
||||
<input type="number" min="0" bind:value={analysis.max_image_bytes} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Prompts</legend>
|
||||
{#each [['system_prompt', 'System prompt'], ['ocr_prompt', 'OCR prompt (slices)'], ['grounding_prompt', 'Grounding prompt']] as [key, label] (key)}
|
||||
{@const k = key as 'system_prompt' | 'ocr_prompt' | 'grounding_prompt'}
|
||||
<div class="prompt">
|
||||
<div class="prompt-head">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
disabled={analysis[k] == null}
|
||||
onclick={() => resetPrompt(k)}>Reset to default</button
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
rows="6"
|
||||
class="mono"
|
||||
value={analysis[k] ?? ''}
|
||||
placeholder={promptDefaults?.[k] ?? ''}
|
||||
oninput={(e) => {
|
||||
const v = (e.currentTarget as HTMLTextAreaElement).value;
|
||||
if (analysis) analysis[k] = v.trim() === '' ? null : v;
|
||||
}}
|
||||
></textarea>
|
||||
<small class="muted">
|
||||
{analysis[k] == null
|
||||
? 'Using compiled default (shown as placeholder).'
|
||||
: `${analysis[k]?.length ?? 0} chars (override).`}
|
||||
</small>
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -564,7 +441,6 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
@@ -584,39 +460,6 @@
|
||||
color: var(--danger, #dc2626);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: var(--font-xs);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.prompt {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.prompt-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
button.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-xs);
|
||||
padding: 0;
|
||||
}
|
||||
button.link:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.env dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
|
||||
Reference in New Issue
Block a user