feat(web): refonte visuelle « Emerald » (thème clair/sombre, polices, tokens)

Adopte le langage visuel du design system Emerald dans l'IDE web :
neutres zinc + accent emerald, polices auto-hébergées Inter + JetBrains
Mono, radii/pills mono, points de statut, motif terminal, halo/grain.
Refonte purement visuelle et additive (layout, routing, stores, protocole
inchangés).

- style.css : défauts sombres dans @theme + override clair sous
  html[data-theme=light] ; accent scindé (bright #34d399 / solid #059669 /
  hover) ; tokens border-soft/info/warn/danger/coffee ; idiomes globaux
  (focus/selection accent, scrollbars fines, jl-pulse/jl-blink, classes
  .chip/.status/.eyebrow/.label-mono/.caret/.halo/.grain, .btn*/.badge pill).
- lib/theme.ts : singleton themeMode/resolvedTheme (dark/light/system,
  persistedRef arb.theme) + application dataset.theme/metas ; script
  anti-FOUC inline dans index.html ; toggle ActivityBar + Réglages.
- Thèmes Monaco + xterm dark/light réactifs (palette ANSI + coloration
  syntaxique) ; fonts.ready + remeasureFonts pour l'alignement des glyphes.
- status-tokens : tons sémantiques tokenisés ; balayage des ~340 couleurs
  Tailwind brutes vers les tokens sur tout components/* et views/*
  (exceptions assumées commentées).
- GroupSummary.color exploité : icône de groupe teintée + ColorSwatchPicker
  (lib/group-colors.ts) dans les modals de groupe.
- Polices via @fontsource-variable/{inter,jetbrains-mono} (offline).
This commit is contained in:
2026-07-20 21:30:57 +02:00
parent 008e976e01
commit 063f5e928b
59 changed files with 986 additions and 366 deletions

View File

@@ -4,21 +4,26 @@
<!-- Préférences (client) -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<SlidersHorizontal :size="16" /> {{ t('settings.preferences') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.preferencesHint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('settings.preferencesHint') }}</p>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-zinc-300">{{ t('settings.language') }}</span>
<span class="text-sm text-fg-muted">{{ t('settings.language') }}</span>
<LanguageSwitcher />
</div>
<div class="flex flex-col gap-2 border-t border-zinc-800/80 pt-3">
<div class="flex items-center justify-between gap-3 border-t border-border/80 pt-3">
<span class="text-sm text-fg-muted">{{ t('theme.label') }}</span>
<SegmentedControl :model-value="themeMode" :options="themeOptions" @update:model-value="onTheme" />
</div>
<div class="flex flex-col gap-2 border-t border-border/80 pt-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm text-zinc-300">{{ t('settings.notifications') }}</p>
<p class="text-xs text-zinc-500">
<p class="text-sm text-fg-muted">{{ t('settings.notifications') }}</p>
<p class="text-xs text-fg-subtle">
{{ push.supported ? (push.enabled ? t('settings.notificationsEnabled') : t('settings.notificationsDisabled')) : t('settings.pushUnsupported') }}
</p>
</div>
@@ -43,15 +48,15 @@
<!-- Accès & sécurité : tokens -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Shield :size="16" /> {{ t('settings.security') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.securityHint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('settings.securityHint') }}</p>
<!-- Création -->
<form class="flex flex-wrap items-end gap-2" @submit.prevent="createToken">
<label class="flex min-w-40 flex-1 flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.newTokenLabel') }}</span>
<span class="text-xs text-fg-muted">{{ t('settings.newTokenLabel') }}</span>
<input v-model="newLabel" class="input" :placeholder="t('settings.newTokenPlaceholder')" maxlength="64" />
</label>
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="!newLabel.trim()">
@@ -60,10 +65,10 @@
</form>
<!-- Valeur en clair (une seule fois) -->
<div v-if="createdToken" class="card-inset flex flex-col gap-2 border-emerald-800/60 bg-emerald-950/20">
<p class="text-xs text-emerald-300">{{ t('settings.copyTokenHint') }}</p>
<div v-if="createdToken" class="card-inset flex flex-col gap-2 border-accent/60 bg-accent/15">
<p class="text-xs text-accent">{{ t('settings.copyTokenHint') }}</p>
<div class="flex items-center gap-2">
<code class="min-w-0 flex-1 truncate rounded bg-zinc-950 px-2 py-1 font-mono text-xs text-zinc-100">{{ createdToken.token }}</code>
<code class="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-xs text-fg">{{ createdToken.token }}</code>
<BaseButton size="sm" :icon="copiedNew ? Check : Copy" @click="copy(createdToken.token, 'new')">
{{ copiedNew ? t('settings.copied') : t('settings.copy') }}
</BaseButton>
@@ -78,13 +83,13 @@
:key="tok.id"
class="card-inset flex flex-wrap items-center gap-x-3 gap-y-1"
>
<KeyRound :size="15" class="shrink-0 text-zinc-500" />
<KeyRound :size="15" class="shrink-0 text-fg-subtle" />
<div class="min-w-0 flex-1">
<p class="flex items-center gap-2 truncate text-sm text-zinc-200">
<p class="flex items-center gap-2 truncate text-sm text-fg">
{{ tok.label }}
<span v-if="tok.current" class="badge bg-emerald-500/15 text-emerald-300">{{ t('settings.current') }}</span>
<span v-if="tok.current" class="badge bg-accent/15 text-accent">{{ t('settings.current') }}</span>
</p>
<p class="text-[11px] text-zinc-500">
<p class="text-[11px] text-fg-subtle">
{{ t('settings.created', { date: fmt(tok.createdAt) }) }} ·
{{ tok.lastUsedAt ? t('settings.lastUsed', { date: fmt(tok.lastUsedAt) }) : t('settings.neverUsed') }}
</p>
@@ -104,17 +109,17 @@
<!-- Découverte des dépôts -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<ScanSearch :size="16" /> {{ t('settings.discovery') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.discoveryHint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('settings.discoveryHint') }}</p>
<div class="flex flex-col gap-2">
<span class="text-xs text-zinc-400">{{ t('settings.scanRoots') }}</span>
<p v-if="rootsDraft.length === 0" class="text-xs text-zinc-600">{{ t('settings.scanRootsEmpty') }}</p>
<span class="text-xs text-fg-muted">{{ t('settings.scanRoots') }}</span>
<p v-if="rootsDraft.length === 0" class="text-xs text-fg-subtle">{{ t('settings.scanRootsEmpty') }}</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="(root, i) in rootsDraft" :key="root" class="card-inset flex items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono text-xs text-zinc-200" :title="root">{{ root }}</span>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-fg" :title="root">{{ root }}</span>
<BaseButton size="sm" variant="ghost" icon-only :icon="X" :aria-label="t('settings.removeRoot')" @click="rootsDraft.splice(i, 1)" />
</li>
</ul>
@@ -125,9 +130,9 @@
</div>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.scanInterval') }}</span>
<span class="text-xs text-fg-muted">{{ t('settings.scanInterval') }}</span>
<input v-model.number="intervalDraft" type="number" min="0" max="1440" class="input w-32" />
<span class="text-xs text-zinc-500">{{ t('settings.scanIntervalHint') }}</span>
<span class="text-xs text-fg-subtle">{{ t('settings.scanIntervalHint') }}</span>
</label>
<div>
@@ -139,33 +144,33 @@
<!-- Claude CLI -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Terminal :size="16" /> {{ t('settings.claudeCli') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.claudeCliHint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('settings.claudeCliHint') }}</p>
<!-- Diagnostic de détection -->
<div v-if="settings.server" class="card-inset flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-emerald-400' : 'text-rose-400'" />
<span :class="claudeBin.ok ? 'text-zinc-300' : 'text-rose-300'">
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-accent' : 'text-danger'" />
<span :class="claudeBin.ok ? 'text-fg-muted' : 'text-danger'">
{{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }}
</span>
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-zinc-200" :title="claudeBin.path">{{ claudeBin.path }}</code>
<span v-if="claudeBin.source" class="badge bg-zinc-700/40 text-zinc-300">
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-fg" :title="claudeBin.path">{{ claudeBin.path }}</code>
<span v-if="claudeBin.source" class="badge bg-surface-3/40 text-fg-muted">
{{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }}
</span>
</div>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.claudeBinPathLabel') }}</span>
<span class="text-xs text-fg-muted">{{ t('settings.claudeBinPathLabel') }}</span>
<input v-model.trim="claudeBinDraft" class="input font-mono" :placeholder="t('settings.claudeBinPathPlaceholder')" />
<span class="text-xs text-zinc-500">{{ t('settings.claudeBinPathHint') }}</span>
<span class="text-xs text-fg-subtle">{{ t('settings.claudeBinPathHint') }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.claudeHomeLabel') }}</span>
<span class="text-xs text-fg-muted">{{ t('settings.claudeHomeLabel') }}</span>
<input v-model.trim="claudeHomeDraft" class="input font-mono" :placeholder="t('settings.claudeHomePlaceholder')" />
<span class="text-xs text-zinc-500">{{ t('settings.claudeHomeHint') }}</span>
<span class="text-xs text-fg-subtle">{{ t('settings.claudeHomeHint') }}</span>
</label>
<div>
@@ -177,15 +182,15 @@
<!-- Sessions (rétention / archivage auto) -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Archive :size="16" /> {{ t('settings.sessions') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.sessionsHint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('settings.sessionsHint') }}</p>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.retentionDays') }}</span>
<span class="text-xs text-fg-muted">{{ t('settings.retentionDays') }}</span>
<input v-model.number="retentionDraft" type="number" min="0" max="3650" class="input w-32" />
<span class="text-xs text-zinc-500">{{ t('settings.retentionDaysHint') }}</span>
<span class="text-xs text-fg-subtle">{{ t('settings.retentionDaysHint') }}</span>
</label>
<div>
@@ -200,31 +205,31 @@
<!-- Sécurité & conformité -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Shield :size="16" /> {{ t('settings.compliance') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.complianceHint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('settings.complianceHint') }}</p>
<div class="flex flex-wrap gap-2">
<BaseButton size="sm" :icon="Download" :loading="exporting" @click="exportData">{{ t('settings.exportData') }}</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="auditing" @click="loadAudit">{{ t('settings.refreshAudit') }}</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="Trash2" @click="deleteMyData">{{ t('settings.deleteData') }}</BaseButton>
</div>
<ul v-if="audit.length" class="flex flex-col divide-y divide-zinc-800/80 text-xs">
<ul v-if="audit.length" class="flex flex-col divide-y divide-border/80 text-xs">
<li v-for="e in audit" :key="e.id" class="flex items-center justify-between gap-3 py-1.5">
<span class="font-mono text-zinc-300">{{ e.action }}</span>
<span class="truncate text-zinc-500">{{ e.actor }}</span>
<span class="shrink-0 text-zinc-600">{{ new Date(e.ts).toLocaleString() }}</span>
<span class="font-mono text-fg-muted">{{ e.action }}</span>
<span class="truncate text-fg-subtle">{{ e.actor }}</span>
<span class="shrink-0 text-fg-subtle">{{ new Date(e.ts).toLocaleString() }}</span>
</li>
</ul>
</section>
<!-- Serveur (lecture seule) -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Server :size="16" /> {{ t('settings.server') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.serverHint') }}</p>
<dl v-if="settings.server" class="flex flex-col divide-y divide-zinc-800/80">
<p class="text-xs text-fg-subtle">{{ t('settings.serverHint') }}</p>
<dl v-if="settings.server" class="flex flex-col divide-y divide-border/80">
<ServerRow :label="t('settings.version')" :value="settings.server.version" />
<ServerRow :label="t('settings.port')" :value="String(settings.server.port)" flag="--port" />
<ServerRow :label="t('settings.bind')" :value="settings.server.bind" flag="--bind" />
@@ -242,17 +247,12 @@
<!-- Support -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Coffee :size="16" /> {{ t('settings.support') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.supportHint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('settings.supportHint') }}</p>
<div>
<a
:href="BUYMEACOFFEE_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<a :href="BUYMEACOFFEE_URL" target="_blank" rel="noopener noreferrer" class="btn-coffee">
<Coffee :size="16" /> {{ t('settings.supportCta') }}
</a>
</div>
@@ -263,7 +263,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { AlertCircle, Archive, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Terminal, Trash2, X } from '@lucide/vue';
import { AlertCircle, Archive, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Monitor, Moon, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Sun, Terminal, Trash2, X } from '@lucide/vue';
import type {
AuditLogEntry,
AuditLogsResponse,
@@ -281,6 +281,8 @@ import { useToastsStore } from '../stores/toasts';
import PageHeader from '../components/layout/PageHeader.vue';
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import SegmentedControl from '../components/ui/SegmentedControl.vue';
import { themeMode, setThemeMode, type ThemeMode } from '../lib/theme';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ServerRow from '../components/settings/ServerRow.vue';
import GitConnectionsSection from '../components/settings/GitConnectionsSection.vue';
@@ -292,6 +294,15 @@ const push = usePushStore();
const toasts = useToastsStore();
// ---- Préférences ----
const themeOptions = computed(() => [
{ value: 'dark', label: t('theme.dark'), icon: Moon },
{ value: 'light', label: t('theme.light'), icon: Sun },
{ value: 'system', label: t('theme.system'), icon: Monitor },
]);
function onTheme(v: string): void {
setThemeMode(v as ThemeMode);
}
const testing = ref(false);
async function sendTest(): Promise<void> {
testing.value = true;