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
+12 -11
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { ChatSession } from '../../lib/chat-session.js';
import { t } from '../../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
export class ChatPage extends ChatSession {
@@ -105,17 +106,17 @@ export class ChatPage extends ChatSession {
<div class="mobile-section-header">
<span class="mobile-section-title">
${this._inProject ? html`
<button class="chat-page-back" title="Back to General"
<button class="chat-page-back" title=${t('chat.mobile.back_general')}
@click=${() => this._exitProject()}>
<i class="bi bi-chevron-left"></i>
</button>
<i class="bi bi-folder2-open"></i> ${this.label || 'Project'}
` : html`<i class="bi bi-chat-dots-fill"></i> Chat`}
<i class="bi bi-folder2-open"></i> ${this.label || t('chat.mobile.project')}
` : html`<i class="bi bi-chat-dots-fill"></i> ${t('chat.mobile.chat')}`}
</span>
<div class="chat-page-header-actions">
<button
class="btn btn-sm btn-outline-secondary"
title="New conversation"
title=${t('chat.new_session')}
@click=${() => this._startNewSession()}
><i class="bi bi-trash"></i></button>
</div>
@@ -125,14 +126,14 @@ export class ChatPage extends ChatSession {
${this._messages.length === 0 ? html`
<div class="chat-page-empty">
<i class="bi bi-stars"></i>
<p>Ask me anything</p>
<p>${t('chat.mobile.ask')}</p>
</div>
` : this._messages.map(m => renderMsg(this, m))}
${this._waiting ? html`
<div class="copilot-msg assistant copilot-thinking">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Thinking…
${t('chat.thinking')}
</div>
` : nothing}
</div>
@@ -152,7 +153,7 @@ export class ChatPage extends ChatSession {
<textarea
class="chat-page-textarea"
rows="1"
placeholder="Type a message…"
placeholder=${t('chat.mobile.placeholder')}
@input=${(e) => this._autoResize(e.target)}
@paste=${(e) => this._onPaste(e)}
></textarea>
@@ -160,7 +161,7 @@ export class ChatPage extends ChatSession {
<div class="chat-page-toolbar-left">
<button
class="btn btn-sm btn-outline-secondary chat-page-attach-btn"
title="Attach files"
title=${t('chat.attach')}
@click=${() => this.querySelector('.chat-page-file-input')?.click()}
><i class="bi bi-paperclip"></i></button>
${this._providers.length > 1 ? html`
@@ -179,7 +180,7 @@ export class ChatPage extends ChatSession {
${this._hasTranscribe ? html`
<button
class="chat-page-mic-btn ${this._recording ? 'chat-page-mic-btn--recording' : ''}"
title="${this._recording ? 'Stop recording' : 'Record voice'}"
title=${this._recording ? t('chat.mobile.stop_record') : t('chat.mobile.record_voice')}
@click=${() => this._toggleRecording()}
>
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
@@ -189,13 +190,13 @@ export class ChatPage extends ChatSession {
? html`<button
class="chat-page-send chat-page-send--stop"
@click=${() => this._cancel()}
title="Stop"
title=${t('chat.stop')}
><i class="bi bi-stop-fill"></i></button>`
: nothing}
<button
class="chat-page-send"
@click=${() => this._send()}
title="Send"
title=${t('chat.send')}
><i class="bi bi-send-fill"></i></button>
</div>
</div>
+19 -6
View File
@@ -1,3 +1,5 @@
import { t } from '../../lib/i18n.js';
// Shared vocabulary for the Connectors list and a connector's own page.
//
// Both surfaces have to answer "what state is this connector in?" and both draw the
@@ -13,14 +15,25 @@ export function connectorIconUrl(name, size = 'sm') {
/// How each status reads on a chip. `tone` maps to the `connector-chip--*` accents
/// in `web/css/connectors.css`.
export const STATUS_LABEL = {
active: { text: 'active', tone: 'ok' },
pending: { text: 'needs fix', tone: 'script' },
needs_login: { text: 'needs sign-in', tone: 'script' },
enabled: { text: 'enabled', tone: 'scope' },
off: { text: 'off', tone: '' },
available: { text: 'available', tone: '' },
active: { tone: 'ok' },
pending: { tone: 'script' },
needs_login: { tone: 'script' },
enabled: { tone: 'scope' },
off: { tone: '' },
available: { tone: '' },
};
export function statusText(status) {
return {
active: t('connectors.status.active'),
pending: t('connectors.status.needs_fix'),
needs_login: t('connectors.status.needs_signin'),
enabled: t('connectors.status.enabled'),
off: t('connectors.status.off'),
available: t('connectors.status.available'),
}[status] ?? status;
}
/// The one place that decides what a connector's state *is*, from whichever runtime
/// rows exist for it.
///
+4 -3
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../../lib/base.js';
import { fileWatcher } from '../../lib/file-watcher.js';
import { t } from '../../lib/i18n.js';
/**
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
@@ -346,7 +347,7 @@ export class FileViewerBase extends LightElement {
const showingSource = this._htmlMode === 'source';
return html`<button
class=${btnClass}
title=${showingSource ? 'Show preview' : 'Show source'}
title=${showingSource ? t('fv.mode_preview') : t('fv.mode_source')}
@click=${() => this._toggleHtmlMode()}>
<i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i>
</button>`;
@@ -389,7 +390,7 @@ export class FileViewerBase extends LightElement {
if (this._kind === 'binary') {
return html`<div class="fv-state text-muted">
<i class="bi bi-file-earmark-binary fs-3 d-block mb-2"></i>
Preview not available for this file type.
${t('fv.binary_unavailable')}
</div>`;
}
if (this._kind === 'html') {
@@ -417,7 +418,7 @@ export class FileViewerBase extends LightElement {
return html`
${this._compileError
? html`<details class="fv-compile-error">
<summary><i class="bi bi-exclamation-triangle text-warning"></i>&nbsp;LaTeX compilation failed — showing source instead</summary>
<summary><i class="bi bi-exclamation-triangle text-warning"></i>&nbsp;${t('fv.latex_failed')}</summary>
<pre>${this._compileError}</pre>
</details>`
: nothing}
+3 -2
View File
@@ -1,4 +1,5 @@
import { html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
import { FileViewerBase } from './file-viewer-base.js';
/**
@@ -43,14 +44,14 @@ export class MobileFileViewerPage extends FileViewerBase {
<div class="mobile-file-viewer">
<div class="mobile-section-header">
<span class="mobile-section-title">
<button class="chat-page-back" title="Back" @click=${() => this._back()}>
<button class="chat-page-back" title=${t('fv.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i>
</button>
<span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span>
</span>
<span class="fv-header-actions">
${this._renderModeToggle('chat-page-back')}
<button class="chat-page-back" title="Download" @click=${() => this._download()}>
<button class="chat-page-back" title=${t('fv.download')} @click=${() => this._download()}>
<i class="bi bi-download"></i>
</button>
</span>