AI tracker: owner-only Setup tab (per-provider connections, skills, instructions, plugins, routines, agents, plan/key labels, notes; ai.provider_setup_providers + provider_setup_entries, secrets refused)
Some checks failed
Deploy production / deploy (push) Failing after 1m3s

This commit is contained in:
2026-10-26 09:09:32 +00:00
parent 9e89a615c6
commit 8d2da984e0
9 changed files with 43668 additions and 6 deletions

View File

@@ -1,8 +1,8 @@
import { isOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots and /trackers/ai/usage as a backstop).
// /trackers/ai: the news tab is public; bots, usage and setup are owner-only (their own loads call
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots, /usage and /setup as a backstop).
export const load: LayoutServerLoad = (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });

View File

@@ -6,6 +6,7 @@
import Newspaper from 'lucide-svelte/icons/newspaper';
import Bot from 'lucide-svelte/icons/bot';
import Gauge from 'lucide-svelte/icons/gauge';
import SlidersHorizontal from 'lucide-svelte/icons/sliders-horizontal';
let { data, children } = $props();
@@ -14,7 +15,8 @@
...(data.owner
? [
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true }
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true },
{ name: 'setup', href: '/trackers/ai/setup', icon: SlidersHorizontal, locked: true }
]
: [])
]);
@@ -25,7 +27,10 @@
<TrackerIcon name="ai" class="size-10" />
<div>
<h1>ai</h1>
<p>AI and software news, plus what my bots are doing and how much I use each model.</p>
<p>
AI and software news, plus what my bots are doing, how much I use each model and how each
one is set up.
</p>
</div>
</header>
<nav class="ai-tabs" aria-label="AI tracker sections">
@@ -118,4 +123,7 @@
.tab-usage {
background: linear-gradient(135deg, #34d399, #0d9488);
}
.tab-setup {
background: linear-gradient(135deg, #a78bfa, #6d28d9);
}
</style>

View File

@@ -0,0 +1,44 @@
import { requireOwner } from '#lib/server/owner.js';
import {
addEntry,
addProvider,
deleteEntry,
deleteProvider,
loadSetup,
updateEntry,
updateProvider
} from '#lib/server/ai-setup.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad, RequestEvent } from './$types';
// Owner only: how each AI provider is set up (connections, skills, instructions, ...).
// Actions don't run the load, so each one checks the owner again (hooks.server.ts also 404s
// this path for everyone else).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
providers: await loadSetup(),
now: Date.now(),
seo: { title: 'AI tracker · setup', description: 'How my AI tools are set up.', index: false }
};
};
type Handler = (form: FormData) => Promise<{ ok: true } | { ok: false; message: string }>;
const action = (fn: Handler) => async (event: RequestEvent) => {
requireOwner(event);
const form = await event.request.formData();
const r = await fn(form);
return r.ok
? { ok: true as const }
: fail(400, { message: r.message, scope: String(form.get('form_key') ?? '') });
};
export const actions: Actions = {
addProvider: action(addProvider),
updateProvider: action(updateProvider),
deleteProvider: action(deleteProvider),
addEntry: action(addEntry),
updateEntry: action(updateEntry),
deleteEntry: action(deleteEntry)
};

View File

