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,16 +4,22 @@
v-for="item in items"
:key="item.view"
type="button"
class="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors"
:class="isActive(item.view) ? 'bg-surface-3 text-fg' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
class="relative flex h-10 w-10 items-center justify-center rounded-[9px] transition-colors"
:class="isActive(item.view) ? 'bg-surface-2 text-accent' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
:title="item.label"
:aria-label="item.label"
@click="ide.toggleActivity(item.view)"
>
<span
v-if="isActive(item.view)"
class="absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-full bg-accent"
aria-hidden="true"
/>
<component :is="item.icon" :size="20" :stroke-width="1.75" />
<!-- Exception assumee : texte quasi-noir fixe (text-zinc-950), lisible sur bg-warn en clair ET sombre. -->
<span
v-if="item.badge"
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-warn px-1 text-[9px] font-semibold text-zinc-950"
>
{{ item.badge }}
</span>
@@ -26,7 +32,7 @@
type="button"
:title="l.label"
:aria-label="l.label"
class="flex h-10 w-10 items-center justify-center rounded-lg text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
class="flex h-10 w-10 items-center justify-center rounded-[9px] text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
@click="l.onSelect"
>
<component :is="l.icon" :size="20" :stroke-width="1.75" />
@@ -38,11 +44,23 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, FolderTree, GitCompare, LifeBuoy, LogOut, Settings, SquareTerminal } from '@lucide/vue';
import {
Boxes,
FolderTree,
GitCompare,
LifeBuoy,
LogOut,
Monitor,
Moon,
Settings,
SquareTerminal,
Sun,
} from '@lucide/vue';
import { useIdeStore, type ActivityView } from '../../stores/ide';
import { useModalsStore } from '../../stores/modals';
import { useNav } from '../../composables/useNav';
import { useSession } from '../../composables/useSession';
import { themeMode, cycleTheme } from '../../lib/theme';
const { t } = useI18n();
const ide = useIdeStore();
@@ -63,7 +81,16 @@ const items = computed(() => [
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
]);
// Icône du toggle selon le mode courant (dark → lune, light → soleil, system → écran).
const themeIcon = computed(() => (themeMode.value === 'light' ? Sun : themeMode.value === 'system' ? Monitor : Moon));
const links = computed(() => [
{
key: 'theme',
icon: themeIcon.value,
label: `${t('theme.label')} : ${t(`theme.${themeMode.value}`)}`,
onSelect: () => cycleTheme(),
},
{ key: 'settings', icon: Settings, label: t('nav.settings'), onSelect: () => void modals.open(SettingsOverlay) },
{ key: 'help', icon: LifeBuoy, label: t('nav.help'), onSelect: () => void modals.open(HelpOverlay) },
{ key: 'logout', icon: LogOut, label: t('common.logout'), onSelect: () => void logout() },

View File

@@ -1,14 +1,14 @@
<template>
<div v-if="waiting.length" class="flex flex-col gap-2 border-b border-border px-2 py-2">
<div class="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-amber-400">
<div class="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-warn">
<AlertTriangle :size="13" />
{{ t('attention.title') }}
<span class="ml-auto rounded-full bg-amber-500/20 px-1.5 text-[10px] text-amber-300">{{ waiting.length }}</span>
<span class="ml-auto rounded-full bg-warn/20 px-1.5 text-[10px] text-warn">{{ waiting.length }}</span>
</div>
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
<button
type="button"
class="truncate text-left font-mono text-[11px] text-amber-300/80 transition-colors hover:text-amber-200"
class="truncate text-left font-mono text-[11px] text-warn/80 transition-colors hover:text-warn"
:title="s.cwd"
@click="ide.openTerminal(s.id)"
>

View File

@@ -2,7 +2,7 @@
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-[60]" @pointerdown="close" @contextmenu.prevent="close">
<div
class="absolute min-w-44 rounded-md border border-border bg-surface-1 py-1 shadow-lg"
class="absolute min-w-44 rounded-lg border border-border bg-surface-1 py-1 shadow-pop"
:style="menuStyle"
@pointerdown.stop
>
@@ -11,7 +11,7 @@
:key="i"
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors disabled:opacity-40"
:class="item.danger ? 'text-red-400 hover:bg-red-500/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'"
:class="item.danger ? 'text-danger hover:bg-danger/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'"
:disabled="item.disabled"
@click="select(item)"
>

View File

@@ -23,7 +23,7 @@
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
<div
v-if="conflict && activeTab.view === 'editor'"
class="flex flex-wrap items-center gap-2 border-b border-amber-700/40 bg-amber-950/30 px-3 py-1.5 text-xs text-amber-200"
class="flex flex-wrap items-center gap-2 border-b border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn"
>
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
<div class="ml-auto flex items-center gap-2">
@@ -31,7 +31,7 @@
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
</div>
</div>
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-rose-400">{{ loadError }}</p>
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-danger">{{ loadError }}</p>
<div class="relative min-h-0 flex-1">
<div v-show="activeTab.view === 'editor'" ref="host" class="h-full min-h-0" />
@@ -64,6 +64,7 @@ import { useIdeStore, type EditorTab } from '../../stores/ide';
import { gitApi } from '../../lib/git-api';
import { ApiError } from '../../lib/api';
import { loadMonaco } from '../../composables/useMonaco';
import { resolvedTheme } from '../../lib/theme';
import EditorTabs from './EditorTabs.vue';
import DiffViewer from '../workspace/DiffViewer.vue';
import SegmentedControl from '../ui/SegmentedControl.vue';
@@ -109,14 +110,19 @@ onMounted(async () => {
if (disposed || !host.value) return;
editor.value = monaco.editor.create(host.value, {
value: '',
theme: 'arboretum-dark',
theme: resolvedTheme.value === 'light' ? 'arboretum-light' : 'arboretum-dark',
automaticLayout: true,
minimap: { enabled: false },
wordWrap: 'on',
fontSize: 13,
fontFamily: "'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace",
scrollBeyondLastLine: false,
tabSize: 2,
});
// Re-mesure après chargement de la fonte web (sinon curseur/colonnes décalés).
void document.fonts?.ready.then(() => {
if (!disposed) monaco?.editor.remeasureFonts();
});
editor.value.onDidChangeModelContent(() => {
if (!shownTabId) return;
const entry = entries.get(shownTabId);
@@ -227,6 +233,11 @@ watch(
},
);
// bascule de thème -> réapplique le thème Monaco (setTheme est global et idempotent).
watch(resolvedTheme, (r) => {
monaco?.editor.setTheme(r === 'light' ? 'arboretum-light' : 'arboretum-dark');
});
// onglets fermés -> dispose leurs modèles et view states (évite les fuites mémoire).
watch(
() => ide.editorTabs.map((tb) => tb.id),

View File

@@ -40,9 +40,9 @@ const pendingCloseId = ref<string | null>(null);
const basename = (path: string): string => path.split('/').pop() ?? path;
function closeButtonClass(id: string): string {
if (pendingCloseId.value === id) return 'text-amber-400 opacity-100';
if (pendingCloseId.value === id) return 'text-warn opacity-100';
// dirty : point visible en permanence ; propre : croix visible au survol de l'onglet.
return ide.isDirty(id) ? 'text-amber-400 opacity-100 group-hover:text-fg' : 'opacity-0 group-hover:opacity-100';
return ide.isDirty(id) ? 'text-warn opacity-100 group-hover:text-fg' : 'opacity-0 group-hover:opacity-100';
}
function requestClose(id: string): void {

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<div class="label-mono flex items-center gap-1 px-3 py-2">
{{ t('ide.activity.groups') }}
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
<Plus :size="14" />
@@ -17,7 +17,12 @@
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<Boxes
:size="13"
class="shrink-0"
:class="g.color ? '' : 'text-fg-subtle'"
:style="g.color ? { color: g.color } : undefined"
/>
<span class="truncate">{{ g.label }}</span>
<span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</button>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<div class="label-mono flex items-center gap-1 px-3 py-2">
{{ t('ide.projects') }}
<button
type="button"

View File

@@ -35,7 +35,7 @@
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
<span v-if="isDirty(wt)" class="ml-auto shrink-0 pl-1 text-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })"></span>
<span v-if="isDirty(wt)" class="ml-auto shrink-0 pl-1 text-warn" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })"></span>
</button>
<button
type="button"

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<div class="label-mono flex items-center gap-1 px-3 py-2">
{{ t('ide.activity.sessions') }}
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('sessions.newSession')" @click="openAddMenu">
<Plus :size="14" />
@@ -24,8 +24,8 @@
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" @click="onRowClick($event, s)">
<SessionStateBadge :session="s" />
<span class="truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
<BaseBadge v-if="s.source === 'discovered'" tone="sky" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="amber" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
<BaseBadge v-if="s.source === 'discovered'" tone="info" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="warn" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
</button>
<button
type="button"

View File

@@ -42,10 +42,10 @@ const activeWt = computed(() => {
const wsStatus = computed(() => wsClient.status.value);
const wsDot = computed(() =>
wsStatus.value === 'open'
? 'bg-emerald-500'
? 'bg-accent'
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
? 'bg-amber-500'
: 'bg-zinc-600',
? 'bg-warn'
: 'bg-fg-subtle',
);
const dockCount = computed(() => ide.dockSessionIds.length);
</script>

View File

@@ -1,7 +1,12 @@
<template>
<div class="flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
<div class="flex shrink-0 items-center gap-1 px-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<SquareTerminal :size="12" /> {{ t('ide.terminals') }}
<div class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
<span class="flex items-center gap-1" aria-hidden="true">
<span class="h-2 w-2 rounded-full bg-danger" />
<span class="h-2 w-2 rounded-full bg-warn" />
<span class="h-2 w-2 rounded-full bg-accent" />
</span>
{{ t('ide.terminals') }}
</div>
<div
@@ -38,7 +43,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ChevronDown, SquareTerminal, X } from '@lucide/vue';
import { ChevronDown, X } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide';
import { useSessionsStore } from '../../stores/sessions';

View File

@@ -3,7 +3,7 @@
<div class="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-surface-1 p-4">
<h2 class="font-semibold text-fg">{{ title }}</h2>
<p v-if="message" class="whitespace-pre-line text-sm text-fg-muted">{{ message }}</p>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div class="flex items-center justify-end gap-2">
<button type="button" class="btn text-xs" :disabled="busy" @click="emit('close')">
{{ cancelLabel ?? t('common.cancel') }}

View File

@@ -12,6 +12,11 @@
<input v-model="label" class="input" :placeholder="t('groups.namePlaceholder')" required :disabled="busy" />
</label>
<div class="flex flex-col gap-1.5 text-xs text-fg-muted">
{{ t('groups.colorLabel') }}
<ColorSwatchPicker v-model="color" :none-label="t('groups.colorNone')" />
</div>
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
@@ -27,7 +32,7 @@
</div>
</div>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="busy || label.trim() === ''">
{{ busy ? t('common.working') : t('groups.new') }}
@@ -46,6 +51,7 @@ import { useI18n } from 'vue-i18n';
import { useGroupsStore } from '../../../stores/groups';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useToastsStore } from '../../../stores/toasts';
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
const emit = defineEmits<{ close: [] }>();
@@ -55,6 +61,7 @@ const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const label = ref('');
const color = ref<string | null>(null);
const selectedRepoIds = ref<string[]>([]);
const busy = ref(false);
const errorMsg = ref<string | null>(null);
@@ -64,7 +71,11 @@ async function onSubmit(): Promise<void> {
busy.value = true;
errorMsg.value = null;
try {
await groups.createGroup({ label: label.value.trim(), repoIds: [...selectedRepoIds.value] });
await groups.createGroup({
label: label.value.trim(),
repoIds: [...selectedRepoIds.value],
...(color.value ? { color: color.value } : {}),
});
toasts.success(t('toast.groupCreated'));
emit('close');
} catch (err) {

View File

@@ -6,6 +6,11 @@
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.close') }}</button>
</header>
<div class="flex flex-col gap-1.5 text-xs text-fg-muted">
{{ t('groups.colorLabel') }}
<ColorSwatchPicker :model-value="group?.color ?? null" :none-label="t('groups.colorNone')" @update:model-value="setColor" />
</div>
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
@@ -20,7 +25,7 @@
</label>
</div>
</div>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
</div>
</div>
</template>
@@ -33,6 +38,7 @@ import { useI18n } from 'vue-i18n';
import { useGroupsStore } from '../../../stores/groups';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useToastsStore } from '../../../stores/toasts';
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
const props = defineProps<{ groupId: string }>();
const emit = defineEmits<{ close: [] }>();
@@ -48,6 +54,20 @@ const errorMsg = ref<string | null>(null);
const group = computed(() => groupsStore.byId(props.groupId));
const repoIds = computed<string[]>(() => group.value?.repoIds ?? []);
async function setColor(c: string | null): Promise<void> {
if (busy.value) return;
busy.value = true;
errorMsg.value = null;
try {
await groupsStore.updateGroup(props.groupId, { color: c });
toasts.success(t('toast.groupUpdated'));
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function toggle(repoId: string): Promise<void> {
if (busy.value) return;
busy.value = true;

View File

@@ -24,7 +24,7 @@
</select>
</label>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="creating || cwd.trim() === ''">
{{ creating ? t('common.working') : t('sessions.newSession') }}

View File

@@ -56,7 +56,7 @@
</label>
</template>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="error" class="text-xs text-danger">{{ error }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="busy || !canSubmit">
{{ busy ? t('common.working') : mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}

View File

@@ -1,6 +1,6 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-pop">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"

View File

@@ -1,6 +1,6 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-pop">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"