Major rebranding, i18n, dashboard, shared folders, role capabilities

- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette
  (terracotta accent, --radius tokens, WCAG contrast, reduced-motion),
  updated favicon, tray icon, skaldkonur asset
- i18n: backend crate (i18n.rs, locale column, ui_locale config),
  frontend library (web/lib/i18n.js, I18nMixin, t(key)),
  translation files (web/i18n/), every component wired
- Dashboard: <dashboard-page> replaces old home-page content;
  <app-copilot> becomes the landing page (full/dock layout modes)
- Shared folders: API endpoints (shared_folders.rs), frontend page,
  can_write membership, container mount topology, user_fs routing
- Role capabilities: new db table & authorization seam (data not enums),
  roles.attrs JSON for ui_mode / interface select
- Setup: skald-setup prompts for language + password, sets ui_locale
- General: components migrated to CSS variables, Lit conventions cleanup,
  connectors/catalog/marketplace/approval refactoring
This commit is contained in:
2026-07-18 21:38:42 +01:00
parent 2b35312abd
commit 126886e309
109 changed files with 6228 additions and 1390 deletions
+120 -98
View File
@@ -1,40 +1,29 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const DEFAULT_PRIORITY = 999999;
const ACTIONS = ['require', 'allow', 'deny'];
const ACTION_STYLE = {
require: { icon: 'bi-person-check', label: 'Require', bg: 'rgba(234,179,8,0.12)', color: '#a16207' },
allow: { icon: 'bi-check-circle', label: 'Allow', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' },
deny: { icon: 'bi-slash-circle', label: 'Deny', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' },
require: { icon: 'bi-person-check', bg: 'rgba(234,179,8,0.12)', color: '#a16207' },
allow: { icon: 'bi-check-circle', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' },
deny: { icon: 'bi-slash-circle', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' },
};
const CATEGORY_LABELS = {
filesystem: 'File System',
shell: 'Shell',
subagent: 'Agents',
introspection: 'Introspection',
config: 'Config',
// Tools injected dynamically outside the ToolRegistry (interface/plugin/
// provider tools), surfaced via runtime discovery — see docs/approval.
dynamic: 'Dynamic',
};
const CATEGORY_ORDER = [
'File System', 'Shell', 'Agents', 'Introspection', 'Config', 'Dynamic',
];
const CATEGORY_ORDER = ['filesystem', 'shell', 'subagent', 'introspection', 'config', 'dynamic'];
// File System permission model. Each path row maps to exactly one approval rule via a
// synthetic `@fs_*` tool_pattern token (understood by the backend matcher). A single
// selector collapses the (access-class × action) axes into the mental model from the
// mockup: Allow read / Allow write / Deny / Require.
const FS_ACCESS = {
allow_read: { tool_pattern: '@fs_read', action: 'allow', label: 'Allow read' },
allow_write: { tool_pattern: '@fs_any', action: 'allow', label: 'Allow write' },
deny: { tool_pattern: '@fs_any', action: 'deny', label: 'Deny' },
require: { tool_pattern: '@fs_any', action: 'require', label: 'Require' },
allow_read: { tool_pattern: '@fs_read', action: 'allow' },
allow_write: { tool_pattern: '@fs_any', action: 'allow' },
deny: { tool_pattern: '@fs_any', action: 'deny' },
require: { tool_pattern: '@fs_any', action: 'require' },
};
// Priority band for the settable "Default" row (below specific fs path rules, above the
// global `*` catch-all at 999999).
@@ -91,6 +80,8 @@ export class ApprovalRulesPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
if (e.detail.page !== 'approval') {
this._open = false;
@@ -118,6 +109,11 @@ export class ApprovalRulesPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
this._error = null;
try {
@@ -125,8 +121,8 @@ export class ApprovalRulesPage extends LightElement {
fetch('/api/approval/rules'),
fetch('/api/approval/tools'),
]);
if (!rulesRes.ok) throw new Error(`Rules: HTTP ${rulesRes.status}`);
if (!toolsRes.ok) throw new Error(`Tools: HTTP ${toolsRes.status}`);
if (!rulesRes.ok) throw new Error(`HTTP ${rulesRes.status}`);
if (!toolsRes.ok) throw new Error(`HTTP ${toolsRes.status}`);
this._rules = await rulesRes.json();
this._tools = await toolsRes.json();
} catch (e) {
@@ -331,7 +327,7 @@ export class ApprovalRulesPage extends LightElement {
async _addFsRule() {
const clean = this._normalizeFsPath(this._fsNewPath);
if (!clean) { this._error = 'Enter a directory path.'; return; }
if (!clean) { this._error = t('approval.error.enter_path'); return; }
const access = FS_ACCESS[this._fsNewAccess] ?? FS_ACCESS.allow_read;
this._fsSaving = new Set([...this._fsSaving, 'new']);
this._error = null;
@@ -386,7 +382,7 @@ export class ApprovalRulesPage extends LightElement {
}
async _deleteFsRule(rule) {
if (!confirm(`Remove File System rule for "${this._fsDisplayPath(rule)}"?`)) return;
if (!confirm(t('approval.confirm.delete_fs', { path: this._fsDisplayPath(rule) }))) return;
this._fsSaving = new Set([...this._fsSaving, rule.id]);
this._error = null;
try {
@@ -443,15 +439,25 @@ export class ApprovalRulesPage extends LightElement {
// ── Tool grouping ─────────────────────────────────────────────────────────────
_catLabel(key) {
return {
filesystem: t('approval.category.filesystem'),
shell: t('approval.category.shell'),
subagent: t('approval.category.subagent'),
introspection: t('approval.category.introspection'),
config: t('approval.category.config'),
dynamic: t('approval.category.dynamic'),
}[key] ?? key;
}
_groupedTools() {
if (!this._tools) return [];
const map = new Map();
const metaMap = new Map(); // category key → { description }
const metaMap = new Map();
for (const t of this._tools.built_in) {
// Filesystem tools are gated by path in the File System panel, not per-tool here.
if (t.category === 'filesystem') continue;
const cat = t.category ? (CATEGORY_LABELS[t.category] ?? t.category) : 'Other';
const cat = t.category || 'other';
if (!map.has(cat)) map.set(cat, []);
map.get(cat).push(t);
}
@@ -459,7 +465,7 @@ export class ApprovalRulesPage extends LightElement {
for (const t of this._tools.mcp) {
const serverId = t.server ?? t.name;
const meta = servers[serverId] ?? {};
const key = `MCP · ${meta.friendly_name ?? serverId}`;
const key = `mcp:${serverId}`;
if (!map.has(key)) {
map.set(key, []);
if (meta.description) metaMap.set(key, meta.description);
@@ -472,9 +478,9 @@ export class ApprovalRulesPage extends LightElement {
if (map.has(cat)) result.push([cat, map.get(cat), null]);
}
for (const [key, tools] of map.entries()) {
if (!CATEGORY_ORDER.includes(key) && key !== 'Other') result.push([key, tools, metaMap.get(key) ?? null]);
if (!CATEGORY_ORDER.includes(key) && key !== 'other') result.push([key, tools, metaMap.get(key) ?? null]);
}
if (map.has('Other')) result.push(['Other', map.get('Other'), null]);
if (map.has('other')) result.push(['other', map.get('other'), null]);
return result;
}
@@ -513,14 +519,14 @@ export class ApprovalRulesPage extends LightElement {
_selectTool(name) { this._form = { ...this._form, tool_pattern: name }; }
async _save() {
if (!this._form.tool_pattern.trim()) { this._error = 'Tool pattern is required.'; return; }
if (!this._form.tool_pattern.trim()) { this._error = t('approval.error.tool_required'); return; }
const p = Number(this._form.priority);
if (this._formMode === 'override' && p >= 0) {
this._error = 'Override rules must have priority < 0.'; return;
this._error = t('approval.error.override_prio'); return;
}
if (this._formMode === 'lowprio' && (p <= 0 || p >= DEFAULT_PRIORITY)) {
this._error = `Low priority rules must have priority between 1 and ${DEFAULT_PRIORITY - 1}.`; return;
this._error = t('approval.error.lowprio_range', { max: DEFAULT_PRIORITY - 1 }); return;
}
this._saving = true;
@@ -555,7 +561,7 @@ export class ApprovalRulesPage extends LightElement {
}
async _delete(rule) {
if (!confirm(`Delete rule for "${rule.tool_pattern}"?`)) return;
if (!confirm(t('approval.confirm.delete_rule', { pattern: rule.tool_pattern }))) return;
try {
const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -582,8 +588,8 @@ export class ApprovalRulesPage extends LightElement {
const current = this._form.tool_pattern;
const allTools = [
{ name: '*', description: 'Any tool', source: 'glob', server: null },
{ name: 'mcp__*', description: 'Any MCP tool', source: 'glob', server: null },
{ name: '*', description: t('approval.tool.any'), source: 'glob', server: null },
{ name: 'mcp__*', description: t('approval.tool.any_mcp'), source: 'glob', server: null },
...this._tools.built_in,
...this._tools.mcp,
];
@@ -596,17 +602,21 @@ export class ApprovalRulesPage extends LightElement {
);
const groups = {};
for (const t of filtered) {
const key = t.source === 'mcp' ? `MCP · ${t.server}` : t.source === 'built-in' ? 'Built-in' : 'Glob';
for (const tool of filtered) {
const key = tool.source === 'mcp'
? t('approval.tool.group_mcp', { server: tool.server })
: tool.source === 'built-in'
? t('approval.tool.group_builtin')
: t('approval.tool.group_glob');
if (!groups[key]) groups[key] = [];
groups[key].push(t);
groups[key].push(tool);
}
return html`
<div class="apr-tool-picker">
<input
class="form-control form-control-sm mb-2"
placeholder="Search tools…"
placeholder=${t('approval.tool.search')}
.value=${this._toolFilter}
@input=${(e) => { this._toolFilter = e.target.value; }}
/>
@@ -624,7 +634,7 @@ export class ApprovalRulesPage extends LightElement {
</button>
`)}
`)}
${filtered.length === 0 ? html`<div class="text-muted p-2">No results</div>` : nothing}
${filtered.length === 0 ? html`<div class="text-muted p-2">${t('approval.tool.no_results')}</div>` : nothing}
</div>
</div>
`;
@@ -640,8 +650,8 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-form-header">
<i class="bi ${isOverride ? 'bi-exclamation-triangle' : 'bi-arrow-down-circle'}"></i>
<span>${this._editingId === 'new'
? (isOverride ? 'New override rule' : 'New low priority rule')
: 'Edit rule'}</span>
? (isOverride ? t('approval.form.new_override') : t('approval.form.new_lowprio'))
: t('approval.form.edit')}</span>
<button class="apr-form-close" @click=${() => this._cancelEdit()}>
<i class="bi bi-x"></i>
</button>
@@ -649,31 +659,31 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-form-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Tool pattern <span class="text-danger">*</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.tool_pattern')} <span class="text-danger">*</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="e.g. mcp__whatsapp__* or execute_cmd"
placeholder=${t('approval.form.tool_pattern_ph')}
.value=${f.tool_pattern}
@input=${(e) => this._patch('tool_pattern', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">Use <code>*</code> as a trailing wildcard, e.g. <code>mcp__whatsapp__*</code></div>
<div class="form-text" style="font-size:0.75rem">${unsafeHTML(t('approval.form.tool_pattern_hint'))}</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Select tool</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.select_tool')}</label>
${this._renderToolPicker()}
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Path pattern <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.path_pattern')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="e.g. data/* or data/notes/*"
placeholder=${t('approval.form.path_pattern_ph')}
.value=${f.path_pattern}
@input=${(e) => this._patch('path_pattern', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">Filter by file path. Use <code>*</code> as a wildcard.</div>
<div class="form-text" style="font-size:0.75rem">${unsafeHTML(t('approval.form.path_pattern_hint'))}</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Action</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.action')}</label>
<select
class="form-select form-select-sm"
.value=${f.action}
@@ -683,7 +693,7 @@ export class ApprovalRulesPage extends LightElement {
</select>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.priority')}</label>
<input
type="number"
class="form-control form-control-sm"
@@ -692,47 +702,47 @@ export class ApprovalRulesPage extends LightElement {
/>
<div class="form-text" style="font-size:0.75rem">
${isOverride
? html`Must be <strong>&lt; 0</strong> (e.g. 10)`
: html`Must be <strong>1 ${DEFAULT_PRIORITY - 1}</strong>`}
? unsafeHTML(t('approval.form.priority_override_hint'))
: unsafeHTML(t('approval.form.priority_lowprio_hint', { max: DEFAULT_PRIORITY - 1 }))}
</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Source <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.source')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<select
class="form-select form-select-sm"
@change=${(e) => this._patch('source', e.target.value)}
>
<option value="" ?selected=${!f.source}>Any</option>
<option value="" ?selected=${!f.source}>${t('approval.form.source_any')}</option>
${['web', 'telegram', 'cron'].map(s => html`
<option value=${s} ?selected=${f.source === s}>${s}</option>
`)}
</select>
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Agent ID <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.agent_id')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="main (empty = any)"
placeholder=${t('approval.form.agent_id_ph')}
.value=${f.agent_id}
@input=${(e) => this._patch('agent_id', e.target.value)}
/>
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Note <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.note')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input
class="form-control form-control-sm"
placeholder="Short description…"
placeholder=${t('approval.form.note_ph')}
.value=${f.note}
@input=${(e) => this._patch('note', e.target.value)}
/>
</div>
</div>
<div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelEdit()}>Cancel</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelEdit()}>${t('approval.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()} ?disabled=${this._saving}>
${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
: html`<i class="bi bi-check-lg me-1"></i>Save`}
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('approval.form.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${t('approval.form.save')}`}
</button>
</div>
</div>
@@ -750,18 +760,18 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-card-row1">
<span class="apr-action-badge">
<i class="bi ${s.icon}"></i>
${s.label}
${{ require: t('approval.action.require'), allow: t('approval.action.allow'), deny: t('approval.action.deny') }[rule.action] ?? rule.action}
</span>
<code class="apr-pattern">${rule.tool_pattern}</code>
<span class="apr-priority-badge" title="Priority">
<span class="apr-priority-badge" title=${t('approval.card.priority')}>
<i class="bi bi-list-ol"></i>
${rule.priority}
</span>
<div class="apr-card-actions">
<button class="apr-btn-icon apr-btn-edit" title="Edit" @click=${() => this._startEdit(rule)}>
<button class="apr-btn-icon apr-btn-edit" title=${t('approval.card.edit')} @click=${() => this._startEdit(rule)}>
<i class="bi bi-pencil"></i>
</button>
<button class="apr-btn-icon apr-btn-delete" title="Delete" @click=${() => this._delete(rule)}>
<button class="apr-btn-icon apr-btn-delete" title=${t('approval.card.delete')} @click=${() => this._delete(rule)}>
<i class="bi bi-trash"></i>
</button>
</div>
@@ -784,10 +794,10 @@ export class ApprovalRulesPage extends LightElement {
_renderChipGroup(currentAction, onChange) {
const chips = [
{ action: null, label: '—' },
{ action: 'allow', label: 'Allow' },
{ action: 'require', label: 'Req' },
{ action: 'deny', label: 'Deny' },
{ action: null, label: t('approval.chip.unset') },
{ action: 'allow', label: t('approval.action.allow') },
{ action: 'require', label: t('approval.chip.req') },
{ action: 'deny', label: t('approval.action.deny') },
];
return html`
<div class="apr-chip-group">
@@ -827,11 +837,14 @@ export class ApprovalRulesPage extends LightElement {
const open = this._openSections.has(key);
const groupId = this._selectedGroup.id;
const configured = tools.filter(t => this._getSimpleRule(t.name, groupId) !== null).length;
const label = key.startsWith('mcp:')
? t('approval.tool.group_mcp', { server: key.slice(4) })
: this._catLabel(key);
return html`
<div class="apr-cat-section ${open ? 'apr-cat-section--open' : ''}">
<div class="apr-cat-header" @click=${() => this._toggleSection(key)}>
<i class="bi bi-chevron-${open ? 'down' : 'right'} apr-cat-chevron"></i>
<span class="apr-cat-name">${key}</span>
<span class="apr-cat-name">${label}</span>
${description ? html`<span class="apr-cat-desc">${description}</span>` : nothing}
<span class="apr-cat-count ${configured === 0 ? 'apr-cat-count--muted' : ''}">
${configured > 0 ? `${configured}/` : ''}${tools.length}
@@ -851,12 +864,12 @@ export class ApprovalRulesPage extends LightElement {
return html`
<div class="apr-matrix">
<div class="apr-matrix-header">
<span class="apr-matrix-title">Per-tool</span>
<span class="apr-matrix-subtitle">priority = 0 · exact tool name · no path/source filters</span>
<span class="apr-matrix-title">${t('approval.matrix.title')}</span>
<span class="apr-matrix-subtitle">${t('approval.matrix.subtitle')}</span>
</div>
<div class="apr-matrix-body">
${groups.length === 0
? html`<div class="text-muted p-4 text-center" style="font-size:0.85rem">Loading tools…</div>`
? html`<div class="text-muted p-4 text-center" style="font-size:0.85rem">${t('approval.matrix.loading')}</div>`
: groups.map(([key, tools, desc]) => this._renderCategorySection(key, tools, desc))}
</div>
</div>
@@ -865,6 +878,15 @@ export class ApprovalRulesPage extends LightElement {
// ── File System panel ─────────────────────────────────────────────────────────
_fsAccessLabel(key) {
return {
allow_read: t('approval.fs.allow_read'),
allow_write: t('approval.fs.allow_write'),
deny: t('approval.fs.deny'),
require: t('approval.fs.require'),
}[key] ?? key;
}
_renderFsAccessSelect(value, onChange, allowUnset) {
return html`
<select
@@ -872,10 +894,10 @@ export class ApprovalRulesPage extends LightElement {
@change=${(e) => onChange(e.target.value || null)}
>
${allowUnset
? html`<option value="" ?selected=${!value}>Require (system default)</option>`
? html`<option value="" ?selected=${!value}>${t('approval.fs.default')}</option>`
: nothing}
${Object.entries(FS_ACCESS).map(([k, v]) => html`
<option value=${k} ?selected=${value === k}>${v.label}</option>
${Object.entries(FS_ACCESS).map(([k]) => html`
<option value=${k} ?selected=${value === k}>${this._fsAccessLabel(k)}</option>
`)}
</select>
`;
@@ -892,7 +914,7 @@ export class ApprovalRulesPage extends LightElement {
? html`<span class="spinner-border spinner-border-sm ms-auto" style="flex-shrink:0"></span>`
: html`
${this._renderFsAccessSelect(value, (v) => v && this._setFsAccess(rule, v), false)}
<button class="apr-btn-icon apr-btn-delete" title="Remove" @click=${() => this._deleteFsRule(rule)}>
<button class="apr-btn-icon apr-btn-delete" title=${t('approval.card.remove')} @click=${() => this._deleteFsRule(rule)}>
<i class="bi bi-trash"></i>
</button>
`}
@@ -907,7 +929,7 @@ export class ApprovalRulesPage extends LightElement {
<i class="bi bi-plus-circle apr-fs-row-icon"></i>
<input
class="form-control form-control-sm font-monospace apr-fs-path-input"
placeholder="Add directory path, e.g. docs"
placeholder=${t('approval.fs.add_ph')}
.value=${this._fsNewPath}
@input=${(e) => { this._fsNewPath = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') this._addFsRule(); }}
@@ -916,8 +938,8 @@ export class ApprovalRulesPage extends LightElement {
class="form-select form-select-sm apr-fs-select"
@change=${(e) => { this._fsNewAccess = e.target.value; }}
>
${Object.entries(FS_ACCESS).map(([k, v]) => html`
<option value=${k} ?selected=${this._fsNewAccess === k}>${v.label}</option>
${Object.entries(FS_ACCESS).map(([k]) => html`
<option value=${k} ?selected=${this._fsNewAccess === k}>${this._fsAccessLabel(k)}</option>
`)}
</select>
<button class="btn btn-sm btn-primary apr-fs-add-btn" @click=${() => this._addFsRule()} ?disabled=${saving}>
@@ -940,19 +962,19 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-side-panel-header" @click=${() => { this._fsOpen = !this._fsOpen; }}>
<i class="bi bi-chevron-${isOpen ? 'down' : 'right'} apr-cat-chevron"></i>
<i class="bi bi-hdd-stack apr-panel-icon"></i>
<span class="apr-panel-title">File System</span>
<span class="apr-panel-subtitle">path-scoped read / write access</span>
<span class="apr-panel-title">${t('approval.fs.title')}</span>
<span class="apr-panel-subtitle">${t('approval.fs.subtitle')}</span>
${rules.length > 0 ? html`<span class="apr-count-badge">${rules.length}</span>` : nothing}
</div>
${isOpen ? html`
<div class="apr-side-panel-body">
${rules.length === 0
? html`<div class="apr-panel-empty">No path rules yet — add one below.</div>`
? html`<div class="apr-panel-empty">${t('approval.fs.empty')}</div>`
: rules.map(r => this._renderFsRow(r))}
${this._renderFsAddRow()}
<div class="apr-fs-row apr-fs-default">
<i class="bi bi-skip-end-fill apr-fs-row-icon"></i>
<span class="apr-fs-path apr-fs-default-label">Default <span class="apr-default-hint">unmatched paths</span></span>
<span class="apr-fs-path apr-fs-default-label">${t('approval.fs.default_label')} <span class="apr-default-hint">${t('approval.fs.default_hint')}</span></span>
${this._renderFsAccessSelect(defValue, (v) => this._setFsDefault(v), true)}
</div>
</div>
@@ -976,13 +998,13 @@ export class ApprovalRulesPage extends LightElement {
<button
class="btn btn-sm btn-outline-secondary apr-panel-add-btn"
@click=${(e) => { e.stopPropagation(); onAdd(); }}
><i class="bi bi-plus-lg me-1"></i>Add</button>
><i class="bi bi-plus-lg me-1"></i>${t('approval.sidebar.add')}</button>
</div>
${isOpen ? html`
<div class="apr-side-panel-body">
${formActive ? this._renderForm() : nothing}
${rules.length === 0 && !formActive
? html`<div class="apr-panel-empty">No rules yet.</div>`
? html`<div class="apr-panel-empty">${t('approval.sidebar.empty')}</div>`
: rules.map(r => this._renderCard(r))}
</div>
` : nothing}
@@ -998,12 +1020,12 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-default-bar">
<div class="apr-default-label">
<i class="bi bi-skip-end-fill me-1"></i>
<strong>Default action</strong>
<span class="apr-default-hint">if no rule matches</span>
<strong>${t('approval.default_bar.title')}</strong>
<span class="apr-default-hint">${t('approval.default_bar.hint')}</span>
</div>
${this._renderChipGroup(action, (a) => this._setDefaultAction(a))}
${action === null
? html`<span class="apr-default-unset">system default: allow</span>`
? html`<span class="apr-default-unset">${t('approval.default_bar.unset')}</span>`
: nothing}
</div>
`;
@@ -1029,11 +1051,11 @@ export class ApprovalRulesPage extends LightElement {
<i class="bi bi-arrow-left"></i>
</button>
<h2 class="apr-title">
${isDefault ? html`<span class="apr-group-default-badge" style="vertical-align:middle">Default</span>` : nothing}
${isDefault ? html`<span class="apr-group-default-badge" style="vertical-align:middle">${t('approval.header.default_badge')}</span>` : nothing}
${group.name}
</h2>
<div class="apr-header-right">
<span class="apr-header-count">${totalRules} rule${totalRules === 1 ? '' : 's'}</span>
<span class="apr-header-count">${totalRules === 1 ? t('approval.header.rule_count', { n: totalRules }) : t('approval.header.rule_count_plural', { n: totalRules })}</span>
</div>
</div>
@@ -1044,9 +1066,9 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-rules-body">
${this._renderSidePanel(
'override',
'Overrides',
t('approval.sidebar.overrides'),
'bi-exclamation-triangle-fill',
'priority < 0 · evaluated first',
t('approval.sidebar.overrides_sub'),
overrides,
this._overrideOpen,
() => { this._overrideOpen = !this._overrideOpen; },
@@ -1059,9 +1081,9 @@ export class ApprovalRulesPage extends LightElement {
${this._renderSidePanel(
'lowprio',
'Low Priority',
t('approval.sidebar.lowprio'),
'bi-arrow-down-circle-fill',
'priority 1999998 · evaluated after per-tool',
t('approval.sidebar.lowprio_sub'),
lowPrio,
this._lowPrioOpen,
() => { this._lowPrioOpen = !this._lowPrioOpen; },