@@ -0,0 +1,826 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import { relativeTime } from '#lib/relative-time.js';
import Plug from 'lucide-svelte/icons/plug';
import WandSparkles from 'lucide-svelte/icons/wand-sparkles';
import ScrollText from 'lucide-svelte/icons/scroll-text';
import Puzzle from 'lucide-svelte/icons/puzzle';
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
import Bot from 'lucide-svelte/icons/bot';
import KeyRound from 'lucide-svelte/icons/key-round';
import StickyNote from 'lucide-svelte/icons/sticky-note';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import ExternalLink from 'lucide-svelte/icons/external-link';
let { data, form } = $props();
type Provider = (typeof data.providers)[number];
type Entry = Provider['entries'][number];
type SectionKey = Entry['section'];
const SECTIONS = [
{
key: 'connections',
label: 'Connections',
hint: 'connectors, MCP servers, integrations',
icon: Plug,
from: '#38bdf8',
to: '#2563eb'
},
{
key: 'skills',
label: 'Skills',
hint: '',
icon: WandSparkles,
from: '#fbbf24',
to: '#ea580c'
},
{
key: 'instructions',
label: 'Custom instructions',
hint: 'markdown',
icon: ScrollText,
from: '#a78bfa',
to: '#7c3aed'
},
{
key: 'plugins',
label: 'Plugins & extensions',
hint: '',
icon: Puzzle,
from: '#f472b6',
to: '#db2777'
},
{
key: 'routines',
label: 'Scheduled tasks & routines',
hint: '',
icon: CalendarClock,
from: '#34d399',
to: '#059669'
},
{ key: 'agents', label: 'Agents & bots', hint: '', icon: Bot, from: '#fb923c', to: '#dc2626' },
{
key: 'plan',
label: 'Plan & API keys',
hint: 'labels only',
icon: KeyRound,
from: '#facc15',
to: '#ca8a04'
},
{
key: 'notes',
label: 'Notes',
hint: 'markdown',
icon: StickyNote,
from: '#a1a1aa',
to: '#52525b'
}
] as const satisfies readonly { key: SectionKey }[];
const MARKDOWN: SectionKey[] = ['instructions', 'notes'];
const COLORS = {
orange: ['#fdba74', '#c2410c'],
green: ['#6ee7b7', '#047857'],
blue: ['#93c5fd', '#1d4ed8'],
violet: ['#c4b5fd', '#6d28d9'],
pink: ['#f9a8d4', '#be185d'],
sky: ['#7dd3fc', '#0369a1'],
amber: ['#fde68a', '#b45309'],
zinc: ['#d4d4d8', '#3f3f46']
} as const;
type Color = keyof typeof COLORS;
const colorKeys = Object.keys(COLORS) as Color[];
/** Which inline form is open: p:new, p:<id>, e:<id> or n:<providerId>:<section>. */
let editing = $state<string | null>(null);
let busy = $state(false);
/** Form key of the last submit, so a validation error shows next to the right form. */
let lastKey = $state('');
const open = (key: string) => {
editing = editing === key ? null : key;
};
const submit =
(key: string, confirmText?: string): SubmitFunction =>
({ cancel }) => {
if (confirmText && !confirm(confirmText)) return cancel();
busy = true;
lastKey = key;
return async ({ result, update }) => {
await update({ reset: false });
busy = false;
if (result.type === 'success') editing = null;
};
};
const errorFor = (key: string) => (form?.message && lastKey === key ? form.message : null);
const grouped = (p: Provider) => {
const out = {} as Record<SectionKey, Entry[]>;
for (const e of p.entries) (out[e.section] ??= []).push(e);
return out;
};
const stamp = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
const host = (url: string) => {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
};
</script>
{#snippet providerForm(p?: Provider)}
{@const key = p ? `p:${p.id}` : 'p:new'}
<form
class="edit"
method="POST"
action={p ? '?/updateProvider' : '?/addProvider'}
use:enhance={submit(key)}
>
<input type="hidden" name="form_key" value={key} />
{#if p}<input type="hidden" name="id" value={p.id} />{/if}
<label>name<input name="name" value={p?.name ?? ''} maxlength="60" required /></label>
<label
>products<input
name="products"
value={p?.products ?? ''}
maxlength="120"
placeholder="e.g. app + CLI"
/></label
>
<fieldset class="colors">
<legend>colour</legend>
{#each colorKeys as c (c)}
<label class="color" title={c}>
<input type="radio" name="color" value={c} checked={(p?.color ?? 'zinc') === c} />
<span style:--from={COLORS[c][0]} style:--to={COLORS[c][1]}></span>
</label>
{/each}
</fieldset>
<div class="actions">
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
<button type="submit" class="primary" disabled={busy}>{p ? 'save' : 'add provider'}</button>
</div>
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
</form>
{/snippet}
{#snippet entryForm(p: Provider, section: SectionKey, e?: Entry)}
{@const key = e ? `e:${e.id}` : `n:${p.id}:${section}`}
<form
class="edit"
method="POST"
action={e ? '?/updateEntry' : '?/addEntry'}
use:enhance={submit(key)}
>
<input type="hidden" name="form_key" value={key} />
<input type="hidden" name="section" value={section} />
{#if e}<input type="hidden" name="id" value={e.id} />{:else}<input
type="hidden"
name="provider_id"
value={p.id}
/>{/if}
<label class="wide"
>name<input
name="name"
value={e?.name ?? ''}
maxlength="120"
required
placeholder={section === 'plan' ? 'e.g. Pro plan, or a key label like "server key"' : ''}
/></label
>
<label
>scope<select name="scope" value={e?.scope ?? ''}>
<option value="">—</option>
<option value="global">global</option>
<option value="per-repo">per-repo</option>
<option value="app">app</option>
</select></label
>
<label
>link<input
name="link"
type="url"
value={e?.link ?? ''}
maxlength="500"
placeholder="https://…"
/></label
>
<label class="wide"
>note<input
name="note"
value={e?.note ?? ''}
maxlength="400"
placeholder="short note"
/></label
>
{#if MARKDOWN.includes(section) || e?.body}
<label class="wide"
>{section === 'instructions' ? 'instructions' : 'details'} (markdown)<textarea
name="body"
rows="6"
value={e?.body ?? ''}
maxlength="20000"></textarea></label
>
{/if}
{#if section === 'plan'}
<p class="hint wide">
<KeyRound size={12} /> Labels only: never paste a key value. Anything that looks like a key or
token is refused.
</p>
{/if}
<div class="actions">
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
<button type="submit" class="primary" disabled={busy}>{e ? 'save' : 'add'}</button>
</div>
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
</form>
{/snippet}
<div class="bar">
<p class="meta">
{data.providers.length} providers · connections, skills and instructions per tool · labels only, never
keys
</p>
<button
type="button"
class="primary"
aria-expanded={editing === 'p:new'}
onclick={() => open('p:new')}><Plus size={15} />add provider</button
>
</div>
{#if editing === 'p:new'}
<div class="card new">{@render providerForm()}</div>
{/if}
{#if !data.providers.length}
<p class="empty">No providers yet.</p>
{/if}
<div class="cards">
{#each data.providers as p (p.id)}
{@const groups = grouped(p)}
{@const [from, to] = COLORS[p.color as Color] ?? COLORS.zinc}
{@const emptySections = SECTIONS.filter((s) => !groups[s.key])}
<article class="card" style:--from={from} style:--to={to}>
<header class="head">
<span class="avatar" aria-hidden="true">{p.name.slice(0, 1).toUpperCase()}</span>
<div class="title">
<h2>{p.name}</h2>
{#if p.products}<p class="products">{p.products}</p>{/if}
</div>
<time datetime={p.updatedAt} title={`${stamp(p.updatedAt)} (Amsterdam)`}
>updated {relativeTime(p.updatedAt, data.now)}</time
>
<div class="tools">
<button
type="button"
class="icon"
aria-label="edit {p.name}"
title="edit"
onclick={() => open(`p:${p.id}`)}><Pencil size={14} /></button
>
<form
method="POST"
action="?/deleteProvider"
use:enhance={submit(
`pd:${p.id}`,
`Delete ${p.name} and its ${p.entries.length} entries?`
)}
>
<input type="hidden" name="id" value={p.id} />
<button type="submit" class="icon danger" aria-label="delete {p.name}" title="delete"
><Trash2 size={14} /></button
>
</form>
</div>
</header>
{#if editing === `p:${p.id}`}{@render providerForm(p)}{/if}
{#each SECTIONS as s (s.key)}
{@const list = groups[s.key]}
{#if list || editing === `n:${p.id}:${s.key}`}
<details class="section" open={(list?.length ?? 0) <= 8}>
<summary>
<span class="sicon" style:--from={s.from} style:--to={s.to}><s.icon size={13} /></span
>
<span class="slabel">{s.label}</span>
{#if s.hint}<span class="shint">{s.hint}</span>{/if}
<span class="count">{list?.length ?? 0}</span>
<button
type="button"
class="icon add"
aria-label="add to {s.label}"
title="add"
onclick={(ev) => {
ev.preventDefault();
open(`n:${p.id}:${s.key}`);
}}><Plus size={14} /></button
>
</summary>
{#if editing === `n:${p.id}:${s.key}`}{@render entryForm(p, s.key)}{/if}
{#if list}
<ul class="entries">
{#each list as e (e.id)}
<li class="entry">
{#if editing === `e:${e.id}`}
{@render entryForm(p, s.key, e)}
{:else}
<div class="eline">
<span class="ename">{e.name}</span>
{#if e.scope}<span class="scope scope-{e.scope}">{e.scope}</span>{/if}
{#if e.link}
<a
class="elink"
href={e.link}
target="_blank"
rel="noopener noreferrer"
title={e.link}><ExternalLink size={12} />{host(e.link)}</a
>
{/if}
<span class="etools">
<button
type="button"
class="icon"
aria-label="edit {e.name}"
title="edit"
onclick={() => open(`e:${e.id}`)}><Pencil size={13} /></button
>
<form
method="POST"
action="?/deleteEntry"
use:enhance={submit(`ed:${e.id}`, `Delete “${e.name}”?`)}
>
<input type="hidden" name="id" value={e.id} />
<button
type="submit"
class="icon danger"
aria-label="delete {e.name}"
title="delete"><Trash2 size={13} /></button
>
</form>
</span>
</div>
{#if e.note}<p class="enote">{e.note}</p>{/if}
{#if e.bodyHtml}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- owner-written markdown, rendered on the server with raw HTML escaped -->
<div class="md">{@html e.bodyHtml}</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
{#if s.key === 'plan'}
<p class="hint"><KeyRound size={12} /> Labels only, never key values.</p>
{/if}
</details>
{/if}
{/each}
{#if emptySections.some((s) => editing !== `n:${p.id}:${s.key}`)}
<div class="chips">
{#each emptySections as s (s.key)}
{#if editing !== `n:${p.id}:${s.key}`}
<button
type="button"
class="chip"
onclick={() => open(`n:${p.id}:${s.key}`)}
style:--from={s.from}
style:--to={s.to}><span class="sicon"><s.icon size={11} /></span>{s.label}</button
>
{/if}
{/each}
</div>
{/if}
</article>
{/each}
</div>
<style>
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.meta,
.empty {
color: var(--site-muted);
font-size: 0.85rem;
}
.empty {
padding: 2rem 0;
}
button {
cursor: pointer;
font: inherit;
}
.primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.4rem 0.85rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.85rem;
font-weight: 600;
transition: opacity 0.15s;
}
.primary:hover {
opacity: 0.85;
}
.primary:disabled {
opacity: 0.5;
}
.ghost {
padding: 0.4rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
font-size: 0.85rem;
}
.ghost:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem;
border-radius: 0.4rem;
color: var(--site-muted);
transition:
color 0.15s,
background 0.15s;
}
.icon:hover {
color: var(--site-text);
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.icon.danger:hover {
color: #f87171;
background: rgb(248 113 113 / 0.1);
}
.cards {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 26rem), 1fr));
align-items: start;
}
.card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.85rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
transition: border-color 0.15s;
min-width: 0;
}
.card:hover {
border-color: var(--site-muted);
}
.head {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-areas: 'avatar title tools' 'avatar time tools';
column-gap: 0.7rem;
align-items: center;
}
.avatar {
grid-area: avatar;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
border-radius: 0.7rem;
color: #fff;
font-weight: 700;
font-size: 1.05rem;
background: linear-gradient(135deg, var(--from), var(--to));
}
.title {
grid-area: title;
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-wrap: wrap;
}
h2 {
font-weight: 600;
font-size: 1.05rem;
letter-spacing: -0.01em;
}
.products {
font-size: 0.8rem;
color: var(--site-muted);
}
.head time {
grid-area: time;
font-size: 0.72rem;
color: var(--site-muted);
}
.tools {
grid-area: tools;
display: flex;
gap: 0.1rem;
}
.section {
border-top: 1px solid var(--site-border);
padding-top: 0.5rem;
}
summary {
display: flex;
align-items: center;
gap: 0.45rem;
list-style: none;
cursor: pointer;
font-size: 0.85rem;
}
summary::-webkit-details-marker {
display: none;
}
.sicon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 0.45rem;
color: #fff;
background: linear-gradient(135deg, var(--from), var(--to));
flex-shrink: 0;
}
.slabel {
font-weight: 600;
}
.shint {
font-size: 0.72rem;
color: var(--site-muted);
}
.count {
margin-left: auto;
font-size: 0.72rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
padding: 0.05rem 0.45rem;
border: 1px solid var(--site-border);
border-radius: 999px;
}
.entries {
display: flex;
flex-direction: column;
margin-top: 0.4rem;
}
.entry {
padding: 0.35rem 0 0.35rem 1.85rem;
border-radius: 0.4rem;
}
.entry + .entry {
border-top: 1px dashed color-mix(in srgb, var(--site-border) 70%, transparent);
}
.eline {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.ename {
font-weight: 500;
}
.scope {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.05rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
}
.scope-global {
color: #38bdf8;
border-color: rgb(56 189 248 / 0.4);
}
.scope-per-repo {
color: #a78bfa;
border-color: rgb(167 139 250 / 0.4);
}
.scope-app {
color: #34d399;
border-color: rgb(52 211 153 / 0.4);
}
.elink {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--site-muted);
text-decoration: none;
}
.elink:hover {
color: var(--site-text);
}
.etools {
margin-left: auto;
display: inline-flex;
opacity: 0;
transition: opacity 0.15s;
}
.entry:hover .etools,
.entry:focus-within .etools {
opacity: 1;
}
@media (hover: none) {
.etools {
opacity: 1;
}
}
.enote {
font-size: 0.8rem;
color: var(--site-muted);
line-height: 1.35;
}
.md {
margin-top: 0.3rem;
font-size: 0.82rem;
line-height: 1.45;
padding: 0.5rem 0.65rem;
border-left: 2px solid var(--site-border);
overflow-wrap: anywhere;
}
.md :global(p + p),
.md :global(ul),
.md :global(ol) {
margin-top: 0.4rem;
}
.md :global(ul) {
list-style: disc;
padding-left: 1.1rem;
}
.md :global(ol) {
list-style: decimal;
padding-left: 1.1rem;
}
.md :global(code) {
font-size: 0.78rem;
padding: 0.05rem 0.3rem;
border-radius: 0.3rem;
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.md :global(a) {
text-decoration: underline;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
border-top: 1px solid var(--site-border);
padding-top: 0.55rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
border: 1px dashed var(--site-border);
border-radius: 999px;
font-size: 0.75rem;
color: var(--site-muted);
transition:
color 0.15s,
border-color 0.15s;
}
.chip:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.chip .sicon {
width: 1.15rem;
height: 1.15rem;
border-radius: 999px;
}
.edit {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 10rem), 1fr));
gap: 0.5rem;
align-items: end;
padding: 0.7rem;
margin-top: 0.4rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-bg);
}
.entry .edit {
margin-left: -1.85rem;
}
.edit label,
.colors legend {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
.edit .wide {
grid-column: 1 / -1;
}
.edit input:not([type='radio']),
.edit select,
.edit textarea {
font: inherit;
font-size: 0.85rem;
text-transform: none;
letter-spacing: normal;
color: var(--site-text);
background: var(--site-surface);
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0.35rem 0.5rem;
min-width: 0;
}
.edit textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
}
.colors {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
border: 0;
padding: 0;
}
.colors legend {
margin-bottom: 0.25rem;
}
.color input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.color span {
display: block;
width: 1.4rem;
height: 1.4rem;
border-radius: 0.45rem;
background: linear-gradient(135deg, var(--from), var(--to));
outline: 2px solid transparent;
outline-offset: 2px;
cursor: pointer;
}
.color input:checked + span {
outline-color: var(--site-text);
}
.color input:focus-visible + span {
outline-color: #38bdf8;
}
.actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.hint {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: #ca8a04;
margin-top: 0.35rem;
}
.edit .hint {
margin-top: 0;
}
.error {
grid-column: 1 / -1;
color: #f87171;
font-size: 0.8rem;
}
.new {
padding: 0.2rem 0.85rem 0.85rem;
}
</style>