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) { + + + + + +`; +} + +function buildCommitDrawerHtml(themeName) { + return ` + + + + + + + +
+
+
+ Detalles del Commit + +
+
+
+ +
+ 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 ` + + + + + + + +
+
+
+
+ CommitFlow +
+
+
+ main +
+
+ + + + + + + + + + + + +
+
+ +
+
+ + +
+ + +
+
+ +
+
Commits: 128
+
Autores: 4
+
Ramas: 6
+
Stashes: 1
+
+ +
+ + + + + + + + + + + + + + + + + +
cf4821amain feat(mcp): streamable HTTP and stdio authenticated serverIgnacioJust now
ab8291fdev feat(ai): Connect with your AI studio and key generatorIgnacio10m ago
+
+
+ +`; +} + +function buildRebaseStudioHtml(themeName) { + return ` + + + + + + + + + +`; +} + +function buildCommitDrawerHtml(themeName) { + return ` + + + + + + + +
+
+
+ Detalles del Commit + +
+
+
+ +
+ 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 ` + + + + + + + +
+
+
+
+ +

Conectar con tu IA

+ + + Activo en 127.0.0.1:47291 + +
+

Genera claves de acceso seguras y conecta Claude Code, Antigravity o cualquier IA mediante MCP.

+
+
+ +
+
+ + Generar Nueva Clave API +
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ +
+
+ +
+
+ + Copia tu clave ahora. Por motivos de seguridad, no volverá a mostrarse. +
+
+ + +
+
+ +
+
+ + Comandos de Conexión Rápida +
+ +
+ + + + +
+ +
+
claude mcp add --transport sse commitflow http://127.0.0.1:47291/sse?token=cf_live_9b4e7a82c31045f918e9d023b7498c1a
+ +
+
+ +
+
+ + Claves API Generadas +
+
+ + + + + + + + + + + + + + + + + + + + + +
NombrePrefijoPermisosCreaciónEstadoAcciones
Antigravity Agentcf_live_9b4e...readNov 1, 2026Activa
+
+
+
+ +`; +} + +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.