From 99e781cd1048808fa94d17d13ee73044ba2472e6 Mon Sep 17 00:00:00 2001
From: Ignaciobrenas
Date: Sat, 14 Nov 2026 23:08:29 +0100
Subject: [PATCH 01/10] feat(design): add DESIGN.md, agent design/QA skills,
and staging PR workflow (#6)
---
.claude/skills/commitflow-canvas-dag/SKILL.md | 19 ++++++
.../skills/commitflow-issue-workflow/SKILL.md | 27 ++++++++
.claude/skills/commitflow-qa-testing/SKILL.md | 20 ++++++
.claude/skills/commitflow-ui-design/SKILL.md | 19 ++++++
.github/workflows/ci.yml | 4 +-
.github/workflows/staging-pr-check.yml | 23 +++++++
DESIGN.md | 61 +++++++++++++++++++
scripts/bootstrap-issues.sh | 29 +++++++++
8 files changed, 200 insertions(+), 2 deletions(-)
create mode 100644 .claude/skills/commitflow-canvas-dag/SKILL.md
create mode 100644 .claude/skills/commitflow-issue-workflow/SKILL.md
create mode 100644 .claude/skills/commitflow-qa-testing/SKILL.md
create mode 100644 .claude/skills/commitflow-ui-design/SKILL.md
create mode 100644 .github/workflows/staging-pr-check.yml
create mode 100644 DESIGN.md
create mode 100644 scripts/bootstrap-issues.sh
diff --git a/.claude/skills/commitflow-canvas-dag/SKILL.md b/.claude/skills/commitflow-canvas-dag/SKILL.md
new file mode 100644
index 0000000..4aedc4b
--- /dev/null
+++ b/.claude/skills/commitflow-canvas-dag/SKILL.md
@@ -0,0 +1,19 @@
+---
+name: commitflow-canvas-dag
+description: Guía técnica para diseñar y optimizar el motor topológico del DAG, renderizado de splines Bézier, virtual scrolling (< 150 nodos DOM), minimapa de ramas, drag-and-drop y Web Workers / OffscreenCanvas en CommitFlow.
+---
+
+# CommitFlow DAG & Virtual Scroll Skill
+
+Cuando trabajes en `src/graph/graphLayout.ts`, `src/graph/dagWorkerClient.ts` o el renderizado del grafo en `src/webview/main.ts`:
+1. **Presupuesto de DOM y Rendimiento:**
+ - El scroll virtual (`updateVirtualWindow`) DEBE mantener `< 150` filas DOM montadas en el viewport en todo momento, reciclando elementos del pool cuando sea posible incluso con `100,000+` commits.
+ - Usa cálculos incrementales con `requestAnimationFrame` y evita reflows síncronos (`offsetHeight`/`getBoundingClientRect`) dentro de bucles por fila.
+2. **Motor Topológico y Offload (`GraphLayout` & `DagWorkerEngine`):**
+ - Mantén el cálculo puro en funciones deterministas sin dependencias de `vscode` para poder ejecutarlas tanto en Node.js como dentro de un Web Worker.
+ - Al usar `OffscreenCanvas` (para el minimapa o carriles), provee siempre fallback limpio para entornos sin `OffscreenCanvas`.
+ - Soporta hashes SHA-1 (40 chars) y SHA-256 (64 chars), así como merges múltiples (octopus) y commits raíz huérfanos.
+3. **Interactividad & A11y:**
+ - Todo nodo SVG/Canvas decorativo debe llevar `aria-hidden="true"`.
+ - Cada fila de commit debe mantener su `role="row"`, `aria-label` descriptivo con ramas/tags/padres y sincronización exacta con la navegación por teclado (`ArrowUp`, `ArrowDown`, `PageUp`, `PageDown`, `Home`, `End`).
+ - Las operaciones Drag-and-Drop sobre ramas/commits (reset, checkout, cherry-pick, rebase) DEBEN pedir confirmación explícita en modal seguro antes de mutar Git.
diff --git a/.claude/skills/commitflow-issue-workflow/SKILL.md b/.claude/skills/commitflow-issue-workflow/SKILL.md
new file mode 100644
index 0000000..11d31f0
--- /dev/null
+++ b/.claude/skills/commitflow-issue-workflow/SKILL.md
@@ -0,0 +1,27 @@
+---
+name: commitflow-issue-workflow
+description: Flujo de trabajo GitFlow estricto de CommitFlow: main <- staging <- ramas por issue (feat|fix|design|test|perf/-), enlace con gh issue develop, Conventional Commits y PRs hacia staging.
+---
+
+# CommitFlow Issue & Branch Workflow Skill
+
+Sigue siempre este ciclo de vida GitFlow adaptado al repositorio `Ignaciobrenas/CommitFlow`:
+1. **Jerarquía de Ramas:**
+ - `main`: Producción estable y releases del Marketplace. Solo recibe PRs desde `staging` (o `hotfix/*`).
+ - `staging`: Rama de integración continua. **Todas** las ramas de trabajo nacen de `staging` y regresan mediante PR hacia `staging`.
+ - Nomenclatura de ramas (compatible con `IssueBranchLinker`):
+ - `feat/-` (nuevas funcionalidades)
+ - `fix/-` (corrección de bugs)
+ - `design/-` (UI/UX, `DESIGN.md`, `SKILL.md`, accesibilidad)
+ - `test/-` (suites de tests, fuzzing, E2E, CI)
+ - `perf/-` (rendimiento, Web Workers, `OffscreenCanvas`, watcher)
+2. **Crear rama enlazada al Issue:**
+ ```bash
+ git checkout staging && git pull origin staging
+ gh issue develop --base staging --name /- --checkout
+ ```
+3. **Commits Convencionales:**
+ - Usa `type(scope): description (#ID)`, por ejemplo: `feat(engine): add binary packfile reader in GitFsReader (#10)`.
+4. **Pull Request hacia `staging`:**
+ - Incluye siempre `Refs #` en el cuerpo del PR hacia `staging` (y `Closes #` en el PR de release `staging` -> `main` para cierre automático).
+ - Antes de abrir o mergear el PR, ejecuta `npm run compile`, `npm test` y `npm run build`.
diff --git a/.claude/skills/commitflow-qa-testing/SKILL.md b/.claude/skills/commitflow-qa-testing/SKILL.md
new file mode 100644
index 0000000..6a2a48c
--- /dev/null
+++ b/.claude/skills/commitflow-qa-testing/SKILL.md
@@ -0,0 +1,20 @@
+---
+name: commitflow-qa-testing
+description: Guía de la pirámide completa de QA para CommitFlow: tests unitarios, property-based fuzzing, pruebas E2E del servidor MCP (stdio + HTTP/SSE), auditoría responsiva y WCAG 2.1 AA en Chromium headless, benchmarks de 50k commits y smoke tests E2E de extensión VS Code.
+---
+
+# CommitFlow QA & Testing Pyramid Skill
+
+Antes de dar por finalizada cualquier feature, bugfix o refactorización, valida las capas aplicables de la pirámide de calidad:
+1. **Typecheck y Compilación (`npm run compile` & `npm run build`):**
+ - Cero errores de TypeScript estricto y bundle limpio en `dist/`.
+2. **Tests Unitarios y de Integración (`npm test` -> `test/runTests.ts`):**
+ - Cubre lógica pura (`GraphLayout`, `GitFsReader`, `GitParser`, `SshConfigParser`, `IssueBranchLinker`, `ConventionalCommitsBuilder`, `IpcSchemaValidator`, `AdaptiveDebouncer`).
+3. **Property-Based Fuzzing (`test/fuzzTests.ts`):**
+ - Ejecuta 1,000+ iteraciones deterministas inyectando `packed-refs` corruptos, reflogs truncados, buffers binarios aleatorios, alias SSH circulares, rutas Unicode y grafos complejos sin lanzar excepciones no controladas.
+4. **E2E del Servidor MCP (`test/mcpE2eTests.ts`):**
+ - Prueba real contra repositorio temporal en disco: `stdio` (`dist/mcp.js`) y `CommitFlowHttpServer` (`/health`, `/sse`, `/mcp`), JSON-RPC 2.0 `initialize`, todas las tools y resources, scoping de repo, rechazo 401/403, rate-limiting 429 y log de auditoría.
+5. **Webview Multi-Viewport, Temas y WCAG 2.1 AA (`scripts/verify-responsive.js`):**
+ - Verifica en Chromium/Edge headless los 4 breakpoints (`240px` Nano, `360px` Sidebar, `520px` Compact, `1100px` Wide Split) y temas (`dark`, `light`, `high-contrast`, `high-contrast-light`) con `0` overflows, `0` colisiones y `0` violaciones A11y.
+6. **Benchmarks y Empaquetado (`scripts/benchmark-50k.js` & `npx vsce package`):**
+ - Verifica layout de 50,000 commits dentro del presupuesto de latencia/memoria y que el `.vsix` se empaquete sin errores.
diff --git a/.claude/skills/commitflow-ui-design/SKILL.md b/.claude/skills/commitflow-ui-design/SKILL.md
new file mode 100644
index 0000000..d10fa9c
--- /dev/null
+++ b/.claude/skills/commitflow-ui-design/SKILL.md
@@ -0,0 +1,19 @@
+---
+name: commitflow-ui-design
+description: Diseña e implementa interfaces Webview para CommitFlow siguiendo DESIGN.md, variables CSS de VS Code, Codicons, accesibilidad WCAG 2.1 AA y los 4 breakpoints responsivos multi-dock. Úsala al crear o modificar componentes UI, drawers, modales o temas.
+---
+
+# CommitFlow UI & Webview Design Skill
+
+Antes de escribir código UI en `src/graph/graphWebviewProvider.ts`, `src/webview/main.ts`, `src/webview/primitives.ts` o `src/webview/style.css`:
+1. Lee `DESIGN.md` en la raíz del proyecto.
+2. Verifica que **NO** se importe ni se referencie `@vscode/webview-ui-toolkit` (deprecado en enero de 2025). Utiliza tokens `--vscode-*` y los primitivos nativos en `src/webview/primitives.ts` (`CfButton`, `CfBadge`, `CfDrawer`, `CfTabs`, `CfToast`, `CfSkeleton`, `clampPopoverToViewport`).
+3. **Checklist obligatorio por componente:**
+ - [ ] Funciona en temas VS Code: Dark+, Light+, High Contrast Dark (`.vscode-high-contrast`) y High Contrast Light (`.vscode-high-contrast-light`).
+ - [ ] Verificado en los 4 viewports multi-dock: Nano (`240px`), Sidebar Compact (`360px`), Compact (`520px`), Wide Split (`1100px`) y Bottom Panel (`1000x250px`).
+ - [ ] Usa `tabular-nums` (`font-variant-numeric: tabular-nums`) para hashes SHA, timestamps y contadores `+N/-N`.
+ - [ ] Incluye roles ARIA semánticos (`treegrid`, `row`, `gridcell` en el grafo; `role="dialog"` y `trapModalFocus` en modales) y foco visible (`outline: 1px solid var(--vscode-focusBorder)`).
+ - [ ] Cero `innerHTML` con datos de commits/issues/usuarios sin escapar (`escapeHtml`); CSP estricto con `nonce`.
+ - [ ] Popovers/drawers usan `clampPopoverToViewport` y se cierran al hacer clic fuera o pulsar `Escape`.
+ - [ ] Mensajes de estado vacío claros con CTA accionable y `CfSkeleton` loaders no bloqueantes.
+ - [ ] Respeta `@media (prefers-reduced-motion: reduce)`.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d3eb164..005a543 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI & Quality Gate
on:
push:
- branches: [ main ]
+ branches: [ main, staging ]
pull_request:
- branches: [ main ]
+ branches: [ main, staging ]
jobs:
build-and-test:
diff --git a/.github/workflows/staging-pr-check.yml b/.github/workflows/staging-pr-check.yml
new file mode 100644
index 0000000..af69174
--- /dev/null
+++ b/.github/workflows/staging-pr-check.yml
@@ -0,0 +1,23 @@
+name: Staging PR & Issue Link Gate
+
+on:
+ pull_request:
+ branches: [ staging, main ]
+
+jobs:
+ verify-issue-link:
+ name: Verify Issue Reference in PR
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check PR references an Issue
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ const text = `${pr.title}\n${pr.body || ''}`;
+ const hasIssueRef = /#\d+/.test(text);
+ if (!hasIssueRef) {
+ core.setFailed('El PR debe referenciar al menos un Issue (ej: "Refs #6" o "Closes #6").');
+ } else {
+ core.info(`PR #${pr.number} references issue(s) cleanly.`);
+ }
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..9aa1359
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,61 @@
+# CommitFlow Design System (DESIGN.md)
+
+## 1. Visual Theme & Atmosphere
+- **Philosophy:** **"Precision Developer Telemetry"**. High information density without visual noise; native VS Code integration that respects 100% of the user's active IDE theme (Light, Dark, High Contrast Dark, High Contrast Light) while delivering vivid semantic identity across the commit DAG via curated chromatic lane palettes.
+- **Surface Hierarchy:** Strictly layered via native VS Code tokens with sensible fallbacks:
+ - Primary Canvas / Editor Surface: `var(--vscode-editor-background)` (`--bg-primary`)
+ - Secondary Chrome / Sidebar / Header: `var(--vscode-sideBar-background)` (`--bg-secondary`)
+ - Elevated / Tabs / Popovers / Modals: `var(--vscode-editorGroupHeader-tabsBackground)` / `var(--vscode-panel-background)` (`--bg-tertiary`)
+ - Interactive States: `var(--vscode-list-hoverBackground)` (`--bg-hover`), `var(--vscode-list-activeSelectionBackground)` (`--bg-active`)
+ - Borders & Focus: `var(--vscode-widget-border)` / `var(--vscode-panel-border)` (`--border-subtle`), `var(--vscode-focusBorder)` (`--focus-ring`)
+
+## 2. Color Palette & Tokens
+- **Base IDE Integration:**
+ - Foreground: `var(--vscode-editor-foreground)` / `var(--vscode-foreground)`
+ - Secondary Muted Text: `var(--vscode-descriptionForeground)`
+ - Focus Ring: `var(--vscode-focusBorder)` (must always remain visible on keyboard focus)
+ - High Contrast Borders: `var(--vscode-contrastBorder)`, `var(--vscode-contrastActiveBorder)`
+- **Semantic Status Tokens:**
+ - `Success / Bisect Good / Valid Sig / Ready PR`: `#10b981` (`--accent-emerald` in Dark) / `#1a7f37` (Light)
+ - `Danger / Bisect Bad / Conflict / Drop`: `#ef4444` / `#f43f5e` (`--accent-rose` in Dark) / `#cf222e` (Light)
+ - `Warning / Untrusted / Dirty / Pending`: `#f59e0b` (`--accent-amber` in Dark) / `#9a6700` (Light)
+ - `Probe / Active Bisect / Primary Accent`: `#38bdf8` / `#06b6d4` (`--accent-cyan` in Dark) / `#0969da` (Light)
+ - `MCP / AI Studio / Rebase Accent`: `#8b5cf6` (`--accent-violet` in Dark) / `#8250df` (Light)
+- **DAG Chromatic Lane Palettes (`GraphLayout.THEME_LANE_COLORS`):**
+ - Supported presets: `auto`, `dark`, `light`, `cyberpunk`, `dracula`, `github`, `neon-pink`, `neon-green`, `neon-blue`.
+ - Adjacent lanes must maintain clear perceptual color separation. In High Contrast themes (`vscode-high-contrast`, `vscode-high-contrast-light`), borders and focus outlines override subtle alpha fills.
+
+## 3. Typography & Rhythm
+- **UI Font:** `var(--vscode-font-family)` with adaptive sizing (`--font-size-base: 12px`, `--font-size-sm: 11px`, `--font-size-xs: 10px`).
+- **Monospace Telemetry Font:** `var(--vscode-editor-font-family)` with mandatory `font-variant-numeric: tabular-nums` (`class="tabular-nums"`) for:
+ - Commit SHAs (`shortHash`, full 40/64-char SHA)
+ - Diff line deltas (`+N / -M`)
+ - Relative/absolute timestamps, branch pill counters, and stat bar counters
+ - File paths and bisect step estimators
+- **Spacing & Radii:** `--radius-xs: 4px`, `--radius-sm: 6px`, `--radius-md: 8px`, `--radius-lg: 10px`, `--radius-xl: 12px`, `--radius-full: 9999px`.
+- **Density Tiers (`commitflow.graph.density`):** `compact` (`22px` row height), `comfortable` (`26px`), `spacious` (`32px`).
+
+## 4. Adaptive Multi-Dock Breakpoints
+CommitFlow operates seamlessly across left/right Activity Bar sidebars, bottom terminal panel, and full Editor panels using container-aware layout classes:
+- **Nano (`<= 260px` / `.layout-nano`):**
+ - Navigation tabs collapse to icon-only buttons with native `title` + `aria-label` tooltips.
+ - Secondary metadata (author email, timestamps, extra stats dividers) are hidden cleanly.
+ - Minimum touch/click target size `28x28px`.
+- **Sidebar Compact (`261px - 440px` / `.layout-sidebar-compact`):**
+ - Single-column stacked layout for DAG rows and cards.
+ - Inspector drawer renders as a full-width overlay drawer clamped cleanly inside the viewport.
+- **Compact (`441px - 600px` / `.layout-width-compact`):**
+ - Compact branch pills, abbreviated badges, wrapped flex toolbars with zero horizontal overflow.
+- **Wide / Bottom Panel (`> 600px`):**
+ - Dual-pane split view (DAG + Minimap on the left, Commit/PR Inspector Drawer on the right) or horizontal bottom-dock layout (`>= 540px` width and `<= 380px` height -> `.auto-detected-bottom` / `.layout-height-short`).
+
+## 5. Component Rules (Zero Deprecated Toolkits)
+1. **ZERO `@vscode/webview-ui-toolkit`:** Officially deprecated and archived in January 2025. Never import or depend on it.
+2. **Native UI Primitives (`src/webview/primitives.ts`):** All new UI elements use CommitFlow's lightweight zero-dependency primitives (`CfButton`, `CfBadge`, `CfDrawer`, `CfTabs`, `CfToast`, `CfSkeleton`) built exclusively with `--vscode-*` CSS variables and `@vscode/codicons`.
+3. **Icons:** Use `@vscode/codicons` (``) or accessible inline SVGs marked with `aria-hidden="true"`.
+4. **Viewport Clamping & Popover Dismissal:** All popovers, tooltips, context menus, and drawers must clamp against viewport edges (`clampPopoverToViewport`) and dismiss on outside click or `Escape`.
+5. **Motion & Reduced Motion:** Use `--ease-spring` / `--duration-fast` for crisp transitions, and strictly disable animations, spinners, and pulse keyframes inside `@media (prefers-reduced-motion: reduce)`.
+6. **Security & CSP:** Enforce `default-src 'none'` with cryptographic `nonce` per load; never inject untrusted user or commit strings via raw `innerHTML` without HTML escaping.
+7. **Accessibility (WCAG 2.1 AA):**
+ - Graph container uses `role="treegrid"` with `role="row"` and `role="gridcell"`, descriptive `aria-label` per commit, and roving `tabindex` (`ArrowUp`, `ArrowDown`, `PageUp`, `PageDown`, `Home`, `End`).
+ - All modals trap `Tab`/`Shift+Tab` focus (`trapModalFocus`) and restore focus on close.
diff --git a/scripts/bootstrap-issues.sh b/scripts/bootstrap-issues.sh
new file mode 100644
index 0000000..db868c1
--- /dev/null
+++ b/scripts/bootstrap-issues.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# 1. Asegurar main actualizada y crear staging (si no existe)
+git checkout main
+git pull origin main
+if ! git show-ref --verify --quiet refs/heads/staging; then
+ git checkout -b staging
+ git push -u origin staging
+else
+ git checkout staging
+ git pull origin staging
+fi
+
+# 2. Crear etiquetas organizadas en el repositorio
+gh label create "epic:design" --color "ec4899" --description "Design system, SKILLS, UI/UX y responsive" --force
+gh label create "epic:engine" --color "0284c7" --description "GitFsReader offline, performance y DAG" --force
+gh label create "epic:features" --color "10b981" --description "Funcionalidades Git, Providers y Conflict Studio" --force
+gh label create "epic:security" --color "ef4444" --description "Seguridad MCP, CSP y validacion IPC" --force
+gh label create "epic:testing" --color "8b5cf6" --description "Playwright, Fuzzing, A11y, MCP E2E y CI" --force
+
+create_issue() {
+ local title="$1"
+ local label="$2"
+ local body="$3"
+ gh issue create --title "$title" --label "$label" --body "$body"
+}
+
+echo "Usa create_issue \"\" \"\" \"\" para crear nuevos issues enlazables a staging."
From 88a16de31aa1b58063f7bea9a99d96775e8c7f3c Mon Sep 17 00:00:00 2001
From: Ignaciobrenas
Date: Sat, 14 Nov 2026 23:10:46 +0100
Subject: [PATCH 02/10] feat(design): add native UI primitives and audit zero
deprecated toolkit (#7)
---
src/webview/main.ts | 64 +++----
src/webview/primitives.ts | 376 ++++++++++++++++++++++++++++++++++++++
src/webview/style.css | 289 ++++++++++++++++++++++++++++-
test/runTests.ts | 41 ++++-
4 files changed, 725 insertions(+), 45 deletions(-)
create mode 100644 src/webview/primitives.ts
diff --git a/src/webview/main.ts b/src/webview/main.ts
index a02be90..00408f2 100644
--- a/src/webview/main.ts
+++ b/src/webview/main.ts
@@ -24,6 +24,19 @@ import {
UserPersonalStats,
} from '../git/types';
import { t, Language } from '../i18n/locales';
+import {
+ CfButton,
+ CfBadge,
+ CfDrawer,
+ CfTabs,
+ CfToast,
+ CfSkeleton,
+ clampPopoverToViewport,
+ escapeHtml,
+} from './primitives';
+
+// Expose native UI primitives for modular feature modules
+export { CfButton, CfBadge, CfDrawer, CfTabs, CfToast, CfSkeleton, escapeHtml };
declare function acquireVsCodeApi(): {
postMessage(msg: any): void;
@@ -741,12 +754,13 @@ function applyFilters(): void {
}
function renderLoading(): void {
- tableContainer.innerHTML = `
-
-
-
${tr('graph.loading')}
-
- `;
+ tableContainer.innerHTML = '';
+ const skeleton = CfSkeleton({ rows: 10, variant: 'graph' });
+ const label = document.createElement('div');
+ label.style.cssText = 'text-align:center; font-size:var(--font-size-xs); color:var(--text-muted); padding:6px;';
+ label.textContent = tr('graph.loading');
+ tableContainer.appendChild(label);
+ tableContainer.appendChild(skeleton);
}
function renderError(err: string): void {
@@ -1418,20 +1432,20 @@ function showHoverPopover(e: MouseEvent, node: GraphNode): void {
hoverPopover.innerHTML = `
-

+
-
${commit.author.name}
-
${commit.author.relativeTime} • \`${commit.shortHash}\`
+
${escapeHtml(commit.author.name)}
+
${escapeHtml(commit.author.relativeTime)} • ${escapeHtml(commit.shortHash)}
- ${commit.subject}
+ ${escapeHtml(commit.subject)}
${
commit.refs.length > 0
? `
${commit.refs
.map(
(r) =>
- ` ${r.name}`
+ ` ${escapeHtml(r.name)}`
)
.join('')}
`
@@ -1439,26 +1453,9 @@ function showHoverPopover(e: MouseEvent, node: GraphNode): void {
}
`;
- const popoverWidth = Math.min(300, window.innerWidth - 20);
- const popoverHeight = 130;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
-
- // Clamp top position: if bottom doesn't fit, flip to top
- let top = rect.bottom + 4;
- if (top + popoverHeight > window.innerHeight) {
- top = Math.max(8, rect.top - popoverHeight - 4);
- }
-
- // Clamp left position: ensure at least 8px from left and right viewport edges
- let left = rect.left + 40;
- if (left + popoverWidth > window.innerWidth - 8) {
- left = Math.max(8, window.innerWidth - popoverWidth - 8);
- }
-
- hoverPopover.style.top = `${Math.round(top)}px`;
- hoverPopover.style.left = `${Math.round(left)}px`;
- hoverPopover.style.maxWidth = `${popoverWidth}px`;
hoverPopover.classList.add('visible');
+ clampPopoverToViewport(hoverPopover, rect, 8);
}
function hideHoverPopover(): void {
@@ -2834,15 +2831,6 @@ function renderWorktrees(): void {
});
}
-function escapeHtml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
function renderCloudPatches(): void {
const listEl = document.getElementById('cloud-patches-list');
if (!listEl) return;
diff --git a/src/webview/primitives.ts b/src/webview/primitives.ts
new file mode 100644
index 0000000..46130eb
--- /dev/null
+++ b/src/webview/primitives.ts
@@ -0,0 +1,376 @@
+/**
+ * Native zero-dependency Webview UI primitives for CommitFlow.
+ * Built strictly on VS Code CSS variables (--vscode-*) and @vscode/codicons.
+ * Replaces deprecated @vscode/webview-ui-toolkit with accessible, theme-aware components.
+ */
+
+export interface CfButtonOptions {
+ id?: string;
+ label?: string;
+ icon?: string;
+ variant?: 'primary' | 'secondary' | 'danger' | 'icon' | 'sm';
+ title?: string;
+ ariaLabel?: string;
+ disabled?: boolean;
+ className?: string;
+ onClick?: (e: MouseEvent) => void;
+}
+
+export interface CfBadgeOptions {
+ text: string;
+ variant?: 'default' | 'success' | 'danger' | 'warning' | 'info' | 'purple' | 'subtle';
+ icon?: string;
+ title?: string;
+ tabular?: boolean;
+ className?: string;
+}
+
+export interface CfDrawerOptions {
+ id: string;
+ title: string;
+ onClose?: () => void;
+}
+
+export interface CfTabItem {
+ id: string;
+ label: string;
+ icon?: string;
+ badge?: number;
+ title?: string;
+}
+
+export interface CfTabsOptions {
+ tabs: CfTabItem[];
+ activeTabId?: string;
+ onSelect?: (tabId: string) => void;
+}
+
+export interface CfSkeletonOptions {
+ rows?: number;
+ variant?: 'graph' | 'card' | 'drawer';
+}
+
+export function escapeHtml(str: string): string {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function CfButton(options: CfButtonOptions): HTMLButtonElement {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ if (options.id) btn.id = options.id;
+ const variant = options.variant || 'secondary';
+ btn.className = `cf-btn cf-btn-${variant}${options.className ? ` ${options.className}` : ''}`;
+
+ if (options.title) btn.title = options.title;
+ if (options.ariaLabel || options.title || options.label) {
+ btn.setAttribute('aria-label', options.ariaLabel || options.title || options.label || '');
+ }
+ if (options.disabled) btn.disabled = true;
+
+ if (options.icon) {
+ const iconEl = document.createElement('i');
+ iconEl.className = `codicon codicon-${options.icon}`;
+ iconEl.setAttribute('aria-hidden', 'true');
+ btn.appendChild(iconEl);
+ }
+
+ if (options.label) {
+ const span = document.createElement('span');
+ span.textContent = options.label;
+ btn.appendChild(span);
+ }
+
+ if (options.onClick) {
+ btn.addEventListener('click', options.onClick);
+ }
+
+ return btn;
+}
+
+export function CfBadge(options: CfBadgeOptions): HTMLSpanElement {
+ const badge = document.createElement('span');
+ const variant = options.variant || 'default';
+ badge.className = `cf-badge cf-badge-${variant}${options.tabular ? ' tabular-nums' : ''}${
+ options.className ? ` ${options.className}` : ''
+ }`;
+ if (options.title) badge.title = options.title;
+
+ if (options.icon) {
+ const iconEl = document.createElement('i');
+ iconEl.className = `codicon codicon-${options.icon}`;
+ iconEl.setAttribute('aria-hidden', 'true');
+ badge.appendChild(iconEl);
+ }
+
+ const textSpan = document.createElement('span');
+ textSpan.textContent = options.text;
+ badge.appendChild(textSpan);
+
+ return badge;
+}
+
+export function CfDrawer(options: CfDrawerOptions): {
+ element: HTMLElement;
+ body: HTMLElement;
+ open: () => void;
+ close: () => void;
+} {
+ const el = document.createElement('aside');
+ el.id = options.id;
+ el.className = 'cf-drawer';
+ el.setAttribute('role', 'region');
+ el.setAttribute('aria-label', options.title);
+
+ const header = document.createElement('div');
+ header.className = 'cf-drawer-header';
+
+ const titleEl = document.createElement('span');
+ titleEl.className = 'cf-drawer-title';
+ titleEl.textContent = options.title;
+
+ const closeBtn = CfButton({
+ icon: 'close',
+ variant: 'icon',
+ ariaLabel: `Close ${options.title}`,
+ title: 'Close',
+ onClick: () => {
+ el.classList.remove('open');
+ options.onClose?.();
+ },
+ });
+
+ header.appendChild(titleEl);
+ header.appendChild(closeBtn);
+
+ const body = document.createElement('div');
+ body.className = 'cf-drawer-body';
+
+ el.appendChild(header);
+ el.appendChild(body);
+
+ return {
+ element: el,
+ body,
+ open: () => el.classList.add('open'),
+ close: () => {
+ el.classList.remove('open');
+ options.onClose?.();
+ },
+ };
+}
+
+export function CfTabs(options: CfTabsOptions): HTMLElement {
+ const nav = document.createElement('div');
+ nav.className = 'cf-tabs';
+ nav.setAttribute('role', 'tablist');
+
+ let activeId = options.activeTabId || options.tabs[0]?.id || '';
+
+ const buttons: HTMLButtonElement[] = [];
+
+ for (const tab of options.tabs) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = `cf-tab-btn${tab.id === activeId ? ' active' : ''}`;
+ btn.setAttribute('role', 'tab');
+ btn.setAttribute('aria-selected', tab.id === activeId ? 'true' : 'false');
+ btn.dataset.tabId = tab.id;
+ if (tab.title) btn.title = tab.title;
+
+ if (tab.icon) {
+ const iconEl = document.createElement('i');
+ iconEl.className = `codicon codicon-${tab.icon}`;
+ iconEl.setAttribute('aria-hidden', 'true');
+ btn.appendChild(iconEl);
+ }
+
+ const labelSpan = document.createElement('span');
+ labelSpan.textContent = tab.label;
+ btn.appendChild(labelSpan);
+
+ if (typeof tab.badge === 'number') {
+ const badge = document.createElement('span');
+ badge.className = 'tab-badge tabular-nums';
+ badge.textContent = String(tab.badge);
+ btn.appendChild(badge);
+ }
+
+ btn.addEventListener('click', () => {
+ activeId = tab.id;
+ for (const b of buttons) {
+ const isSelected = b.dataset.tabId === activeId;
+ b.classList.toggle('active', isSelected);
+ b.setAttribute('aria-selected', isSelected ? 'true' : 'false');
+ }
+ options.onSelect?.(tab.id);
+ });
+
+ buttons.push(btn);
+ nav.appendChild(btn);
+ }
+
+ return nav;
+}
+
+export class CfToast {
+ private static container: HTMLElement | null = null;
+
+ private static ensureContainer(): HTMLElement {
+ if (this.container && document.body.contains(this.container)) {
+ return this.container;
+ }
+ const existing = document.getElementById('cf-toast-container');
+ if (existing) {
+ this.container = existing;
+ return existing;
+ }
+ const el = document.createElement('div');
+ el.id = 'cf-toast-container';
+ el.className = 'cf-toast-container';
+ el.setAttribute('aria-live', 'polite');
+ el.setAttribute('aria-atomic', 'false');
+ document.body.appendChild(el);
+ this.container = el;
+ return el;
+ }
+
+ public static showToast(
+ message: string,
+ type: 'info' | 'success' | 'warning' | 'error' = 'info',
+ durationMs: number = 3500
+ ): HTMLElement {
+ const container = this.ensureContainer();
+ const toast = document.createElement('div');
+ toast.className = `cf-toast cf-toast-${type}`;
+ toast.setAttribute('role', 'status');
+
+ const iconName =
+ type === 'success'
+ ? 'pass'
+ : type === 'warning'
+ ? 'warning'
+ : type === 'error'
+ ? 'error'
+ : 'info';
+
+ const iconEl = document.createElement('i');
+ iconEl.className = `codicon codicon-${iconName}`;
+ iconEl.setAttribute('aria-hidden', 'true');
+
+ const msgEl = document.createElement('span');
+ msgEl.className = 'cf-toast-message';
+ msgEl.textContent = message;
+
+ const closeBtn = CfButton({
+ icon: 'close',
+ variant: 'icon',
+ ariaLabel: 'Dismiss notification',
+ onClick: () => toast.remove(),
+ });
+
+ toast.appendChild(iconEl);
+ toast.appendChild(msgEl);
+ toast.appendChild(closeBtn);
+ container.appendChild(toast);
+
+ if (durationMs > 0) {
+ setTimeout(() => {
+ toast.classList.add('cf-toast-leaving');
+ setTimeout(() => toast.remove(), 180);
+ }, durationMs);
+ }
+
+ return toast;
+ }
+}
+
+export function CfSkeleton(options: CfSkeletonOptions = {}): HTMLElement {
+ const rows = Math.max(1, options.rows ?? 8);
+ const variant = options.variant || 'graph';
+ const wrapper = document.createElement('div');
+ wrapper.className = `cf-skeleton cf-skeleton-${variant}`;
+ wrapper.setAttribute('role', 'status');
+ wrapper.setAttribute('aria-busy', 'true');
+ wrapper.setAttribute('aria-label', 'Loading content');
+
+ for (let i = 0; i < rows; i++) {
+ const row = document.createElement('div');
+ row.className = 'cf-skeleton-row';
+ const dot = document.createElement('span');
+ dot.className = 'cf-skeleton-dot';
+ const barPrimary = document.createElement('span');
+ barPrimary.className = 'cf-skeleton-bar';
+ barPrimary.style.width = `${55 + ((i * 13) % 30)}%`;
+ const barMeta = document.createElement('span');
+ barMeta.className = 'cf-skeleton-bar cf-skeleton-bar-short';
+ row.appendChild(dot);
+ row.appendChild(barPrimary);
+ row.appendChild(barMeta);
+ wrapper.appendChild(row);
+ }
+
+ return wrapper;
+}
+
+export interface RectLike {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+ width: number;
+ height: number;
+}
+
+export function computeClampedPosition(
+ anchorRect: RectLike,
+ popoverWidth: number,
+ popoverHeight: number,
+ viewportWidth: number,
+ viewportHeight: number,
+ padding: number = 8
+): { top: number; left: number; maxWidth: number } {
+ const maxWidth = Math.max(160, Math.min(popoverWidth, viewportWidth - padding * 2));
+ let top = anchorRect.bottom + 4;
+ if (top + popoverHeight > viewportHeight - padding) {
+ top = Math.max(padding, anchorRect.top - popoverHeight - 4);
+ }
+
+ let left = anchorRect.left + 24;
+ if (left + maxWidth > viewportWidth - padding) {
+ left = Math.max(padding, viewportWidth - maxWidth - padding);
+ }
+ if (left < padding) {
+ left = padding;
+ }
+
+ return {
+ top: Math.round(top),
+ left: Math.round(left),
+ maxWidth: Math.round(maxWidth),
+ };
+}
+
+export function clampPopoverToViewport(
+ popover: HTMLElement,
+ anchorRect: RectLike,
+ padding: number = 8
+): void {
+ const measuredWidth = popover.offsetWidth || 300;
+ const measuredHeight = popover.offsetHeight || 130;
+ const pos = computeClampedPosition(
+ anchorRect,
+ measuredWidth,
+ measuredHeight,
+ window.innerWidth,
+ window.innerHeight,
+ padding
+ );
+ popover.style.top = `${pos.top}px`;
+ popover.style.left = `${pos.left}px`;
+ popover.style.maxWidth = `${pos.maxWidth}px`;
+}
diff --git a/src/webview/style.css b/src/webview/style.css
index 8509694..5747cf6 100644
--- a/src/webview/style.css
+++ b/src/webview/style.css
@@ -3605,20 +3605,297 @@ body.layout-height-nano .stats-bar {
background: var(--bg-hover) !important;
}
-body.vscode-high-contrast .commit-row.selected,
+body.vscode-high-contrast .commit-row.selected {
+ outline: 2px solid var(--vscode-contrastActiveBorder, #ffffff);
+ outline-offset: -2px;
+}
+
body.vscode-high-contrast-light .commit-row.selected {
- outline: 2px solid #ffffff;
+ outline: 2px solid var(--vscode-contrastActiveBorder, #0f4a85);
outline-offset: -2px;
}
-body.vscode-high-contrast .modal-card,
+body.vscode-high-contrast .modal-card {
+ border: 2px solid var(--vscode-contrastBorder, #ffffff) !important;
+}
+
body.vscode-high-contrast-light .modal-card {
- border: 2px solid #ffffff !important;
+ border: 2px solid var(--vscode-contrastBorder, #0f4a85) !important;
+}
+
+body.vscode-high-contrast .rebase-todo-item {
+ border: 1px solid var(--vscode-contrastBorder, #ffffff) !important;
}
-body.vscode-high-contrast .rebase-todo-item,
body.vscode-high-contrast-light .rebase-todo-item {
- border: 1px solid #ffffff !important;
+ border: 1px solid var(--vscode-contrastBorder, #0f4a85) !important;
+}
+
+/* ==========================================================================
+ Native UI Primitives (Issue #7) & Telemetry Typography
+ ========================================================================== */
+.tabular-nums,
+.commit-hash,
+.file-delta-badge,
+.stat-value,
+.tab-badge,
+.rebase-item-sha {
+ font-variant-numeric: tabular-nums;
+}
+
+.cf-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ border-radius: var(--radius-xs);
+ border: 1px solid transparent;
+ font-family: var(--vscode-font-family);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background var(--duration-fast) ease, border-color var(--duration-fast) ease, transform var(--duration-fast) ease;
+}
+
+.cf-btn:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder, var(--focus-ring));
+ outline-offset: 1px;
+}
+
+.cf-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.cf-btn-primary {
+ padding: 5px 10px;
+ background: var(--vscode-button-background, var(--accent-cyan));
+ color: var(--vscode-button-foreground, #ffffff);
+}
+
+.cf-btn-primary:hover:not(:disabled) {
+ background: var(--vscode-button-hoverBackground, var(--accent-blue));
+}
+
+.cf-btn-secondary {
+ padding: 5px 10px;
+ background: var(--vscode-button-secondaryBackground, var(--bg-tertiary));
+ color: var(--vscode-button-secondaryForeground, var(--text-primary));
+ border-color: var(--border-subtle);
+}
+
+.cf-btn-danger {
+ padding: 5px 10px;
+ background: rgba(239, 68, 68, 0.16);
+ color: var(--vscode-errorForeground, #ef4444);
+ border-color: rgba(239, 68, 68, 0.4);
+}
+
+.cf-btn-icon {
+ min-width: 28px;
+ min-height: 28px;
+ padding: 4px;
+ background: transparent;
+ color: var(--text-muted);
+}
+
+.cf-btn-icon:hover:not(:disabled) {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.cf-btn-sm {
+ padding: 3px 8px;
+ font-size: var(--font-size-xs);
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border-color: var(--border-subtle);
+}
+
+.cf-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 7px;
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.cf-badge-success {
+ background: rgba(16, 185, 129, 0.16);
+ color: #10b981;
+ border-color: rgba(16, 185, 129, 0.35);
+}
+
+.cf-badge-danger {
+ background: rgba(239, 68, 68, 0.16);
+ color: #ef4444;
+ border-color: rgba(239, 68, 68, 0.35);
+}
+
+.cf-badge-warning {
+ background: rgba(245, 158, 11, 0.16);
+ color: #f59e0b;
+ border-color: rgba(245, 158, 11, 0.35);
+}
+
+.cf-badge-info {
+ background: rgba(6, 182, 212, 0.16);
+ color: #06b6d4;
+ border-color: rgba(6, 182, 212, 0.35);
+}
+
+.cf-badge-purple {
+ background: rgba(139, 92, 246, 0.16);
+ color: #8b5cf6;
+ border-color: rgba(139, 92, 246, 0.35);
+}
+
+.cf-drawer {
+ display: none;
+ flex-direction: column;
+ background: var(--bg-secondary);
+ border-left: 1px solid var(--border-subtle);
+}
+
+.cf-drawer.open {
+ display: flex;
+}
+
+.cf-drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.cf-drawer-title {
+ font-weight: 600;
+ font-size: var(--font-size-base);
+}
+
+.cf-drawer-body {
+ padding: 12px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.cf-tabs {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.cf-tab-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-xs);
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.cf-tab-btn.active {
+ background: var(--bg-active);
+ color: var(--text-primary);
+ border-color: var(--border-hover);
+}
+
+.cf-toast-container {
+ position: fixed;
+ bottom: 14px;
+ right: 14px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-width: min(360px, calc(100vw - 24px));
+ pointer-events: none;
+}
+
+.cf-toast {
+ pointer-events: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: var(--radius-md);
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-subtle);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ font-size: var(--font-size-sm);
+}
+
+.cf-toast-success { border-color: rgba(16, 185, 129, 0.5); }
+.cf-toast-warning { border-color: rgba(245, 158, 11, 0.5); }
+.cf-toast-error { border-color: rgba(239, 68, 68, 0.5); }
+
+.cf-toast-message {
+ flex: 1;
+}
+
+.cf-skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.cf-skeleton-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 22px;
+}
+
+.cf-skeleton-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--bg-tertiary);
+ flex-shrink: 0;
+}
+
+.cf-skeleton-bar {
+ height: 10px;
+ border-radius: var(--radius-xs);
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
+ background-size: 200% 100%;
+ animation: cf-shimmer 1.4s infinite linear;
+}
+
+.cf-skeleton-bar-short {
+ width: 64px;
+ margin-left: auto;
+}
+
+@keyframes cf-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ .branch-pulse-dot,
+ .codicon-modifier-spin {
+ animation: none !important;
+ }
}
.sr-only {
diff --git a/test/runTests.ts b/test/runTests.ts
index 1c22bef..ec3867c 100644
--- a/test/runTests.ts
+++ b/test/runTests.ts
@@ -20,6 +20,7 @@ import { ApiKeyManager } from '../src/mcp/apiKeyManager';
import { CommitFlowHttpServer } from '../src/mcp/commitflowHttpServer';
import { PersistenceManager } from '../src/state/persistenceManager';
import { GitFsReader } from '../src/git/gitFsReader';
+import { computeClampedPosition, escapeHtml } from '../src/webview/primitives';
console.log('Starting CommitFlow Extended Test Suite...\n');
@@ -1296,7 +1297,45 @@ Host *.internal.corp
}
console.log(' [PASS] AI Studio, Multi-Client Snippets & Instant Key Revocation E2E tests passed!');
- console.log('\n[PASS] ALL 42 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n');
+ console.log('\nTesting Native UI Primitives & Zero Deprecated Toolkit Audit...');
+ {
+ const pkgJson = fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8');
+ assert.ok(!pkgJson.includes('@vscode/webview-ui-toolkit'), 'package.json must NOT depend on deprecated @vscode/webview-ui-toolkit');
+
+ const primitivesSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'webview', 'primitives.ts'), 'utf8');
+ assert.ok(primitivesSrc.includes('export function CfButton'));
+ assert.ok(primitivesSrc.includes('export function CfBadge'));
+ assert.ok(primitivesSrc.includes('export function CfDrawer'));
+ assert.ok(primitivesSrc.includes('export function CfTabs'));
+ assert.ok(primitivesSrc.includes('export class CfToast'));
+ assert.ok(primitivesSrc.includes('export function CfSkeleton'));
+
+ assert.ok(fs.existsSync(path.join(__dirname, '..', 'DESIGN.md')), 'DESIGN.md must exist');
+ assert.ok(fs.existsSync(path.join(__dirname, '..', '.claude', 'skills', 'commitflow-ui-design', 'SKILL.md')));
+ assert.ok(fs.existsSync(path.join(__dirname, '..', '.claude', 'skills', 'commitflow-canvas-dag', 'SKILL.md')));
+ assert.ok(fs.existsSync(path.join(__dirname, '..', '.claude', 'skills', 'commitflow-qa-testing', 'SKILL.md')));
+ assert.ok(fs.existsSync(path.join(__dirname, '..', '.claude', 'skills', 'commitflow-issue-workflow', 'SKILL.md')));
+
+ assert.ok(cssContent.includes('.cf-btn-primary'));
+ assert.ok(cssContent.includes('.cf-skeleton'));
+ assert.ok(cssContent.includes('prefers-reduced-motion: reduce'));
+ assert.ok(cssContent.includes('tabular-nums'));
+
+ const clamped = computeClampedPosition(
+ { left: 210, right: 235, top: 680, bottom: 700, width: 25, height: 20 },
+ 300,
+ 130,
+ 240,
+ 720,
+ 8
+ );
+ assert.ok(clamped.left >= 8 && clamped.left + clamped.maxWidth <= 240);
+ assert.ok(clamped.top < 680, 'Popover near bottom should flip upward');
+ assert.strictEqual(escapeHtml(''), '<script>"x"</script>');
+ }
+ console.log(' [PASS] Native UI Primitives & Zero Deprecated Toolkit Audit tests passed!');
+
+ console.log('\n[PASS] ALL 43 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n');
})().catch((err) => {
console.error('[CommitFlow] Test suite failed:', err);
process.exit(1);
From de7953b2fce8e7b4f423a4f0390a0bc48a994566 Mon Sep 17 00:00:00 2001
From: Ignaciobrenas
Date: Sat, 14 Nov 2026 23:14:27 +0100
Subject: [PATCH 03/10] feat(rebase): complete interactive Rebase Studio with
fixup, drag-and-drop, context menu & responsive checks (Refs #4)
---
scripts/capture-screenshots.js | 411 +++++++++++++++--
scripts/verify-responsive.js | 705 ++++++++++++++++++++++++++++++
src/graph/graphWebviewProvider.ts | 14 +-
src/webview/main.ts | 292 ++++++++++++-
src/webview/style.css | 514 ++++++++++++++++++++--
test/runTests.ts | 47 +-
6 files changed, 1905 insertions(+), 78 deletions(-)
create mode 100644 scripts/verify-responsive.js
diff --git a/scripts/capture-screenshots.js b/scripts/capture-screenshots.js
index 171c8ca..a1af7a7 100644
--- a/scripts/capture-screenshots.js
+++ b/scripts/capture-screenshots.js
@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
-const { execFileSync } = require('child_process');
+const { spawn } = require('child_process');
+const os = require('os');
const edgePath = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
const outDir = path.resolve(__dirname, '..', 'docs', 'screenshots');
@@ -72,6 +73,7 @@ function buildGraphHtml(themeName) {
+
+
+
+
+
+
+
+
+ Reordena los commits arrastrando o usando los botones. Selecciona la acción para cada commit:
+ 3 commits
+
+
+
+
+
+
f775f85
+
feat(release): release v0.4.8 with universal responsive design
+
Ignacio
+
+
+
+
+
+
+
+
+
aa1779d
+
fix(ui): close all popups, popovers and modals when clicking outside
+
Ignacio
+
+
+
+
+
+
+
+
+
98cd31a
+
chore: remove temporary debug logs
+
Ignacio
+
+
+
+
+
+
+
+ Simulación previa completada: 0 conflictos de fusión detectados.
+
+
+
+
+
+
+`;
+}
+
+function buildCommitDrawerHtml(themeName) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+ Ignacio Breñas
+ ignaciobrenas@gmail.com
+ Hace 5 minutos (cf4821a)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mensaje del commit
+
feat(responsive): complete responsive design and virtual DAG windowing engine
+
+
+
+
+ Archivos Modificados
+ 3 archivos
+
+
+
+
+ M
+ src/webview/style.css
+
+
+42
+
+
+
+ M
+ src/webview/main.ts
+
+
+18
+
+
+
+
+
+
+
+`;
+}
+
+function buildIssuesHubHtml(themeName) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #104
+ open
+ Vinculado
+
+
+
+
+
+
+
Responsive UI overhaul for sidebars and auxiliary windows
+
+ frontend
+ v0.4.8
+
+
+
+
+
+
+`;
+}
+
+function buildBisectViewHtml(themeName) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ Git Bisect Activo
+
+
+ Probando commit e4a19b8 (~2 pasos restantes)
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
const themes = ['dark', 'light', 'high-contrast'];
const viewports = [
{ name: 'narrow-280px', width: 280, height: 750 },
+ { name: 'sidebar-360px', width: 360, height: 750 },
{ name: 'medium-450px', width: 450, height: 800 },
+ { name: 'split-750px', width: 750, height: 800 },
{ name: 'full-1200px', width: 1200, height: 800 },
];
-for (const theme of themes) {
- const graphHtml = buildGraphHtml(theme);
- const graphFile = path.join(outDir, `temp_graph_${theme}.html`);
- fs.writeFileSync(graphFile, graphHtml);
-
- const aiHtml = buildAiStudioHtml(theme);
- const aiFile = path.join(outDir, `temp_ai_${theme}.html`);
- fs.writeFileSync(aiFile, aiHtml);
-
- for (const vp of viewports) {
- const graphImg = path.join(outDir, `graph_${theme}_${vp.name}.png`);
- execFileSync(edgePath, [
- '--headless',
- '--disable-gpu',
- `--screenshot=${graphImg}`,
- `--window-size=${vp.width},${vp.height}`,
- `file://${graphFile}`,
- ]);
-
- const aiImg = path.join(outDir, `ai_studio_${theme}_${vp.name}.png`);
- execFileSync(edgePath, [
- '--headless',
- '--disable-gpu',
- `--screenshot=${aiImg}`,
- `--window-size=${vp.width},${vp.height}`,
- `file://${aiFile}`,
- ]);
-
- console.log(`Captured: graph & ai_studio for theme=${theme}, viewport=${vp.name}`);
+const views = [
+ { prefix: 'graph', builder: buildGraphHtml },
+ { prefix: 'ai_studio', builder: buildAiStudioHtml },
+ { prefix: 'rebase_studio', builder: buildRebaseStudioHtml },
+ { prefix: 'commit_drawer', builder: buildCommitDrawerHtml },
+ { prefix: 'issues_hub', builder: buildIssuesHubHtml },
+ { prefix: 'bisect_view', builder: buildBisectViewHtml },
+];
+
+async function captureAll() {
+ const profileDir = path.join(os.tmpdir(), 'commitflow_edge_cdp_' + Date.now());
+ const edgeProc = spawn(edgePath, [
+ '--headless=new',
+ '--remote-debugging-port=9222',
+ '--disable-gpu',
+ '--user-data-dir=' + profileDir,
+ 'about:blank'
+ ]);
+
+ let wsUrl = '';
+ for (let i = 0; i < 30; i++) {
+ await new Promise(r => setTimeout(r, 200));
+ try {
+ const res = await fetch('http://127.0.0.1:9222/json/list');
+ const data = await res.json();
+ const page = data.find(p => p.type === 'page');
+ if (page && page.webSocketDebuggerUrl) {
+ wsUrl = page.webSocketDebuggerUrl;
+ break;
+ }
+ } catch(e) {}
}
- fs.unlinkSync(graphFile);
- fs.unlinkSync(aiFile);
+ if (!wsUrl) {
+ edgeProc.kill();
+ throw new Error('Could not connect to Edge DevTools Protocol');
+ }
+
+ const ws = new WebSocket(wsUrl);
+ let id = 1;
+ const callbacks = new Map();
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ if (msg.id && callbacks.has(msg.id)) {
+ callbacks.get(msg.id)(msg.result);
+ callbacks.delete(msg.id);
+ }
+ };
+
+ await new Promise(r => ws.onopen = r);
+
+ function send(method, params = {}) {
+ return new Promise((resolve) => {
+ const reqId = id++;
+ callbacks.set(reqId, resolve);
+ ws.send(JSON.stringify({ id: reqId, method, params }));
+ });
+ }
+
+ await send('Page.enable');
+ const tree = await send('Page.getFrameTree');
+ const frameId = tree.frameTree.frame.id;
+
+ for (const theme of themes) {
+ for (const view of views) {
+ const html = view.builder(theme);
+ await send('Page.setDocumentContent', { frameId, html });
+
+ for (const vp of viewports) {
+ await send('Emulation.setDeviceMetricsOverride', {
+ width: vp.width,
+ height: vp.height,
+ deviceScaleFactor: 1,
+ mobile: false
+ });
+
+ // Small pause for layout calculation and fonts
+ await new Promise(r => setTimeout(r, 120));
+
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ const outImg = path.join(outDir, `${view.prefix}_${theme}_${vp.name}.png`);
+ fs.writeFileSync(outImg, Buffer.from(shot.data, 'base64'));
+ console.log(`Captured (CDP): ${view.prefix} for theme=${theme}, viewport=${vp.name} (${vp.width}x${vp.height})`);
+ }
+ }
+ }
+
+ ws.close();
+ edgeProc.kill();
+
+ setTimeout(() => {
+ try { fs.rmSync(profileDir, { recursive: true, force: true }); } catch(e) {}
+ }, 1000);
+
+ console.log('All comprehensive screenshots captured successfully in docs/screenshots/');
}
-console.log('All screenshots captured successfully in docs/screenshots/');
+captureAll().catch((err) => {
+ console.error('Capture error:', err);
+ process.exit(1);
+});
diff --git a/scripts/verify-responsive.js b/scripts/verify-responsive.js
new file mode 100644
index 0000000..b9ec7d8
--- /dev/null
+++ b/scripts/verify-responsive.js
@@ -0,0 +1,705 @@
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+const os = require('os');
+
+const edgePath = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
+
+const cssWebview = fs.readFileSync(path.resolve(__dirname, '..', 'dist', 'webview.css'), 'utf8');
+const cssCodicon = fs.readFileSync(path.resolve(__dirname, '..', 'dist', 'codicon.css'), 'utf8');
+
+const themeVariables = {
+ dark: `
+ --vscode-editor-background: #1e1e1e;
+ --vscode-editor-foreground: #d4d4d4;
+ --vscode-sideBar-background: #252526;
+ --vscode-sideBar-foreground: #cccccc;
+ --vscode-input-background: #3c3c3c;
+ --vscode-input-foreground: #cccccc;
+ --vscode-input-border: #3c3c3c;
+ --vscode-button-background: #007acc;
+ --vscode-button-foreground: #ffffff;
+ --vscode-button-hoverBackground: #0062a3;
+ --vscode-button-secondaryBackground: #3a3d41;
+ --vscode-button-secondaryForeground: #ffffff;
+ --vscode-errorForeground: #f48771;
+ --vscode-testing-iconPassed: #89d185;
+ --vscode-editorWarning-foreground: #cca700;
+ `,
+ light: `
+ --vscode-editor-background: #ffffff;
+ --vscode-editor-foreground: #333333;
+ --vscode-sideBar-background: #f3f3f3;
+ --vscode-sideBar-foreground: #616161;
+ --vscode-input-background: #ffffff;
+ --vscode-input-foreground: #333333;
+ --vscode-input-border: #cecece;
+ --vscode-button-background: #007acc;
+ --vscode-button-foreground: #ffffff;
+ --vscode-button-hoverBackground: #0062a3;
+ --vscode-button-secondaryBackground: #e5e5e5;
+ --vscode-button-secondaryForeground: #333333;
+ --vscode-errorForeground: #e51400;
+ --vscode-testing-iconPassed: #388a34;
+ --vscode-editorWarning-foreground: #bf8803;
+ `,
+ 'high-contrast': `
+ --vscode-editor-background: #000000;
+ --vscode-editor-foreground: #ffffff;
+ --vscode-sideBar-background: #000000;
+ --vscode-sideBar-foreground: #ffffff;
+ --vscode-input-background: #000000;
+ --vscode-input-foreground: #ffffff;
+ --vscode-input-border: #6fc3df;
+ --vscode-contrastActiveBorder: #f38518;
+ --vscode-button-background: #000000;
+ --vscode-button-foreground: #ffffff;
+ --vscode-button-border: #6fc3df;
+ --vscode-button-hoverBackground: #1a1a1a;
+ --vscode-button-secondaryBackground: #000000;
+ --vscode-button-secondaryForeground: #ffffff;
+ --vscode-errorForeground: #f48771;
+ --vscode-testing-iconPassed: #89d185;
+ --vscode-editorWarning-foreground: #cca700;
+ `,
+};
+
+function buildGraphHtml(themeName) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
Commits: 128
+
Autores: 4
+
Ramas: 6
+
Stashes: 1
+
+
+
+
+
+
+ cf4821a |
+ |
+ main feat(mcp): streamable HTTP and stdio authenticated server |
+ Ignacio |
+ Just now |
+
+
+ ab8291f |
+ |
+ dev feat(ai): Connect with your AI studio and key generator |
+ Ignacio |
+ 10m ago |
+
+
+
+
+
+
+`;
+}
+
+function buildRebaseStudioHtml(themeName) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ Reordena los commits arrastrando o usando los botones. Selecciona la acción:
+ 3 commits
+
+
+
+
+
+
f775f85
+
feat(release): release v0.4.8 with universal responsive design
+
Ignacio
+
+
+
+
+
+
+
+
+
aa1779d
+
fix(ui): close all popups, popovers and modals when clicking outside
+
Ignacio
+
+
+
+
+
+
+
+
+
98cd31a
+
chore: remove temporary debug logs
+
Ignacio
+
+
+
+
+
+
+
+ Simulación previa completada: 0 conflictos de fusión detectados.
+
+
+
+
+
+
+`;
+}
+
+function buildCommitDrawerHtml(themeName) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+ Ignacio Breñas
+ ignaciobrenas@gmail.com
+ Hace 5 minutos (cf4821a)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mensaje del commit
+
feat(responsive): complete responsive design and virtual DAG windowing engine
+
+
+
+
+ Archivos Modificados
+ 3 archivos
+
+
+
+
+ M
+ src/webview/style.css
+
+
+42
+
+
+
+ M
+ src/webview/main.ts
+
+
+18
+
+
+
+
+
+
+
+`;
+}
+
+function buildAiStudioHtml(themeName) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Generar Nueva Clave API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
claude mcp add --transport sse commitflow http://127.0.0.1:47291/sse?token=cf_live_9b4e7a82c31045f918e9d023b7498c1a
+
+
+
+
+
+
+
+
+
+
+ | Nombre |
+ Prefijo |
+ Permisos |
+ Creación |
+ Estado |
+ Acciones |
+
+
+
+
+ | Antigravity Agent |
+ cf_live_9b4e... |
+ read |
+ Nov 1, 2026 |
+ Activa |
+ |
+
+
+
+
+
+
+
+`;
+}
+
+const viewports = [
+ { name: 'nano-240px', width: 240, height: 700 },
+ { name: 'narrow-280px', width: 280, height: 750 },
+ { name: 'sidebar-360px', width: 360, height: 750 },
+ { name: 'medium-450px', width: 450, height: 800 },
+ { name: 'split-750px', width: 750, height: 800 },
+ { name: 'full-1200px', width: 1200, height: 800 },
+];
+
+const views = [
+ { name: 'Graph View', builder: buildGraphHtml },
+ { name: 'AI Studio', builder: buildAiStudioHtml },
+ { name: 'Rebase Studio Modal', builder: buildRebaseStudioHtml },
+ { name: 'Commit Drawer', builder: buildCommitDrawerHtml },
+];
+
+async function verifyResponsiveAndButtons() {
+ console.log('=== CommitFlow Responsive & Button Interaction Verification ===\n');
+
+ const profileDir = path.join(os.tmpdir(), 'commitflow_verify_cdp_' + Date.now());
+ const edgeProc = spawn(edgePath, [
+ '--headless=new',
+ '--remote-debugging-port=9223',
+ '--disable-gpu',
+ '--user-data-dir=' + profileDir,
+ 'about:blank'
+ ]);
+
+ let wsUrl = '';
+ for (let i = 0; i < 30; i++) {
+ await new Promise(r => setTimeout(r, 200));
+ try {
+ const res = await fetch('http://127.0.0.1:9223/json/list');
+ const data = await res.json();
+ const page = data.find(p => p.type === 'page');
+ if (page && page.webSocketDebuggerUrl) {
+ wsUrl = page.webSocketDebuggerUrl;
+ break;
+ }
+ } catch(e) {}
+ }
+
+ if (!wsUrl) {
+ edgeProc.kill();
+ throw new Error('Could not connect to Edge DevTools Protocol on port 9223');
+ }
+
+ const ws = new WebSocket(wsUrl);
+ let id = 1;
+ const callbacks = new Map();
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ if (msg.id && callbacks.has(msg.id)) {
+ callbacks.get(msg.id)(msg.result);
+ callbacks.delete(msg.id);
+ }
+ };
+
+ await new Promise(r => ws.onopen = r);
+
+ function send(method, params = {}) {
+ return new Promise((resolve) => {
+ const reqId = id++;
+ callbacks.set(reqId, resolve);
+ ws.send(JSON.stringify({ id: reqId, method, params }));
+ });
+ }
+
+ await send('Page.enable');
+ await send('Runtime.enable');
+ const tree = await send('Page.getFrameTree');
+ const frameId = tree.frameTree.frame.id;
+
+ let totalAudits = 0;
+ let passedAudits = 0;
+ let totalButtonsVerified = 0;
+
+ for (const view of views) {
+ console.log(`\n▶ Auditing View: ${view.name}`);
+ const html = view.builder('dark');
+ await send('Page.setDocumentContent', { frameId, html });
+
+ for (const vp of viewports) {
+ await send('Emulation.setDeviceMetricsOverride', {
+ width: vp.width,
+ height: vp.height,
+ deviceScaleFactor: 1,
+ mobile: false
+ });
+
+ await new Promise(r => setTimeout(r, 100));
+
+ const evalRes = await send('Runtime.evaluate', {
+ expression: `
+ (function() {
+ const results = {
+ horizontalOverflows: [],
+ overlappingElements: [],
+ buttonsInfo: []
+ };
+
+ // 1. Check for unintentional horizontal overflows
+ const bodyWidth = document.body.clientWidth;
+ const allElements = Array.from(document.querySelectorAll('*'));
+ for (const el of allElements) {
+ const cs = window.getComputedStyle(el);
+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;
+ // Ignore intentional horizontal scroll containers, inputs, and ellipsis text
+ if (cs.overflowX === 'auto' || cs.overflowX === 'scroll') continue;
+ const tag = el.tagName.toLowerCase();
+ if (tag === 'input' || tag === 'textarea') continue;
+ if (cs.textOverflow === 'ellipsis') continue;
+ if (cs.overflowX === 'hidden' && (cs.textOverflow === 'clip' || cs.textOverflow === 'ellipsis')) continue;
+ if (el.scrollWidth > el.clientWidth + 3 && el.clientWidth > 0) {
+ // Ignore small sub-pixel text rendering difference
+ const cls = (el.className && typeof el.className === 'string') ? el.className.slice(0, 30) : '';
+ const id = el.id ? '#' + el.id : '';
+ results.horizontalOverflows.push({
+ selector: tag + id + (cls ? '.' + cls.split(' ').join('.') : ''),
+ clientWidth: el.clientWidth,
+ scrollWidth: el.scrollWidth
+ });
+ }
+ }
+
+ // 2. Check all interactive buttons & controls
+ const buttons = Array.from(document.querySelectorAll('button, select, input[type="text"]'));
+ for (const btn of buttons) {
+ const rect = btn.getBoundingClientRect();
+ const cs = window.getComputedStyle(btn);
+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;
+
+ const isVisible = rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight;
+ let isObscured = false;
+ if (isVisible) {
+ const centerX = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
+ const centerY = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
+ const topEl = document.elementFromPoint(centerX, centerY);
+ if (topEl && !btn.contains(topEl) && !topEl.contains(btn)) {
+ isObscured = true;
+ }
+ }
+
+ results.buttonsInfo.push({
+ tag: btn.tagName.toLowerCase(),
+ id: btn.id || '',
+ text: (btn.textContent || btn.value || btn.title || '').trim().slice(0, 25),
+ width: Math.round(rect.width),
+ height: Math.round(rect.height),
+ isVisible,
+ isObscured
+ });
+ }
+
+ // 3. Collision/Overlap detection between direct siblings in flex/grid
+ const containers = Array.from(document.querySelectorAll('.drawer-action-buttons, .bisect-actions, .issues-filter-pills, .snippet-subtabs, .modal-footer, .header-row-primary'));
+ for (const container of containers) {
+ const children = Array.from(container.children).filter(c => {
+ const s = window.getComputedStyle(c);
+ return s.display !== 'none' && s.visibility !== 'hidden';
+ });
+ for (let i = 0; i < children.length; i++) {
+ for (let j = i + 1; j < children.length; j++) {
+ const r1 = children[i].getBoundingClientRect();
+ const r2 = children[j].getBoundingClientRect();
+ if (r1.width === 0 || r2.width === 0) continue;
+ // Check intersection with 3px tolerance
+ const overlapX = Math.max(0, Math.min(r1.right, r2.right) - Math.max(r1.left, r2.left));
+ const overlapY = Math.max(0, Math.min(r1.bottom, r2.bottom) - Math.max(r1.top, r2.top));
+ if (overlapX > 3 && overlapY > 3) {
+ results.overlappingElements.push({
+ container: container.className,
+ el1: children[i].tagName + (children[i].id ? '#' + children[i].id : ''),
+ el2: children[j].tagName + (children[j].id ? '#' + children[j].id : ''),
+ overlapX,
+ overlapY
+ });
+ }
+ }
+ }
+ }
+
+ return results;
+ })()
+ `,
+ returnByValue: true
+ });
+
+ const res = evalRes.result.value;
+ totalAudits++;
+
+ const hasOverflows = res.horizontalOverflows.length > 0;
+ const hasOverlaps = res.overlappingElements.length > 0;
+ const obscuredButtons = res.buttonsInfo.filter(b => b.isVisible && b.isObscured);
+
+ if (!hasOverflows && !hasOverlaps && obscuredButtons.length === 0) {
+ passedAudits++;
+ console.log(` ✔ [PASS] Viewport ${vp.name.padEnd(14)} (${vp.width}x${vp.height}) - ${res.buttonsInfo.length} buttons OK, 0 overlaps, 0 overflows`);
+ } else {
+ console.log(` ✖ [WARN] Viewport ${vp.name} (${vp.width}x${vp.height}):`);
+ if (hasOverflows) {
+ console.log(` Overflows:`, res.horizontalOverflows);
+ }
+ if (hasOverlaps) {
+ console.log(` Overlaps ("se pisa"):`, res.overlappingElements);
+ }
+ if (obscuredButtons.length > 0) {
+ console.log(` Obscured buttons:`, obscuredButtons);
+ }
+ }
+
+ totalButtonsVerified += res.buttonsInfo.length;
+ }
+ }
+
+ ws.close();
+ edgeProc.kill();
+
+ setTimeout(() => {
+ try { fs.rmSync(profileDir, { recursive: true, force: true }); } catch(e) {}
+ }, 1000);
+
+ console.log(`\n======================================================`);
+ console.log(`AUDIT RESULTS: ${passedAudits}/${totalAudits} audits passed with zero collisions.`);
+ console.log(`TOTAL BUTTONS & CONTROLS VERIFIED: ${totalButtonsVerified}`);
+ console.log(`======================================================\n`);
+
+ if (passedAudits !== totalAudits) {
+ throw new Error(`Some viewports have responsive or button layout issues`);
+ }
+}
+
+verifyResponsiveAndButtons().catch((err) => {
+ console.error('Audit failed:', err);
+ process.exit(1);
+});
diff --git a/src/graph/graphWebviewProvider.ts b/src/graph/graphWebviewProvider.ts
index ba39d3d..f3db611 100644
--- a/src/graph/graphWebviewProvider.ts
+++ b/src/graph/graphWebviewProvider.ts
@@ -2184,7 +2184,7 @@ Always check repo state and active branch before making modifications.