diff --git a/.hallmark/audit-analysis-1100.png b/.hallmark/audit-analysis-1100.png new file mode 100644 index 0000000..cad3233 Binary files /dev/null and b/.hallmark/audit-analysis-1100.png differ diff --git a/.hallmark/audit-analysis-1280.png b/.hallmark/audit-analysis-1280.png new file mode 100644 index 0000000..9a4b6ad Binary files /dev/null and b/.hallmark/audit-analysis-1280.png differ diff --git a/.hallmark/audit-analysis-320.png b/.hallmark/audit-analysis-320.png new file mode 100644 index 0000000..38a50bf Binary files /dev/null and b/.hallmark/audit-analysis-320.png differ diff --git a/.hallmark/audit-analysis-768.png b/.hallmark/audit-analysis-768.png new file mode 100644 index 0000000..09bb249 Binary files /dev/null and b/.hallmark/audit-analysis-768.png differ diff --git a/.hallmark/audit-empty-320.png b/.hallmark/audit-empty-320.png new file mode 100644 index 0000000..3a50008 Binary files /dev/null and b/.hallmark/audit-empty-320.png differ diff --git a/.hallmark/audit-error-375.png b/.hallmark/audit-error-375.png new file mode 100644 index 0000000..c1bee50 Binary files /dev/null and b/.hallmark/audit-error-375.png differ diff --git a/.hallmark/audit-loading-375.png b/.hallmark/audit-loading-375.png new file mode 100644 index 0000000..cdbcc92 Binary files /dev/null and b/.hallmark/audit-loading-375.png differ diff --git a/.hallmark/audit-settings-1280.png b/.hallmark/audit-settings-1280.png new file mode 100644 index 0000000..e095044 Binary files /dev/null and b/.hallmark/audit-settings-1280.png differ diff --git a/.hallmark/audit.md b/.hallmark/audit.md new file mode 100644 index 0000000..62eeedb --- /dev/null +++ b/.hallmark/audit.md @@ -0,0 +1,185 @@ +# Git-Up Hallmark audit, field-manual redesign, Impeccable polish, and dynamic reading refinement + +**Date:** 2026-09-05 +**Scope:** Existing Git-Up application, redesigned in place +**Visual fingerprint:** Theme-inverted repository field manual + sticky right context rail + open ruled analysis ledger +**Contract:** Preserve every feature, route, view, interaction, provider, state path, content item, protected logo, Oreo, and the local Tabler/Iconify registry. + +## Outcome + +The earlier “strong” attempt was rejected because it still shared too much of the original silhouette. This pass deliberately changes the first-screen composition, global workspace orientation, light/dark surface relationship, mobile treatment, and analysis-panel containment. + +The result cannot be mistaken for the prior all-dark, left-sidebar, rounded-card interface: + +- In dark mode, the repository brief is now a large light field-manual sheet. +- In light mode, that sheet inverts to a large dark field. +- The entire desktop workspace/context rail moved from the left side to the right and now remains available while scrolling. +- Mobile uses the same full-width inversion rather than collapsing back into the old dark stack. +- Analysis opens with a full-width inverted scan console and continues as an unboxed ruled ledger. + +No production component, feature, route, provider, content section, or interaction was added or removed. This pass changed presentation CSS only; application logic and data flow were not touched. + +The final Impeccable polish pass preserves that visual world rather than concealing another redesign. It repairs intermediate-width usability, long repository identity handling, display tracking, error visibility, modal layering, and layout-motion defects while leaving product truth and behavior intact. + +The latest dynamic reading refinement responds to the remaining flatness without recomposing the product again. Existing square-canvas infrastructure is now visible and calm; the ambient workspace field is viewport-sized instead of document-sized; the headline resolves to three intentional lines at common desktop and 375px mobile widths; reading text is larger; and repeated section rules yield to whitespace, selective tonal grouping, and one focal route surface. No application, API, state, form, route, or event-handler logic changed. + +## Unmistakable visual changes + +### 1. Theme-inverted field manual + +The empty view is no longer another dark panel on the dark application canvas. Its complete repository brief—eyebrow, headline, explanation, URL field, analysis action, reader-depth choices, helper notes, shortcut, and error state—sits on one deliberately inverted manual sheet. + +- Dark theme: light neutral sheet with dark ink and a dark command block. +- Light theme: dark neutral sheet with light ink and a light command block. +- The Git-Up mint remains an accent for the route edge, primary action, active reader mode, and focus state rather than becoming a generic full-screen brand wash. +- The desktop composition remains asymmetric; the statement and scan controls occupy separate fields. +- The mobile view keeps the inversion and full-width sheet, making the change visible even at 320px. + +### 2. Right-hand workspace context + +The original left-sidebar silhouette is gone on desktop. + +- Above 1100px, analyses, provider access, history, repository files, and local context occupy a 304px right rail. +- From 721–1100px, the complete rail remains readable at 220px; labels, repository history, file filtering/tree navigation, and local context are no longer clipped into an unusable icon strip. +- At 720px and below, the rail yields to the existing bottom navigation. +- The rail uses open ledger rows instead of rounded navigation tiles. +- It docks below the real 72px top bar and remains sticky while the main route ledger scrolls. + +No sidebar component or destination was replaced; CSS grid placement changes its visual ownership only. + +### 3. Inverted analysis console + +The existing repository form, reader-depth selector, trust notes, and shortcut remain separate DOM components, but visually join into one full-width manual band above the analysis. + +- Repository input and action receive a high-contrast command treatment. +- The selected reader mode becomes a dark/light cutout inside the opposing manual surface. +- Repository identity is larger; through 1399px its action group moves to a separate row so names and canonical URLs remain intact at common laptop widths. +- The same inverted treatment carries into route status, so the next step reads as the operative instruction rather than another card. + +### 4. Open chapter-led analysis ledger + +Rounded dashboard containment remains removed from the main reading flow, while the repeated rule-on-every-section rhythm has also been retired. + +- Major panels are transparent and separated by chapter-scale whitespace rather than matching top and bottom rules. +- Panel headings use a compact square route marker, a 20px heading, and readable 13px support copy. +- Installation rows keep their functional route line but no longer add a horizontal divider after every step. +- Failure entries and the install contract use restrained tonal grouping; supporting dependency, requirement, explanation, file, and note groups receive quiet surfaces. +- The route index is one tonal full-width control with directional underlines rather than five divided cells. +- The install path remains the dominant wide column; supporting evidence remains the narrower rail. +- Overview, health, failures, graph, contract, evidence, install progress, commands, and recovery content remain present and behaviorally identical. + +### 5. Mobile is visibly redesigned too + +The prior attempts were least distinct on mobile. The final mobile composition now has: + +- A full-width neutral manual sheet directly beneath the dark/light top bar +- A theme-inverted headline and repository command block +- Flush, square reader-mode rows rather than soft cards +- A matching inverted bottom navigation surface +- Recoverable errors placed directly after the repository control rather than below the reader-depth block +- The same existing Oreo controls and assistant behavior, with the mascot clearing critical mobile error copy and yielding beneath true modal overlays + +### 6. Contribution motion and editorial reading flow + +The requested open-source references were studied and adapted through the existing vanilla stack rather than imported wholesale: + +- **particles.js:** existing square nodes are slightly larger and more legible, drift slowly toward the route direction, pulse asynchronously, and repel within a narrow non-blocking radius. The canvas is fixed to the visible workspace and bounded around the context rail, reducing a long analysis page from a multi-thousand-pixel canvas to one viewport. +- **GitHub contribution language + NES.css restraint:** the top bar now carries a seven-row field of 6px activity cells; the page and manual use crisp square contribution cues without importing a novelty font or turning controls into retro game widgets. +- **Animate.css:** local duration and delay rules stage the existing eyebrow, headline, repository control, reader mode, and helper notes once, using opacity and a 12px-or-smaller translation only. +- **ScrollReveal:** the existing IntersectionObserver now provides a short vertical context cue and stagger, still seals after first reveal, uses no scroll listener, and cannot hide no-JS or rerendered content. +- **Hover.css:** primary actions receive a single 1px lift and route links a directional underline; there is no scaling, sweeping sheen, cursor glow, or layout-property animation. +- **Vanilla Framework:** headline measure, three-line composition, body measure, chapter spacing, and reading/control type separation were tightened without replacing component ownership. +- **Lenis principles:** anchors use native smooth scrolling with a fixed-header offset; no scroll-hijacking dependency was added, preserving sticky rails, browser controls, and accessibility. + +The static square field, both live canvases, entrances, marquee, hover cues, and native smooth scroll all become fully static under `prefers-reduced-motion`. + +## Copy audit + +No user-facing copy was rewritten. Existing language is concrete and product-specific: repository evidence, install paths, failures, contracts, recovery, and reader depth. It contains no fabricated proof metrics, fake testimonials, placeholder brands, generic “unleash/seamless/supercharge” marketing language, or emoji-as-interface. + +Repository health numbers and failure counts are computed from scanned repository evidence at runtime, not invented marketing claims. + +## Hallmark slop-test disposition + +### Closed in the rendered result + +- **Visual/structural:** no purple-blue branding gradient, equal icon-card feature grid, centred-everything hero, generic Hero → Features → CTA template, or whitespace-only section rhythm. +- **Silhouette variety:** the field-manual inversion and right context rail differ structurally from both earlier Hallmark entries and are recorded in `.hallmark/log.json`. +- **Card treatment:** major analysis sections are open chapters rather than nested rounded promotional cards; only supporting/reference groups receive low-contrast tonal containment. +- **Microinteractions:** no `transition: all`, repeated hover scaling, bouncy UI easing, cursor spotlight, blurred reveal, decorative sheen, compound card hover, or focus-ring fade. Existing primary actions alone receive a 1px tactile lift plus tokenized shadow, and route-index underlines use transform-only motion. +- **Accent discipline:** mint remains a restrained route/focus/action signal. The large inverted areas are neutral theme surfaces, not accent fills. +- **Icons:** all application glyphs remain in the 50-role local Tabler/Iconify registry. The protected logo and Oreo are brand assets, not a second UI icon library. +- **Responsive safety:** both roots use `overflow-x: clip`; display text uses `overflow-wrap: anywhere` and `min-width: 0`; controls remain single-line; every grid collapses in document order; the rail is 304px above 1100px, 220px from 721–1100px, and replaced by mobile navigation at 720px and below. +- **Sticky safety:** the right context rail docks at `top: 72px`, below the sticky top bar rather than competing at `top: 0`. +- **Input states:** repository input and action share a 50px base height; focus is immediate; disabled state uses native state, cursor, opacity, and saturation channels; recoverable errors remain visible immediately after the control. +- **Contrast:** theme-specific manual tokens cover surface, ink, muted ink, rules, accent, command surface, command ink, and command-muted ink. All rendered dark/light combinations pass axe-core. +- **Chrome/copy:** no fake browser, phone, terminal, or IDE chrome; no invented claims or generic marketing footer. +- **Mobile gates:** 320, 375, 414, 719, 720, 721, and 768px boundaries are explicitly audited, including the full field-manual inversion and the rail-to-dock ownership change. + +### Existing product-contract exceptions retained deliberately + +- **Gate 24:** the mature application retains compact component measurements outside a newly invented named spacing scale. Major rhythm is normalized without mass-rewriting established controls. +- **Gate 31:** Oreo remains a protected Lottie mascot by explicit instruction. +- **Gates 37–38:** Oreo’s protected local handwriting register remains scoped to its messenger. The application itself stays within the established display/body/mono system. +- **Gate 48, legacy scope:** the final field-manual layer introduces no raw color or font-family values outside tokens. Pre-existing state-specific colors in the mature base stylesheet were not mass-migrated beyond the requested redesign boundary. + +Functional meters, evidence notices, segmented decisions, scan phases, path controls, and failure states remain because they communicate real application state rather than decorative marketing content. + +## Impeccable detector disposition + +The required Impeccable detector ran exactly once against `public/styles.css` and `public/magic.css`. It exited 2 with 10 warnings, which were treated as defect evidence rather than a substitute for rendered review. + +- Five side-tab findings: four 3px side accents and one 8px analysis-console side accent were removed or moved into the established top-rule system. +- Three layout-transition findings: all width transitions were removed. +- Two border-on-rounded findings were reviewed and retained narrowly: `.route-status` and `.mobile-nav` are explicitly square (`border-radius: 0`) and use edge-aligned top rules as field-manual/dock boundaries, not rounded-card decoration. + +Focused structural gates now prevent layout-property transitions and thick route side borders from returning. Per the skill’s instruction, the detector was not run a second time. The exact initial output and disposition are preserved in `.hallmark/impeccable-detector.json`. + +## Preserved without behavioral change + +- Empty, loading, analysis, settings, failure, command-palette, and Oreo views +- Route composition, progress, recovery, generated install script, contract, graph, history, file browsing, and reader-depth controls +- Pollinations device authorization and the independent Custom API provider +- Data flow, persistence, API routing, cancellation, recovery, keyboard handling, and state transitions +- Every copy item, route, content section, button, link, field, option, panel, mobile destination, and responsive interaction +- Git-Up dark/light logos, Oreo mascot asset and behavior, and the 50-role local Tabler/Iconify registry +- Signature strip, ticker, contribution header, workspace field, and reduced-motion fallbacks + +## Validation + +- Full Node suite: **77/77 tests passed**; smoke check passed. +- Focused Hallmark/UI suite: **16/16 passed**, including new gates for contribution layers, restrained motion, three-line headline measure, and reduced divider treatment. +- Dynamic-refinement browser audit: **6/6 focused groups passed across 26 rendered viewport/theme/state layouts**. + - Dark empty and analysis views at 320, 375, 414, 720, 768, 1100, 1200, 1280, 1440, and 1920px + - Light empty and analysis views at 375, 768, and 1440px + - Canvas semantics, rail-bound geometry, theme opacity, headline line count, reading sizes, native smooth-anchor offset, hover scale exclusion, and computed divider removal + - Both live canvas image digests changed over time, proving the contribution and workspace fields animate + - Twelve equivalent reader-mode rerenders produced linear listener registration, zero duplicate registrations on a retained target, and exactly two retained canvases + - Particle canvas dimensions remain bounded to the visible workspace rather than the full 6,000–9,000px document height +- Impeccable polish audit remains **20/20 passed** across breakpoint boundaries, intermediate file-tree usability, long repository truth, error, modal-focus, and disabled states. +- Continuous responsive sweep: **352/352 layouts passed** across 176 sampled widths from 320–1920px in empty and analysis states, including 719/720/721px and 1099/1100/1101px boundaries. + - Zero root/body horizontal overflow + - Zero wrapped clickable labels +- axe-core: **0 violations across 14 WCAG A/AA runs** covering dark/light, empty, loading/disabled, error, analysis/success, settings-modal, mobile, and desktop states. +- JavaScript syntax checks and CSS delimiter accounting passed; `transition: all`, layout-property transitions, hover scaling outside the protected Oreo mascot, and unnamed z-index declarations remain zero. +- `git diff --check`: passed. +- Runtime Puter references: zero; obsolete spotlight references/listeners: zero; local icon roles: 50. +- Protected SHA-256 values unchanged: + - Dark logo: `10cd816671f9258f2c0f19e8cc95202ac1c7e461c5897768068b1a026388cec6` + - Light logo: `11a848e2a4bba058e9a0d8fd449a44b4641c5d5ae8753cfefc9014436428e6df` + - Oreo fallback: `b06061246432ab12c014f43e697318363d6a238fc3d78f3d6ce3fb63901e3417` + +## Evidence + +- Latest dynamic/motion/layout audit: `.hallmark/refinement-browser-audit.json` +- Latest 14-state accessibility audit: `.hallmark/refinement-axe.json` +- Latest empty views: `.hallmark/refinement-empty-1440-dark.png`, `.hallmark/refinement-empty-1440-light.png`, and `.hallmark/refinement-empty-375-dark.png` +- Latest analysis views: `.hallmark/refinement-analysis-1440-dark.png`, `.hallmark/refinement-analysis-1440-light.png`, `.hallmark/refinement-analysis-768-dark.png`, and `.hallmark/refinement-analysis-375-dark.png` +- Latest deep contract view: `.hallmark/refinement-contract-1440-dark.png` +- Latest state views: `.hallmark/refinement-error-375-dark.png`, `.hallmark/refinement-loading-375-dark.png`, and `.hallmark/refinement-settings-1280-dark.png` +- Machine-readable continuous-width audit: `.hallmark/responsive-audit.json` +- Machine-readable Impeccable polish audit: `.hallmark/polish-audit.json` +- One-time detector output and disposition: `.hallmark/impeccable-detector.json` (not rerun) +- Prior visual baseline and browser evidence remain in `.hallmark/browser-audit.json`, `.hallmark/axe-audit.json`, and the `audit-*.png` files. + +The latest screenshots were captured after entrance animation settled. Motion itself is verified by time-separated canvas digests in the refinement browser audit. diff --git a/.hallmark/axe-audit.json b/.hallmark/axe-audit.json new file mode 100644 index 0000000..b2acd52 --- /dev/null +++ b/.hallmark/axe-audit.json @@ -0,0 +1,76 @@ +{ + "date": "2026-09-05", + "design_pass": "field-manual inversion with Impeccable polish", + "engine": "Chromium 153 via Playwright 1.63.0", + "auditor": "axe-core 4.10.3", + "standards": [ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "wcag22aa" + ], + "runs": [ + { + "label": "empty/dark/320", + "violations": [] + }, + { + "label": "empty/dark/1280", + "violations": [] + }, + { + "label": "analysis/dark/320", + "violations": [] + }, + { + "label": "analysis/dark/1280", + "violations": [] + }, + { + "label": "empty/light/320", + "violations": [] + }, + { + "label": "empty/light/1280", + "violations": [] + }, + { + "label": "analysis/light/320", + "violations": [] + }, + { + "label": "analysis/light/1280", + "violations": [] + }, + { + "label": "error/dark/375", + "violations": [] + }, + { + "label": "loading-disabled/dark/375", + "violations": [] + }, + { + "label": "settings/dark/1280", + "violations": [] + }, + { + "label": "settings/light/375", + "violations": [] + }, + { + "label": "error/light/375", + "violations": [] + }, + { + "label": "loading-disabled/light/375", + "violations": [] + } + ], + "summary": { + "runs": 14, + "violations": 0 + }, + "result": "pass" +} diff --git a/.hallmark/browser-audit.json b/.hallmark/browser-audit.json new file mode 100644 index 0000000..95932e2 --- /dev/null +++ b/.hallmark/browser-audit.json @@ -0,0 +1,223 @@ +{ + "date": "2026-09-05", + "engine": "Chromium 153 via Playwright 1.63.0", + "checks": [ + { + "label": "empty/dark/320", + "viewport": 320, + "rootScrollWidth": 320, + "bodyScrollWidth": 320, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "dark" + }, + { + "label": "empty/dark/375", + "viewport": 375, + "rootScrollWidth": 375, + "bodyScrollWidth": 375, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "dark" + }, + { + "label": "empty/dark/414", + "viewport": 414, + "rootScrollWidth": 414, + "bodyScrollWidth": 414, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "dark" + }, + { + "label": "empty/dark/768", + "viewport": 768, + "rootScrollWidth": 768, + "bodyScrollWidth": 768, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "dark" + }, + { + "label": "empty/dark/1280", + "viewport": 1280, + "rootScrollWidth": 1280, + "bodyScrollWidth": 1280, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "dark" + }, + { + "label": "empty/dark/1440", + "viewport": 1440, + "rootScrollWidth": 1440, + "bodyScrollWidth": 1440, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "dark" + }, + { + "label": "empty/light/320", + "viewport": 320, + "rootScrollWidth": 320, + "bodyScrollWidth": 320, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "light" + }, + { + "label": "empty/light/375", + "viewport": 375, + "rootScrollWidth": 375, + "bodyScrollWidth": 375, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "light" + }, + { + "label": "empty/light/414", + "viewport": 414, + "rootScrollWidth": 414, + "bodyScrollWidth": 414, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "light" + }, + { + "label": "empty/light/768", + "viewport": 768, + "rootScrollWidth": 768, + "bodyScrollWidth": 768, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "light" + }, + { + "label": "empty/light/1280", + "viewport": 1280, + "rootScrollWidth": 1280, + "bodyScrollWidth": 1280, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "light" + }, + { + "label": "empty/light/1440", + "viewport": 1440, + "rootScrollWidth": 1440, + "bodyScrollWidth": 1440, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": "anywhere", + "theme": "light" + }, + { + "label": "analysis/dark/320", + "viewport": 320, + "rootScrollWidth": 320, + "bodyScrollWidth": 320, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": null, + "theme": "dark" + }, + { + "label": "analysis/dark/375", + "viewport": 375, + "rootScrollWidth": 375, + "bodyScrollWidth": 375, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": null, + "theme": "dark" + }, + { + "label": "analysis/dark/414", + "viewport": 414, + "rootScrollWidth": 414, + "bodyScrollWidth": 414, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": null, + "theme": "dark" + }, + { + "label": "analysis/dark/768", + "viewport": 768, + "rootScrollWidth": 768, + "bodyScrollWidth": 768, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": null, + "theme": "dark" + }, + { + "label": "analysis/dark/1280", + "viewport": 1280, + "rootScrollWidth": 1280, + "bodyScrollWidth": 1280, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": null, + "theme": "dark" + }, + { + "label": "analysis/dark/1440", + "viewport": 1440, + "rootScrollWidth": 1440, + "bodyScrollWidth": 1440, + "offenders": [], + "wrapped": [], + "h1Fits": true, + "h1Wrap": null, + "theme": "dark" + }, + { + "label": "analysis/reduced/375", + "particles": "none", + "marquee": "none", + "revealOpacity": "1", + "revealTransition": "0s" + }, + { + "label": "contrast/light-primary", + "ratio": 6.110961169887189, + "color": "rgb(255, 255, 255)", + "background": "rgb(9, 112, 77)" + } + ], + "design_pass": "field-manual inversion with Impeccable polish", + "summary": { + "viewport_state_checks": 20, + "result": "pass" + }, + "companion": ".hallmark/polish-audit.json covers breakpoint boundaries, long content, error, modal, and disabled states" +} diff --git a/.hallmark/impeccable-detector.json b/.hallmark/impeccable-detector.json new file mode 100644 index 0000000..c1dda55 --- /dev/null +++ b/.hallmark/impeccable-detector.json @@ -0,0 +1,140 @@ +{ + "date": "2026-09-05", + "skill_version": "4.0.0", + "targets": [ + "public/styles.css", + "public/magic.css" + ], + "run_count": 1, + "exit_code": 2, + "initial_findings": [ + { + "antipattern": "side-tab", + "name": "Side-tab accent border", + "description": "Thick colored border on one side of a card \u2014 the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.", + "severity": "warning", + "category": "slop", + "file": "/home/user/Git-up/public/styles.css", + "line": 234, + "snippet": "border-left: 3px solid var(--route)" + }, + { + "antipattern": "side-tab", + "name": "Side-tab accent border", + "description": "Thick colored border on one side of a card \u2014 the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.", + "severity": "warning", + "category": "slop", + "file": "/home/user/Git-up/public/styles.css", + "line": 742, + "snippet": "border-left: 3px solid var(--route)" + }, + { + "antipattern": "side-tab", + "name": "Side-tab accent border", + "description": "Thick colored border on one side of a card \u2014 the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.", + "severity": "warning", + "category": "slop", + "file": "/home/user/Git-up/public/styles.css", + "line": 769, + "snippet": "border-left: 3px solid var(--route)" + }, + { + "antipattern": "side-tab", + "name": "Side-tab accent border", + "description": "Thick colored border on one side of a card \u2014 the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.", + "severity": "warning", + "category": "slop", + "file": "/home/user/Git-up/public/styles.css", + "line": 1198, + "snippet": "border-left: 3px solid var(--route)" + }, + { + "antipattern": "layout-transition", + "name": "Layout property animation", + "description": "Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.", + "severity": "warning", + "category": "quality", + "file": "/home/user/Git-up/public/styles.css", + "line": 241, + "snippet": "transition: width" + }, + { + "antipattern": "layout-transition", + "name": "Layout property animation", + "description": "Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.", + "severity": "warning", + "category": "quality", + "file": "/home/user/Git-up/public/styles.css", + "line": 429, + "snippet": "transition: width" + }, + { + "antipattern": "layout-transition", + "name": "Layout property animation", + "description": "Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.", + "severity": "warning", + "category": "quality", + "file": "/home/user/Git-up/public/styles.css", + "line": 663, + "snippet": "transition: width" + }, + { + "antipattern": "side-tab", + "name": "Side-tab accent border", + "description": "Thick colored border on one side of a card \u2014 the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.", + "severity": "warning", + "category": "slop", + "file": "/home/user/Git-up/public/magic.css", + "line": 520, + "snippet": "border-left: 8px solid var(--route)" + }, + { + "antipattern": "border-accent-on-rounded", + "name": "Border accent on rounded element", + "description": "Thick accent border on a rounded card \u2014 the border clashes with the rounded corners. Remove the border or the border-radius.", + "severity": "warning", + "category": "slop", + "file": "/home/user/Git-up/public/magic.css", + "line": 657, + "snippet": "border-top: 8px solid" + }, + { + "antipattern": "border-accent-on-rounded", + "name": "Border accent on rounded element", + "description": "Thick accent border on a rounded card \u2014 the border clashes with the rounded corners. Remove the border or the border-radius.", + "severity": "warning", + "category": "slop", + "file": "/home/user/Git-up/public/magic.css", + "line": 856, + "snippet": "border-top: 6px solid" + } + ], + "disposition": { + "remediated": [ + { + "kind": "side-tab", + "count": 5, + "resolution": "Removed the four 3px side-tab accents and replaced the 8px analysis-console side accent with an established top rule." + }, + { + "kind": "layout-transition", + "count": 3, + "resolution": "Removed all width transitions so state updates no longer animate layout properties." + } + ], + "intentional_exceptions": [ + { + "kind": "border-accent-on-rounded", + "original_target": ".route-status", + "reason": "The element is explicitly square (border-radius: 0); its top rule is the field-manual section boundary, not a rounded-card accent." + }, + { + "kind": "border-accent-on-rounded", + "original_target": ".mobile-nav", + "reason": "The dock is explicitly square and edge-aligned; its top rule connects mobile navigation to the established route/manual system." + } + ] + }, + "post_remediation_verification": "Focused structural gates assert that layout-property transitions and thick route side borders remain absent. Per Impeccable guidance, the detector was not rerun.", + "result": "reviewed-and-resolved" +} diff --git a/.hallmark/log.json b/.hallmark/log.json new file mode 100644 index 0000000..8afa518 --- /dev/null +++ b/.hallmark/log.json @@ -0,0 +1,76 @@ +[ + { + "date": "2026-09-05", + "macrostructure": "Route workbench (preserved)", + "theme": "Existing Git-Up route ledger", + "enrichment": "none — function carries the app", + "brief": "Conservative anti-slop audit cleanup with every product feature preserved", + "scope": "app", + "mode": "audit-cleanup" + }, + { + "date": "2026-09-05", + "macrostructure": "Asymmetric route ledger workbench", + "theme": "Existing Git-Up route ledger", + "enrichment": "quiet repository dot field plus protected contribution and Oreo systems", + "variation_knobs": [ + "split entry composition instead of single-column hero", + "ruled route sequence instead of rounded stacked cards", + "wide path ledger plus narrow evidence rail", + "square utility geometry and low-depth surfaces" + ], + "brief": "First strong attempt; rejected because too much of the original dark-shell silhouette remained", + "scope": "app", + "mode": "redesign-in-place" + }, + { + "date": "2026-09-05", + "macrostructure": "Inverted field manual + right context rail", + "theme": "Existing Git-Up route ledger with neutral manual inversion", + "enrichment": "theme-inverted repository sheet; protected contribution and Oreo systems remain", + "variation_knobs": [ + "workspace context moved from left rail to sticky right rail", + "neutral light sheet on dark theme and dark sheet on light theme", + "full-width manual header band in analysis state", + "open ruled ledger sections instead of contained dashboard cards", + "mobile first screen inherits the same inversion rather than reverting to the old stack" + ], + "brief": "Unmistakable visual redesign after the prior attempt still looked too similar; every feature, route, component, provider, content item, brand asset, icon, and Oreo behavior remains", + "scope": "app", + "mode": "redesign-in-place" + }, + { + "date": "2026-09-05", + "macrostructure": "Inverted field manual + right context rail (preserved)", + "theme": "Existing Git-Up route ledger with neutral manual inversion", + "enrichment": "production refinement only; no replacement visual world", + "variation_knobs": [ + "readable 220px context rail from 721–1100px instead of the clipped intermediate rail", + "existing mobile navigation takes ownership at 720px and below", + "repository identity and actions stack through 1399px", + "display tracking conforms to the -0.04em craft floor", + "recoverable errors and modal/Oreo layering no longer obscure critical state", + "layout-property transitions and thick side-tab accents are removed" + ], + "brief": "Impeccable polish of the accepted field-manual redesign; repaired responsive, typography, state, interaction, and layering defects without changing content, behavior, providers, logo, icons, or Oreo functionality", + "scope": "app presentation and regression gates", + "mode": "impeccable-polish" + }, + { + "date": "2026-09-05", + "macrostructure": "Inverted field manual + right context rail (preserved)", + "theme": "Existing Git-Up route ledger with contribution-square activity", + "enrichment": "calm animated GitHub-style square field, editorial type rhythm, and selective tonal grouping", + "variation_knobs": [ + "three-line hero statement at common desktop and 375px mobile widths", + "seven-row topbar contribution canvas plus slow viewport-bound square particles", + "short opacity/translate entrances with once-only reveal sealing", + "major chapter borders removed in favor of whitespace and focused surfaces", + "one highlighted middle route stop instead of three equally contained feature tiles", + "native smooth anchor motion and restrained 1px primary-action feedback" + ], + "brief": "Dynamic visual refinement of the accepted field manual; no DOM, feature, workflow, content, provider, state, form, API, or event-handler change", + "scope": "presentation modules and focused visual regression gates", + "mode": "taste-preserve-refinement" + } +] diff --git a/.hallmark/polish-audit.json b/.hallmark/polish-audit.json new file mode 100644 index 0000000..a07f772 --- /dev/null +++ b/.hallmark/polish-audit.json @@ -0,0 +1,44 @@ +{ + "date": "2026-09-05", + "engine": "Chromium 153 via Playwright 1.63.0", + "purpose": "Impeccable polish regression audit", + "checks": [ + "empty/320", + "empty/375", + "empty/414", + "empty/719", + "empty/720", + "analysis/721", + "analysis/768", + "analysis/1100", + "analysis/1279", + "analysis/1280", + "analysis/1366", + "analysis/1399", + "analysis/1400", + "empty/1440", + "invalid/error/375", + "settings/focus-trap/escape", + "disabled/state-channels", + "long-name/320", + "long-name/768", + "long-name/1100" + ], + "coverage": [ + "320\u20131440px breakpoint boundaries", + "220px intermediate file rail", + "analysis identity/action stacking through 1399px", + "long repository names", + "recoverable mobile error", + "modal focus entry, bidirectional Tab trap, and Escape dismissal", + "multi-channel disabled state", + "root/body overflow and clickable-label wrapping", + "runtime page errors" + ], + "summary": { + "checks": 20, + "passed": 20, + "failed": 0 + }, + "result": "pass" +} diff --git a/.hallmark/preflight.json b/.hallmark/preflight.json new file mode 100644 index 0000000..35efe54 --- /dev/null +++ b/.hallmark/preflight.json @@ -0,0 +1,80 @@ +{ + "date": "2026-09-05", + "project": "Git-Up", + "font_stack": { + "display": "Avenir Next / Trebuchet MS / system sans", + "body": "system UI sans", + "mono": "system monospace", + "source": "public/styles.css :root" + }, + "palette": { + "system": "dark and light CSS custom properties", + "anchor": "Git-Up mint route color", + "manual_inversion": "neutral light field on dark theme; neutral dark field on light theme", + "source": "public/styles.css :root and [data-theme=light]" + }, + "motion": { + "stance": "calm repository activity plus restrained product feedback", + "runtime": "vanilla CSS and JavaScript over existing particles.js and canvas infrastructure; no new framework", + "reduced_motion": true + }, + "spacing": "established compact 4px/8px control rhythm with larger editorial section intervals", + "framework": "vanilla HTML, CSS, and ES modules", + "preserved": [ + "route-workbench information architecture", + "Git-Up mint anchor, typography, and dark/light mode behavior", + "all views, components, providers, content, and interactions", + "main logo, Oreo mascot, and local Tabler/Iconify glyph system" + ], + "introduced": [ + "theme-inverted field-manual entry surface", + "sticky right-hand context rail replacing the old left-rail silhouette", + "matching full-width manual analysis control band", + "open chapter-led analysis flow instead of rounded dashboard cards or repeated panel rules", + "larger readable display and analysis typography with a three-line headline at common widths", + "restrained radius, depth, texture, and accent footprint", + "explicit route-ink and manual-surface token families", + "continuous responsive overflow, wrapping, touch, focus, disabled-state, and accessibility gates" + ], + "polish": { + "command": "Impeccable polish", + "incumbent_identity": "preserved", + "responsive_contract": { + "wide_above_1100": "304px sticky context rail", + "intermediate_721_to_1100": "220px readable context rail with history, file filter/tree, and local context retained", + "mobile_at_or_below_720": "context rail hidden; existing bottom navigation exposed", + "analysis_at_or_below_1399": "repository identity and action group stack to protect names and canonical URLs" + }, + "typography": [ + "display tracking held at -0.04em craft floor", + "hero headline reflowed to three lines at 375px and common desktop widths", + "hero body copy constrained to 46ch; chapter reading text raised to 13px or more" + ], + "state_finish": [ + "recoverable errors follow the repository control before reader-depth choices", + "Oreo yields to modal layering and clears the mobile error region", + "disabled controls use cursor, opacity, and saturation channels" + ], + "detector": "ran exactly once; eight findings remediated and two square top-rule exceptions documented" + }, + "dynamic_refinement": { + "mode": "preserve", + "dials": { + "design_variance": 6, + "motion_intensity": 5, + "visual_density": 5 + }, + "reference_mapping": { + "Animate.css": "scoped duration and delay with short one-shot entrances", + "Hover.css": "1px primary-action lift and transform-only route underline", + "Vanilla Framework": "type measure, spacing, and component-scoped discipline", + "NES.css": "crisp square activity cells and selective hard-edged accents", + "particles.js": "slow density-scaled square workspace activity", + "Lenis": "native smooth scrolling and anchor offset without scroll hijacking", + "ScrollReveal": "existing once-only IntersectionObserver with stagger and seal" + }, + "divider_strategy": "major chapters separated by whitespace; tonal grouping reserved for supporting evidence; functional form and command boundaries retained", + "performance": "workspace canvas fixed to the visible viewport and bounded around the 304px/220px rail", + "functional_change": "none" + } +} diff --git a/.hallmark/refinement-analysis-1440-dark.png b/.hallmark/refinement-analysis-1440-dark.png new file mode 100644 index 0000000..724b791 Binary files /dev/null and b/.hallmark/refinement-analysis-1440-dark.png differ diff --git a/.hallmark/refinement-analysis-1440-light.png b/.hallmark/refinement-analysis-1440-light.png new file mode 100644 index 0000000..3d4d9cb Binary files /dev/null and b/.hallmark/refinement-analysis-1440-light.png differ diff --git a/.hallmark/refinement-analysis-375-dark.png b/.hallmark/refinement-analysis-375-dark.png new file mode 100644 index 0000000..ae58b9a Binary files /dev/null and b/.hallmark/refinement-analysis-375-dark.png differ diff --git a/.hallmark/refinement-analysis-768-dark.png b/.hallmark/refinement-analysis-768-dark.png new file mode 100644 index 0000000..8779482 Binary files /dev/null and b/.hallmark/refinement-analysis-768-dark.png differ diff --git a/.hallmark/refinement-axe.json b/.hallmark/refinement-axe.json new file mode 100644 index 0000000..efac3f1 --- /dev/null +++ b/.hallmark/refinement-axe.json @@ -0,0 +1,66 @@ +{ + "date": "2026-09-05", + "engine": "Chromium via Playwright + axe-core", + "runs": [ + { + "label": "empty/dark/320", + "violations": [] + }, + { + "label": "empty/dark/1280", + "violations": [] + }, + { + "label": "analysis/dark/320", + "violations": [] + }, + { + "label": "analysis/dark/1280", + "violations": [] + }, + { + "label": "empty/light/320", + "violations": [] + }, + { + "label": "empty/light/1280", + "violations": [] + }, + { + "label": "analysis/light/320", + "violations": [] + }, + { + "label": "analysis/light/1280", + "violations": [] + }, + { + "label": "error/dark/375", + "violations": [] + }, + { + "label": "loading-disabled/dark/375", + "violations": [] + }, + { + "label": "settings/dark/1280", + "violations": [] + }, + { + "label": "settings/light/375", + "violations": [] + }, + { + "label": "error/light/375", + "violations": [] + }, + { + "label": "loading-disabled/light/375", + "violations": [] + } + ], + "summary": { + "runs": 14, + "violations": 0 + } +} diff --git a/.hallmark/refinement-browser-audit.json b/.hallmark/refinement-browser-audit.json new file mode 100644 index 0000000..baa4ac7 --- /dev/null +++ b/.hallmark/refinement-browser-audit.json @@ -0,0 +1,1030 @@ +{ + "date": "2026-09-05", + "engine": "Chromium via Playwright", + "result": "pass", + "summary": "6 focused groups; 26 rendered viewport/theme/state layouts", + "checks": [ + "layout-and-canvas:26", + "animated-pixel-layers", + "restrained-hover-feedback", + "native-smooth-anchor-offset", + "listener-and-canvas-lifecycle", + "prefers-reduced-motion" + ], + "states": [ + { + "label": "empty/dark/320", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 320, + "rootWidth": 320, + "bodyWidth": 320, + "wrapped": [], + "h1Lines": 4, + "heroCopyFont": 15, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 320, + "height": 772, + "canvas": [ + 320, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/320", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 320, + "rootWidth": 320, + "bodyWidth": 320, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 320, + "height": 772, + "canvas": [ + 320, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/375", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 375, + "rootWidth": 375, + "bodyWidth": 375, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 15, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 375, + "height": 772, + "canvas": [ + 375, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/375", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 375, + "rootWidth": 375, + "bodyWidth": 375, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 375, + "height": 772, + "canvas": [ + 375, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/414", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 414, + "rootWidth": 414, + "bodyWidth": 414, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 15, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 414, + "height": 772, + "canvas": [ + 414, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/414", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 414, + "rootWidth": 414, + "bodyWidth": 414, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 414, + "height": 772, + "canvas": [ + 414, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/720", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 720, + "rootWidth": 720, + "bodyWidth": 720, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 15, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 720, + "height": 764, + "canvas": [ + 720, + 764 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/720", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 720, + "rootWidth": 720, + "bodyWidth": 720, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "0px", + "width": 720, + "height": 764, + "canvas": [ + 720, + 764 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/768", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 768, + "rootWidth": 768, + "bodyWidth": 768, + "wrapped": [], + "h1Lines": 4, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "220px", + "width": 548, + "height": 828, + "canvas": [ + 548, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/768", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 768, + "rootWidth": 768, + "bodyWidth": 768, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "220px", + "width": 548, + "height": 828, + "canvas": [ + 548, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/1100", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1100, + "rootWidth": 1100, + "bodyWidth": 1100, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "220px", + "width": 880, + "height": 828, + "canvas": [ + 880, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/1100", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1100, + "rootWidth": 1100, + "bodyWidth": 1100, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "220px", + "width": 880, + "height": 828, + "canvas": [ + 880, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/1200", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1200, + "rootWidth": 1200, + "bodyWidth": 1200, + "wrapped": [], + "h1Lines": 4, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 896, + "height": 828, + "canvas": [ + 896, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/1200", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1200, + "rootWidth": 1200, + "bodyWidth": 1200, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 896, + "height": 828, + "canvas": [ + 896, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/1280", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1280, + "rootWidth": 1280, + "bodyWidth": 1280, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 976, + "height": 828, + "canvas": [ + 976, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/1280", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1280, + "rootWidth": 1280, + "bodyWidth": 1280, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 976, + "height": 828, + "canvas": [ + 976, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/1440", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1440, + "rootWidth": 1440, + "bodyWidth": 1440, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 1136, + "height": 828, + "canvas": [ + 1136, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/1440", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1440, + "rootWidth": 1440, + "bodyWidth": 1440, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 1136, + "height": 828, + "canvas": [ + 1136, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/dark/1920", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1920, + "rootWidth": 1920, + "bodyWidth": 1920, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 1616, + "height": 828, + "canvas": [ + 1616, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "analysis/dark/1920", + "theme": "dark", + "scrollBehavior": "smooth", + "viewport": 1920, + "rootWidth": 1920, + "bodyWidth": 1920, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.24", + "display": "block", + "right": "304px", + "width": 1616, + "height": 828, + "canvas": [ + 1616, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.58", + "canvas": true + } + }, + { + "label": "empty/light/375", + "theme": "light", + "scrollBehavior": "smooth", + "viewport": 375, + "rootWidth": 375, + "bodyWidth": 375, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 15, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.16", + "display": "block", + "right": "0px", + "width": 375, + "height": 772, + "canvas": [ + 375, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.3", + "canvas": true + } + }, + { + "label": "analysis/light/375", + "theme": "light", + "scrollBehavior": "smooth", + "viewport": 375, + "rootWidth": 375, + "bodyWidth": 375, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.16", + "display": "block", + "right": "0px", + "width": 375, + "height": 772, + "canvas": [ + 375, + 772 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.3", + "canvas": true + } + }, + { + "label": "empty/light/768", + "theme": "light", + "scrollBehavior": "smooth", + "viewport": 768, + "rootWidth": 768, + "bodyWidth": 768, + "wrapped": [], + "h1Lines": 4, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.16", + "display": "block", + "right": "220px", + "width": 548, + "height": 828, + "canvas": [ + 548, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.3", + "canvas": true + } + }, + { + "label": "analysis/light/768", + "theme": "light", + "scrollBehavior": "smooth", + "viewport": 768, + "rootWidth": 768, + "bodyWidth": 768, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.16", + "display": "block", + "right": "220px", + "width": 548, + "height": 828, + "canvas": [ + 548, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.3", + "canvas": true + } + }, + { + "label": "empty/light/1440", + "theme": "light", + "scrollBehavior": "smooth", + "viewport": 1440, + "rootWidth": 1440, + "bodyWidth": 1440, + "wrapped": [], + "h1Lines": 3, + "heroCopyFont": 16, + "panelHeadingFont": null, + "readingFont": null, + "borders": { + "initial": "0px", + "routeTop": "0px", + "routeBottom": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.16", + "display": "block", + "right": "304px", + "width": 1136, + "height": 828, + "canvas": [ + 1136, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.3", + "canvas": true + } + }, + { + "label": "analysis/light/1440", + "theme": "light", + "scrollBehavior": "smooth", + "viewport": 1440, + "rootWidth": 1440, + "bodyWidth": 1440, + "wrapped": [], + "h1Lines": 0, + "heroCopyFont": null, + "panelHeadingFont": 20, + "readingFont": 14, + "borders": { + "analysis": "0px", + "panelTop": "0px", + "panelBottom": "0px", + "panelHeading": "0px", + "step": "0px" + }, + "particle": { + "ariaHidden": "true", + "pointerEvents": "none", + "position": "fixed", + "opacity": "0.16", + "display": "block", + "right": "304px", + "width": 1136, + "height": 828, + "canvas": [ + 1136, + 828 + ] + }, + "topbar": { + "ariaHidden": "true", + "pointerEvents": "none", + "opacity": "0.3", + "canvas": true + } + } + ] +} diff --git a/.hallmark/refinement-contract-1440-dark.png b/.hallmark/refinement-contract-1440-dark.png new file mode 100644 index 0000000..428285d Binary files /dev/null and b/.hallmark/refinement-contract-1440-dark.png differ diff --git a/.hallmark/refinement-empty-1440-dark.png b/.hallmark/refinement-empty-1440-dark.png new file mode 100644 index 0000000..a50d5b1 Binary files /dev/null and b/.hallmark/refinement-empty-1440-dark.png differ diff --git a/.hallmark/refinement-empty-1440-light.png b/.hallmark/refinement-empty-1440-light.png new file mode 100644 index 0000000..8feecd5 Binary files /dev/null and b/.hallmark/refinement-empty-1440-light.png differ diff --git a/.hallmark/refinement-empty-375-dark.png b/.hallmark/refinement-empty-375-dark.png new file mode 100644 index 0000000..cee17c4 Binary files /dev/null and b/.hallmark/refinement-empty-375-dark.png differ diff --git a/.hallmark/refinement-error-375-dark.png b/.hallmark/refinement-error-375-dark.png new file mode 100644 index 0000000..5551959 Binary files /dev/null and b/.hallmark/refinement-error-375-dark.png differ diff --git a/.hallmark/refinement-loading-375-dark.png b/.hallmark/refinement-loading-375-dark.png new file mode 100644 index 0000000..81eedcc Binary files /dev/null and b/.hallmark/refinement-loading-375-dark.png differ diff --git a/.hallmark/refinement-settings-1280-dark.png b/.hallmark/refinement-settings-1280-dark.png new file mode 100644 index 0000000..ff3cd81 Binary files /dev/null and b/.hallmark/refinement-settings-1280-dark.png differ diff --git a/.hallmark/responsive-audit.json b/.hallmark/responsive-audit.json new file mode 100644 index 0000000..88243d3 --- /dev/null +++ b/.hallmark/responsive-audit.json @@ -0,0 +1,202 @@ +{ + "date": "2026-09-05", + "engine": "Chromium 153 via Playwright 1.63.0", + "range": { + "min": 320, + "max": 1920 + }, + "states": [ + "empty", + "analysis" + ], + "widths": [ + 320, + 330, + 340, + 350, + 360, + 370, + 375, + 380, + 390, + 400, + 410, + 414, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 719, + 720, + 721, + 730, + 740, + 750, + 760, + 767, + 768, + 769, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 899, + 900, + 901, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1099, + 1100, + 1101, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1199, + 1200, + 1201, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1279, + 1280, + 1281, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600, + 1610, + 1620, + 1630, + 1640, + 1650, + 1660, + 1670, + 1680, + 1690, + 1700, + 1710, + 1720, + 1730, + 1740, + 1750, + 1760, + 1770, + 1780, + 1790, + 1800, + 1810, + 1820, + 1830, + 1840, + 1850, + 1860, + 1870, + 1880, + 1890, + 1900, + 1910, + 1920 + ], + "assertions": [ + "document root has no horizontal overflow", + "body has no horizontal overflow", + "visible primary controls retain single-line labels" + ], + "summary": { + "sampled_widths": 176, + "layouts": 352, + "passed": 352, + "failed": 0 + }, + "result": "pass" +} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..118a405 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,29 @@ +# Third-party notices + +## Tabler Icons + +The UI icons embedded in `public/icons.js` are selected from **Tabler Icons 3.46.0** through Iconify (`tabler:*`). + +Source: + +MIT License + +Copyright (c) 2020-2026 Paweł Kuna + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/ASSETS.md b/docs/ASSETS.md index d546487..67b788a 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -10,6 +10,7 @@ This register covers assets used by the root Git-Up application and README. The | `assets/logo/icon_light_mode.png` | Primary light-mode app icon and favicon. | Pre-existing repository asset; no upstream source URL was present in the checkout. | Project maintainers / not declared in files. | Not separately declared; root project license is also not declared. | Not available. | 2026-09-05 | None in this pass. | No third-party attribution identified. Maintainers should document the logo source/ownership before publishing a formal release. | | `public/assets/logo/icon_Dark_mode.png` | Duplicate dark-mode icon kept for static-host compatibility. | Same as `assets/logo/icon_Dark_mode.png`. | Project maintainers / not declared in files. | Not separately declared; root project license is also not declared. | Not available. | 2026-09-05 | None in this pass. | Same as source logo. | | `public/assets/logo/icon_light_mode.png` | Duplicate light-mode icon kept for static-host compatibility. | Same as `assets/logo/icon_light_mode.png`. | Project maintainers / not declared in files. | Not separately declared; root project license is also not declared. | Not available. | 2026-09-05 | None in this pass. | Same as source logo. | +| `public/icons.js` | Local registry of 50 reusable UI glyphs on one 24×24 outline grid. | [Tabler Icons 3.46.0 via Iconify](https://icon-sets.iconify.design/tabler/) | Paweł Kuna / Tabler contributors. | MIT. | | 2026-09-05 | Semantically selected and retrieved through `skills/iconify-skill`; embedded as sanitized `currentColor` SVG bodies with a shared renderer. No logo or Oreo mascot asset is included. | Tabler Icons © 2020-2026 Paweł Kuna, licensed under MIT. Full text: `THIRD_PARTY_NOTICES.md`. | | `assets/mascot/oreo-route-bot.svg` | Standby local Oreo artwork (currently unused by the app; kept as a fallback). | Created from scratch in this audit. | Arena.ai agent for the Git-Up maintainers. | Project-owned; no separate license until the root project chooses one. | Not available. | 2026-09-05 | Original SVG; no external references, script, raster images, or embedded fonts. | No third-party attribution required. | | Remote Lottie robot (`stZ4jBVCdO.lottie`) + dotLottie player module | Animated Oreo chatbot button mascot, loaded from the network at runtime. | (player: ) | LottieFiles uploader / not declared; player component authors. | Not verified from the repository; maintainers confirmed this exact source. | Not available. | 2026-09-05 | None; streamed as-is with `loop autoplay`. | Attribution/licensing of the animation file is unverified — confirm rights with the LottieFiles source before a formal release. | | `docs/assets/git-up-workflow.svg` | README architecture/workflow diagram. | Created from scratch in this audit from repository evidence. | Arena.ai agent for the Git-Up maintainers. | Project-owned; no separate license until the root project chooses one. | Not available. | 2026-09-05 | Original SVG; no external references, script, raster images, or embedded fonts. | No third-party attribution required. | @@ -19,5 +20,6 @@ This register covers assets used by the root Git-Up application and README. The ## Safety notes - The new SVG files contain only static shapes, gradients, and text metadata. They do not contain scripts, remote image references, event handlers, or embedded data URIs. +- The embedded Tabler UI glyphs contain static SVG paths/groups only, inherit `currentColor`, and require no icon CDN or runtime network request. - The Oreo button streams a remote dotLottie animation plus an external player module at runtime (sources recorded in the table above). This reintroduces a browser-side network dependency by maintainer request; if the CDN or animation host is unreachable, the button renders without its animation. - Important README visuals are local files; external badges are allowed only for stable GitHub-owned status endpoints. diff --git a/public/ai/ai-http.js b/public/ai/ai-http.js new file mode 100644 index 0000000..4fb3fff --- /dev/null +++ b/public/ai/ai-http.js @@ -0,0 +1,91 @@ +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function responsePayload(response) { + try { return await response.json(); } catch { return null; } +} + +export async function postJson(path, body, fetchImpl = globalThis.fetch) { + const response = await fetchImpl(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const payload = await responsePayload(response); + if (!response.ok || !payload?.ok) throw new Error(payload?.error || `Request failed with status ${response.status}.`); + return payload; +} + +export async function analyzeWithStream(body, { onProgress = () => {}, fetchImpl = globalThis.fetch } = {}) { + const response = await fetchImpl('/api/analyze-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`Stream endpoint returned ${response.status}`); + if (!response.body?.getReader) throw new Error('Streaming is not available in this browser.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const raw = line.slice(6).trim(); + if (!raw) continue; + try { + const message = JSON.parse(raw); + if (message.phase === 'error') throw new Error(message.error || 'Stream reported an error.'); + if (message.phase === 'result') { + onProgress({ phase: 'done', label: message.label || 'Analysis complete', percent: 100, error: '' }); + return message.guide; + } + onProgress({ phase: message.phase, label: message.label || '', percent: Number(message.percent) || 0, error: '' }); + } catch (error) { + if (error instanceof SyntaxError) continue; + throw error; + } + } + } + throw new Error('Stream ended without a result.'); +} + +export async function analyzeWithPoll(body, { + onProgress = () => {}, + aiReady = false, + fetchImpl = globalThis.fetch, + phaseDelay = 350, +} = {}) { + const phases = [ + { phase: 'repository', label: 'Reading repository metadata…', percent: 10 }, + { phase: 'files', label: 'Scanning setup files…', percent: 25 }, + { phase: 'ai', label: aiReady ? 'Reviewing with AI…' : 'Building local guide…', percent: 45 }, + { phase: 'failures', label: 'Checking failure history…', percent: 55 }, + { phase: 'health', label: 'Computing health score…', percent: 65 }, + { phase: 'path', label: 'Composing install path…', percent: 78 }, + { phase: 'contract', label: 'Building install contract…', percent: 90 }, + { phase: 'tuning', label: 'Tuning for your level…', percent: 95 }, + ]; + for (const phase of phases) { + onProgress(phase); + if (phaseDelay > 0) await wait(phaseDelay); + } + const payload = await postJson('/api/analyze', body, fetchImpl); + onProgress({ phase: 'done', label: 'Analysis complete', percent: 100, error: '' }); + return payload.guide; +} + +/** Keep the existing SSE-first, JSON-fallback behavior shared by both adapters. */ +export async function runAnalysis(body, options = {}) { + try { + return await analyzeWithStream(body, options); + } catch { + return analyzeWithPoll(body, options); + } +} diff --git a/public/ai/ai-provider.js b/public/ai/ai-provider.js new file mode 100644 index 0000000..06c8a83 --- /dev/null +++ b/public/ai/ai-provider.js @@ -0,0 +1,56 @@ +export const AI_PROVIDER = Object.freeze({ + CUSTOM: 'custom', + POLLINATIONS: 'pollinations', +}); + +export const AI_CONNECTION = Object.freeze({ + NO_PROVIDER: 'NO_PROVIDER', + CUSTOM_CONNECTED: 'CUSTOM_CONNECTED', + CUSTOM_ERROR: 'CUSTOM_ERROR', + POLLINATIONS_CONNECTED: 'POLLINATIONS_CONNECTED', + POLLINATIONS_ERROR: 'POLLINATIONS_ERROR', + POLLINATIONS_AUTH_REQUIRED: 'POLLINATIONS_AUTH_REQUIRED', + POLLINATIONS_CONNECTING: 'POLLINATIONS_CONNECTING', +}); + +export function normaliseAiProvider(value) { + return value === AI_PROVIDER.POLLINATIONS ? AI_PROVIDER.POLLINATIONS : AI_PROVIDER.CUSTOM; +} + +export function customConfigReady(config) { + return Boolean(config?.baseUrl && config?.apiKey && config?.model); +} + +/** + * One source of truth for the status shown in the topbar and settings modal. + * Pollinations authorization is kept separately because switching providers + * must never destroy either provider's configuration or browser session. + */ +export function activeConnectionState({ activeProvider, customReady, customError = false, pollinationsStatus }) { + if (normaliseAiProvider(activeProvider) === AI_PROVIDER.POLLINATIONS) { + if (pollinationsStatus === AI_CONNECTION.POLLINATIONS_CONNECTED) return AI_CONNECTION.POLLINATIONS_CONNECTED; + if (pollinationsStatus === AI_CONNECTION.POLLINATIONS_CONNECTING) return AI_CONNECTION.POLLINATIONS_CONNECTING; + if (pollinationsStatus === AI_CONNECTION.POLLINATIONS_ERROR) return AI_CONNECTION.POLLINATIONS_ERROR; + return AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED; + } + if (customError) return AI_CONNECTION.CUSTOM_ERROR; + return customReady ? AI_CONNECTION.CUSTOM_CONNECTED : AI_CONNECTION.NO_PROVIDER; +} + +export function connectionPresentation(connection) { + switch (connection) { + case AI_CONNECTION.CUSTOM_CONNECTED: + return { label: 'Custom API Connected', tone: 'online' }; + case AI_CONNECTION.POLLINATIONS_CONNECTED: + return { label: 'Free AI Connected', tone: 'online' }; + case AI_CONNECTION.POLLINATIONS_CONNECTING: + return { label: 'Free AI Awaiting Approval…', tone: 'pending' }; + case AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED: + return { label: 'Free AI — Authorization Required', tone: 'offline' }; + case AI_CONNECTION.CUSTOM_ERROR: + case AI_CONNECTION.POLLINATIONS_ERROR: + return { label: 'AI Connection Error', tone: 'warning' }; + default: + return { label: 'AI Not Connected', tone: 'offline' }; + } +} diff --git a/public/ai/ai-service.js b/public/ai/ai-service.js new file mode 100644 index 0000000..8647719 --- /dev/null +++ b/public/ai/ai-service.js @@ -0,0 +1,48 @@ +import { AI_PROVIDER, normaliseAiProvider } from './ai-provider.js'; +import { customApiProvider } from './custom-api-provider.js'; +import { freeAiMessage, pollinationsProvider } from './pollinations-provider.js'; + +/** + * Central provider router. The application asks for a logical operation and + * never calls an OpenAI-compatible or Pollinations endpoint directly. + */ +export function createAiService({ custom = customApiProvider, pollinations = pollinationsProvider } = {}) { + const adapters = { + [AI_PROVIDER.CUSTOM]: custom, + [AI_PROVIDER.POLLINATIONS]: pollinations, + }; + return Object.freeze({ + async generate({ provider, operation, payload, config, onProgress, fetchImpl }) { + const active = normaliseAiProvider(provider); + return adapters[active].generate({ operation, payload, config, onProgress, fetchImpl }); + }, + + fetchCustomModels(values) { + return custom.fetchModels(values); + }, + + isFreeAiConnected(options) { + return pollinations.isConnected(options); + }, + + connectFreeAi(options) { + return pollinations.connect(options); + }, + + cancelFreeAiConnection() { + return pollinations.cancelConnection(); + }, + + disconnectFreeAi() { + return pollinations.disconnect(); + }, + + testFreeAi(options) { + return pollinations.testConnection(options); + }, + + freeAiMessage, + }); +} + +export const aiService = createAiService(); diff --git a/public/ai/custom-api-provider.js b/public/ai/custom-api-provider.js new file mode 100644 index 0000000..0b0c590 --- /dev/null +++ b/public/ai/custom-api-provider.js @@ -0,0 +1,29 @@ +import { customConfigReady } from './ai-provider.js'; +import { postJson, runAnalysis } from './ai-http.js'; + +/** Adapter for the pre-existing OpenAI-compatible server proxy. */ +export const customApiProvider = Object.freeze({ + id: 'custom', + + isConnected(config) { + return customConfigReady(config); + }, + + async generate({ operation, payload, config, onProgress, fetchImpl }) { + const body = { ...payload, config: customConfigReady(config) ? { ...config } : null }; + if (operation === 'analyze') { + return runAnalysis(body, { + onProgress, + fetchImpl, + aiReady: customConfigReady(config), + }); + } + if (operation === 'insight') return (await postJson('/api/insight', body, fetchImpl)).insight; + if (operation === 'recover') return (await postJson('/api/recover', body, fetchImpl)).recovery; + throw new Error(`Unsupported AI operation: ${operation}`); + }, + + async fetchModels({ baseUrl, apiKey, fetchImpl }) { + return (await postJson('/api/models', { baseUrl, apiKey }, fetchImpl)).models || []; + }, +}); diff --git a/public/ai/pollinations-provider.js b/public/ai/pollinations-provider.js new file mode 100644 index 0000000..bb27de3 --- /dev/null +++ b/public/ai/pollinations-provider.js @@ -0,0 +1,370 @@ +import { postJson, runAnalysis } from './ai-http.js'; + +export const POLLINATIONS_DEFAULT_MODEL = 'openai'; +export const POLLINATIONS_AUTH_BASE_URL = 'https://enter.pollinations.ai'; +export const POLLINATIONS_API_BASE_URL = 'https://gen.pollinations.ai/v1'; +export const POLLINATIONS_SESSION_KEY = 'git-up-pollinations-token'; + +const DEVICE_SCOPE = 'generate'; +const MAX_DEVICE_LIFETIME_SECONDS = 30 * 60; +const MIN_POLL_INTERVAL_MS = 5_000; + +export class FreeAIError extends Error { + constructor(code, message, cause) { + super(message); + this.name = 'FreeAIError'; + this.code = code; + this.cause = cause; + } +} + +function contentText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((part) => typeof part === 'string' ? part : (part?.text || part?.content || '')) + .filter(Boolean) + .join(''); +} + +export function extractPollinationsText(response) { + if (typeof response === 'string') return response; + const candidates = [ + response?.choices?.[0]?.message?.content, + response?.choices?.[0]?.text, + response?.output_text, + response?.text, + response?.content, + ]; + for (const candidate of candidates) { + const text = contentText(candidate); + if (text.trim()) return text; + } + return ''; +} + +function defaultStorage() { + try { return globalThis.sessionStorage || null; } + catch { return null; } +} + +function abortableWait(ms, signal, setTimer, clearTimer) { + if (signal?.aborted) return Promise.reject(new FreeAIError('authorization-cancelled', 'Pollinations authorization was cancelled.')); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimer(timer); + signal?.removeEventListener?.('abort', onAbort); + reject(new FreeAIError('authorization-cancelled', 'Pollinations authorization was cancelled.')); + }; + const timer = setTimer(() => { + signal?.removeEventListener?.('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener?.('abort', onAbort, { once: true }); + }); +} + +function asSeconds(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} + +function authorizationError(error) { + if (error instanceof FreeAIError) return error; + if (error?.name === 'AbortError') return new FreeAIError('authorization-cancelled', 'Pollinations authorization was cancelled.', error); + return new FreeAIError('network-error', 'Pollinations could not be reached. Check your network and try again.', error); +} + +async function readJson(response) { + try { return await response.json(); } + catch { return {}; } +} + +function trustedVerificationUri(value) { + try { + const parsed = new URL(String(value || '')); + if (parsed.protocol === 'https:' && parsed.hostname === 'enter.pollinations.ai') return parsed.href; + } catch { /* use the known authorization page below */ } + return `${POLLINATIONS_AUTH_BASE_URL}/device`; +} + +/** + * Browser-side Pollinations adapter. Device authorization needs no developer + * secret or pasted key. Its delegated token is held in memory and, when + * available, sessionStorage only; it is sent solely in Pollinations request + * headers and never to Git-Up's server. + */ +export function createPollinationsProvider({ + fetchImpl = (url, options) => globalThis.fetch(url, options), + storage = defaultStorage(), + now = () => Date.now(), + wait, + setTimer = (handler, ms) => globalThis.setTimeout(handler, ms), + clearTimer = (timer) => globalThis.clearTimeout(timer), +} = {}) { + let token = ''; + let connectPromise = null; + let connectController = null; + + try { + const saved = storage?.getItem?.(POLLINATIONS_SESSION_KEY); + if (typeof saved === 'string' && saved.length <= 16_384) token = saved.trim(); + } catch { /* memory-only authorization still works */ } + + const removeToken = () => { + token = ''; + try { storage?.removeItem?.(POLLINATIONS_SESSION_KEY); } catch { /* ignore disabled storage */ } + }; + const saveToken = (value) => { + const nextToken = String(value || '').trim(); + if (!nextToken || nextToken.length > 16_384) { + throw new FreeAIError('invalid-response', 'Pollinations returned an invalid authorization response.'); + } + token = nextToken; + try { storage?.setItem?.(POLLINATIONS_SESSION_KEY, token); } catch { /* keep it in memory for this page */ } + }; + const selectedFetch = (override) => override || fetchImpl; + const pause = wait || ((ms, signal) => abortableWait(ms, signal, setTimer, clearTimer)); + + const request = async (url, options, { signal, timeoutMs = 20_000, overrideFetch } = {}) => { + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(); + signal?.addEventListener?.('abort', onAbort, { once: true }); + if (signal?.aborted) controller.abort(); + const timer = setTimer(() => { timedOut = true; controller.abort(); }, timeoutMs); + try { + return await selectedFetch(overrideFetch)(url, { ...options, signal: controller.signal }); + } catch (error) { + if (signal?.aborted) throw new FreeAIError('authorization-cancelled', 'Pollinations authorization was cancelled.', error); + if (timedOut || error?.name === 'AbortError') throw new FreeAIError('network-error', 'Pollinations took too long to respond. Please try again.', error); + throw new FreeAIError('network-error', 'Pollinations could not be reached. Check your network and try again.', error); + } finally { + clearTimer(timer); + signal?.removeEventListener?.('abort', onAbort); + } + }; + + const responseError = (status, payload, fallbackCode = 'request-failed') => { + const remoteCode = String(payload?.error?.code || payload?.error || payload?.code || '').toLowerCase(); + if (status === 401 || status === 403 || /invalid_token|expired_token/.test(remoteCode)) { + const hadToken = Boolean(token); + removeToken(); + return hadToken + ? new FreeAIError('auth-expired', 'Your Pollinations authorization has expired. Reconnect Free AI and try again.') + : new FreeAIError(fallbackCode, 'Pollinations could not authorize this browser session. Please try again.'); + } + if (status === 402 || /insufficient|balance|budget/.test(remoteCode)) { + return new FreeAIError('insufficient-balance', 'Your Pollinations account does not have enough Pollen for this request. Add or earn Pollen, then retry.'); + } + if (status === 429 || /rate.limit|too_many/.test(remoteCode)) { + return new FreeAIError('rate-limited', 'Pollinations is receiving too many requests. Wait a moment, then retry.'); + } + if (status >= 500) return new FreeAIError('request-failed', 'Pollinations is temporarily unavailable. Please retry shortly.'); + return new FreeAIError(fallbackCode, 'Pollinations could not complete the request. Please retry.'); + }; + + const verifySession = async (overrideFetch) => { + if (!token) return false; + const response = await request(`${POLLINATIONS_AUTH_BASE_URL}/api/device/userinfo`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }, { overrideFetch }); + if (response.ok) return true; + throw responseError(response.status, await readJson(response), 'test-failed'); + }; + + const chat = async (messages, options = {}, overrideFetch) => { + if (!token) throw new FreeAIError('auth-required', 'Free AI is not connected.'); + const body = { + model: POLLINATIONS_DEFAULT_MODEL, + messages, + stream: false, + ...(Number.isFinite(options.temperature) ? { temperature: options.temperature } : {}), + ...(Number.isFinite(options.max_tokens) ? { max_tokens: options.max_tokens } : {}), + }; + const response = await request(`${POLLINATIONS_API_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }, { timeoutMs: 90_000, overrideFetch }); + const payload = await readJson(response); + if (!response.ok) throw responseError(response.status, payload); + const text = extractPollinationsText(payload); + if (!text.trim()) throw new FreeAIError('invalid-response', 'Pollinations returned an empty response. Please retry.'); + return text; + }; + + const prepare = async (operation, payload, overrideFetch) => { + const result = await postJson('/api/ai/prepare', { operation, payload }, overrideFetch || fetchImpl); + if (!Array.isArray(result.request?.messages) || !result.request.messages.length) { + throw new FreeAIError('prepare-failed', 'Git-Up could not prepare the Free AI request.'); + } + return result.request; + }; + + const provider = { + id: 'pollinations', + + async isConnected({ fetchImpl: overrideFetch } = {}) { + return verifySession(overrideFetch); + }, + + connect({ onAuthorization, signal: externalSignal, fetchImpl: overrideFetch } = {}) { + if (token) return Promise.resolve(true); + if (connectPromise) return connectPromise; + + const controller = new AbortController(); + connectController = controller; + const forwardAbort = () => controller.abort(); + externalSignal?.addEventListener?.('abort', forwardAbort, { once: true }); + if (externalSignal?.aborted) controller.abort(); + + const task = (async () => { + let codeResponse; + try { + codeResponse = await request(`${POLLINATIONS_AUTH_BASE_URL}/api/device/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope: DEVICE_SCOPE }), + }, { signal: controller.signal, overrideFetch }); + } catch (error) { + throw authorizationError(error); + } + const code = await readJson(codeResponse); + if (controller.signal.aborted) throw new FreeAIError('authorization-cancelled', 'Pollinations authorization was cancelled.'); + if (!codeResponse.ok) throw responseError(codeResponse.status, code, 'authorization-failed'); + const deviceCode = typeof code.device_code === 'string' ? code.device_code.trim() : ''; + const userCode = typeof code.user_code === 'string' ? code.user_code.trim() : ''; + if (!deviceCode || deviceCode.length > 4_096 || !userCode || userCode.length > 64) { + throw new FreeAIError('invalid-response', 'Pollinations returned an invalid authorization response.'); + } + + const lifetimeSeconds = Math.min(asSeconds(code.expires_in, MAX_DEVICE_LIFETIME_SECONDS), MAX_DEVICE_LIFETIME_SECONDS); + const expiresAt = now() + (lifetimeSeconds * 1_000); + let pollInterval = Math.max(asSeconds(code.interval, 5) * 1_000, MIN_POLL_INTERVAL_MS); + const authorization = Object.freeze({ + userCode, + verificationUri: trustedVerificationUri(code.verification_uri_complete || code.verification_uri), + expiresAt, + }); + try { onAuthorization?.(authorization); } catch { /* UI callbacks cannot interrupt authorization */ } + + const maxPolls = Math.ceil((lifetimeSeconds * 1_000) / MIN_POLL_INTERVAL_MS) + 1; + for (let attempts = 0; attempts < maxPolls && now() < expiresAt; attempts += 1) { + const waitMs = Math.min(pollInterval, Math.max(0, expiresAt - now())); + await pause(waitMs, controller.signal); + if (controller.signal.aborted) throw new FreeAIError('authorization-cancelled', 'Pollinations authorization was cancelled.'); + if (now() >= expiresAt) break; + + let tokenResponse; + try { + tokenResponse = await request(`${POLLINATIONS_AUTH_BASE_URL}/api/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }, { signal: controller.signal, overrideFetch }); + } catch (error) { + throw authorizationError(error); + } + const result = await readJson(tokenResponse); + if (controller.signal.aborted) throw new FreeAIError('authorization-cancelled', 'Pollinations authorization was cancelled.'); + if (tokenResponse.ok && typeof result.access_token === 'string') { + saveToken(result.access_token); + return true; + } + + const pollError = String(result.error || result.code || '').toLowerCase(); + if (pollError === 'authorization_pending') continue; + if (pollError === 'slow_down') { + pollInterval += MIN_POLL_INTERVAL_MS; + continue; + } + if (pollError === 'access_denied' || pollError === 'authorization_declined') { + throw new FreeAIError('authorization-denied', 'Pollinations authorization was denied. You can reconnect whenever you are ready.'); + } + if (pollError === 'expired_token' || pollError === 'device_code_expired') { + throw new FreeAIError('authorization-expired', 'The Pollinations authorization code expired. Start a new connection.'); + } + throw responseError(tokenResponse.status, result, 'authorization-failed'); + } + throw new FreeAIError('authorization-expired', 'The Pollinations authorization code expired. Start a new connection.'); + })(); + + connectPromise = task.finally(() => { + externalSignal?.removeEventListener?.('abort', forwardAbort); + if (connectController === controller) connectController = null; + connectPromise = null; + }); + return connectPromise; + }, + + cancelConnection() { + if (!connectController) return false; + connectController.abort(); + return true; + }, + + async disconnect() { + this.cancelConnection(); + removeToken(); + return true; + }, + + async testConnection({ fetchImpl: overrideFetch } = {}) { + if (!token) throw new FreeAIError('auth-required', 'Free AI is not connected.'); + const connected = await verifySession(overrideFetch); + if (!connected) throw new FreeAIError('auth-required', 'Free AI is not connected.'); + const text = await chat([ + { role: 'user', content: 'Reply with exactly: Git-Up connection successful.' }, + ], { temperature: 0, max_tokens: 24 }, overrideFetch); + if (!/Git-Up connection successful\./i.test(text)) { + throw new FreeAIError('test-failed', 'Pollinations answered, but the connection check was incomplete.'); + } + return true; + }, + + async generate({ operation, payload, onProgress = () => {}, fetchImpl: overrideFetch }) { + if (!token) throw new FreeAIError('auth-required', 'Free AI is not connected.'); + onProgress({ phase: 'ai', label: 'Preparing Free AI review…', percent: 8, error: '' }); + const prepared = await prepare(operation, payload, overrideFetch); + onProgress({ phase: 'ai', label: 'Reviewing with Free AI…', percent: 28, error: '' }); + const providerResult = await chat(prepared.messages, prepared.options || {}, overrideFetch); + onProgress({ phase: 'ai', label: 'Free AI review complete…', percent: 44, error: '' }); + const body = { ...payload, provider: 'pollinations', providerResult }; + + if (operation === 'analyze') { + const afterAiProgress = (progress) => onProgress({ + ...progress, + percent: Math.max(45, 45 + Math.round((Number(progress.percent) || 0) * 0.55)), + }); + return runAnalysis(body, { onProgress: afterAiProgress, fetchImpl: overrideFetch, aiReady: true }); + } + if (operation === 'insight') return (await postJson('/api/insight', body, overrideFetch || fetchImpl)).insight; + if (operation === 'recover') return (await postJson('/api/recover', body, overrideFetch || fetchImpl)).recovery; + throw new Error(`Unsupported AI operation: ${operation}`); + }, + }; + + return Object.freeze(provider); +} + +export const pollinationsProvider = createPollinationsProvider(); + +export function freeAiMessage(error) { + switch (error?.code) { + case 'auth-required': return 'Free AI is not connected. Authorize Pollinations and try again.'; + case 'auth-expired': return 'Your Pollinations authorization expired. Reconnect Free AI and try again.'; + case 'authorization-cancelled': return 'Pollinations authorization was cancelled. You can reconnect whenever you’re ready.'; + case 'authorization-denied': return 'Pollinations authorization was denied. Start a new connection when you’re ready.'; + case 'authorization-expired': return 'The Pollinations authorization code expired. Start a new connection.'; + case 'insufficient-balance': return 'Your Pollinations account does not have enough Pollen. Add or earn Pollen, then retry.'; + case 'rate-limited': return 'Pollinations is receiving too many requests. Wait a moment, then retry.'; + case 'network-error': return 'Pollinations could not be reached. Check your network and try again.'; + case 'test-failed': return 'Pollinations could not verify this browser session. Reconnect and try again.'; + default: return 'Free AI is temporarily unavailable. Retry or reconnect; Git-Up will not switch providers automatically.'; + } +} diff --git a/public/app.js b/public/app.js index 8b6207b..c2de4fd 100644 --- a/public/app.js +++ b/public/app.js @@ -1,58 +1,21 @@ import { composeSteps, keyOf, progressOf, applyRevision, revisionEntry, selectionsLabel, EXPERTISE_LEVELS, tuneGuide } from './path-engine.js'; -import { bindSpotlight, bindTickers, bindReveals } from './magic.js'; +import { bindTickers, bindReveals } from './magic.js'; import { initParticles } from './particles-workspace.js'; import { initTopbarContributions } from './topbar-contributions.js'; +import { icon } from './icons.js'; +import { AI_CONNECTION, AI_PROVIDER, activeConnectionState, connectionPresentation, customConfigReady, normaliseAiProvider } from './ai/ai-provider.js'; +import { aiService } from './ai/ai-service.js'; +import { POLLINATIONS_DEFAULT_MODEL } from './ai/pollinations-provider.js'; const root = document.querySelector('#app'); -const icons = { - mark: '', - github: '', - settings: '', - plus: '', - grid: '', - clock: '', - external: '', - arrow: '', - search: '', - check: '', - copy: '', - close: '', - key: '', - eye: '', - eyeOff: '', - spark: '', - layers: '', - list: '', - terminal: '', - info: '', - warning: '', - refresh: '', - download: '', - book: '', - dots: '', - folder: '', - file: '', - bulb: '', - shield: '', - compass: '', - pencil: '', - trash: '', - chevron: '', - chat: '', - route: '', - flag: '', - sun: '', - moon: '', -}; -function icon(name, size = 17, className = '') { return ``; } function esc(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); } /** Strip key-like secrets from anything rendered back to the page (banners, insights, recovery notes). */ function redactSecrets(value) { return String(value ?? '') - .replace(/sk-[A-Za-z0-9-_]{8,}/g, '[redacted-key]') + .replace(/sk[-_][A-Za-z0-9-_]{8,}/g, '[redacted-key]') .replace(/gh[pousr]_[A-Za-z0-9]{8,}/g, '[redacted-token]') .replace(/AKIA[0-9A-Z]{12,}/g, '[redacted-key]') .replace(/xox[bpas]-[A-Za-z0-9-]{6,}/g, '[redacted-token]') @@ -69,6 +32,7 @@ function loadJson(key, fallback) { try { const v = JSON.parse(localStorage.getIt function displayName(entry) { return (entry?.label || '').trim() || entry?.name || entry?.url || 'Repository'; } function persistHistory() { try { localStorage.setItem('git-up-history', JSON.stringify(state.history.slice(0, 20))); } catch { /* ignore */ } } const savedSettings = loadJson('git-up-settings', {}); +const savedActiveProvider = normaliseAiProvider(savedSettings?.activeProvider); let savedTheme = 'dark'; try { savedTheme = localStorage.getItem('git-up-theme') || 'dark'; } catch { /* ignore */ } if (savedTheme !== 'light' && savedTheme !== 'dark') savedTheme = 'dark'; @@ -95,6 +59,14 @@ const state = { toast: null, settings: { baseUrl: savedSettings.baseUrl || 'https://api.openai.com/v1', endpoint: savedSettings.endpoint || '/chat/completions', model: savedSettings.model || '', apiKey: sessionStorage.getItem('git-up-api-key') || '' }, modelOptions: Array.isArray(savedSettings.modelOptions) ? savedSettings.modelOptions : [], + ai: { + activeProvider: savedActiveProvider, + pollinationsStatus: AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED, + customError: false, + busy: '', + message: '', + authorization: null, + }, history: savedHistory, secretVisible: false, // Oreo the cat bot — floating messenger chatbot (replaces the old inline explorer) @@ -122,7 +94,65 @@ const state = { progress: { phase: '', label: '', percent: 0, error: '' }, }; -function hasAiConfig() { return Boolean(state.settings.baseUrl && state.settings.apiKey && state.settings.model); } +// Persist the safe default when a removed or unknown provider id is found. +if (Object.prototype.hasOwnProperty.call(savedSettings || {}, 'activeProvider') + && savedSettings.activeProvider !== savedActiveProvider) persistAiSettings(); + +function hasCustomAiConfig() { return customConfigReady(state.settings); } +function aiConnection() { + return activeConnectionState({ + activeProvider: state.ai.activeProvider, + customReady: hasCustomAiConfig(), + customError: state.ai.customError, + pollinationsStatus: state.ai.pollinationsStatus, + }); +} +function hasAiConfig() { + const connection = aiConnection(); + return connection === AI_CONNECTION.CUSTOM_CONNECTED || connection === AI_CONNECTION.POLLINATIONS_CONNECTED; +} +function persistAiSettings() { + try { + localStorage.setItem('git-up-settings', JSON.stringify({ + baseUrl: state.settings.baseUrl, + endpoint: state.settings.endpoint, + model: state.settings.model, + modelOptions: state.modelOptions, + activeProvider: state.ai.activeProvider, + })); + } catch { /* storage can be disabled without breaking the current session */ } +} +function providerSummary() { + if (state.ai.activeProvider === AI_PROVIDER.POLLINATIONS) { + return state.ai.pollinationsStatus === AI_CONNECTION.POLLINATIONS_CONNECTED ? 'AI review via Git-Up Free AI' : 'Git-Up Free AI selected (authorization required)'; + } + return hasCustomAiConfig() ? `AI review via configured provider (model ${state.settings.model || 'unknown'})` : 'heuristic fallback (no AI configured)'; +} +const FREE_AI_AUTH_REQUIRED_CODES = new Set([ + 'auth-required', + 'auth-expired', + 'authorization-cancelled', + 'authorization-denied', + 'authorization-expired', +]); +async function generateAI(operation, payload, onProgress = () => {}) { + const provider = state.ai.activeProvider; + try { + const result = await aiService.generate({ provider, operation, payload, config: state.settings, onProgress }); + if (provider === AI_PROVIDER.POLLINATIONS) state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_CONNECTED; + else state.ai.customError = false; + return result; + } catch (error) { + if (provider === AI_PROVIDER.POLLINATIONS) { + state.ai.pollinationsStatus = FREE_AI_AUTH_REQUIRED_CODES.has(error?.code) + ? AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED + : AI_CONNECTION.POLLINATIONS_ERROR; + console.error('Git-Up Free AI request failed', { code: error?.code || 'request-failed', type: error?.name || 'Error' }); + throw new Error(`Free AI: ${aiService.freeAiMessage(error)}`); + } + throw error; + } +} function sourceLabel() { return state.guide?.source === 'ai' ? 'AI reviewed' : 'Local scan'; } function showToast(message, type = 'success') { state.toast = { message, type }; @@ -136,6 +166,7 @@ function errorKindOf(message) { const text = String(message || ''); if (/rate limit/i.test(text)) return { title: 'GitHub rate limit reached', hint: 'Add a server GitHub token, or wait an hour. File-name evidence is still usable.' }; if (/private|not found/i.test(text)) return { title: 'Repository not found or private', hint: 'Git-Up scans public repositories. Check the owner and name, or make the repo public first.' }; + if (/Free AI/i.test(text)) return { title: 'Free AI is unavailable', hint: 'Retry, reconnect Pollinations, or choose Custom API from AI settings. Git-Up will not switch providers without you.' }; if (/AI provider|model|endpoint|base URL|key/i.test(text)) return { title: 'AI provider hiccup', hint: 'The heuristic guide still works with no key. Check the provider settings and retry.' }; if (/URL|github\.com|owner/i.test(text)) return { title: 'Check the repository URL', hint: 'HTTPS, SSH, and git@ forms all work, for example https://github.com/owner/repo.' }; if (/network|fetch|failed/i.test(text)) return { title: 'Network problem', hint: 'Check your connection and try again. Recent analyses are kept in history.' }; @@ -143,7 +174,7 @@ function errorKindOf(message) { } function topbar() { - const aiReady = hasAiConfig(); + const connection = connectionPresentation(aiConnection()); const toLight = state.theme !== 'light'; return `
@@ -156,10 +187,10 @@ function topbar() {
Route/${state.mode === 'analysis' ? esc(displayName({ label: '', name: shortName(state.guide) })) : 'New analysis'}
-
${aiReady ? 'AI connected' : 'Local scan ready'}
- + + - +
`; } @@ -233,7 +264,7 @@ function fileTreePanel() { return `
${paths.length}
-
${treeNodeHtml(tree)}
+
${treeNodeHtml(tree)}
${!paths.length ? '
No file listing available for this analysis.
' : `
${topCounts} top-level ${topCounts === 1 ? 'entry' : 'entries'} · click a folder to expand
`}
`; } @@ -245,33 +276,33 @@ function sidebar() { const renaming = state.renamingId === entry.id; if (renaming) { return `
- ${icon('clock', 14)} + ${icon('history', 14)}
`; } return `
- +
`; }).join('') : '
Your analyzed repositories will appear here. Hover any entry for rename / remove.
'; return ``; } @@ -279,10 +310,10 @@ function repoForm() { const kind = state.error ? errorKindOf(state.error) : null; return `
${icon('github', 17)}
- +
${expertisePicker()} -
${icon('github', 12)} Public repositories${icon('key', 12)} SSH, HTTPS, and Git URLs${icon('shield', 12)} Heuristic scan works with no AI key⌘ ↵
+
${icon('github', 12)} Public repositories${icon('link', 12)} SSH, HTTPS, and Git URLs${icon('shield', 12)} Heuristic scan works with no AI key⌘ ↵
${state.error ? `` : ''}`; } @@ -398,7 +429,7 @@ function plainOverviewPanel() { return `
${icon('chat', 15)}

In plain English

No jargon — share this with anyone, even if they have never opened a project before

for everyone
-
${icon('spark', 14)}${esc(overview.analogy || '')}
+
${icon('sparkles', 14)}${esc(overview.analogy || '')}
What problem it solves

${esc(overview.problem || '')}

Who it helps

${esc(overview.audience || '')}

@@ -411,8 +442,8 @@ function plainOverviewPanel() { // --- Oreo the cat bot: floating messenger chatbot ---------------------------- // Replaces the old inline "Curious Explorer" section. The chat lives in a // fixed bottom-right container (see oreoHtml + styles.css) so it is available -// on every view. Answers come from the existing /api/insight endpoint so no -// new backend is required; messages render as sanitised standard markdown. +// on every view. Answers use the central AI service so the active provider is +// transparent to the chatbot; messages render as sanitised standard markdown. // The mascot is the remote Lottie robot animation, played with the // dotLottie player component (see the module script in index.html). const OREO_NAME = 'Oreo the cat bot'; @@ -917,7 +948,7 @@ function oreoSessionSnapshot() { snap.activeStep = remaining[0] || null; snap.completedSteps = completed; snap.remainingSteps = remaining.slice(1); - snap.provider = hasAiConfig() ? `AI review via configured provider (model ${state.settings.model || 'unknown'})` : 'heuristic fallback (no AI configured)'; + snap.provider = providerSummary(); if (guide.contract?.contractId) snap.contractId = guide.contract.contractId; if (guide.contract?.verification) snap.verification = guide.contract.verification; const lastFailure = state.failures[state.failures.length - 1]; @@ -943,15 +974,8 @@ async function sendOreoMessage(raw) { pushOreo('bot', `Ooo good question! 🤩 First, toss a GitHub repo link in the box above, and I\'ll sniff it out like a treat! 🦴🐾\n\nAbout **${esc(text).slice(0, 120)}**:\n- Any public \`github.com/owner/repo\` link works! 🔗\n- No API key needed for the quick sniff! 🆓\n- Then come back and I\'ll cheer you through every step! 📣`); state.chat.suggestions = [...OREO_QUICK]; } else { - const config = hasAiConfig() ? { ...state.settings } : null; - const response = await fetch('/api/insight', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ repoUrl: state.repoUrl, mode: 'custom', question: text, baseMode: 'recommendations', config, session: oreoSessionSnapshot() }), - }); - const payload = await response.json(); - if (!response.ok || !payload.ok) throw new Error(payload.error || 'The chat could not answer right now.'); - const { md, followUps } = insightToMarkdown(payload.insight); + const insight = await generateAI('insight', { repoUrl: state.repoUrl, mode: 'custom', question: text, baseMode: 'recommendations', session: oreoSessionSnapshot() }); + const { md, followUps } = insightToMarkdown(insight); pushOreo('bot', md); state.chat.suggestions = followUps.length ? followUps : [...OREO_QUICK]; } @@ -1019,8 +1043,8 @@ function oreoHtml() { function routeNav() { return ``; @@ -1057,7 +1081,7 @@ function analysisView() {
${sourceLabel()}${esc(guide.confidence || 'medium')} confidence${formatDate(guide.analyzedAt)}
-
${expertiseSwitch()}
+
${expertiseSwitch()}
${routeNav()} ${resumeBanner()} @@ -1073,17 +1097,55 @@ function analysisView() { ${plainOverviewPanel()}
-
${icon('layers', 15)}

Dependencies

Installed as part of this repository

${guide.dependencies?.length || 0}
${dependencyList(guide.dependencies)}
${icon('key', 15)}

Requirements

Needed before the app can work

${guide.requirements?.length || 0}
${requirementsList(guide.requirements)}
- ${guide.notes?.length ? `
${icon('book', 15)}

Notes from the scan

Keep these caveats close while you set up

${guide.notes.map((note) => `
${esc(note)}
`).join('')}
` : ''} +
${icon('packages', 15)}

Dependencies

Installed as part of this repository

${guide.dependencies?.length || 0}
${dependencyList(guide.dependencies)}
${icon('requirements', 15)}

Requirements

Needed before the app can work

${guide.requirements?.length || 0}
${requirementsList(guide.requirements)}
+ ${guide.notes?.length ? `
${icon('notes', 15)}

Notes from the scan

Keep these caveats close while you set up

${guide.notes.map((note) => `
${esc(note)}
`).join('')}
` : ''}
-
${icon('info', 15)}

Explanation

Understand the why, without cluttering the checklist

${esc(explanation.title || step?.title || 'Installation step')}

${esc(explanation.body || step?.detail || '')}

${guide.environment?.length ? `
${icon('key', 14)}Environment detected: ${guide.environment.map(esc).join(', ')}. Keep secrets out of Git.
` : `
${icon('info', 14)}Commands are shown for your terminal. Git-Up never executes code on your machine.
`}
${files.length ? `
${icon('book', 15)}

Inspected files

The setup evidence behind this guide

${files.map((file) => `${esc(file)}`).join('')}
` : ''}
+
${icon('info', 15)}

Explanation

Understand the why, without cluttering the checklist

${esc(explanation.title || step?.title || 'Installation step')}

${esc(explanation.body || step?.detail || '')}

${guide.environment?.length ? `
${icon('key', 14)}Environment detected: ${guide.environment.map(esc).join(', ')}. Keep secrets out of Git.
` : `
${icon('info', 14)}Commands are shown for your terminal. Git-Up never executes code on your machine.
`}
${files.length ? `
${icon('fileSearch', 15)}

Inspected files

The setup evidence behind this guide

${files.map((file) => `${esc(file)}`).join('')}
` : ''}
`; } function settingsModal() { + const provider = state.ai.activeProvider; const models = [...new Set([...(state.modelOptions || []), state.settings.model].filter(Boolean))]; - return ``; + const providerPicker = `
AI provider
+ + +
`; + const head = ``; + + if (provider === AI_PROVIDER.CUSTOM) { + return ``; + } + + const pollinationsStatus = state.ai.pollinationsStatus; + const connected = pollinationsStatus === AI_CONNECTION.POLLINATIONS_CONNECTED; + const connecting = state.ai.busy === 'connect'; + const testing = state.ai.busy === 'test'; + const busy = Boolean(state.ai.busy); + const failed = pollinationsStatus === AI_CONNECTION.POLLINATIONS_ERROR; + const authorization = connecting ? state.ai.authorization : null; + const statusCopy = connecting + ? (authorization + ? `Waiting for Pollinations approval for code ${authorization.userCode}…` + : 'Requesting a Pollinations authorization code…') + : testing + ? 'Sending a small Pollinations test request…' + : state.ai.message || (connected + ? 'Free AI Connected — you’re ready to use Git-Up’s AI features.' + : failed + ? 'Free AI connection failed. Please reconnect and try again.' + : 'Free AI is not connected. Authorize Pollinations to continue.'); + const statusClass = connected && !failed ? 'good' : failed ? 'bad' : ''; + const authorizationPanel = authorization + ? `
Authorization code${esc(authorization.userCode)}

Open Pollinations, confirm this code, then return here. Git-Up will finish connecting automatically; the code expires on its own.

` + : ''; + const actions = connected + ? `` + : connecting + ? `${authorization ? `Open Pollinations ${icon('external', 13)}` : ''}` + : `${failed ? '' : ''}${failed && hasCustomAiConfig() ? '' : ''}`; + return ``; } function installModal() { return ``; @@ -1091,10 +1153,10 @@ function installModal() { function resumeBanner() { if (!state.resumeNote) return ''; const note = state.resumeNote; - return `
${icon('clock', 13)}${esc(note)}
`; + return `
${icon('history', 13)}${esc(note)}
`; } -function toastHtml() { return state.toast ? `
${icon(state.toast.type === 'error' ? 'warning' : 'check', 15)}${esc(state.toast.message)}
` : ''; } +function toastHtml() { return state.toast ? `
${icon(state.toast.type === 'error' ? 'warning' : 'checkCircle', 15)}${esc(state.toast.message)}
` : ''; } // =========================================================================== // Git-Up v2 feature layer @@ -1224,13 +1286,13 @@ function healthPanel() { `).join(''); const evidence = health.evidence || {}; return `
-
${icon('shield', 15)}

Install health

Scored from repository evidence before any steps are shown — ${esc(health.method)}

weight disclosed
+
${icon('health', 15)}

Install health

Scored from repository evidence before any steps are shown — ${esc(health.method)}

weight disclosed
${scoreRing(health.score, health.band.tone)}
${esc(health.band.label)}${esc(health.band.note)}
${bars}
${health.caps?.length ? `
${health.caps.map((cap) => `
${icon('warning', 13)}${esc(cap)}
`).join('')}
` : ''} -
${icon('clock', 12)} pushed ${evidence.ageDays === null || evidence.ageDays === undefined ? 'unknown' : `${evidence.ageDays}d ago`}${icon('chat', 12)} ${evidence.threadsSampled || 0} threads sampled${icon('warning', 12)} ${evidence.openIssues || 0} open issues${icon('refresh', 12)} branch ${esc(evidence.branch || 'main')}: ${esc(evidence.ci?.state || 'no-ci')}${esc(health.method)}
+
${icon('clock', 12)} pushed ${evidence.ageDays === null || evidence.ageDays === undefined ? 'unknown' : `${evidence.ageDays}d ago`}${icon('messages', 12)} ${evidence.threadsSampled || 0} threads sampled${icon('bug', 12)} ${evidence.openIssues || 0} open issues${icon('graph', 12)} branch ${esc(evidence.branch || 'main')}: ${esc(evidence.ci?.state || 'no-ci')}${esc(health.method)}
`; } @@ -1278,9 +1340,9 @@ function failureFirstPanel() { `; }).join(''); return `
-
${icon('warning', 15)}

How this install usually breaks

Read before the steps — ranked by what ${esc(scan.totalThreads || 0)} recent ${scan.totalThreads === 1 ? 'thread' : 'threads'} actually say

${patterns.length} found
+
${icon('bug', 15)}

How this install usually breaks

Read before the steps — ranked by what ${esc(scan.totalThreads || 0)} recent ${scan.totalThreads === 1 ? 'thread' : 'threads'} actually say

${patterns.length} found
${signatureStrip(patterns)} - ${patterns.length ? `
${rows}
` : `
${icon('check', 15)}${esc(scan.notice || 'No installation failures were found in the recent threads.')}
`} + ${patterns.length ? `
${rows}
` : `
${icon('checkCircle', 15)}${esc(scan.notice || 'No installation failures were found in the recent threads.')}
`}
sampled ${scan.sampled?.issues || 0} issues · ${scan.sampled?.pulls || 0} pull requests${scan.sampled?.discussions ? ` · ${scan.sampled.discussions} discussions` : ''}${scan.sources?.discussions === 'none' ? 'discussions need a server GitHub token (GraphQL only)' : ''}
${scan.notice ? `

${esc(scan.notice)}

` : ''}
`; @@ -1353,7 +1415,7 @@ function pathGraphPanel() { const pills = steps.map((entry, index) => `${String(index + 1).padStart(2, '0')} ${esc(entry.title)}`).join(''); const label = selectionsLabel(graph.axes, state.pathSelections); return `
-
${icon('grid', 15)}

Choose your path

${esc(graph.note || 'Pick a branch — only the relevant steps stay in the checklist.')}

${label ? `${esc(label)}` : ''}
+
${icon('graph', 15)}

Choose your path

${esc(graph.note || 'Pick a branch — only the relevant steps stay in the checklist.')}

${label ? `${esc(label)}` : ''}
${graphSvg(graph.axes, state.pathSelections, steps.length)} ${graphAltSelects(graph)}
${graph.axes.map((axis) => `
${esc(axis.label)}${esc(axis.prompt)}
`).join('')}
@@ -1367,7 +1429,7 @@ function pathGraphPanel() { return `
${alerts.map((alert) => `
${icon(alert.tone === 'warn' ? 'warning' : 'info', 13)}${esc(alert.text)}
`).join('')}
`; })()}
${icon('list', 12)}${steps.length} steps on this branch${pills}
- ${Object.keys(state.pathSelections).length ? `
` : ''} + ${Object.keys(state.pathSelections).length ? `
` : ''}
`; } @@ -1407,15 +1469,15 @@ function installStepsPanel() { const patched = (entry.patchedFor || []).map((id) => (state.guide?.failureScan?.patterns || []).find((pattern) => pattern.id === id)?.label).filter(Boolean); return `
-
${entry.revision > 1 ? icon('refresh', 13) : String(index + 1).padStart(2, '0')}
+
${entry.revision > 1 ? icon('history', 13) : String(index + 1).padStart(2, '0')}

${esc(entry.id)}${esc(entry.title)}${entry.revision > 1 ? 'revised' : ''}

Status: ${status.label}${isDone && entry.revision > 1 ? ' · kept through revision' : ''}

${entry.detail ? `

${esc(entry.detail)}

` : ''} - ${entry.guard ? `

${icon('shield', 12)}${esc(entry.guard)}

` : ''} + ${entry.guard ? `

${icon('shieldLock', 12)}${esc(entry.guard)}

` : ''} ${patched.length ? `

${icon('check', 12)}patched for: ${patched.map(esc).join(', ')}

` : ''} ${entry.command ? `
${esc(entry.command)}
` : ''} - ${entry.verify ? `
${esc(entry.verify)}
` : ''} + ${entry.verify ? `
${icon('check', 13, 'verify-icon')}${esc(entry.verify)}
` : ''}
${isDone ? `` : ''} @@ -1434,7 +1496,7 @@ function installStepsPanel() { ${icon('compass', 12)}${esc(profile.short)} · ${esc(profile.explanation === 'minimal' ? 'prose hidden, one copy-paste block available' : profile.explanation === 'full' ? 'every step explains why it exists' : 'one line of reasoning per step')} ${state.guide?.fastPath ? `` : ''} ${hidden ? `` : ''} - ${revision > 1 ? `` : ''} + ${revision > 1 ? `` : ''}
`; } @@ -1443,9 +1505,9 @@ function recoveryReport() { const recovery = state.recovery; if (!recovery) return ''; const matched = (recovery.matched || []).map((entry) => `
  • ${esc(entry.label || entry.id)}${entry.hit ? `${esc(String(entry.hit).slice(0, 80))}` : ''}
  • `).join(''); - const checks = (recovery.checks || []).map((check) => `
    ${esc(check)}
    `).join(''); + const checks = (recovery.checks || []).map((check) => `
    ${esc(check)}
    `).join(''); return `
    -
    ${icon('refresh', 15)}

    Recovery applied — revision ${Number(recovery.revision) || currentRevision()}

    Completed steps are locked and untouched. Only the failing step forward was rewritten.

    ${esc(recovery.source === 'ai' ? 'AI diagnosis' : 'local rule match')} · ${esc(recovery.confidence || 'medium')} confidence
    +
    ${icon('lifebuoy', 15)}

    Recovery applied — revision ${Number(recovery.revision) || currentRevision()}

    Completed steps are locked and untouched. Only the failing step forward was rewritten.

    ${esc(recovery.source === 'ai' ? 'AI diagnosis' : 'local rule match')} · ${esc(recovery.confidence || 'medium')} confidence

    ${safe(recovery.diagnosis || 'The path was rebuilt from the fault forward.')}

    ${recovery.secondSuspect ? `

    ${safe(recovery.secondSuspect)}

    ` : ''} @@ -1453,7 +1515,7 @@ function recoveryReport() { ${checks ? `

    Prove the fix landed

    ${checks}` : ''} ${(recovery.followUps || []).length ? `

    Answer next

      ${recovery.followUps.map((q) => `
    • ${esc(q)}
    • `).join('')}
    ` : ''} ${recovery.note ? `

    ${safe(recovery.note)}

    ` : ''} -
    +
    `; } @@ -1461,7 +1523,7 @@ function recoveryReport() { function revisionTrail() { if (!state.revisions.length) return ''; return `
    -
    ${icon('clock', 15)}

    How this path changed

    Every correction stays on the record — nothing is silently rewritten

    ${state.revisions.length} revision${state.revisions.length === 1 ? '' : 's'}
    +
    ${icon('history', 15)}

    How this path changed

    Every correction stays on the record — nothing is silently rewritten

    ${state.revisions.length} revision${state.revisions.length === 1 ? '' : 's'}
      ${state.revisions.map((entry) => `
    1. v${entry.revision}

      ${esc(entry.failedStepTitle)} did not work

      ${esc(entry.diagnosis)}

      @@ -1482,13 +1544,13 @@ function failureModal() {
    `; } @@ -1520,12 +1582,8 @@ async function submitRecovery(event) { completedSteps: steps.slice(0, Math.max(0, index)), remainingSteps: steps.slice(Math.max(0, index)), guide: { requirements: state.guide?.requirements || [], steps: state.guide?.steps || [], failureScan: state.guide?.failureScan }, - config: hasAiConfig() ? { ...state.settings } : null, }; - const response = await fetch('/api/recover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - const payload = await response.json(); - if (!response.ok || !payload.ok) throw new Error(payload.error || 'The path could not be rebuilt.'); - const recovery = payload.recovery; + const recovery = await generateAI('recover', body); if (!state.guide.originalSteps) state.guide.originalSteps = state.guide.steps.map((entry) => ({ ...entry })); const previous = state.guide.steps; const revised = applyRevision(previous, recovery, failedStep?.id); @@ -1588,13 +1646,13 @@ function contractPanel() { ${section('Exact versions expected', contract.expects?.length ? `
      ${contract.expects.map((entry) => `
    • ${esc(entry.name)}${esc(entry.required)}
      ${esc(entry.detectedFrom)}${esc(entry.confidence)}
    • `).join('')}
    ` : '

    Nothing is pinned by the project itself — see the gap note below.

    ')} ${section('What gets installed', contract.installs?.length ? `
      ${contract.installs.map((entry) => `
    • ${esc(entry.what)}${esc(entry.detail)}
      ${esc(entry.kind)}
    • `).join('')}
    ` : '

    No manifest was readable, so nothing could be listed.

    ')} ${section('Permissions this needs', `
      ${(contract.permissions || []).map((entry) => `
    • ${esc(entry.capability)}${esc(entry.why)}
      ${esc(entry.risk)}
    • `).join('')}
    `)} - ${section('What “working” looks like', `

    ${esc(contract.workingState)}

    ${contract.verification?.command ? `
    ${esc(contract.verification.command)}

    ${icon('check', 12)}${esc(contract.verification.expect)}

    ` : ''}`)} + ${section('What “working” looks like', `

    ${esc(contract.workingState)}

    ${contract.verification?.command ? `
    ${icon('check', 13, 'verify-icon')}${esc(contract.verification.command)}

    ${icon('check', 12)}${esc(contract.verification.expect)}

    ` : ''}`)} ${contract.gaps?.length ? `

    ${icon('info', 13)} What this contract could not determine

      ${contract.gaps.map((gap) => `
    • ${esc(gap.field)} — ${esc(gap.reason)}
    • `).join('')}
    ` : ''}

    After you finish, confirm

      ${items.map((item) => `
    • ${item.hint ? `${esc(item.hint)}` : ''}
    • `).join('')}
    - ${items.length && ticked === items.length ? `
    ${icon('check', 14)}Contract satisfied — ${esc(contract.contractId)} verified on this machine.
    ` : ''} + ${items.length && ticked === items.length ? `
    ${icon('checkCircle', 14)}Contract satisfied — ${esc(contract.contractId)} verified on this machine.
    ` : ''}
    sealed by${esc(contract.signature.by)}${esc(contract.signature.method)}
    @@ -1614,6 +1672,7 @@ function closeModal() { // Clean up any focus trap from the outgoing modal. const oldModal = root.querySelector('.modal[aria-modal="true"]'); if (oldModal?._trapHandler) { document.removeEventListener('keydown', oldModal._trapHandler); oldModal._trapHandler = null; } + if (state.modal === 'settings' && state.ai.busy === 'connect') aiService.cancelFreeAiConnection(); state.modal = null; render(); if (state.lastFocusId) { @@ -1646,10 +1705,10 @@ function mobileBottomNav() { const isAnalysis = state.mode === 'analysis' && state.guide; const items = [ { id: 'nav-route', label: 'Route', icon: 'route', action: 'scroll-path', show: isAnalysis }, - { id: 'nav-graph', label: 'Graph', icon: 'grid', action: 'scroll-graph', show: isAnalysis }, + { id: 'nav-graph', label: 'Graph', icon: 'graph', action: 'scroll-graph', show: isAnalysis }, { id: 'nav-contract', label: 'Contract', icon: 'shield', action: 'scroll-contract', show: isAnalysis }, - { id: 'nav-palette', label: 'Menu', icon: 'search', action: 'palette', show: true }, - { id: 'nav-settings', label: 'AI', icon: 'settings', action: 'settings', show: true }, + { id: 'nav-palette', label: 'Menu', icon: 'command', action: 'palette', show: true }, + { id: 'nav-settings', label: 'AI', icon: 'aiSettings', action: 'settings', show: true }, ]; const visible = items.filter((item) => item.show); return ``; @@ -1663,20 +1722,20 @@ function paletteActions() { const analysis = state.mode === 'analysis' && state.guide; const scroll = (id) => () => { closeModal(); document.querySelector(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; const actions = [ - { id: 'new-analysis', label: 'Start a new analysis', icon: 'plus', group: 'Actions', run: () => { closeModal(); newAnalysis(); } }, + { id: 'new-analysis', label: 'Start a new analysis', icon: 'add', group: 'Actions', run: () => { closeModal(); newAnalysis(); } }, ]; if (analysis) { actions.push( { id: 'go-path', label: 'Go to installation steps', icon: 'route', group: 'Sections', hint: '1', run: scroll('#route-path') }, - { id: 'go-failures', label: 'Go to failure evidence', icon: 'warning', group: 'Sections', hint: '2', run: scroll('#route-failures') }, - { id: 'go-graph', label: 'Go to path graph', icon: 'grid', group: 'Sections', hint: '3', run: scroll('#route-graph') }, + { id: 'go-failures', label: 'Go to failure evidence', icon: 'bug', group: 'Sections', hint: '2', run: scroll('#route-failures') }, + { id: 'go-graph', label: 'Go to path graph', icon: 'graph', group: 'Sections', hint: '3', run: scroll('#route-graph') }, { id: 'go-contract', label: 'Go to install contract', icon: 'shield', group: 'Sections', hint: '4', run: scroll('#route-contract') }, { id: 'go-evidence', label: 'Go to files and evidence', icon: 'book', group: 'Sections', hint: '5', run: scroll('#route-evidence') }, { id: 'install', label: 'Generate install script', icon: 'terminal', group: 'Actions', run: () => { closeModal(); state.lastFocusId = 'install'; state.modal = 'install'; render(); } }, ); } actions.push( - { id: 'settings', label: 'AI provider settings', icon: 'settings', group: 'Actions', run: () => { closeModal(); state.lastFocusId = 'settings'; state.modal = 'settings'; render(); setTimeout(() => document.querySelector('#base-url')?.focus(), 30); } }, + { id: 'settings', label: 'AI provider settings', icon: 'aiSettings', group: 'Actions', run: () => { closeModal(); openAiSettings(); } }, { id: 'theme', label: state.theme === 'light' ? 'Switch to dark mode' : 'Switch to daylight mode', icon: state.theme === 'light' ? 'moon' : 'sun', group: 'Actions', run: () => { state.theme = state.theme === 'light' ? 'dark' : 'light'; try { localStorage.setItem('git-up-theme', state.theme); } catch { /* ignore */ } @@ -1686,7 +1745,7 @@ function paletteActions() { const recent = state.history.slice(0, 4).map((entry) => ({ id: `history-${entry.id}`, label: displayName(entry), - icon: 'clock', + icon: 'history', group: 'Recent repositories', run: () => { closeModal(); restoreHistory(entry.id); }, })); @@ -1850,16 +1909,10 @@ async function analyze() { }; try { - const config = hasAiConfig() ? { ...state.settings } : null; - const body = JSON.stringify({ repoUrl: state.repoUrl, expertise: state.expertise, config }); - - let guide = null; - try { - guide = await analyzeWithStream(body); - } catch { - guide = await analyzeWithPoll(body); - } - + const guide = await generateAI('analyze', { repoUrl: state.repoUrl, expertise: state.expertise }, (progress) => { + state.progress = progress; + render(); + }); if (!guide) throw new Error('The repository could not be analyzed.'); finish(guide); } catch (error) { @@ -1867,66 +1920,6 @@ async function analyze() { } } -async function analyzeWithStream(body) { - const response = await fetch('/api/analyze-stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); - if (!response.ok) throw new Error(`Stream endpoint returned ${response.status}`); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const raw = line.slice(6).trim(); - if (!raw) continue; - try { - const msg = JSON.parse(raw); - if (msg.phase === 'error') throw new Error(msg.error || 'Stream reported an error.'); - if (msg.phase === 'result') { - state.progress = { phase: 'done', label: msg.label || 'Analysis complete', percent: 100, error: '' }; - render(); - return msg.guide; - } - state.progress = { phase: msg.phase, label: msg.label || '', percent: Number(msg.percent) || 0, error: '' }; - render(); - } catch (parseError) { - if (parseError instanceof SyntaxError) continue; - throw parseError; - } - } - } - throw new Error('Stream ended without a result.'); -} - -async function analyzeWithPoll(body) { - const fallbackPhases = [ - { phase: 'repository', label: 'Reading repository metadata…', percent: 10 }, - { phase: 'files', label: 'Scanning setup files…', percent: 25 }, - { phase: 'ai', label: hasAiConfig() ? 'Reviewing with AI…' : 'Building local guide…', percent: 45 }, - { phase: 'failures', label: 'Checking failure history…', percent: 55 }, - { phase: 'health', label: 'Computing health score…', percent: 65 }, - { phase: 'path', label: 'Composing install path…', percent: 78 }, - { phase: 'contract', label: 'Building install contract…', percent: 90 }, - { phase: 'tuning', label: 'Tuning for your level…', percent: 95 }, - ]; - for (const p of fallbackPhases) { - state.progress = p; - render(); - await new Promise((r) => setTimeout(r, 350)); - } - const response = await fetch('/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); - const payload = await response.json(); - if (!response.ok || !payload.ok) throw new Error(payload.error || 'The repository could not be analyzed.'); - state.progress = { phase: 'done', label: 'Analysis complete', percent: 100, error: '' }; - render(); - return payload.guide; -} - function readSettingsFromForm() { const baseUrl = document.querySelector('#base-url')?.value.trim() || ''; const endpoint = document.querySelector('#endpoint')?.value.trim() || '/chat/completions'; @@ -1939,31 +1932,174 @@ async function fetchModels() { const status = document.querySelector('#settings-status'); const values = readSettingsFromForm(); if (!values.baseUrl || !values.apiKey) { if (status) { status.className = 'settings-status bad'; status.textContent = 'Add a base URL and API key first.'; } return; } - if (button) { button.disabled = true; button.innerHTML = `${icon('refresh', 13, 'spinner')} Fetching…`; } + if (button) { button.disabled = true; button.innerHTML = `${icon('loader', 13, 'spinner')} Fetching…`; } if (status) { status.className = 'settings-status'; status.textContent = 'Contacting your provider…'; } try { - const response = await fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: values.baseUrl, apiKey: values.apiKey }) }); - const payload = await response.json(); - if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not fetch models.'); - state.modelOptions = payload.models || []; + state.modelOptions = await aiService.fetchCustomModels({ baseUrl: values.baseUrl, apiKey: values.apiKey }); state.settings = { ...state.settings, ...values }; + state.ai.customError = false; render(); const newStatus = document.querySelector('#settings-status'); if (newStatus) { newStatus.className = 'settings-status good'; newStatus.textContent = `${state.modelOptions.length} model${state.modelOptions.length === 1 ? '' : 's'} available. Select one below.`; } } catch (error) { - if (status) { status.className = 'settings-status bad'; status.textContent = error.message || 'Could not fetch models.'; } - if (button) { button.disabled = false; button.innerHTML = `${icon('refresh', 13)} Fetch models`; } + state.settings = { ...state.settings, ...values }; + state.ai.customError = true; + render(); + const newStatus = document.querySelector('#settings-status'); + if (newStatus) { newStatus.className = 'settings-status bad'; newStatus.textContent = error.message || 'Could not fetch models.'; } } } + function saveSettings(event) { event.preventDefault(); const values = readSettingsFromForm(); state.settings = values; - sessionStorage.setItem('git-up-api-key', values.apiKey); - localStorage.setItem('git-up-settings', JSON.stringify({ baseUrl: values.baseUrl, endpoint: values.endpoint, model: values.model, modelOptions: state.modelOptions })); + state.ai.activeProvider = AI_PROVIDER.CUSTOM; + state.ai.customError = false; + try { sessionStorage.setItem('git-up-api-key', values.apiKey); } catch { /* session-only storage may be disabled */ } + persistAiSettings(); closeModal(); - showToast(hasAiConfig() ? 'AI provider connected for this session.' : 'Provider configuration saved. Local scan remains available.'); + showToast(hasCustomAiConfig() ? 'Custom API connected for this session.' : 'Provider configuration saved. Local scan remains available.'); +} + +function captureCustomSettings() { + if (!document.querySelector('#base-url')) return; + const values = readSettingsFromForm(); + state.settings = { ...state.settings, ...values }; + try { sessionStorage.setItem('git-up-api-key', values.apiKey); } catch { /* ignore */ } +} + +function switchAiProvider(nextProvider) { + const next = normaliseAiProvider(nextProvider); + if (state.ai.activeProvider === AI_PROVIDER.CUSTOM) captureCustomSettings(); + if (next !== AI_PROVIDER.POLLINATIONS && state.ai.busy === 'connect') aiService.cancelFreeAiConnection(); + state.ai.activeProvider = next; + state.ai.authorization = null; + persistAiSettings(); + render(); + if (state.modal === 'settings') setTimeout(() => document.querySelector(`[name="ai-provider"][value="${next}"]`)?.focus(), 0); + if (next === AI_PROVIDER.POLLINATIONS + && state.ai.busy !== 'connect' + && state.ai.pollinationsStatus !== AI_CONNECTION.POLLINATIONS_ERROR) refreshFreeAiConnection(); +} + +async function refreshFreeAiConnection() { + try { + const connected = await aiService.isFreeAiConnected(); + state.ai.pollinationsStatus = connected ? AI_CONNECTION.POLLINATIONS_CONNECTED : AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED; + if (connected) state.ai.message = ''; + } catch (error) { + state.ai.pollinationsStatus = FREE_AI_AUTH_REQUIRED_CODES.has(error?.code) + ? AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED + : AI_CONNECTION.POLLINATIONS_ERROR; + state.ai.message = aiService.freeAiMessage(error); + console.error('Git-Up Free AI status check failed', { code: error?.code || 'status-failed', type: error?.name || 'Error' }); + } + render(); +} + +async function connectFreeAi() { + if (state.ai.busy) return; + const verifyAfterConnect = state.ai.pollinationsStatus === AI_CONNECTION.POLLINATIONS_ERROR; + state.ai.busy = 'connect'; + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_CONNECTING; + state.ai.message = ''; + state.ai.authorization = null; + render(); + try { + await aiService.connectFreeAi({ + onAuthorization: (authorization) => { + if (state.ai.activeProvider !== AI_PROVIDER.POLLINATIONS || state.ai.busy !== 'connect') { + aiService.cancelFreeAiConnection(); + return; + } + state.ai.authorization = authorization; + render(); + setTimeout(() => document.querySelector('a[href^="https://enter.pollinations.ai/"]')?.focus(), 0); + }, + }); + if (verifyAfterConnect) await aiService.testFreeAi(); + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_CONNECTED; + state.ai.message = 'Free AI Connected — you’re ready to use Git-Up’s AI features.'; + showToast('Free AI connected through Pollinations.'); + } catch (error) { + state.ai.pollinationsStatus = FREE_AI_AUTH_REQUIRED_CODES.has(error?.code) + ? AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED + : AI_CONNECTION.POLLINATIONS_ERROR; + state.ai.message = aiService.freeAiMessage(error); + console.error('Git-Up Free AI connection failed', { code: error?.code || 'connect-failed', type: error?.name || 'Error' }); + } finally { + state.ai.busy = ''; + state.ai.authorization = null; + render(); + } +} + +function cancelFreeAiConnect() { + if (state.ai.busy !== 'connect') return; + state.ai.message = 'Cancelling Pollinations authorization…'; + aiService.cancelFreeAiConnection(); + render(); +} + +async function testFreeAi() { + if (state.ai.busy) return; + state.ai.busy = 'test'; + state.ai.message = ''; + render(); + try { + await aiService.testFreeAi(); + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_CONNECTED; + state.ai.message = 'Free AI is working. Pollinations completed the test request.'; + } catch (error) { + state.ai.pollinationsStatus = FREE_AI_AUTH_REQUIRED_CODES.has(error?.code) + ? AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED + : AI_CONNECTION.POLLINATIONS_ERROR; + state.ai.message = aiService.freeAiMessage(error); + console.error('Git-Up Free AI test failed', { code: error?.code || 'test-failed', type: error?.name || 'Error' }); + } finally { + state.ai.busy = ''; + render(); + } } + +async function disconnectFreeAi() { + if (state.ai.busy) return; + const confirmed = typeof window.confirm !== 'function' || window.confirm('Disconnect Free AI? Your Custom API configuration will remain unchanged.'); + if (!confirmed) return; + state.ai.busy = 'disconnect'; + state.ai.message = 'Disconnecting Free AI…'; + render(); + try { + await aiService.disconnectFreeAi(); + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED; + state.ai.message = 'Pollinations authorization removed from this session. Your Custom API configuration is still available.'; + showToast('Pollinations authorization removed.'); + } catch (error) { + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_ERROR; + state.ai.message = aiService.freeAiMessage(error); + console.error('Git-Up Free AI disconnect failed', { code: error?.code || 'disconnect-failed', type: error?.name || 'Error' }); + } finally { + state.ai.busy = ''; + render(); + } +} + +function focusSettingsPrimary() { + const selector = state.ai.activeProvider === AI_PROVIDER.POLLINATIONS ? '[data-action="connect-free-ai"], [data-action="test-free-ai"]' : '#base-url'; + setTimeout(() => document.querySelector(selector)?.focus(), 30); +} + +function openAiSettings() { + state.lastFocusId = 'settings'; + state.modal = 'settings'; + render(); + focusSettingsPrimary(); + if (state.ai.activeProvider === AI_PROVIDER.POLLINATIONS + && state.ai.busy !== 'connect' + && state.ai.pollinationsStatus !== AI_CONNECTION.POLLINATIONS_ERROR) refreshFreeAiConnection(); +} + async function copyText(text) { try { await navigator.clipboard.writeText(text); showToast('Copied to clipboard.'); } catch { showToast('Copy was blocked by the browser. Select the command manually.', 'error'); } @@ -1991,8 +2127,15 @@ function bindEvents() { document.querySelector('#repo-form')?.addEventListener('submit', (event) => { event.preventDefault(); analyze(); }); document.querySelector('#repo-input')?.addEventListener('input', (event) => { state.repoUrl = event.target.value; }); document.querySelector('#settings-form')?.addEventListener('submit', saveSettings); + document.querySelectorAll('[name="ai-provider"]').forEach((el) => el.addEventListener('change', () => { if (el.checked) switchAiProvider(el.value); })); + document.querySelectorAll('[data-action="use-free-ai"]').forEach((el) => el.addEventListener('click', () => switchAiProvider(AI_PROVIDER.POLLINATIONS))); + document.querySelectorAll('[data-action="use-custom-ai"]').forEach((el) => el.addEventListener('click', () => switchAiProvider(AI_PROVIDER.CUSTOM))); + document.querySelectorAll('[data-action="connect-free-ai"]').forEach((el) => el.addEventListener('click', connectFreeAi)); + document.querySelectorAll('[data-action="cancel-free-ai-connect"]').forEach((el) => el.addEventListener('click', cancelFreeAiConnect)); + document.querySelectorAll('[data-action="test-free-ai"]').forEach((el) => el.addEventListener('click', testFreeAi)); + document.querySelectorAll('[data-action="disconnect-free-ai"]').forEach((el) => el.addEventListener('click', disconnectFreeAi)); document.querySelectorAll('[data-action="new-analysis"]').forEach((el) => el.addEventListener('click', (e) => { e.preventDefault(); newAnalysis(); })); - document.querySelectorAll('[data-action="settings"]').forEach((el) => el.addEventListener('click', () => { state.lastFocusId = 'settings'; state.modal = 'settings'; render(); setTimeout(() => document.querySelector('#base-url')?.focus(), 30); })); + document.querySelectorAll('[data-action="settings"]').forEach((el) => el.addEventListener('click', openAiSettings)); document.querySelectorAll('[data-action="theme"]').forEach((el) => el.addEventListener('click', () => { state.theme = state.theme === 'light' ? 'dark' : 'light'; try { localStorage.setItem('git-up-theme', state.theme); } catch { /* ignore */ } @@ -2139,8 +2282,7 @@ function bindEvents() { if (action) action.run(); } }); - // Magic UI ports: cursor spotlight on cards, count-up tickers, scroll reveal (re-bind each render). - bindSpotlight(document); + // Supporting value ticker and restrained reveal hooks re-bind after each render. bindTickers(document); bindReveals(document); // Workspace particle field (particles.js engine): initializes exactly once @@ -2167,7 +2309,7 @@ function saveRename(id) { document.addEventListener('keydown', (event) => { if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') { event.preventDefault(); togglePalette(); } - if ((event.metaKey || event.ctrlKey) && event.key === ',') { event.preventDefault(); state.lastFocusId = 'settings'; state.modal = 'settings'; render(); setTimeout(() => document.querySelector('#base-url')?.focus(), 30); } + if ((event.metaKey || event.ctrlKey) && event.key === ',') { event.preventDefault(); openAiSettings(); } if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && state.mode !== 'loading') { event.preventDefault(); analyze(); } if (event.key === 'Escape' && state.modal) { closeModal(); } else if (event.key === 'Escape' && state.openMenuId) { state.openMenuId = null; render(); } @@ -2201,7 +2343,8 @@ resumeActiveSession(); loadOreoPrefs(); render(); +if (state.ai.activeProvider === AI_PROVIDER.POLLINATIONS) refreshFreeAiConnection(); // Named exports exist only so tests/render.test.mjs can drive the real client // module in Node. The browser loads this file as a module either way. -export { state, render, activePath, installScript, setPathOption, setExpertise, emptySession, sessionSnapshot, hydrateSession, oreoMarkdown, oreoHtml, toggleOreo, sendOreoMessage, setOreoMuted, setOreoHidden, loadOreoPrefs, saveOreoPrefs, oreoClampPos, oreoShouldSuppressClick, oreoSpringStep, oreoPanelPositionStyle, OREO_NAME, OREO_TIPS }; +export { state, render, activePath, installScript, setPathOption, setExpertise, emptySession, sessionSnapshot, hydrateSession, oreoMarkdown, oreoHtml, toggleOreo, sendOreoMessage, setOreoMuted, setOreoHidden, loadOreoPrefs, saveOreoPrefs, oreoClampPos, oreoShouldSuppressClick, oreoSpringStep, oreoPanelPositionStyle, switchAiProvider, aiConnection, hasAiConfig, hasCustomAiConfig, persistAiSettings, refreshFreeAiConnection, connectFreeAi, cancelFreeAiConnect, testFreeAi, disconnectFreeAi, OREO_NAME, OREO_TIPS }; diff --git a/public/icons.js b/public/icons.js new file mode 100644 index 0000000..06f2511 --- /dev/null +++ b/public/icons.js @@ -0,0 +1,89 @@ +/** + * Git-Up's local UI icon system. + * + * Every glyph below was selected and retrieved through skills/iconify-skill + * from Tabler Icons 3.46.0 (Iconify prefix: tabler). The SVG bodies stay + * local so icons render without a CDN or runtime network request. + * + * Tabler Icons copyright (c) 2020-2026 Paweł Kuna, licensed under MIT. + * See ../THIRD_PARTY_NOTICES.md. Git-Up's logo and Oreo mascot are purposely + * outside this registry and remain their original assets. + */ + +const ICONS = Object.freeze({ + github: Object.freeze({ id: 'tabler:brand-github', body: `` }), + aiSettings: Object.freeze({ id: 'tabler:settings-ai', body: `` }), + add: Object.freeze({ id: 'tabler:circle-plus', body: `` }), + dashboard: Object.freeze({ id: 'tabler:layout-dashboard', body: `` }), + graph: Object.freeze({ id: 'tabler:git-branch', body: `` }), + history: Object.freeze({ id: 'tabler:history', body: `` }), + clock: Object.freeze({ id: 'tabler:clock', body: `` }), + external: Object.freeze({ id: 'tabler:external-link', body: `` }), + arrow: Object.freeze({ id: 'tabler:arrow-right', body: `` }), + command: Object.freeze({ id: 'tabler:command', body: `` }), + search: Object.freeze({ id: 'tabler:search', body: `` }), + check: Object.freeze({ id: 'tabler:check', body: `` }), + checkCircle: Object.freeze({ id: 'tabler:circle-check', body: `` }), + copy: Object.freeze({ id: 'tabler:copy', body: `` }), + close: Object.freeze({ id: 'tabler:x', body: `` }), + link: Object.freeze({ id: 'tabler:link', body: `` }), + key: Object.freeze({ id: 'tabler:key', body: `` }), + eye: Object.freeze({ id: 'tabler:eye', body: `` }), + eyeOff: Object.freeze({ id: 'tabler:eye-off', body: `` }), + sparkles: Object.freeze({ id: 'tabler:sparkles', body: `` }), + packages: Object.freeze({ id: 'tabler:packages', body: `` }), + requirements: Object.freeze({ id: 'tabler:clipboard-list', body: `` }), + list: Object.freeze({ id: 'tabler:list-check', body: `` }), + terminal: Object.freeze({ id: 'tabler:terminal-2', body: `` }), + info: Object.freeze({ id: 'tabler:info-circle', body: `` }), + warning: Object.freeze({ id: 'tabler:alert-triangle', body: `` }), + bug: Object.freeze({ id: 'tabler:bug', body: `` }), + loader: Object.freeze({ id: 'tabler:loader-2', body: `` }), + refresh: Object.freeze({ id: 'tabler:refresh', body: `` }), + restore: Object.freeze({ id: 'tabler:restore', body: `` }), + book: Object.freeze({ id: 'tabler:book-2', body: `` }), + notes: Object.freeze({ id: 'tabler:notes', body: `` }), + dots: Object.freeze({ id: 'tabler:dots-vertical', body: `` }), + folder: Object.freeze({ id: 'tabler:folder', body: `` }), + file: Object.freeze({ id: 'tabler:file-code', body: `` }), + fileSearch: Object.freeze({ id: 'tabler:file-search', body: `` }), + health: Object.freeze({ id: 'tabler:activity-heartbeat', body: `` }), + shield: Object.freeze({ id: 'tabler:shield-check', body: `` }), + shieldLock: Object.freeze({ id: 'tabler:shield-lock', body: `` }), + compass: Object.freeze({ id: 'tabler:compass', body: `` }), + edit: Object.freeze({ id: 'tabler:edit', body: `` }), + trash: Object.freeze({ id: 'tabler:trash', body: `` }), + chevron: Object.freeze({ id: 'tabler:chevron-right', body: `` }), + chat: Object.freeze({ id: 'tabler:message-circle', body: `` }), + messages: Object.freeze({ id: 'tabler:messages', body: `` }), + route: Object.freeze({ id: 'tabler:route', body: `` }), + flag: Object.freeze({ id: 'tabler:flag-3', body: `` }), + sun: Object.freeze({ id: 'tabler:sun-high', body: `` }), + moon: Object.freeze({ id: 'tabler:moon-stars', body: `` }), + lifebuoy: Object.freeze({ id: 'tabler:lifebuoy', body: `` }), +}); + +/** Public source IDs make the finite icon set straightforward to audit/test. */ +export const ICON_IDS = Object.freeze(Object.fromEntries( + Object.entries(ICONS).map(([name, glyph]) => [name, glyph.id]), +)); + +function iconClass(value) { + const extra = String(value || '') + .split(/\s+/) + .filter((token) => /^[a-z][a-z0-9_-]*$/i.test(token)); + return ['ui-icon', ...extra].join(' '); +} + +/** + * Render one decorative icon. Accessible names belong on its surrounding + * button/link/heading, so the SVG itself is consistently hidden from AT. + */ +export function icon(name, size = 17, className = '') { + const glyph = ICONS[name] || ICONS.info; + const requested = Number(size); + const dimension = Number.isFinite(requested) + ? Math.max(8, Math.min(64, requested)) + : 17; + return ``; +} diff --git a/public/magic.css b/public/magic.css index a569e3a..e0cebde 100644 --- a/public/magic.css +++ b/public/magic.css @@ -1,154 +1,58 @@ -/* =========================================================================== - Git-Up — Magic UI ports (vanilla CSS/JS, no dependencies) - Techniques adapted from skills/magicui: shimmer-button, magic-card - (spotlight), number-ticker, dot-pattern. Loaded after styles.css so these - rules layer on top; nothing in styles.css is modified. - Every effect is theme-aware (dark + [data-theme="light"]) and disabled - under prefers-reduced-motion, matching the existing pattern. - =========================================================================== */ +/* Git-Up supporting presentation layer. + The product keeps its ticker, route texture, reveal hooks, signature strip, + command palette, workspace field, and contribution header. Motion remains + transform/opacity based; cursor glows, recurring sheens, and scale effects + are intentionally absent. */ -/* Smooth cursor trailing for the spotlight (unsupported browsers ignore). */ -@property --mx { syntax: ''; inherits: false; initial-value: -300px; } -@property --my { syntax: ''; inherits: false; initial-value: -300px; } - -/* --- 1. Shimmer sweep on the primary CTAs (shimmer-button technique) -------- - Selector-based so no template changes are needed. The sheen is a skewed - white gradient that sweeps across the button every few seconds. Kept on the - two true entry points only (analyze + new analysis); repeated panels use the - plain hover treatment so the effect never becomes wallpaper. */ -.analyze-button, .new-analysis { - position: relative; - overflow: hidden; -} -.analyze-button::after, .new-analysis::after { - content: ''; - position: absolute; - top: -20%; - bottom: -20%; - left: -70%; - width: 45%; - background: linear-gradient(105deg, transparent, rgba(255, 255, 255, .35), transparent); - transform: skewX(-20deg); - pointer-events: none; - animation: magic-sheen 6.5s ease-in-out infinite; -} -.analyze-button:hover::after, .new-analysis:hover::after { - animation-duration: 2.6s; -} -@keyframes magic-sheen { - 0% { left: -70%; } - 55%, 100% { left: 140%; } -} - -/* --- 2. Card spotlight (magic-card gradient mode, vanilla port) ------------- - A radial mint glow follows the cursor. Painted as a background-image layer - so it always sits below content. Position defaults off-element so nothing - shows until the first pointermove. */ -.panel, .seg-option, .explorer-btn, .route-preview, .route-status { - background-image: radial-gradient(240px circle at var(--mx, -300px) var(--my, -300px), rgba(127, 224, 178, .12), transparent 70%); - background-repeat: no-repeat; - transition: --mx .18s ease-out, --my .18s ease-out; -} -[data-theme="light"] .panel, -[data-theme="light"] .seg-option, -[data-theme="light"] .explorer-btn, -[data-theme="light"] .route-preview, -[data-theme="light"] .route-status { - background-image: radial-gradient(240px circle at var(--mx, -300px) var(--my, -300px), rgba(15, 157, 108, .14), transparent 70%); -} - -/* --- 3. Number ticker (health score) ----------------------------------------- - The value counts up via rAF; keep tabular figures so it doesn't jitter. */ +/* Health values use fixed-width numerals so count-up updates do not reflow. */ .ring-value { font-variant-numeric: tabular-nums; } -/* --- 4. Dot-grid background (dot-pattern technique) -------------------------- - Overrides the ruled-lines body::after layer with a dot grid. Same fixed - full-viewport layer, theme-aware density. */ -body::after { - background-image: radial-gradient(rgba(178, 197, 190, .22) 1px, transparent 1.3px); - background-size: 26px 26px; - opacity: .55; -} -[data-theme="light"] body::after { - background-image: radial-gradient(rgba(28, 62, 50, .24) 1px, transparent 1.3px); - opacity: .5; +/* A contribution-square field replaces the static dot wallpaper. The fixed + layer moves as one GPU-composited sheet; individual live pixels remain on + the existing canvases, so this foundation stays inexpensive. */ +html { + scroll-behavior: smooth; + scroll-padding-top: 96px; } - -/* --- Reduced motion: static, fully usable ----------------------------------- */ -@media (prefers-reduced-motion: reduce) { - .analyze-button::after, .new-analysis::after { display: none; } - .panel, .seg-option, .explorer-btn, .route-preview, .route-status { transition: none; } +body::after { + inset: -40px; + background-image: conic-gradient(from 270deg at 5px 5px, var(--dot) 25%, transparent 0); + background-size: 32px 32px; + opacity: .2; + animation: contribution-drift 38s var(--ease) infinite alternate; + will-change: transform; } - -/* =========================================================================== - Magic layer v2 — additive. Tokens, blur-fade reveal, card border glow, - signature marquee, staggered lists, command palette. Loaded after - styles.css; every rule degrades gracefully and honours reduced motion. - =========================================================================== */ - -/* --- Design tokens: spacing, type, motion, depth ----------------------------- - Additive second :root block; existing --bg/--route/etc. in styles.css stay - authoritative for colour so the surveyor's-ledger palette is untouched. */ -:root { - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 24px; - --space-6: 32px; - --space-7: 48px; - --space-8: 64px; - --type-xs: 10px; - --type-sm: 11px; - --type-md: 12px; - --type-lg: 14px; - --type-xl: 17px; - --type-2xl: 23px; - --shadow-sm: 0 4px 14px rgba(0, 0, 0, .18); - --shadow-md: 0 12px 34px rgba(0, 0, 0, .26); - --dur-reveal: 620ms; - --stagger-step: 55ms; - --ease-out: cubic-bezier(.16, 1, .3, 1); -} -[data-theme="light"] { - --shadow-sm: 0 2px 10px rgba(20, 40, 32, .08); - --shadow-md: 0 10px 26px rgba(20, 40, 32, .12); +[data-theme="light"] body::after { opacity: .14; } +@keyframes contribution-drift { + from { transform: translate3d(-16px, 0, 0); } + to { transform: translate3d(16px, -16px, 0); } } -/* Typography polish: balanced headings, tidy rag. Pure hints — no fallback risk. */ +/* Balanced headings and readable body-copy rags, with safe long-word wraps. */ h1, h2, h3, h4 { text-wrap: balance; } .hero-copy, .repo-description, .panel-subtitle, .recovery-diagnosis { text-wrap: pretty; } -/* --- 5. Blur fade / scroll reveal (data-reveal) ------------------------------- - JS arms the page with `reveal-armed` before the first paint after render; - without JS nothing is ever hidden. Once every target has been revealed the - `reveal-done` seal is set so constant re-renders (tick a checkbox) can never - re-hide content. bindReveals() staggers elements that enter together. */ +/* ScrollReveal's once-only viewport model, implemented on the existing + observer: brief vertical context plus opacity, never scroll-bound motion. + Without JS, nothing is hidden; rerenders never reseal revealed content. */ html.reveal-armed:not(.reveal-done) [data-reveal] { opacity: 0; - transform: translateY(14px); - filter: blur(6px); + transform: translate3d(0, 12px, 0); } html.reveal-armed:not(.reveal-done) [data-reveal].revealed { opacity: 1; - transform: translateY(0); - filter: blur(0); - transition: - opacity var(--dur-reveal) var(--ease-out), - transform var(--dur-reveal) var(--ease-out), - filter var(--dur-reveal) var(--ease-out); + transform: translate3d(0, 0, 0); + transition: opacity var(--dur-major) var(--ease), transform var(--dur-major) var(--ease); transition-delay: var(--reveal-delay, 0ms); } html.reveal-armed:not(.reveal-done) [data-reveal-stagger] > * { opacity: 0; - transform: translateY(10px); + transform: translate3d(0, 8px, 0); } html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > * { opacity: 1; - transform: translateY(0); - transition: - opacity var(--dur-reveal) var(--ease-out), - transform var(--dur-reveal) var(--ease-out); + transform: translate3d(0, 0, 0); + transition: opacity var(--dur-normal) var(--ease), transform var(--dur-normal) var(--ease); } html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child(1) { transition-delay: calc(var(--stagger-step) * 0); } html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child(2) { transition-delay: calc(var(--stagger-step) * 1); } @@ -163,38 +67,9 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child(11) { transition-delay: calc(var(--stagger-step) * 10); } html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child(12) { transition-delay: calc(var(--stagger-step) * 11); } -/* --- 2. Magic card, border-glow mode ------------------------------------------ - A 1px gradient ring that brightens near the cursor, masked to the border - ring only (content box is punched out). Paints above nothing, shifts no - layout, and matches the existing --mx/--my spotlight coordinates. */ -.panel { - position: relative; -} -.panel::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - padding: 1px; - background: radial-gradient(280px circle at var(--mx, -300px) var(--my, -300px), rgba(127, 224, 178, .48), transparent 68%); - -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); - mask-composite: exclude; - opacity: 0; - transition: opacity .4s var(--ease); - pointer-events: none; -} -.panel:hover::before { opacity: 1; } -[data-theme="light"] .panel::before { - background: radial-gradient(280px circle at var(--mx, -300px) var(--my, -300px), rgba(15, 157, 108, .55), transparent 68%); -} - -/* --- 8. Signature marquee (failure-first strip) -------------------------------- - CSS-only infinite loop: the track holds two identical groups and translates - -50%. Pauses on hover and focus-within; masks fade the edges; static wrap - under reduced motion. Hidden from assistive tech — the ranked fail-list - right below carries the same information. */ +/* Failure-signature strip: a slow, data-bearing loop with hover pause and a + static reduced-motion mode. The adjacent screen-reader source carries the + same labels without duplication. */ .sig-marquee { position: relative; overflow: hidden; @@ -202,20 +77,20 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child background: var(--panel-soft); } .sig-marquee:not(.sig-static) { - -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent); - mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent); + -webkit-mask-image: linear-gradient(90deg, transparent, var(--text) 7%, var(--text) 93%, transparent); + mask-image: linear-gradient(90deg, transparent, var(--text) 7%, var(--text) 93%, transparent); } .sig-marquee-track { display: flex; width: max-content; - animation: magic-marquee 44s linear infinite; + animation: signature-loop 44s linear infinite; } .sig-marquee:hover .sig-marquee-track, -.sig-marquee:focus-within .sig-marquee-track { animation-play-state: paused; } +body:focus-within .sig-marquee-track { animation-play-state: paused; } .sig-marquee-group { display: flex; align-items: center; - gap: var(--space-2); + gap: 8px; padding: 9px 14px; } .sig-chip { @@ -249,13 +124,11 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child font-size: 8.5px; } .sig-static .sig-marquee-group { flex-wrap: wrap; } -@keyframes magic-marquee { +@keyframes signature-loop { to { transform: translateX(-50%); } } -/* --- 10. Command palette (Ctrl/Cmd+K) ------------------------------------------ - Keyboard-first section nav + actions. Arrow keys move, Enter runs, Esc - closes; the focus trap from app.js handles Tab. */ +/* Keyboard-first command palette. */ .palette-modal { width: min(560px, 100%); padding: 0; @@ -269,6 +142,7 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child border-bottom: 1px solid var(--line); color: var(--muted-2); } +.palette-input-wrap:focus-within { border-bottom-color: var(--route); } .palette-input { min-width: 0; flex: 1; @@ -276,9 +150,13 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child color: var(--text); background: transparent; border: 0; - outline: 0; + outline: 2px solid transparent; + outline-offset: 1px; font-size: 13px; } +.palette-input:focus-visible { + outline-color: var(--route); +} .palette-input::placeholder { color: var(--muted-2); } .palette-list { max-height: 320px; @@ -308,13 +186,13 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child font-size: 12px; font-weight: 600; text-align: left; - transition: background var(--dur-instant) var(--ease), color var(--dur-instant) var(--ease); + transition: background-color var(--dur-instant) var(--ease), color var(--dur-instant) var(--ease); } -.palette-option:hover { color: var(--text); background: rgba(255, 255, 255, .03); } +.palette-option:hover { color: var(--text); background: var(--panel-raised); } .palette-option.active { color: var(--text); background: var(--route-dim); - border-color: rgba(127, 224, 178, .22); + border-color: var(--route-line); } .palette-option .palette-ic { display: grid; place-items: center; flex: none; width: 22px; color: var(--muted); } .palette-option.active .palette-ic { color: var(--route); } @@ -351,25 +229,19 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child font-family: var(--mono); font-size: 9px; } -[data-theme="light"] .palette-option:hover { background: rgba(28, 62, 50, .05); } -/* --- 11. Workspace particle field (particles.js engine) ------------------------- - Scoped strictly to
    : the host div is absolutely - positioned inside the workspace and paints below .main-inner, so the - topbar, sidebar, modals, toasts and mobile nav are never covered. No - overflow clipping here — overflow:hidden would break the sticky - explanation column. The engine sizes its canvas to the host via JS. - pointer-events:none keeps clicks, selection and keyboard focus untouched. - Only the positioning adapts the reference CSS; the demo's red background, - stats badge and particle counter are intentionally excluded. */ -.main { - position: relative; -} +/* Scoped workspace and contribution canvases remain below app content. */ +.main { position: relative; } #particles-workspace { - position: absolute; - inset: 0; - z-index: 0; + position: fixed; + top: 72px; + right: 304px; + bottom: 0; + left: 0; + z-index: var(--z-workspace); + opacity: .24; pointer-events: none; + overflow: hidden; } #particles-workspace .particles-js-canvas-el { display: block; @@ -377,19 +249,13 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child } .main-inner { position: relative; - z-index: 1; + z-index: var(--z-content); } - -/* --- 12. Top bar GitHub-activity squares --------------------------------------- - Scoped strictly to the header so the brand and the round controls stay - untouched. The host is absolutely positioned inside .topbar (which is - position:sticky, so it anchors here) and paints below the header content. - pointer-events:none keeps the logo/search/moon/settings buttons clickable. - The canvas is self-sizing via JS; no overflow clipping needed. */ #topbar-contrib { position: absolute; inset: 0; - z-index: 0; + z-index: var(--z-workspace); + opacity: .58; pointer-events: none; overflow: hidden; } @@ -400,124 +266,937 @@ html.reveal-armed:not(.reveal-done) [data-reveal-stagger].revealed > *:nth-child } .topbar > *:not(#topbar-contrib) { position: relative; - z-index: 1; + z-index: var(--z-content); } +[data-theme="light"] #particles-workspace { + opacity: .16; + mix-blend-mode: multiply; +} +[data-theme="light"] #topbar-contrib { opacity: .3; } -/* --- Reduced motion v2: everything static, nothing hidden --------------------- */ -@media (prefers-reduced-motion: reduce) { - html.reveal-armed [data-reveal], - html.reveal-armed [data-reveal-stagger] > * { - opacity: 1 !important; - transform: none !important; - filter: none !important; - transition: none !important; +/* Strong visual pass — field-manual inversion. + The product keeps its exact routes, components, controls, copy, icons, and + mascot, but no longer shares the old left-rail / dark-card silhouette. The + repository brief is an inverted manual sheet; workspace context moves to a + right-hand rail; analysis content resolves into an open ruled ledger. */ +body { + font-size: 14px; + line-height: 1.6; +} + +.topbar { + height: 72px; + gap: 24px; + padding: 0 28px; + background: var(--bg); + border-bottom: 2px solid var(--line-strong); +} +.brand { gap: 11px; } +.brand-logo, .brand-img { width: 52px; height: 52px; } +.brand-word { font-size: 19px; } +.brand-sub { + padding-left: 14px; + font-size: 10px; + letter-spacing: .14em; +} +.topbar-center { font-size: 13px; } +.topbar-actions { gap: 8px; } +.connection-pill { + padding: 9px 12px; + background: var(--panel-soft); + border-radius: var(--radius-sm); + font-size: 12px; +} +.icon-button { + width: 42px; + height: 42px; + border-radius: var(--radius-sm); +} + +/* Context now lives on the right. This single move changes the application + silhouette without changing sidebar ownership or any navigation behavior. */ +.layout { + grid-template-columns: minmax(0, 1fr) 304px; + min-height: calc(100vh - 72px); +} +.sidebar { + grid-column: 2; + grid-row: 1; + position: sticky; + top: 72px; + align-self: start; + width: auto; + height: calc(100vh - 72px); + overflow-y: auto; + padding: 36px 24px 28px; + background: var(--panel-soft); + border-right: 0; + border-left: 2px solid var(--line-strong); +} +.main { + grid-column: 1; + grid-row: 1; + max-width: none; + padding: 48px clamp(32px, 5vw, 72px) 112px; +} +.sidebar-label { + margin: 0 8px 12px; + font-size: 10px; + letter-spacing: .18em; +} +.new-analysis { + margin-bottom: 36px; + padding: 13px 16px; + color: var(--route-bright); + background: transparent; + border: 1px solid var(--route-line); + border-radius: 0; + box-shadow: none; +} +.new-analysis:hover { + color: var(--route-ink); + background: var(--route); +} +.sidebar-nav { gap: 4px; } +.nav-item { + padding: 13px 10px; + border: 0; + border-radius: 0; + font-size: 13px; +} +.nav-item.active { + color: var(--route-bright); + background: var(--route-dim); +} +.history { margin-top: 40px; } +.history-list { gap: 4px; } +.history-row, .history-entry { border-radius: 0; } +.history-entry { + padding: 11px 8px; + border: 0; + font-size: 12px; +} +.history-row.active, .history-entry.renaming { background: var(--route-dim); } +.side-section { margin-top: 36px; } +.file-tree { + max-height: 380px; + padding: 8px 0; + background: transparent; + border-right: 0; + border-left: 0; + border-radius: 0; +} +.tree-filter { + height: 42px; + padding: 0 12px; + border-radius: 0; + font-size: 12px; +} +.tree-row { padding: 7px 8px; border-radius: 0; font-size: 12px; } +.local-note { font-size: 11px; } + +/* The opening brief is a light field-manual sheet in dark mode and a dark + sheet in light mode. It is the dominant, immediate before/after signal. */ +.hero { + position: relative; + isolation: isolate; + min-height: 520px; + max-width: none; + margin: 0 0 56px; + padding: 52px 44% 56px 56px; + color: var(--manual-ink); + background: var(--manual); + border-top: 8px solid var(--route); + overflow: hidden; + animation: none; +} +.hero::before { + content: ''; + position: absolute; + inset: -40px; + z-index: var(--z-workspace); + pointer-events: none; + background-image: + conic-gradient(from 270deg at 6px 6px, var(--manual-accent) 25%, transparent 0), + conic-gradient(from 270deg at 6px 6px, var(--manual-accent) 25%, transparent 0); + background-position: 0 0, 18px 18px; + background-size: 18px 18px, 54px 54px; + opacity: .055; + -webkit-mask-image: linear-gradient(100deg, transparent 2%, rgba(0, 0, 0, .14) 34%, #000 74%, rgba(0, 0, 0, .45)); + mask-image: linear-gradient(100deg, transparent 2%, rgba(0, 0, 0, .14) 34%, #000 74%, rgba(0, 0, 0, .45)); + animation: contribution-drift 46s var(--ease) infinite alternate-reverse; + will-change: transform; +} +.hero > .eyebrow, +.hero > h1, +.hero > .hero-copy, +.hero > .repo-form, +.hero > .seg-block, +.hero > .form-meta, +.hero > .error-banner { + position: relative; + z-index: var(--z-content); +} +.hero > .eyebrow { animation: manual-enter 420ms var(--ease) 40ms both; } +.hero > h1 { animation: manual-enter 520ms var(--ease) 80ms both; } +.hero > .hero-copy { animation: manual-enter 520ms var(--ease) 140ms both; } +.hero > .repo-form { animation: manual-enter 460ms var(--ease) 110ms both; } +.hero > .seg-block { animation: manual-enter 460ms var(--ease) 170ms both; } +.hero > .form-meta { animation: manual-enter 460ms var(--ease) 220ms both; } +.hero > .error-banner { animation: manual-enter 320ms var(--ease) both; } +@keyframes manual-enter { + from { opacity: 0; transform: translate3d(0, 12px, 0); } + to { opacity: 1; transform: translate3d(0, 0, 0); } +} +.eyebrow { + margin-bottom: 20px; + color: var(--manual-accent); + font-size: 11px; + letter-spacing: .18em; +} +.eyebrow-line { width: 28px; background: var(--manual-accent); } +h1 { + max-width: 15ch; + color: var(--manual-ink); + font-size: clamp(44px, 3.45vw, 52px); + line-height: 1.01; + letter-spacing: -.04em; + overflow-wrap: anywhere; + min-width: 0; +} +h1 span { color: var(--manual-muted); } +.hero-copy { + max-width: 46ch; + color: var(--manual-muted); + font-size: 16px; + line-height: 1.68; +} + +.repo-form { + max-width: none; + padding: 9px; + gap: 8px; + background: var(--panel); + border: 1px solid var(--line-strong); + border-radius: 0; + box-shadow: none; +} +.repo-form:focus-within { + border-color: var(--route); + box-shadow: 0 0 0 2px var(--route-dim); +} +.repo-input { height: 50px; font-size: 14px; } +.analyze-button { + min-height: 50px; + padding: 0 22px; + border-radius: 0; + font-size: 13px; +} +@media (hover: hover) and (pointer: fine) { + .analyze-button:not(:disabled), + .install-button:not(:disabled), + .new-analysis { + transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), + border-color var(--dur-instant) var(--ease), transform var(--dur-instant) var(--ease), + box-shadow var(--dur-instant) var(--ease); } - #particles-workspace { - display: none; /* engine is never started; the static dot grid remains */ + .analyze-button:hover:not(:disabled), + .install-button:hover:not(:disabled), + .new-analysis:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); } - #topbar-contrib canvas { - opacity: 1; /* static speckle is painted once by JS; nothing animates */ + .analyze-button:active:not(:disabled), + .install-button:active:not(:disabled), + .new-analysis:active { + transform: translateY(1px); + box-shadow: none; } - .sig-marquee-track { - animation: none; + .nav-item, + .history-entry, + .tree-row.dir { transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), transform var(--dur-instant) var(--ease); } + .nav-item:hover, + .history-entry:hover, + .tree-row.dir:hover { transform: translateX(2px); } +} +.seg-block { max-width: none; margin-top: 20px; } +.seg-label { margin-bottom: 10px; font-size: 11px; } +.seg-row { + gap: 0; + padding: 0; + background: transparent; + border: 1px solid var(--line-strong); + border-radius: 0; +} +.seg-option { + min-height: 74px; + padding: 13px 14px; + background: transparent; + border: 0; + border-right: 1px solid var(--line); + border-radius: 0; +} +.seg-option:last-child { border-right: 0; } +.seg-option:hover { background: var(--panel-raised); } +.seg-option.active { + background: var(--route-dim); + border-color: var(--line); + box-shadow: inset 0 -3px 0 var(--route); +} +.seg-option strong { font-size: 12px; } +.seg-option span { font-size: 10.5px; } +.form-meta { + margin-top: 16px; + gap: 8px 16px; + font-size: 11px; +} + +/* Inverted control colors inside the field-manual sheet. */ +.hero .repo-form { + display: block; + background: var(--manual-control); + border-color: var(--manual-control); +} +.hero .analyze-button { width: 100%; margin-top: 8px; } +.hero .repo-input { color: var(--manual-control-text); } +.hero .repo-input::placeholder { color: var(--manual-control-muted); } +.hero .repo-input-wrap svg { color: var(--manual-control-muted); } +.hero .seg-label, .hero .form-meta { color: var(--manual-muted); } +.hero .seg-label em { color: var(--manual-muted); } +.hero .seg-row { border-color: var(--manual-line); } +.hero .seg-option { border-color: var(--manual-line); } +.hero .seg-option:hover { background: var(--manual-line); } +.hero .seg-option strong { color: var(--manual-ink); } +.hero .seg-option span { color: var(--manual-muted); } +.hero .seg-option.active { + background: var(--manual-control); + box-shadow: inset 0 -4px 0 var(--route); +} +.hero .seg-option.active strong { color: var(--route); } +.hero .seg-option.active span { color: var(--manual-control-muted); } +.hero .form-meta .shortcut { border-color: var(--manual-line); } +.hero .error-banner { background: var(--manual-control); } + +@media (min-width: 1200px) { + .hero > .repo-form, + .hero > .seg-block, + .hero > .form-meta, + .hero > .error-banner { + position: absolute; + right: 56px; + left: 56%; width: auto; - flex-wrap: wrap; - } - .sig-marquee:not(.sig-static) { - -webkit-mask-image: none; - mask-image: none; + max-width: none; + margin: 0; } + .hero > .repo-form { top: 56px; } + .hero > .seg-block { top: 210px; } + .hero > .form-meta { top: 352px; } + .hero > .error-banner { top: 416px; } } -/* =========================================================================== - Magic layer v3 — entrance variants, hover refinements, typography polish - =========================================================================== */ - -/* --- Keyframes: entrance variants (Animate.css-style) ----------------------- */ -@keyframes rise-left { - from { opacity: 0; transform: translateX(-18px); } - to { opacity: 1; transform: translateX(0); } +/* Analysis starts with the same manual sheet as a compact scan console. The + three existing siblings remain separate DOM components but form one band. */ +.main-inner > .repo-form { + margin: 0; + padding: 10px 12px; + color: var(--manual-ink); + background: var(--manual); + border: 0; + border-top: 8px solid var(--route); } -@keyframes rise-right { - from { opacity: 0; transform: translateX(18px); } - to { opacity: 1; transform: translateX(0); } +.main-inner > .repo-form .repo-input { color: var(--manual-ink); } +.main-inner > .repo-form .repo-input::placeholder { color: var(--manual-muted); } +.main-inner > .repo-form .repo-input-wrap svg { color: var(--manual-muted); } +.main-inner > .seg-block { + margin: 0; + padding: 18px 20px 0 28px; + color: var(--manual-ink); + background: var(--manual); } -@keyframes scale-in { - from { opacity: 0; transform: scale(0.96); } - to { opacity: 1; transform: scale(1); } +.main-inner > .seg-block .seg-label, +.main-inner > .seg-block .seg-label em { color: var(--manual-muted); } +.main-inner > .seg-block .seg-row { border-color: var(--manual-line); } +.main-inner > .seg-block .seg-option { border-color: var(--manual-line); } +.main-inner > .seg-block .seg-option:hover { background: var(--manual-line); } +.main-inner > .seg-block .seg-option strong { color: var(--manual-ink); } +.main-inner > .seg-block .seg-option span { color: var(--manual-muted); } +.main-inner > .seg-block .seg-option.active { + background: var(--manual-control); + box-shadow: inset 0 -4px 0 var(--route); } +.main-inner > .seg-block .seg-option.active strong { color: var(--route); } +.main-inner > .seg-block .seg-option.active span { color: var(--manual-control-muted); } +.main-inner > .form-meta { + margin: 0 0 48px; + padding: 18px 20px 24px 28px; + color: var(--manual-muted); + background: var(--manual); + border-bottom: 8px solid var(--route); +} +.main-inner > .form-meta .shortcut { border-color: var(--manual-line); } -/* --- Reveal variants: data-reveal="left" | "right" | "scale" --------------- - These override the default translateY(14px) hidden state and the - translateY(0) revealed state when the attribute is present. */ -html.reveal-armed:not(.reveal-done) [data-reveal="left"] { - transform: translateX(-18px); +.initial-content { + margin-top: 0; + padding-top: 14px; + border-top: 0; +} +.section-kicker { + margin-bottom: 20px; + color: var(--text); + font-size: 14px; + letter-spacing: -.01em; } -html.reveal-armed:not(.reveal-done) [data-reveal="right"] { - transform: translateX(18px); +.section-kicker::after { display: none; } +.route-preview { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, .9fr) minmax(0, .9fr); + gap: 18px; + max-width: none; + background: transparent; + border: 0; + border-radius: 0; + overflow: visible; } -html.reveal-armed:not(.reveal-done) [data-reveal="scale"] { - transform: scale(0.96); +.route-stop { + grid-template-columns: 48px minmax(0, 1fr); + gap: 18px; + min-height: 168px; + padding: 30px 22px; + background: transparent; + border: 0; } -html.reveal-armed:not(.reveal-done) [data-reveal="left"].revealed, -html.reveal-armed:not(.reveal-done) [data-reveal="right"].revealed { - transform: translateX(0); +.route-stop:nth-child(2) { + background: var(--panel-soft); + box-shadow: inset 0 1px 0 var(--line-strong), var(--shadow); } -html.reveal-armed:not(.reveal-done) [data-reveal="scale"].revealed { - transform: scale(1); +.route-mile { + padding: 0; + color: var(--route); + background: transparent; + border: 0; + border-radius: 0; + font-size: 19px; + text-align: left; } +.route-stop h3 { font-size: 18px; line-height: 1.35; } +.route-stop p { max-width: 42ch; font-size: 13px; line-height: 1.72; } +.example-row { margin-top: 32px; color: var(--muted); font-size: 11.5px; } +.example-chip { border-radius: 0; font-size: 11.5px; } -/* --- Hover micro-interactions (Hover.css-style) ----------------------------- */ +.analysis-view { margin-top: 0; } +.analysis-head { + gap: 32px; + margin-bottom: 38px; + padding: 0; + border-bottom: 0; +} +.repo-identity { gap: 20px; } +.repo-avatar { + width: 60px; + height: 60px; + background: var(--panel-soft); + border: 1px solid var(--line-strong); + border-radius: 0; + box-shadow: inset 0 3px 0 var(--route); +} +.analysis-head h2 { font-size: 44px; line-height: .98; } +.repo-url { font-size: 11.5px; line-height: 1.6; } +.repo-description { max-width: 62ch; font-size: 14px; line-height: 1.7; } +.analysis-actions { gap: 8px; } +.analysis-actions .seg-inline { + max-width: 280px; + flex-wrap: wrap; + border-radius: 0; +} +.analysis-actions .seg-note { + flex: 1 0 100%; + padding: 3px 7px 1px; + text-align: center; +} +.secondary-button, .install-button { + min-height: 42px; + padding: 9px 15px; + border-radius: 0; + font-size: 12px; +} +.seg-chip { border-radius: 0; font-size: 10.5px; } -/* Nav item: sliding mint accent bar from left */ -.nav-item { position: relative; } -.nav-item::before { +.route-nav { + gap: 4px; + margin: 0 0 28px; + padding: 4px; + background: var(--panel-soft); + border: 0; + box-shadow: inset 0 1px 0 var(--line); +} +.route-nav a { + position: relative; + flex: 1; + justify-content: center; + padding: 12px 10px 13px; + background: transparent; + border: 0; + border-radius: 0; + font-size: 11px; + overflow: hidden; +} +.route-nav a::after { content: ''; position: absolute; - left: 0; top: 50%; - width: 3px; height: 60%; + right: 14px; + bottom: 4px; + left: 14px; + height: 2px; background: var(--route); - border-radius: 0 3px 3px 0; - transform: translateY(-50%) scaleY(0); + transform: scaleX(0); + transform-origin: left; transition: transform var(--dur-normal) var(--ease); } -.nav-item:hover::before { transform: translateY(-50%) scaleY(1); } -.nav-item.active::before { transform: translateY(-50%) scaleY(1); } - -/* Example chip: gentle scale alongside existing lift */ -.example-chip:hover { transform: translateY(-1px) scale(1.02); } +.route-nav a:hover, +.route-nav a:focus-visible { color: var(--route-bright); background: var(--route-dim); } +.route-nav a:hover::after, +.route-nav a:focus-visible::after { transform: scaleX(1); } +.route-status { + margin-bottom: 32px; + padding: 26px 28px; + color: var(--manual-ink); + background: var(--manual); + border: 0; + border-top: 8px solid var(--route); + border-radius: 0; +} +[data-theme="light"] .route-status { background: var(--manual); } +.route-status .route-kicker, +.route-status .progress-copy strong { color: var(--manual-accent); } +.route-status .route-kicker { font-size: 11.5px; } +.route-status .route-verdict { color: var(--manual-ink); font-size: 14px; line-height: 1.7; } +.route-status .route-verdict.dim, +.route-status .progress-copy { color: var(--manual-muted); } +.route-status .progress-copy { font-size: 11px; } +.route-status .progress-track { background: var(--manual-line); } +.route-status .progress-fill { background: var(--manual-accent); } +.overview-strip { + gap: 8px; + margin-bottom: 48px; + background: transparent; + border: 0; + border-radius: 0; + overflow: visible; +} +.overview-cell { + min-height: 96px; + padding: 20px 22px; + background: var(--panel-soft); + border: 0; +} +.overview-cell.summary { box-shadow: inset 3px 0 0 var(--route); } +.overview-cell .cell-label { font-size: 11px; letter-spacing: .08em; } +.overview-cell .cell-value { margin-top: 10px; font-size: 17px; line-height: 1.55; } +.overview-cell .cell-value.mono { font-size: 12.5px; } +.dashboard-grid { + grid-template-columns: minmax(0, 1.55fr) minmax(300px, .7fr); + gap: 36px; +} -/* Copy-mini: subtle scale on hover */ -.copy-mini:hover { transform: scale(1.08); } +/* Panels now read as chapters: whitespace carries the hierarchy, while only + supporting/reference groups receive a tonal surface. */ +.panel { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; +} +.panel + .panel { margin-top: 58px; } +.panel.elevated { box-shadow: none; } +.panel-heading { + gap: 18px; + padding: 18px 0 22px; + background: transparent; + border: 0; +} +.panel-title-wrap { gap: 14px; } +.panel-icon { + width: 34px; + height: 34px; + color: var(--route); + background: var(--route-dim); + border-radius: 0; +} +.panel h3 { margin-top: 1px; font-size: 20px; line-height: 1.3; letter-spacing: -.02em; } +.panel-subtitle { max-width: 62ch; margin-top: 6px; font-size: 13px; line-height: 1.65; } +.panel-count { font-size: 10.5px; } +.install-list { padding: 0; } +.step-row { + gap: 18px; + margin-top: 4px; + padding: 20px 0; + border-bottom: 0; +} +.step-row:hover { background: var(--panel-soft); } +.step-number { + background: transparent; + border-radius: 0; + font-size: 11px; +} +.step-title { font-size: 15px; line-height: 1.45; } +.step-id { background: transparent; border-radius: 0; font-size: 9.5px; } +.step-state { font-size: 10px; } +.step-detail { max-width: 70ch; font-size: 13px; line-height: 1.72; } +.command-block { + padding: 14px 88px 14px 14px; + border-radius: 0; + font-size: 12px; + line-height: 1.6; +} +.copy-mini { border-radius: 0; } +.install-progress { padding: 20px 0 24px; background: transparent; border-top: 0; } +.info-grid { gap: 18px; margin-bottom: 52px; } +.info-grid .info-panel, +.primary-stack > .panel:not(.plain-panel), +.sticky-column > .panel { + padding: 20px; + background: var(--panel-soft); +} +.info-grid .panel + .panel { margin-top: 0; } +.info-panel .panel-heading { padding: 4px 0 18px; } +.info-panel .panel-heading h3 { font-size: 17px; } +.info-list { padding: 4px 0 8px; } +.info-item { padding: 10px 0; border-bottom: 0; font-size: 12.5px; line-height: 1.65; } +.sticky-column > .panel { top: 96px; } -/* Step checkbox: ring glow when checked */ -.step-check:checked { box-shadow: 0 0 0 3px rgba(127, 224, 178, .15); } +.plain-analogy { + padding: 16px 18px; + background: var(--route-dim); + border: 0; + border-radius: 0; + box-shadow: inset 3px 0 0 var(--route); + font-size: 13px; + line-height: 1.7; +} +.plain-grid { + gap: 12px; + border: 0; +} +.plain-card { + padding: 18px; + background: var(--panel-soft); + border: 0; + border-radius: 0; +} +.plain-card:nth-child(2), .plain-card.wide { border: 0; } +.plain-card.wide { background: transparent; box-shadow: inset 0 1px 0 var(--line); } +.plain-card p, +.plain-card ol { font-size: 13px; line-height: 1.72; } +.plain-kicker { font-size: 10px; } +.insight-list { gap: 10px; } +.insight-list li { + padding: 14px 14px 14px 34px; + background: var(--panel-soft); + border: 0; + border-radius: 0; + font-size: 13px; + line-height: 1.7; +} +.insight-list li::before { + top: 21px; + width: 6px; + height: 6px; + border-radius: 0; +} +.health-body { + padding: 28px 24px; + background: var(--panel-soft); +} +.health-factors { gap: 18px; } +.factor-top { font-size: 12.5px; } +.factor p { font-size: 12px; line-height: 1.65; } +.health-foot, +.fail-foot { border-top: 0; } +.cap-item, .fail-notice, .graph-alt, .graph-tradeoff, +.contract-gaps, .fail-expect { border-radius: 0; } +.fail-list { gap: 10px; } +.fail-row { + padding: 20px; + background: var(--panel-soft); + border-bottom: 0; +} +.fail-title h4 { font-size: 14px; line-height: 1.45; } +.fail-main p { font-size: 13px; line-height: 1.7; } +.fail-scale, .fail-foot, .health-foot { font-size: 10.5px; } +.origin-badge, .patched, .unpatched, .path-pill, +.rev-badge, .tag, .quick-pick { border-radius: 0; } +.path-pill { background: transparent; } +.followup-chip { background: transparent; border-radius: 0; } +.contract-meta { + padding: 14px 18px; + border-bottom: 0; +} +.contract-grid { + gap: 28px; + padding: 26px 22px; + background: var(--panel-soft); +} +.contract-section h4 { font-size: 10.5px; } +.contract-list strong { font-size: 13px; } +.contract-list span, .contract-list code { font-size: 12px; line-height: 1.65; } +.contract-check, +.contract-sign, +.contract-guarantees { border-top: 0; } +.contract-meta, .contract-guarantees { font-size: 10.5px; } -/* Tree row (dir): subtle indent slide on hover */ -.tree-row.dir { transition: padding-left var(--dur-normal) var(--ease), color var(--dur-instant) var(--ease), background var(--dur-instant) var(--ease); } -.tree-row.dir:hover { padding-left: 11px; } +/* Reading matter is deliberately larger than controls and metadata. */ +.plain-body, +.explorer-body { padding: 8px 0 0; } +.explorer-grid { gap: 10px; } +.explorer-btn { + min-height: 64px; + padding: 14px; + background: var(--panel-soft); + border: 0; + border-radius: 0; +} +.explorer-btn.active { box-shadow: inset 0 -3px 0 var(--route); } +.explorer-text strong { font-size: 13px; } +.explorer-text small { font-size: 11.5px; line-height: 1.55; } +.explanation-content { padding: 10px 0 4px; } +.explanation-select { min-height: 38px; height: 38px; font-size: 11.5px; border-radius: 0; } +.explanation-content h4 { margin-top: 18px; font-size: 17px; line-height: 1.4; } +.explanation-content p { font-size: 13px; line-height: 1.75; } +.explanation-tip { padding: 12px; border: 0; border-radius: 0; font-size: 12px; line-height: 1.65; } +.files-panel .file-list { padding: 8px 0 2px; } +.file-chip { font-size: 10px; border-radius: 0; } +.insight-intro { font-size: 13px; line-height: 1.75; } +.insight-outro { font-size: 12.5px; line-height: 1.7; border: 0; border-radius: 0; } +.followups { border-top: 0; } +.followup-chip { font-size: 11.5px; } +.health-band span { font-size: 11.5px; line-height: 1.6; } +.cap-item { font-size: 12px; line-height: 1.6; } +.graph-tradeoff { font-size: 12px; line-height: 1.65; } +.contract-state { font-size: 12.5px; line-height: 1.7; } +.contract-check label { font-size: 12.5px; line-height: 1.6; } +.contract-guarantees { font-size: 11.5px; line-height: 1.65; } +.recovery-body { padding: 10px 0 18px; } +.recovery-diagnosis { font-size: 14px; line-height: 1.7; } +.recovery-second, +.recovery-list { font-size: 12.5px; line-height: 1.7; } -/* Followup chip: scale alongside existing lift */ -.followup-chip:hover { transform: translateY(-1px) scale(1.02); } +.modal, .palette-modal { border-radius: 0; } +.text-field, .select-field { border-radius: 0; font-size: 12px; } +.sig-chip { background: transparent; border-radius: 0; font-size: 10.5px; } -/* --- Typography rhythm (Vanilla Framework-style) ----------------------------- */ +#particles-workspace { opacity: .24; } +#topbar-contrib { opacity: .58; } +[data-theme="light"] #particles-workspace { + opacity: .16; + mix-blend-mode: multiply; +} +[data-theme="light"] #topbar-contrib { opacity: .3; } -/* Let headings inherit letter-spacing from body; they already specify - tight negative values which take precedence, but inherit prevents - conflict if body spacing changes. */ -h1, h2, h3, h4 { letter-spacing: inherit; } -h1 { letter-spacing: -.03em; } -h2 { letter-spacing: -.02em; } -h3 { letter-spacing: -.01em; } +@media (max-width: 1399px) { + .analysis-head { display: block; } + .analysis-actions { margin-top: 24px; } +} +@media (max-width: 1199px) { + .hero { + display: flex; + flex-direction: column; + min-height: 0; + padding: 48px; + } + .hero > .repo-form { order: 1; margin-top: 36px; } + .hero > .error-banner { order: 2; width: 100%; max-width: none; } + .hero > .seg-block { order: 3; } + .hero > .form-meta { order: 4; } +} +@media (max-width: 1100px) { + #particles-workspace { right: 220px; } + .layout { grid-template-columns: minmax(0, 1fr) 220px; } + .sidebar { + grid-column: 2; + width: auto; + padding: 28px 16px 24px; + } + .sidebar-label { display: block; } + .nav-item span:not(.nav-icon), + .new-analysis span { display: inline; } + .history, .sidebar-foot { display: block; } + .new-analysis { + width: 100%; + height: auto; + margin: 0 0 32px; + padding: 12px 10px; + } + .nav-item { justify-content: flex-start; padding: 12px 8px; } + .nav-item .nav-icon { width: 22px; } + .main { grid-column: 1; padding-right: 36px; padding-left: 36px; } +} +@media (max-width: 900px) { + .main { padding-top: 36px; } + .hero { padding: 40px; } + .route-preview { display: block; } + .route-stop { + min-height: 0; + border: 0; + } + .route-stop + .route-stop { margin-top: 8px; } + .dashboard-grid { grid-template-columns: minmax(0, 1fr); } + .sticky-column > .panel { position: static; } +} +@media (max-width: 720px) { + #particles-workspace { right: 0; bottom: 64px; } + .topbar { padding: 0 16px; } + .layout { display: block; min-height: calc(100vh - 72px); } + .sidebar { display: none; } + .main { display: block; padding: 32px 20px 96px; } + .mobile-nav, + [data-theme="light"] .mobile-nav { + display: flex; + color: var(--manual-ink); + background: var(--manual); + border-top: 6px solid var(--route); + } + .mobile-nav-item { color: var(--manual-muted); border-radius: 0; } + .mobile-nav-item:hover, .mobile-nav-item:active { + color: var(--manual-accent); + background: transparent; + } + .oreo-float { right: 12px; bottom: 64px; } + body:has(.hero > .error-banner) .oreo-float { top: 76px; bottom: auto; } + body:has(.hero > .error-banner) .oreo-tip { display: none; } + .oreo-panel { + right: 12px; + bottom: 76px; + left: 12px; + width: auto; + height: min(400px, calc(100vh - 190px)); + } + .hero { + margin-right: -20px; + margin-left: -20px; + padding: 40px 20px 48px; + border-top-width: 6px; + } + h1 { max-width: 15ch; font-size: clamp(34px, 9.6vw, 44px); line-height: 1.03; } + .hero-copy { max-width: 52ch; font-size: 15px; line-height: 1.68; } + .repo-form { display: block; } + .analyze-button { width: 100%; margin-top: 4px; } + .seg-row { grid-template-columns: minmax(0, 1fr); } + .seg-option { + min-height: 0; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .seg-option:last-child { border-bottom: 0; } + .main-inner > .repo-form, + .main-inner > .seg-block, + .main-inner > .form-meta { + margin-right: -20px; + margin-left: -20px; + } + .main-inner > .repo-form { padding: 12px 20px; } + .main-inner > .seg-block { padding: 18px 20px 0; } + .main-inner > .form-meta { padding: 18px 20px 24px; } + .overview-strip { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: "summary summary" "branch language" "files files"; + } + .route-nav a { flex: 0 0 calc(50% - 2px); border: 0; } + .route-status { grid-template-columns: minmax(0, 1fr); padding: 22px 20px; } + .info-grid { grid-template-columns: minmax(0, 1fr); gap: 18px; } + .health-body { grid-template-columns: minmax(0, 1fr); } + .fail-row { grid-template-columns: 28px minmax(0, 1fr); padding: 18px; } + .fail-patch { grid-column: 2; } + .contract-grid { grid-template-columns: minmax(0, 1fr); } + .panel-heading { padding: 18px 0; } + .panel-count { max-width: 34%; } +} +@media (max-width: 600px) { + #particles-workspace { top: 64px; } + .topbar { height: 64px; } + .layout { display: block; min-height: calc(100vh - 64px); } + .sidebar { display: none; } + .main { display: block; padding-right: 16px; padding-left: 16px; } + .brand-logo, .brand-img { width: 44px; height: 44px; } + .hero { + margin-top: -32px; + margin-right: -16px; + margin-left: -16px; + padding-right: 16px; + padding-left: 16px; + } + .initial-content { padding-top: 28px; } + .main-inner > .repo-form, + .main-inner > .seg-block, + .main-inner > .form-meta { + margin-right: -16px; + margin-left: -16px; + } + .analysis-head h2 { font-size: 34px; } + .analysis-actions { width: 100%; } + .analysis-actions button { flex: 1; } + .analysis-actions .seg-inline { width: 100%; max-width: none; } + .panel + .panel { margin-top: 42px; } + .install-list { padding: 0; } + .step-row { gap: 10px; padding: 18px 0; } + .step-body { padding-right: 36px; } +} -/* Slightly more breathing room after hero headline */ -h1 + .hero-copy { margin-top: 22px; } +/* The route marker communicates selected navigation; it does not animate on + hover or compete with the existing color state. */ +.nav-item { position: relative; } +.nav-item::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 3px; + height: 60%; + background: var(--route); + border-radius: 0 3px 3px 0; + transform: translateY(-50%); + opacity: 0; +} +.nav-item.active::before { opacity: 1; } -/* Better multi-line readability for step titles */ -.step-title { line-height: 1.65; } +/* Checked work receives one restrained state ring. */ +.step-check:checked { box-shadow: 0 0 0 3px var(--route-dim); } +.step-title { line-height: 1.5; } -/* --- Reduced motion v3: disable all new animations --------------------------- */ @media (prefers-reduced-motion: reduce) { - .nav-item::before { transition: none; transform: translateY(-50%) scaleY(1); } - .tree-row.dir { transition: none; } + html { scroll-behavior: auto; } + body::after, + .hero::before, + .hero > .eyebrow, + .hero > h1, + .hero > .hero-copy, + .hero > .repo-form, + .hero > .seg-block, + .hero > .form-meta, + .hero > .error-banner { + animation: none !important; + transform: none !important; + } + html.reveal-armed [data-reveal], + html.reveal-armed [data-reveal-stagger] > * { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } + #particles-workspace { + display: none; + } + #topbar-contrib canvas { + opacity: 1; + } + .sig-marquee-track { + animation: none; + width: auto; + flex-wrap: wrap; + } + .sig-marquee-track .sig-marquee-group:nth-child(2) { display: none; } + .sig-marquee:not(.sig-static) { + -webkit-mask-image: none; + mask-image: none; + } } diff --git a/public/magic.js b/public/magic.js index 50e395f..f2f79d2 100644 --- a/public/magic.js +++ b/public/magic.js @@ -1,25 +1,7 @@ -// Git-Up — Magic UI ports (vanilla JS, no dependencies). -// Techniques adapted from skills/magicui: magic-card spotlight (cursor-tracked -// radial glow via CSS vars), number-ticker (rAF count-up on visibility), and -// blur-fade scroll reveal (IntersectionObserver + a reveal-done seal so the -// app's constant re-renders never flicker). No top-level DOM access: safe to -// import in Node tests. Call the binders from bindEvents() after every render, -// since render() replaces innerHTML. - -const SPOTLIGHT_SELECTOR = '.panel, .seg-option, .oreo-panel, .oreo-chip, .route-preview, .route-status'; - -/** Track the cursor per card so magic.css can paint the glow at --mx/--my. */ -export function bindSpotlight(scope) { - const root = scope || (typeof document !== 'undefined' ? document : null); - if (!root || !root.querySelectorAll) return; - root.querySelectorAll(SPOTLIGHT_SELECTOR).forEach((el) => { - el.addEventListener('pointermove', (event) => { - const rect = el.getBoundingClientRect(); - el.style.setProperty('--mx', `${event.clientX - rect.left}px`); - el.style.setProperty('--my', `${event.clientY - rect.top}px`); - }); - }); -} +// Git-Up supporting presentation binders (vanilla JS, no dependencies). +// The value ticker counts once on visibility; reveal hooks use a short +// opacity/translate entrance and a reveal-done seal so rerenders never flicker. +// There is no top-level DOM access, keeping this module safe in Node tests. /** Count [data-ticker] elements up to data-ticker-to when scrolled into view. */ export function bindTickers(scope) { @@ -57,7 +39,7 @@ export function bindTickers(scope) { const SEAL_AFTER_MS = 4000; /** - * Blur-fade scroll reveal for [data-reveal] and [data-reveal-stagger]. + * Brief transform-and-opacity reveal for [data-reveal] and [data-reveal-stagger]. * * Contract with magic.css: * - `reveal-armed` on switches the hidden pre-reveal state on, so a @@ -84,13 +66,6 @@ export function bindReveals(scope) { const targets = Array.from(root.querySelectorAll('[data-reveal], [data-reveal-stagger]')); if (!targets.length) return; html.classList.add('reveal-armed'); - // Set --reveal-from for directional/scale reveal variants. - targets.forEach((el) => { - const v = el.getAttribute('data-reveal'); - if (v === 'left') el.style.setProperty('--reveal-from', 'rise-left'); - else if (v === 'right') el.style.setProperty('--reveal-from', 'rise-right'); - else if (v === 'scale') el.style.setProperty('--reveal-from', 'scale-in'); - }); // Later renders in the same view: keep everything visible, never re-animate. if (html.classList.contains('reveal-done')) return; const pending = new Set(targets); @@ -121,7 +96,7 @@ export function bindReveals(scope) { const batch = entries.filter((entry) => entry.isIntersecting && pending.has(entry.target)); batch.forEach((entry, index) => { pending.delete(entry.target); - entry.target.style.setProperty('--reveal-delay', `${index * 70}ms`); + entry.target.style.setProperty('--reveal-delay', `${index * 42}ms`); entry.target.classList.add('revealed'); io.unobserve(entry.target); }); diff --git a/public/particles-workspace.js b/public/particles-workspace.js index b76c719..7f237ec 100644 --- a/public/particles-workspace.js +++ b/public/particles-workspace.js @@ -21,10 +21,10 @@ const CONFIG = { particles: { - // Reference: value 262 / value_area 2558.98. Kept the same density - // mechanism, lowered for a restrained field: ~60 desktop, ~18 mobile - // (count = area/1000 * value/value_area, scaled automatically). - number: { value: 70, density: { enable: true, value_area: 1000 } }, + // Reference: value 262 / value_area 2558.98. Keep density scaling, with a + // modest count bump so the field reads as repository activity at wide + // sizes without turning long analysis pages into particle soup. + number: { value: 78, density: { enable: true, value_area: 1120 } }, // Reference: solid neon green #12f200 (excluded). Git-Up palette: mint // accent plus muted sage tones that read on dark AND light themes. // The engine picks one entry per particle at random. @@ -37,23 +37,25 @@ const CONFIG = { polygon: { nb_sides: 5 }, image: { src: '', width: 100, height: 100 }, }, - // Reference: value 0.576, random, animated. Lowered and slowed. + // Reference: value 0.576, random, animated. Visible enough to read as + // contribution squares after the canvas layer's restrained CSS opacity. opacity: { - value: 0.35, random: true, - anim: { enable: true, speed: 1, opacity_min: 0.08, sync: false }, + value: 0.46, random: true, + anim: { enable: true, speed: 0.72, opacity_min: 0.1, sync: false }, }, // Reference: value ~8 with an extreme size pulse (excluded as demo - // excess). Small, gently breathing nodes instead. + // excess). Crisp 1–3px repository-activity cells instead. size: { - value: 2.6, random: true, - anim: { enable: true, speed: 1.5, size_min: 0.8, sync: false }, + value: 2.8, random: true, + anim: { enable: true, speed: 0.9, size_min: 0.9, sync: false }, }, // Reference: line_linked disabled — kept disabled. No web of lines. line_linked: { enable: false, distance: 150, color: '#7fe0b2', opacity: 0, width: 0 }, - // Reference: slow random drift, bounce keeps density constant. + // Reference: slow random drift. The calmer speed suggests asynchronous + // repository activity rather than decorative snowfall. move: { - enable: true, speed: 1.5, direction: 'none', random: true, - straight: false, out_mode: 'bounce', bounce: false, + enable: true, speed: 0.58, direction: 'top-right', random: true, + straight: false, out_mode: 'out', bounce: false, attract: { enable: false, rotateX: 600, rotateY: 1200 }, }, }, @@ -63,7 +65,7 @@ const CONFIG = { // engine then tracks the cursor globally and repels workspace particles. detect_on: 'window', events: { - // Reference: hover repulse 200px (too aggressive — narrowed to 90px). + // Reference: hover repulse 200px (too aggressive — narrowed to 68px). onhover: { enable: true, mode: 'repulse' }, // Reference: click "push" spawns particles (excluded — clicking must // never mutate anything near UI controls). @@ -73,7 +75,7 @@ const CONFIG = { modes: { grab: { distance: 100, line_linked: { opacity: 1 } }, bubble: { distance: 200, size: 40, duration: 2, opacity: 8, speed: 3 }, - repulse: { distance: 90, duration: 0.4 }, + repulse: { distance: 68, duration: 0.38 }, push: { particles_nb: 2 }, remove: { particles_nb: 2 }, }, diff --git a/public/styles.css b/public/styles.css index 6feff08..39e991e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,7 +1,11 @@ -/* Git-Up — route-centered field manual. - Visual fingerprint: a surveyor's route ledger. Ledger paper, ruled route - spine, mileage markers, stamped trust artifacts. No SaaS hero, no gradients - without meaning, no decorative terminals. */ +/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 + Hallmark · genre: modern-minimal · macrostructure: Inverted field manual + right context rail · designed-as-app + Hallmark · unmistakable in-place redesign · theme: existing Git-Up route ledger + honest: pass (46) · chrome: pass (47) · tokens: pass (48) · icons: pass (30) + responsive: pass (34, 49–57) · motion: pass (17–18, 36) + contrast: pass (40–41) · accessibility: pass + Visual fingerprint: a surveyor's route ledger — ruled paths, mileage markers, + and stamped trust artifacts. Product structure and brand remain unchanged. */ :root { --bg: #0b1114; @@ -13,31 +17,58 @@ --line: rgba(178, 197, 190, .13); --line-strong: rgba(178, 197, 190, .24); --route: #7fe0b2; + --route-ink: var(--bg-deep); --route-bright: #a9f2cd; --route-dim: rgba(127, 224, 178, .12); --route-line: rgba(127, 224, 178, .34); --text: #eef4ef; --muted: #93a3a0; - --muted-2: #5f6f6c; + --muted-2: #7d8d89; --amber: #eec27a; --amber-dim: rgba(238, 194, 122, .12); --red: #ef9a94; --blue: #93b8ef; --stamp: #e5cf9b; - --shadow: 0 26px 80px rgba(0, 0, 0, .28); - --radius-sm: 8px; - --radius: 12px; - --radius-lg: 18px; + --shadow: 0 6px 18px rgba(0, 0, 0, .18); + --shadow-md: 0 8px 24px rgba(0, 0, 0, .2); + --shadow-menu: 0 12px 28px rgba(0, 0, 0, .3); + --shadow-modal: 0 20px 56px rgba(0, 0, 0, .34); + --modal-overlay: rgba(3, 7, 8, .84); + --dot: rgba(178, 197, 190, .16); + --manual: #e8eee9; + --manual-ink: #0a1511; + --manual-muted: #43584f; + --manual-line: rgba(10, 21, 17, .22); + --manual-accent: #086343; + --manual-control: #0b1513; + --manual-control-text: #eef4ef; + --manual-control-muted: #98aaa2; + --radius-sm: 4px; + --radius: 6px; + --radius-lg: 10px; --mono: ui-monospace, 'SFMono-Regular', 'Cascadia Mono', Consolas, monospace; --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --display: 'Avenir Next', 'Trebuchet MS', ui-sans-serif, system-ui, sans-serif; --dur-instant: 120ms; --dur-normal: 220ms; --dur-major: 420ms; + --stagger-step: 35ms; --ease: cubic-bezier(.2, .8, .2, 1); + --z-background: -1; + --z-workspace: 0; + --z-content: 1; + --z-topbar: 20; + --z-menu: 30; + --z-oreo: 40; + --z-oreo-panel: 41; + --z-mobile-nav: 50; + --z-modal: 60; + --z-toast: 80; + --z-skip-link: 100; } * { box-sizing: border-box; } +html, body { overflow-x: clip; } html { min-width: 320px; } body { margin: 0; @@ -47,20 +78,10 @@ body { font-family: var(--sans); -webkit-font-smoothing: antialiased; } -body::before { - content: ''; - position: fixed; - z-index: -2; - inset: 0; - background: - radial-gradient(ellipse at 82% -10%, rgba(127, 224, 178, .07), transparent 38%), - radial-gradient(ellipse at 12% 4%, rgba(147, 184, 239, .06), transparent 32%); - pointer-events: none; -} body::after { content: ''; position: fixed; - z-index: -1; + z-index: var(--z-background); inset: 0; opacity: .05; background-image: repeating-linear-gradient(0deg, transparent 0 27px, rgba(178, 197, 190, .5) 27px 28px); @@ -69,10 +90,19 @@ body::after { button, input, select, textarea { font: inherit; } .mono { font-family: var(--mono); } button { border: 0; cursor: pointer; } -button:disabled { cursor: not-allowed; opacity: .55; } +button:active:not(:disabled) { filter: brightness(.92); } +button:disabled, +input:disabled, +select:disabled, +textarea:disabled { cursor: not-allowed; opacity: .55; filter: saturate(.35); box-shadow: none; } :focus-visible { outline: 2px solid var(--route); outline-offset: 3px; } +h1, h2, h3, h4 { min-width: 0; overflow-wrap: anywhere; font-style: normal; } ::selection { background: rgba(127, 224, 178, .28); color: var(--text); } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +/* Iconify/Tabler glyphs share one 24px grid and stroke weight. Keeping SVGs + block-level inside their flex/grid wrappers removes baseline drift at every + rendered size; interaction color continues to come from the parent. */ +.ui-icon { display: block; flex: 0 0 auto; overflow: visible; pointer-events: none; shape-rendering: geometricPrecision; } .app-shell { min-height: 100vh; } .topbar { @@ -83,18 +113,17 @@ button:disabled { cursor: not-allowed; opacity: .55; } gap: 24px; padding: 0 34px; border-bottom: 1px solid var(--line); - background: rgba(11, 17, 20, .88); - backdrop-filter: blur(14px); + background: var(--bg); position: sticky; top: 0; - z-index: 20; + z-index: var(--z-topbar); } .brand { display: flex; align-items: center; gap: 12px; color: var(--text); text-decoration: none; } /* Mode-wise wordmark: 1254px transparent PNGs, downsized to the 70px topbar. No background or border — the sticker's own outer stroke defines the edge, so a drop-shadow (not a box-shadow) lifts it without boxing it in. */ .brand-logo { position: relative; display: grid; place-items: center; width: 56px; height: 56px; flex: none; transition: transform var(--dur-instant) var(--ease); } -.brand:hover .brand-logo { transform: scale(1.05) rotate(-1deg); } +.brand:hover .brand-logo { transform: translateY(-1px); } .brand-img { grid-area: 1 / 1; width: 56px; height: 56px; object-fit: contain; object-position: center; image-rendering: auto; border-radius: 12px; filter: drop-shadow(0 3px 10px rgba(0, 0, 0, .35)); } .brand-img--light { display: none; } [data-theme="light"] .brand-img--dark { display: none; } @@ -103,26 +132,29 @@ button:disabled { cursor: not-allowed; opacity: .55; } .brand-word em { color: var(--route); font-style: normal; } .brand-sub { color: var(--muted-2); font-family: var(--mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; border-left: 1px solid var(--line); padding-left: 11px; } .topbar-center { display: flex; align-items: center; gap: 9px; color: var(--muted-2); font-size: 12px; letter-spacing: .03em; } -.topbar-center .slash { color: #43545d; } +.topbar-center .slash { color: var(--muted-2); } .topbar-center strong { font-weight: 600; color: var(--muted); } .topbar-actions { display: flex; align-items: center; gap: 10px; } -.connection-pill { display: inline-flex; align-items: center; gap: 8px; padding: 7px 11px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 600; } -.status-dot { width: 7px; height: 7px; background: var(--muted-2); border-radius: 50%; } +.connection-pill { display: inline-flex; align-items: center; gap: 8px; padding: 7px 11px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: transparent; font-size: 11px; font-weight: 600; transition: color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease), background var(--dur-instant) var(--ease); } +.connection-pill:hover { color: var(--text); border-color: var(--line-strong); background: var(--panel-raised); } +.status-dot { flex: none; width: 7px; height: 7px; background: var(--muted-2); border-radius: 50%; } .status-dot.online { background: var(--route); box-shadow: 0 0 0 4px rgba(127, 224, 178, .1); } +.status-dot.pending { background: var(--amber); box-shadow: 0 0 0 4px rgba(238, 194, 122, .1); } +.status-dot.warning { background: var(--red); box-shadow: 0 0 0 4px rgba(239, 154, 148, .1); } .icon-button { width: 34px; height: 34px; display: grid; place-items: center; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 8px; transition: background var(--dur-instant) var(--ease), color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease), transform var(--dur-instant) var(--ease); } -.icon-button:hover { color: var(--text); background: var(--panel-raised); border-color: var(--line); transform: translateY(-1px); } +.icon-button:hover { color: var(--text); background: var(--panel-raised); border-color: var(--line); } .layout { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: calc(100vh - 70px); } .sidebar { padding: 28px 16px 24px; border-right: 1px solid var(--line); display: flex; flex-direction: column; min-width: 0; } .sidebar-label { margin: 0 10px 11px; color: var(--muted-2); text-transform: uppercase; font-size: 10px; letter-spacing: .14em; font-weight: 800; } .sidebar-nav { display: grid; gap: 4px; } -.nav-item { display: flex; align-items: center; gap: 11px; width: 100%; padding: 10px 11px; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 9px; text-align: left; font-size: 12px; font-weight: 600; transition: all var(--dur-instant) var(--ease); } +.nav-item { display: flex; align-items: center; gap: 11px; width: 100%; padding: 10px 11px; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 9px; text-align: left; font-size: 12px; font-weight: 600; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .nav-item:hover { color: var(--text); background: rgba(255,255,255,.03); } .nav-item.active { color: var(--route); background: var(--route-dim); border-color: rgba(127, 224, 178, .16); } .nav-item .nav-icon { width: 16px; display: grid; place-items: center; } .nav-count { margin-left: auto; color: var(--muted-2); font-family: var(--mono); font-size: 10px; } -.new-analysis { margin: 0 0 29px; display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 11px 12px; color: var(--bg-deep); background: var(--route); border-radius: 9px; font-size: 12px; font-weight: 800; box-shadow: 0 10px 20px rgba(0,0,0,.16); transition: transform var(--dur-instant) var(--ease), background var(--dur-instant) var(--ease), box-shadow var(--dur-instant) var(--ease); } -.new-analysis:hover { background: var(--route-bright); transform: translateY(-2px); box-shadow: 0 14px 25px rgba(0,0,0,.25); } +.new-analysis { margin: 0 0 29px; display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 11px 12px; color: var(--route-ink); background: var(--route); border-radius: 9px; font-size: 12px; font-weight: 800; box-shadow: 0 10px 20px rgba(0,0,0,.16); transition: transform var(--dur-instant) var(--ease), background var(--dur-instant) var(--ease), box-shadow var(--dur-instant) var(--ease); } +.new-analysis:hover { background: var(--route-bright); } .new-analysis:active { transform: scale(0.98); } .history { margin-top: 34px; } .history-list { display: grid; gap: 3px; } @@ -146,10 +178,10 @@ h1 span { color: var(--muted); } .repo-form:focus-within { border-color: rgba(127, 224, 178, .55); box-shadow: 0 0 0 4px rgba(127, 224, 178, .07), var(--shadow); } .repo-input-wrap { min-width: 0; flex: 1; display: flex; align-items: center; gap: 11px; padding: 0 11px; } .repo-input-wrap svg { color: var(--muted-2); flex: none; } -.repo-input { min-width: 0; width: 100%; height: 44px; color: var(--text); background: transparent; border: 0; outline: 0; font-size: 13px; } -.repo-input::placeholder { color: #667982; } -.analyze-button { display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 44px; padding: 0 18px; color: var(--bg-deep); white-space: nowrap; background: var(--route); border-radius: 9px; font-size: 12px; font-weight: 800; transition: background var(--dur-instant) var(--ease), transform var(--dur-instant) var(--ease), box-shadow var(--dur-instant) var(--ease); } -.analyze-button:hover { background: var(--route-bright); transform: translateY(-1px); box-shadow: 0 7px 18px rgba(127, 224, 178, .14); } +.repo-input { min-width: 0; width: 100%; height: 44px; color: var(--text); background: transparent; border: 0; outline: 2px solid transparent; outline-offset: 1px; font-size: 13px; } +.repo-input::placeholder { color: var(--muted-2); } +.analyze-button { display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 44px; padding: 0 18px; color: var(--route-ink); white-space: nowrap; background: var(--route); border-radius: 9px; font-size: 12px; font-weight: 800; transition: background var(--dur-instant) var(--ease), transform var(--dur-instant) var(--ease), box-shadow var(--dur-instant) var(--ease); } +.analyze-button:hover { background: var(--route-bright); } .analyze-button:active { transform: scale(0.98); } .analyze-button.loading { pointer-events: none; } .spinner { animation: spin 1s linear infinite; } @@ -168,7 +200,7 @@ h1 span { color: var(--muted); } .route-stop h3 { margin: 2px 0 6px; font-size: 13px; letter-spacing: -.01em; } .route-stop p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; } .example-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 19px; color: var(--muted-2); font-size: 10px; } -.example-chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 9px; color: var(--muted); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; font-family: var(--mono); font-size: 10px; transition: all var(--dur-instant) var(--ease); } +.example-chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 9px; color: var(--muted); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; font-family: var(--mono); font-size: 10px; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .example-chip:hover { color: var(--route); border-color: rgba(127, 224, 178, .35); background: var(--route-dim); } .error-banner { display: flex; align-items: flex-start; gap: 10px; max-width: 920px; margin-top: 16px; padding: 13px 15px; color: #f3b3af; background: rgba(239, 154, 148, .08); border: 1px solid rgba(239, 154, 148, .25); border-radius: 10px; font-size: 12px; line-height: 1.55; animation: rise var(--dur-normal) var(--ease) both; } @@ -179,15 +211,15 @@ h1 span { color: var(--muted); } .analysis-view { margin-top: 34px; animation: rise var(--dur-major) var(--ease) both; } .analysis-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 22px; margin-bottom: 20px; } .repo-identity { display: flex; align-items: flex-start; gap: 14px; min-width: 0; } -.repo-avatar { display: grid; place-items: center; flex: none; width: 43px; height: 43px; color: var(--text); background: linear-gradient(145deg, #22333b, #141f25); border: 1px solid var(--line-strong); border-radius: 11px; box-shadow: 0 8px 22px rgba(0,0,0,.2); } +.repo-avatar { display: grid; place-items: center; flex: none; width: 43px; height: 43px; color: var(--text); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: 11px; } .analysis-head h2 { margin: 0; font-family: var(--display); font-size: 23px; line-height: 1.2; letter-spacing: -.02em; overflow-wrap: anywhere; } .repo-url { display: flex; align-items: center; gap: 7px; margin-top: 6px; color: var(--muted-2); font-family: var(--mono); font-size: 10px; overflow-wrap: anywhere; } .repo-description { max-width: 560px; margin: 9px 0 0; color: var(--muted); font-size: 11.5px; line-height: 1.65; } .analysis-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; flex: none; } -.secondary-button, .install-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 37px; padding: 8px 13px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; font-size: 11px; font-weight: 700; transition: all var(--dur-instant) var(--ease); } +.secondary-button, .install-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 37px; padding: 8px 13px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; font-size: 11px; font-weight: 700; white-space: nowrap; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .secondary-button:hover { color: var(--text); background: var(--panel-raised); border-color: var(--line-strong); } -.install-button { color: var(--bg-deep); background: var(--route); border-color: var(--route); } -.install-button:hover { background: var(--route-bright); transform: translateY(-1px); box-shadow: 0 8px 18px rgba(127, 224, 178, .12); } +.install-button { color: var(--route-ink); background: var(--route); border-color: var(--route); } +.install-button:hover { background: var(--route-bright); } .install-button:active { transform: scale(0.98); } .analysis-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; } .tag { display: inline-flex; align-items: center; gap: 5px; padding: 5px 8px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 6px; font-size: 10px; font-weight: 700; } @@ -197,16 +229,16 @@ h1 span { color: var(--muted); } /* Route furniture: section nav + live status strip keep the six questions in order. */ .route-nav { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 16px; } -.route-nav a { display: inline-flex; align-items: center; gap: 7px; padding: 8px 12px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; font-size: 11px; font-weight: 700; text-decoration: none; transition: all var(--dur-instant) var(--ease); } +.route-nav a { display: inline-flex; align-items: center; gap: 7px; padding: 8px 12px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; font-size: 11px; font-weight: 700; white-space: nowrap; text-decoration: none; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .route-nav a:hover { color: var(--route-bright); border-color: var(--route-line); background: var(--route-dim); } -.route-status { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(240px, .8fr); gap: 20px; align-items: center; margin: 0 0 18px; padding: 16px 20px; background: rgba(17, 25, 30, .78); border: 1px solid var(--line); border-left: 3px solid var(--route); border-radius: var(--radius); } +.route-status { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(240px, .8fr); gap: 20px; align-items: center; margin: 0 0 18px; padding: 16px 20px; background: rgba(17, 25, 30, .78); border: 1px solid var(--line); border-top: 2px solid var(--route); border-radius: 0; } .route-kicker { display: inline-flex; align-items: center; gap: 8px; color: var(--route); font-family: var(--mono); font-size: 11px; font-weight: 700; } .route-verdict { margin: 8px 0 0; color: var(--text); font-size: 12.5px; line-height: 1.6; } .route-verdict.dim { color: var(--muted); } .route-status-meter .progress-copy { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 9px; color: var(--muted); font-size: 10px; } .route-status-meter .progress-copy strong { color: var(--route); font-family: var(--mono); font-weight: 500; } .progress-track { min-height: 4px; height: 4px; overflow: hidden; background: rgba(178, 197, 190, .12); border-radius: 999px; } -.progress-fill { height: 100%; background: var(--route); border-radius: inherit; transition: width var(--dur-normal) var(--ease); } +.progress-fill { height: 100%; background: var(--route); border-radius: inherit; } /* Bento overview: the summary carries the most weight and anchors two rows; branch / language / files read as compact ledger stamps around it. */ @@ -244,12 +276,12 @@ h1 span { color: var(--muted); } .step-dot { display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--line-strong); background: var(--panel-raised); color: var(--muted-2); flex: none; } .step-line { width: 1px; flex: 1; min-height: 26px; margin-top: 5px; background: repeating-linear-gradient(180deg, var(--line-strong) 0 4px, transparent 4px 8px); } .step-row.is-done .step-line { background: var(--route-line); } -.step-dot.status-done { color: var(--bg-deep); background: var(--route); border-color: var(--route); } +.step-dot.status-done { color: var(--route-ink); background: var(--route); border-color: var(--route); } .step-dot.status-failed { color: #f7c7c3; background: rgba(239, 154, 148, .16); border-color: rgba(239, 154, 148, .5); } .step-dot.status-revised { color: var(--route); border-color: var(--route-line); background: var(--route-dim); } .step-dot.status-pending { color: var(--muted-2); } .step-number { display: grid; place-items: center; min-width: 26px; height: 26px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: 7px; font-family: var(--mono); font-size: 10px; font-weight: 500; } -.step-row.is-done .step-number { color: var(--bg-deep); background: var(--route); border-color: var(--route); } +.step-row.is-done .step-number { color: var(--route-ink); background: var(--route); border-color: var(--route); } .step-body { min-width: 0; } .step-title { margin: 2px 0 4px; color: var(--text); font-size: 12px; font-weight: 700; display: flex; align-items: center; flex-wrap: wrap; gap: 7px; } .step-id { color: var(--muted-2); font-size: 9px; border: 1px solid var(--line); border-radius: 5px; padding: 2px 5px; background: var(--panel-soft); } @@ -259,7 +291,7 @@ h1 span { color: var(--muted); } .command-block::before { content: '$'; color: var(--route); flex: none; } .copy-mini { position: absolute; top: 5px; right: 5px; display: inline-flex; align-items: center; gap: 5px; min-height: 23px; padding: 0 7px; color: var(--muted-2); background: transparent; border-radius: 5px; font-size: 9px; font-weight: 700; } .copy-mini:hover { color: var(--text); background: var(--panel-raised); } -.step-check { appearance: none; position: relative; min-width: 18px; width: 18px; height: 18px; margin: 1px 0 0; flex: none; border: 1px solid var(--line-strong); border-radius: 5px; background: transparent; transition: all var(--dur-instant) var(--ease); } +.step-check { appearance: none; position: relative; min-width: 18px; width: 18px; height: 18px; margin: 1px 0 0; flex: none; border: 1px solid var(--line-strong); border-radius: 5px; background: transparent; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .step-check:hover { border-color: var(--route); } .step-check:checked { background: var(--route); border-color: var(--route); } .step-check:checked::after { content: ''; position: absolute; left: 5px; top: 2px; width: 5px; height: 9px; border: solid var(--bg-deep); border-width: 0 2px 2px 0; transform: rotate(45deg); } @@ -285,7 +317,7 @@ h1 span { color: var(--muted); } .sticky-column { min-width: 0; } .sticky-column > .panel { position: sticky; top: 93px; } .explanation-panel .panel-heading { padding-bottom: 15px; } -.explanation-select { width: 100%; min-height: 33px; height: 33px; margin-top: 13px; padding: 0 9px; color: var(--muted); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; outline: 0; font-size: 10px; } +.explanation-select { width: 100%; min-height: 33px; height: 33px; margin-top: 13px; padding: 0 9px; color: var(--muted); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; outline: 2px solid transparent; outline-offset: 1px; font-size: 10px; } .explanation-select:focus { border-color: var(--route); } .explanation-content { padding: 18px 20px 21px; } .explanation-step-label { display: flex; align-items: center; gap: 7px; color: var(--route); font-family: var(--mono); font-size: 9px; letter-spacing: .08em; text-transform: uppercase; } @@ -298,12 +330,12 @@ h1 span { color: var(--muted); } .file-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 15px 17px 17px; } .file-chip { max-width: 100%; padding: 5px 7px; color: var(--muted); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 5px; font-family: var(--mono); font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.toast { position: fixed; z-index: 80; right: 23px; bottom: 22px; display: flex; align-items: center; gap: 9px; max-width: min(360px, calc(100vw - 30px)); padding: 12px 14px; color: var(--text); background: #182420; border: 1px solid var(--line-strong); border-radius: 9px; box-shadow: var(--shadow); font-size: 11px; animation: toast-in var(--dur-normal) var(--ease) both; } +.toast { position: fixed; z-index: var(--z-toast); right: 23px; bottom: 22px; display: flex; align-items: center; gap: 9px; max-width: min(360px, calc(100vw - 30px)); padding: 12px 14px; color: var(--text); background: #182420; border: 1px solid var(--line-strong); border-radius: 9px; box-shadow: var(--shadow); font-size: 11px; animation: toast-in var(--dur-normal) var(--ease) both; } .toast.success svg { color: var(--route); } .toast.error svg { color: var(--red); } -.modal-layer { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 24px; background: rgba(3, 7, 8, .72); backdrop-filter: blur(8px); animation: fade var(--dur-instant) var(--ease) both; } -.modal { width: min(570px, 100%); max-height: min(720px, calc(100vh - 48px)); overflow: auto; background: #11191e; border: 1px solid var(--line-strong); border-radius: 15px; box-shadow: 0 30px 100px rgba(0,0,0,.45); animation: modal-in var(--dur-normal) var(--ease) both; } +.modal-layer { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: 24px; background: var(--modal-overlay); animation: fade var(--dur-instant) var(--ease) both; } +.modal { width: min(570px, 100%); max-height: min(720px, calc(100vh - 48px)); overflow: auto; background: #11191e; border: 1px solid var(--line-strong); border-radius: 15px; box-shadow: var(--shadow-modal); animation: modal-in var(--dur-normal) var(--ease) both; } .modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 23px 24px 19px; border-bottom: 1px solid var(--line); } .modal-head h2 { margin: 0; font-family: var(--display); font-size: 17px; letter-spacing: -.01em; } .modal-head p { margin: 6px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; } @@ -311,7 +343,7 @@ h1 span { color: var(--muted); } .form-field { margin-bottom: 16px; } .form-field label { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; } .form-field label span { color: var(--muted-2); font-family: var(--mono); font-size: 9px; font-weight: 400; } -.text-field, .select-field { width: 100%; min-height: 39px; height: 39px; padding: 0 11px; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; outline: 0; font-size: 11px; transition: border-color var(--dur-instant) var(--ease), box-shadow var(--dur-instant) var(--ease); } +.text-field, .select-field { width: 100%; min-height: 44px; height: 44px; padding: 0 11px; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; outline: 2px solid transparent; outline-offset: 1px; font-size: 11px; transition: border-color var(--dur-instant) var(--ease), box-shadow var(--dur-instant) var(--ease); } .text-field:focus, .select-field:focus { border-color: rgba(127,224,178,.55); box-shadow: 0 0 0 3px rgba(127,224,178,.07), inset 0 0 0 1px rgba(127,224,178,.15); } .text-field::placeholder { color: var(--muted-2); } textarea.text-field { height: auto; } @@ -319,10 +351,10 @@ textarea.text-field { height: auto; } .secret-field .text-field { padding-right: 38px; } .reveal-key { position: absolute; top: 7px; right: 6px; width: 26px; height: 26px; display: grid; place-items: center; color: var(--muted-2); background: transparent; border-radius: 5px; } .reveal-key:hover { color: var(--text); background: var(--panel-raised); } -.field-hint { margin: 7px 0 0; color: var(--muted-2); font-size: 9px; line-height: 1.5; } +.field-hint { min-height: 1lh; margin: 7px 0 0; color: var(--muted-2); font-size: 9px; line-height: 1.5; } .inline-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .model-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: end; } -.fetch-button { min-height: 39px; height: 39px; padding: 0 11px; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line); border-radius: 7px; font-size: 10px; font-weight: 700; white-space: nowrap; } +.fetch-button { min-height: 44px; height: 44px; padding: 0 11px; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line); border-radius: 7px; font-size: 10px; font-weight: 700; white-space: nowrap; } .fetch-button:hover { color: var(--text); border-color: var(--line-strong); } .settings-status { min-height: 17px; margin: 1px 0 15px; color: var(--muted-2); font-size: 10px; } .settings-status.good { color: var(--route); } @@ -330,6 +362,47 @@ textarea.text-field { height: auto; } .modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding-top: 18px; border-top: 1px solid var(--line); } .modal .secondary-button, .modal .install-button { min-height: 37px; height: 37px; } +/* Additive AI provider selector. The existing Custom API fields keep their + original controls; this layer only chooses which independent config is live. */ +.ai-settings-modal { width: min(640px, 100%); } +.provider-picker { min-width: 0; margin: 0 0 20px; padding: 0; border: 0; } +.provider-picker legend { margin-bottom: 8px; color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.provider-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; } +.provider-option { display: flex; align-items: flex-start; gap: 10px; min-width: 0; padding: 12px; color: var(--muted); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 9px; cursor: pointer; transition: color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease), background var(--dur-instant) var(--ease); } +.provider-option:hover { color: var(--text); border-color: var(--line-strong); } +.provider-option.selected { color: var(--text); background: var(--route-dim); border-color: var(--route-line); } +.provider-option input { flex: none; margin: 2px 0 0; accent-color: var(--route); } +.provider-option span { min-width: 0; display: grid; gap: 3px; } +.provider-option strong { font-size: 11px; } +.provider-option small { color: var(--muted-2); font-size: 9px; line-height: 1.45; } +.provider-option.selected small { color: var(--muted); } +.provider-panel { min-width: 0; } +.free-ai-cta { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin: 2px 0 16px; padding: 12px 13px; background: var(--route-dim); border: 1px solid rgba(127, 224, 178, .18); border-radius: 9px; } +.free-ai-cta strong { display: block; color: var(--text); font-size: 10.5px; } +.free-ai-cta p { margin: 3px 0 0; color: var(--muted); font-size: 9.5px; line-height: 1.5; } +.free-ai-cta .secondary-button { flex: none; color: var(--route); background: transparent; border-color: var(--route-line); } +.free-ai-card { padding: 17px; background: var(--panel-soft); border: 1px solid var(--line-strong); border-radius: 11px; } +.free-ai-heading { display: flex; align-items: flex-start; gap: 11px; } +.free-ai-mark { flex: none; width: 34px; height: 34px; display: grid; place-items: center; color: var(--route); background: var(--route-dim); border: 1px solid var(--route-line); border-radius: 9px; } +.free-ai-heading h3 { margin: 0; font-family: var(--display); font-size: 14px; } +.free-ai-heading p { margin: 4px 0 0; color: var(--muted); font-size: 10px; line-height: 1.5; } +.free-ai-model { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 9px 11px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; } +.free-ai-model span { color: var(--muted-2); font-size: 9px; } +.free-ai-model strong { color: var(--muted); font-family: var(--mono); font-size: 9.5px; } +.free-ai-status { display: flex; align-items: flex-start; gap: 9px; margin: 13px 0; min-height: 18px; color: var(--muted); font-size: 10px; line-height: 1.55; } +.free-ai-status .status-dot { margin-top: 4px; } +.free-ai-status.good { color: var(--route); } +.free-ai-status.bad { color: var(--red); } +.free-ai-authorization { display: grid; gap: 7px; margin: -2px 0 13px; padding: 12px; background: var(--route-dim); border: 1px solid var(--route-line); border-radius: 9px; } +.free-ai-authorization > span { color: var(--muted-2); font-size: 9px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; } +.free-ai-authorization .authorization-code { color: var(--route); font-family: var(--mono); font-size: 20px; letter-spacing: .12em; overflow-wrap: anywhere; } +.free-ai-authorization p { margin: 0; color: var(--muted); font-size: 9.5px; line-height: 1.55; } +.free-ai-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.free-ai-actions a { text-decoration: none; } +.danger-button { color: var(--red); } +.free-ai-credit { margin: 13px 0 0; padding-top: 11px; color: var(--muted-2); border-top: 1px solid var(--line); font-size: 9px; line-height: 1.55; } +.free-ai-credit a { color: var(--route); text-underline-offset: 2px; } + .script-modal .modal-body { padding-top: 15px; } .script-copy-note { margin: 0 0 12px; color: var(--muted); font-size: 10px; line-height: 1.6; } .script-block { position: relative; max-height: 310px; overflow: auto; padding: 17px; color: #c0d8d3; background: #090f12; border: 1px solid var(--line); border-radius: 8px; font-family: var(--mono); font-size: 10px; line-height: 1.75; white-space: pre-wrap; } @@ -353,7 +426,7 @@ textarea.text-field { height: auto; } /* Analysis progress bar (live SSE / fallback poll) */ .progress-bar-wrap { display: flex; align-items: center; gap: 12px; margin: 0 0 18px; } .progress-track { flex: 1; height: 6px; background: var(--line); border-radius: 999px; overflow: hidden; } -.progress-fill { height: 100%; background: linear-gradient(90deg, var(--route-dim), var(--route)); border-radius: inherit; transition: width 180ms var(--ease); } +.progress-fill { height: 100%; background: linear-gradient(90deg, var(--route-dim), var(--route)); border-radius: inherit; } .progress-pct { color: var(--muted); font-family: var(--mono); font-size: 10px; min-width: 36px; text-align: right; } .scan-phases .phase-item { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: var(--muted-2); background: var(--panel); border: 1px solid var(--line); border-radius: 9px; font-size: 10.5px; font-weight: 700; transition: color 160ms, border-color 160ms, background 160ms; } .scan-phases .phase-item.active { color: var(--route); border-color: rgba(127,224,178,.3); background: rgba(127,224,178,.06); } @@ -370,6 +443,21 @@ textarea.text-field { height: auto; } 50% { box-shadow: 0 0 0 6px rgba(127,224,178,0); } } +/* Primary labels stay intact as their control groups reflow. */ +.analyze-button, .new-analysis, .fetch-button, .copy-mini, .fail-button, +.seg-chip, .quick-pick { white-space: nowrap; } + +/* Compact desktop chrome gains a full touch target without changing mouse + layouts or the visible glyph size. */ +@media (pointer: coarse) { + .icon-button, .dots-button, .mini-btn, .copy-mini, .reveal-key, + .oreo-ctrl-btn, .oreo-icon-btn, .oreo-send, + .explanation-select, .rename-input, .tree-filter { + min-width: 44px; + min-height: 44px; + } +} + @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fade { from { opacity: 0; } to { opacity: 1; } } @keyframes modal-in { from { opacity: 0; transform: translateY(10px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } } @@ -394,6 +482,8 @@ textarea.text-field { height: auto; } @media (max-width: 800px) { .topbar-center { display: none; } .main { padding-top: 38px; } + .analysis-head { display: block; } + .analysis-actions { margin-top: 17px; } .dashboard-grid { grid-template-columns: 1fr; } .sticky-column > .panel { position: static; } .overview-strip { grid-template-columns: 1fr 1fr; grid-template-areas: "summary summary" "branch language" "files files"; } @@ -414,8 +504,6 @@ textarea.text-field { height: auto; } .analyze-button { width: 100%; margin-top: 3px; } .form-meta { align-items: flex-start; flex-direction: column; gap: 7px; margin-top: 11px; } .form-meta .shortcut { display: none; } - .analysis-head { display: block; } - .analysis-actions { margin-top: 17px; } .analysis-actions button { flex: 1; } .overview-strip { grid-template-columns: 1fr 1fr; grid-template-areas: "summary summary" "branch language" "files files"; } .info-grid { grid-template-columns: 1fr; gap: 17px; } @@ -423,7 +511,10 @@ textarea.text-field { height: auto; } .step-body { padding-right: 30px; } .step-check { position: absolute; top: 15px; right: 10px; margin: 0; min-width: 24px; width: 24px; height: 24px; } .inline-fields { grid-template-columns: 1fr; gap: 0; } - .model-row { grid-template-columns: 1fr; } + .model-row, .provider-options { grid-template-columns: 1fr; } + .free-ai-cta { align-items: stretch; flex-direction: column; } + .free-ai-cta .secondary-button, .free-ai-actions > button, .free-ai-actions > a { width: 100%; justify-content: center; } + .free-ai-model { align-items: flex-start; flex-direction: column; gap: 4px; } .fetch-button { width: 100%; justify-content: center; } .modal-layer { padding: 12px; } .modal-head, .modal-body { padding-left: 17px; padding-right: 17px; } @@ -436,7 +527,7 @@ textarea.text-field { height: auto; } *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } -.skip-link { position: absolute; top: -100px; left: 16px; z-index: 100; padding: 8px 14px; color: var(--bg-deep); background: var(--route); border-radius: 0 0 8px 8px; font-size: 11px; font-weight: 800; text-decoration: none; transition: top var(--dur-instant) var(--ease); } +.skip-link { position: absolute; top: -100px; left: 16px; z-index: var(--z-skip-link); padding: 8px 14px; color: var(--route-ink); background: var(--route); border-radius: 0 0 8px 8px; font-size: 11px; font-weight: 800; text-decoration: none; transition: top var(--dur-instant) var(--ease); } .skip-link:focus { top: 0; } /* --- Feature 1: plain-English overview --- */ @@ -455,8 +546,8 @@ textarea.text-field { height: auto; } .explorer-panel { margin-bottom: 21px; } .explorer-body { padding: 17px 20px 20px; } .explorer-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } -.explorer-btn { display: flex; align-items: flex-start; gap: 11px; min-height: 44px; padding: 13px 14px; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 11px; text-align: left; transition: all var(--dur-instant) var(--ease); } -.explorer-btn:hover:not(:disabled) { border-color: rgba(127, 224, 178, .42); background: var(--route-dim); transform: translateY(-1px); } +.explorer-btn { display: flex; align-items: flex-start; gap: 11px; min-height: 44px; padding: 13px 14px; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 11px; text-align: left; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } +.explorer-btn:hover:not(:disabled) { border-color: rgba(127, 224, 178, .42); background: var(--route-dim); } .explorer-btn.active { border-color: rgba(127, 224, 178, .5); background: rgba(127, 224, 178, .08); box-shadow: 0 0 0 3px rgba(127, 224, 178, .06); } .explorer-btn:disabled { cursor: wait; opacity: .75; } .explorer-ic { display: grid; place-items: center; flex: none; width: 30px; height: 30px; color: var(--route); background: rgba(127, 224, 178, .1); border-radius: 8px; } @@ -484,8 +575,8 @@ textarea.text-field { height: auto; } .followups { margin-top: 14px; padding-top: 14px; border-top: 1px dashed rgba(178, 197, 190, .16); } .followups-label { display: flex; align-items: center; gap: 7px; margin-bottom: 9px; color: var(--muted-2); font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } .followup-chips { display: flex; flex-wrap: wrap; gap: 8px; } -.followup-chip { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-height: 44px; padding: 8px 11px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: 999px; font-size: 11px; text-align: left; transition: all var(--dur-instant) var(--ease); } -.followup-chip:hover { color: var(--route-bright); border-color: rgba(127, 224, 178, .45); background: rgba(127, 224, 178, .08); transform: translateY(-1px); } +.followup-chip { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-height: 44px; padding: 8px 11px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: 999px; font-size: 11px; text-align: left; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } +.followup-chip:hover { color: var(--route-bright); border-color: rgba(127, 224, 178, .45); background: rgba(127, 224, 178, .08); } .chip-arrow { display: inline-grid; place-items: center; flex: none; color: var(--route); } /* --- Feature 2: sidebar history menu + file tree --- */ @@ -496,25 +587,25 @@ textarea.text-field { height: auto; } .history-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .history-sub { display: block; max-width: 100%; color: var(--muted-2); font-family: var(--mono); font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 400; } .menu-wrap { position: relative; flex: none; } -.dots-button { display: grid; place-items: center; width: 26px; height: 26px; color: var(--muted-2); background: transparent; border-radius: 6px; opacity: 0; transition: all var(--dur-instant) var(--ease); } +.dots-button { display: grid; place-items: center; width: 26px; height: 26px; color: var(--muted-2); background: transparent; border-radius: 6px; opacity: 0; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .history-row:hover .dots-button, .dots-button[aria-expanded="true"] { opacity: 1; } .dots-button:hover { color: var(--text); background: var(--panel-raised); } -.menu-pop { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; min-width: 148px; padding: 5px; background: #17231f; border: 1px solid var(--line-strong); border-radius: 10px; box-shadow: 0 18px 45px rgba(0, 0, 0, .4); animation: modal-in var(--dur-instant) var(--ease) both; } +.menu-pop { position: absolute; right: 0; top: calc(100% + 6px); z-index: var(--z-menu); min-width: 148px; padding: 5px; background: #17231f; border: 1px solid var(--line-strong); border-radius: 10px; box-shadow: var(--shadow-menu); animation: modal-in var(--dur-instant) var(--ease) both; } .menu-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; color: var(--muted); background: transparent; border-radius: 7px; font-size: 11px; font-weight: 600; text-align: left; } .menu-item:hover { color: var(--text); background: rgba(255, 255, 255, .05); } .menu-item.danger { color: #f3a5a0; } .menu-item.danger:hover { background: rgba(239, 154, 148, .1); color: #f6bcb8; } .history-entry.renaming { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border: 1px solid rgba(127, 224, 178, .35); border-radius: 8px; } -.rename-input { min-width: 0; flex: 1; height: 28px; padding: 0 8px; color: var(--text); background: var(--bg-deep); border: 1px solid var(--line); border-radius: 6px; font-size: 11px; outline: 0; } +.rename-input { min-width: 0; flex: 1; height: 28px; padding: 0 8px; color: var(--text); background: var(--bg-deep); border: 1px solid var(--line); border-radius: 6px; font-size: 11px; outline: 2px solid transparent; outline-offset: 1px; } .rename-input:focus { border-color: var(--route); } .mini-btn { display: grid; place-items: center; flex: none; width: 26px; height: 26px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line); border-radius: 6px; } -.mini-btn.save { color: var(--bg-deep); background: var(--route); border-color: var(--route); } -.mini-btn:hover { transform: translateY(-1px); } +.mini-btn.save { color: var(--route-ink); background: var(--route); border-color: var(--route); } +.mini-btn:hover { color: var(--text); border-color: var(--line-strong); } .side-section { margin-top: 26px; min-width: 0; } .side-section-head { display: flex; align-items: center; justify-content: space-between; margin: 0 10px 11px; } .side-section-head .sidebar-label { margin: 0; } .side-count { color: var(--muted-2); font-family: var(--mono); font-size: 10px; } -.tree-filter { width: 100%; height: 30px; margin-bottom: 8px; padding: 0 10px; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; font-size: 11px; outline: 0; } +.tree-filter { width: 100%; height: 30px; margin-bottom: 8px; padding: 0 10px; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 7px; font-size: 11px; outline: 2px solid transparent; outline-offset: 1px; } .tree-filter:focus { border-color: rgba(127, 224, 178, .5); } .tree-filter::placeholder { color: var(--muted-2); } .file-tree { max-height: 340px; overflow: auto; padding: 6px; background: rgba(0, 0, 0, .18); border: 1px solid var(--line); border-radius: 10px; scrollbar-width: thin; } @@ -569,7 +660,7 @@ textarea.text-field { height: auto; } .factor-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 10.5px; font-weight: 700; } .factor-top b { font-family: var(--mono); font-size: 10px; font-weight: 500; color: var(--muted); } .factor-track { height: 4px; margin: 5px 0 4px; background: rgba(178,197,190,.12); border-radius: 999px; overflow: hidden; } -.factor-track i { display: block; height: 100%; background: var(--route); border-radius: 999px; transition: width var(--dur-normal) var(--ease); } +.factor-track i { display: block; height: 100%; background: var(--route); border-radius: 999px; } .factor-track i.tone-blue { background: var(--blue); } .factor-track i.tone-amber { background: var(--amber); } .factor-track i.tone-red { background: var(--red); } @@ -648,8 +739,8 @@ textarea.text-field { height: auto; } .path-selection { color: var(--route); text-align: right; } /* --- Feature 1: living path ------------------------------------------------ */ -.steps-panel { border-left: 3px solid var(--route); } -.step-row.is-failed { background: rgba(239,154,148,.05); box-shadow: inset 2px 0 0 var(--red); } +.steps-panel { border-top: 2px solid var(--route); } +.step-row.is-failed { background: rgba(239,154,148,.05); box-shadow: inset 0 -2px 0 var(--red); } .step-row.is-failed .step-number { color: var(--red); border-color: rgba(239,154,148,.4); } .step-row.is-revised .step-number { display: grid; place-items: center; color: var(--route); border-color: rgba(127,224,178,.38); } .rev-badge { margin-left: 7px; padding: 2px 5px; color: var(--route); background: var(--route-dim); border-radius: 4px; font-family: var(--mono); font-size: 8px; text-transform: uppercase; letter-spacing: .06em; vertical-align: 2px; } @@ -657,9 +748,10 @@ textarea.text-field { height: auto; } .step-guard svg, .step-patch svg { flex: none; margin-top: 1px; } .step-patch { color: var(--route); } .verify-block { border-color: rgba(127,224,178,.24); } -.verify-block::before { content: '✓'; color: var(--route); } +.verify-block::before { display: none; } +.verify-icon { margin-top: 1px; color: var(--route); } .step-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 9px; } -.fail-button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 5px 9px; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 6px; font-size: 9.5px; font-weight: 700; cursor: pointer; transition: all var(--dur-instant) var(--ease); } +.fail-button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 5px 9px; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 6px; font-size: 9.5px; font-weight: 700; cursor: pointer; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .fail-button:hover { color: var(--red); border-color: rgba(239,154,148,.45); background: rgba(239,154,148,.07); } .failed-tag { color: var(--red); font-size: 9.5px; font-weight: 700; } .steps-foot { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; gap: 10px; padding: 12px 20px 14px; border-top: 1px solid var(--line); } @@ -674,7 +766,7 @@ textarea.text-field { height: auto; } .rev-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted-2); font-family: var(--mono); font-size: 9px; } /* --- recovery report ------------------------------------------------------- */ -.recovery-panel { border-left: 3px solid var(--route); } +.recovery-panel { border-top: 2px solid var(--route); } .recovery-body { padding: 16px 20px 18px; } .recovery-diagnosis { margin: 0; color: var(--text); font-size: 12px; line-height: 1.65; } .recovery-second { margin: 8px 0 0; color: var(--muted); font-size: 11px; line-height: 1.6; } @@ -732,16 +824,16 @@ textarea.text-field { height: auto; } .seg-label { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 7px; margin-bottom: 8px; color: var(--muted-2); font-size: 10px; font-weight: 700; } .seg-label em { color: var(--muted-2); font-size: 9.5px; font-style: normal; font-weight: 500; } .seg-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } -.seg-option { display: grid; gap: 4px; padding: 11px 12px; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; cursor: pointer; transition: all var(--dur-instant) var(--ease); } +.seg-option { display: grid; gap: 4px; padding: 11px 12px; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; cursor: pointer; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .seg-option strong { color: var(--text); font-size: 11px; } .seg-option span { color: var(--muted); font-size: 9.5px; line-height: 1.45; } .seg-option:hover { border-color: var(--line-strong); background: var(--panel-raised); } .seg-option.active { border-color: var(--route); background: var(--route-dim); } .seg-option.active strong { color: var(--route-bright); } .seg-inline { display: flex; align-items: center; gap: 5px; padding: 3px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; } -.seg-chip { padding: 6px 9px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 9.5px; font-weight: 700; cursor: pointer; transition: all var(--dur-instant) var(--ease); } +.seg-chip { padding: 6px 9px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 9.5px; font-weight: 700; cursor: pointer; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .seg-chip:hover { color: var(--text); } -.seg-chip.active { color: var(--bg-deep); background: var(--route); } +.seg-chip.active { color: var(--route-ink); background: var(--route); } .seg-note { padding-right: 7px; color: var(--muted-2); font-family: var(--mono); font-size: 8.5px; } /* --- failure dialog ------------------------------------------------------- */ @@ -750,7 +842,7 @@ textarea.text-field { height: auto; } .field-label em { color: var(--route); font-style: normal; font-size: 9.5px; } .failure-text { min-height: 120px; padding: 10px; font-family: var(--mono); font-size: 10px; line-height: 1.6; resize: vertical; } .quick-picks { display: flex; flex-wrap: wrap; gap: 6px; } -.quick-pick { padding: 6px 9px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line); border-radius: 999px; font-size: 9.5px; cursor: pointer; transition: all var(--dur-instant) var(--ease); } +.quick-pick { padding: 6px 9px; color: var(--muted); background: var(--panel-raised); border: 1px solid var(--line); border-radius: 999px; font-size: 9.5px; cursor: pointer; transition: color var(--dur-instant) var(--ease), background-color var(--dur-instant) var(--ease), border-color var(--dur-instant) var(--ease); } .quick-pick:hover { color: var(--text); border-color: var(--route); } .fail-expect { display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px; background: var(--panel-soft); border-radius: 8px; } .fail-expect svg { color: var(--route); flex: none; margin-top: 1px; } @@ -779,7 +871,7 @@ textarea.text-field { height: auto; } } /* Mobile bottom navigation — provides section switching on small screens where the sidebar is hidden. */ -.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; padding: 6px 8px; padding-bottom: max(6px, env(safe-area-inset-bottom)); background: rgba(11, 17, 20, .94); backdrop-filter: blur(14px); border-top: 1px solid var(--line); } +.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-mobile-nav); padding: 6px 8px; padding-bottom: max(6px, env(safe-area-inset-bottom)); background: var(--bg); border-top: 1px solid var(--line); } .mobile-nav-item { display: flex; flex-direction: column; align-items: center; gap: 2px; min-height: 44px; min-width: 44px; flex: 1; padding: 6px 4px; color: var(--muted-2); background: transparent; border: 0; border-radius: 8px; font-size: 9px; font-weight: 700; transition: color var(--dur-instant) var(--ease), background var(--dur-instant) var(--ease); } .mobile-nav-item:hover, .mobile-nav-item:active { color: var(--route); background: var(--route-dim); } @media (max-width: 600px) { @@ -802,8 +894,8 @@ textarea.text-field { height: auto; } --ledger: #edf1ee; --line: rgba(28, 62, 50, .14); --line-strong: rgba(28, 62, 50, .28); - --route: #0f9d6c; - --route-bright: #0b7a55; + --route: #09704d; + --route-bright: #075f40; --route-dim: rgba(15, 157, 108, .12); --route-line: rgba(15, 157, 108, .42); --text: #0f1d18; @@ -814,11 +906,23 @@ textarea.text-field { height: auto; } --red: #a93226; --blue: #2456ad; --stamp: #7e6325; - --shadow: 0 14px 44px rgba(20, 40, 32, .12); + --shadow: 0 5px 16px rgba(20, 40, 32, .09); + --shadow-md: 0 7px 20px rgba(20, 40, 32, .1); + --shadow-menu: 0 10px 24px rgba(20, 40, 32, .13); + --shadow-modal: 0 18px 46px rgba(20, 40, 32, .16); + --dot: rgba(28, 62, 50, .14); + --manual: #102019; + --manual-ink: #f2f6f3; + --manual-muted: #afc0b8; + --manual-line: rgba(242, 246, 243, .24); + --manual-accent: #8ce3b7; + --manual-control: #f4f7f5; + --manual-control-text: #102019; + --manual-control-muted: #50635b; } [data-theme="light"] body::after { opacity: .28; } -[data-theme="light"] .topbar { background: rgba(246, 248, 246, .92); } -[data-theme="light"] .mobile-nav { background: rgba(246, 248, 246, .95); } +[data-theme="light"] .topbar { background: var(--bg); } +[data-theme="light"] .mobile-nav { background: var(--bg); } [data-theme="light"] .repo-form, [data-theme="light"] .panel, [data-theme="light"] .route-preview, @@ -864,13 +968,11 @@ textarea.text-field { height: auto; } [data-theme="light"] .menu-item.danger { color: #a93226; } [data-theme="light"] .repo-avatar { color: #17332c; - background: linear-gradient(145deg, #e6edf0, #d4dfe6); + background: var(--panel-raised); border-color: rgba(28, 62, 50, .22); - box-shadow: 0 4px 14px rgba(20, 40, 32, .1); } [data-theme="light"] .brand-logo { filter: none; } [data-theme="light"] .status-dot.online { box-shadow: 0 0 0 4px rgba(15, 157, 108, .14); } -[data-theme="light"] .new-analysis:hover { box-shadow: 0 14px 25px rgba(20, 40, 32, .18); } [data-theme="light"] .rule-label, [data-theme="light"] .seg-label em { color: #52655e; } @@ -899,7 +1001,7 @@ textarea.text-field { height: auto; } position: fixed; right: 20px; bottom: 20px; - z-index: 9999; + z-index: var(--z-oreo); display: flex; flex-direction: column; align-items: flex-end; @@ -942,15 +1044,14 @@ textarea.text-field { height: auto; } justify-content: center; gap: 4px; color: var(--muted); - background: rgba(10, 15, 18, .85); + background: var(--panel); border: 1px solid var(--line); border-radius: 999px; font-size: 11px; font-weight: 700; cursor: pointer; - backdrop-filter: blur(8px); } -.oreo-ctrl-btn:hover { color: var(--text); border-color: var(--line-strong); background: rgba(20, 28, 33, .95); } +.oreo-ctrl-btn:hover { color: var(--text); border-color: var(--line-strong); background: var(--panel-raised); } .oreo-ctrl-btn[aria-pressed="true"] { color: var(--route-bright); border-color: var(--route-line); } .oreo-float.oreo-dragging { transition: none; } .oreo-float.oreo-dragging .oreo-fab { transform: none; } @@ -962,14 +1063,13 @@ textarea.text-field { height: auto; } min-height: 34px; padding: 7px 13px; color: var(--muted); - background: rgba(10, 15, 18, .9); + background: var(--panel); border: 1px solid var(--line-strong); border-radius: 999px; font-size: 11px; font-weight: 800; cursor: pointer; box-shadow: 0 10px 28px rgba(0, 0, 0, .6); - backdrop-filter: blur(8px); } .oreo-show:hover { color: var(--route-bright); border-color: var(--route-line); } .oreo-tip { @@ -998,7 +1098,7 @@ textarea.text-field { height: auto; } position: fixed; left: 264px; bottom: 24px; - z-index: 10000; + z-index: var(--z-oreo-panel); width: min(320px, calc(100vw - 300px)); height: min(440px, calc(100vh - 190px)); min-height: 300px; @@ -1034,7 +1134,7 @@ textarea.text-field { height: auto; } flex: none; width: 38px; height: 38px; - color: var(--bg-deep); + color: var(--route-ink); background: var(--route); border-radius: 50%; font-weight: 800; @@ -1085,7 +1185,7 @@ textarea.text-field { height: auto; } border-radius: 14px; overflow-wrap: anywhere; } -.oreo-msg.user .oreo-bubble-msg { color: var(--bg-deep); background: var(--route); border-bottom-right-radius: 4px; font-weight: 500; } +.oreo-msg.user .oreo-bubble-msg { color: var(--route-ink); background: var(--route); border-bottom-right-radius: 4px; font-weight: 500; } .oreo-msg.bot .oreo-bubble-msg { color: var(--text); background: var(--panel-raised); border: 1px solid var(--line); border-bottom-left-radius: 4px; } /* Standard markdown inside bot bubbles */ .oreo-md p { margin: 0 0 8px; color: inherit; } @@ -1095,7 +1195,7 @@ textarea.text-field { height: auto; } .oreo-md li { line-height: 1.6; } .oreo-md li::marker { color: var(--route); } .oreo-md a { color: var(--route-bright); text-decoration: underline; text-underline-offset: 2px; overflow-wrap: anywhere; } -.oreo-md blockquote { margin: 0 0 8px; padding: 8px 10px; color: #c9ecdd; background: rgba(127, 224, 178, .07); border-left: 3px solid var(--route); border-radius: 0 8px 8px 0; font-size: 11.5px; } +.oreo-md blockquote { margin: 0 0 8px; padding: 8px 10px; color: #c9ecdd; background: rgba(127, 224, 178, .07); border-left: 1px solid var(--route); border-radius: 0 8px 8px 0; font-size: 11.5px; } .oreo-md hr { border: 0; border-top: 1px solid var(--line); margin: 10px 0; } .oreo-code { padding: 1px 5px; color: #b8d7d1; background: #0a1114; border: 1px solid rgba(178, 197, 190, .14); border-radius: 5px; font-family: var(--mono); font-size: 10.5px; } .oreo-pre { margin: 0 0 8px; max-width: 100%; overflow: auto; padding: 10px 11px; color: #c0d8d3; background: #090f12; border: 1px solid var(--line); border-radius: 9px; font-family: var(--mono); font-size: 10.5px; line-height: 1.6; white-space: pre-wrap; } @@ -1130,7 +1230,7 @@ textarea.text-field { height: auto; } border: 1px solid var(--line); border-radius: 999px; font-size: 12px; - outline: 0; + outline: 2px solid transparent; outline-offset: 1px; } .oreo-input:focus { border-color: var(--route); box-shadow: 0 0 0 3px rgba(127, 224, 178, .08); } .oreo-send { @@ -1139,7 +1239,7 @@ textarea.text-field { height: auto; } height: 40px; display: grid; place-items: center; - color: var(--bg-deep); + color: var(--route-ink); background: var(--route); border-radius: 50%; font-size: 15px; @@ -1170,3 +1270,13 @@ textarea.text-field { height: auto; } .oreo-panel { left: 12px; right: 12px; bottom: 76px; width: auto; height: min(400px, calc(100vh - 190px)); } .oreo-tip { max-width: 190px; font-size: 11px; } } + + +/* Native form controls keep a geometry-stable outline and expose it instantly + for keyboard focus, including the protected Oreo composer. */ +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--route); + outline-offset: 1px; +} diff --git a/public/topbar-contributions.js b/public/topbar-contributions.js index 88b8004..a7c7fcb 100644 --- a/public/topbar-contributions.js +++ b/public/topbar-contributions.js @@ -8,7 +8,7 @@ // // The grid never moves. Individual squares gently BLINK: each cell breathes // between a near-invisible floor and its own small peak on its own slow, -// randomized cycle (4–8s), with a rare slightly stronger pulse on a few +// randomized cycle (3.8–7.2s), with a rare slightly stronger pulse on a few // cells — calm repository activity, not a marquee. // // Light mode uses a dark green (not mint) at DECREASED opacity so the header @@ -24,11 +24,11 @@ // DOM stub. // Square size × pitch (px). Rows are computed from the header height so the -// field fills the whole bar (~5 rows at 70px) instead of floating as a thin -// strip — a full contribution-graph wall behind the header content. -const CELL = 7; -const GAP = 5; -const PITCH = CELL + GAP; // 12 +// field fills the whole bar (~7 rows at 72px) instead of floating as a thin +// strip — a compact contribution-graph wall behind the header content. +const CELL = 6; +const GAP = 4; +const PITCH = CELL + GAP; // 10 const FRAME_MS = 1000 / 24; // calm 24fps is plenty for slow blinks function themeOf(doc) { @@ -46,14 +46,14 @@ function paletteFor(theme) { // brand, crumb and controls stay clearly readable. base: [15, 157, 108], bright: [11, 122, 85], - maxAlpha: 0.22, - floorAlpha: 0.04, + maxAlpha: 0.27, + floorAlpha: 0.035, } : { base: [127, 224, 178], bright: [169, 242, 205], - maxAlpha: 0.32, - floorAlpha: 0.045, + maxAlpha: 0.38, + floorAlpha: 0.05, }; } @@ -67,14 +67,14 @@ function makeCells(count, rand = Math.random) { const cells = new Array(count); for (let i = 0; i < count; i += 1) { const roll = rand(); - const peak = roll < 0.7 - ? 0.15 + rand() * 0.2 // faint shimmer - : roll < 0.98 - ? 0.35 + rand() * 0.2 // muted mid blink - : 0.6 + rand() * 0.15; // rare near-max pulse + const peak = roll < 0.6 + ? 0.16 + rand() * 0.22 // quiet contribution + : roll < 0.96 + ? 0.38 + rand() * 0.24 // readable mid blink + : 0.68 + rand() * 0.2; // rare high-activity cell cells[i] = { peak, - period: 4 + rand() * 4, // 4–8s blink cycle + period: 3.8 + rand() * 3.4, // 3.8–7.2s blink cycle phase: rand() * Math.PI * 2, // desynchronized start }; } diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 96eb7c1..bda7b32 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -74,6 +74,32 @@ async function main() { throw new Error(`mascot route returned ${mascot.status} ${mascot.headers.get('content-type')}`); } + const iconModule = await fetch(`http://127.0.0.1:${port}/icons.js`); + const iconSource = await iconModule.text(); + if (iconModule.status !== 200 + || !String(iconModule.headers.get('content-type')).includes('javascript') + || !iconSource.includes("id: 'tabler:settings-ai'") + || !iconSource.includes('data-icon=')) { + throw new Error(`local Iconify module returned ${iconModule.status} ${iconModule.headers.get('content-type')}`); + } + + const aiModule = await fetch(`http://127.0.0.1:${port}/ai/ai-service.js`); + if (aiModule.status !== 200 || !String(aiModule.headers.get('content-type')).includes('javascript')) { + throw new Error(`AI service module returned ${aiModule.status} ${aiModule.headers.get('content-type')}`); + } + const freeAiModule = await fetch(`http://127.0.0.1:${port}/ai/pollinations-provider.js`); + if (freeAiModule.status !== 200 || !String(freeAiModule.headers.get('content-type')).includes('javascript')) { + throw new Error(`Free AI provider module returned ${freeAiModule.status} ${freeAiModule.headers.get('content-type')}`); + } + + const invalidAiPrepare = await fetch(`http://127.0.0.1:${port}/api/ai/prepare`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation: 'unknown', payload: {} }), + }); + const invalidPayload = await invalidAiPrepare.json(); + if (invalidAiPrepare.status !== 400 || invalidPayload.ok !== false) { + throw new Error(`AI prepare validation returned ${invalidAiPrepare.status} ${JSON.stringify(invalidPayload)}`); + } + console.log(`Smoke check passed on http://127.0.0.1:${port}`); } finally { child.kill('SIGTERM'); diff --git a/server.js b/server.js index 18df204..1fc963e 100644 --- a/server.js +++ b/server.js @@ -33,7 +33,7 @@ import { scanInstallFailures, patchNotesFor } from './server/failures.js'; import { computeHealth, fetchCiSignal, healthVerdict } from './server/health.js'; import { buildPathGraph, patchStepsForFailures } from './server/pathgraph.js'; import { buildContract } from './server/contract.js'; -import { recoverPath } from './server/recovery.js'; +import { buildRecoveryAiRequest, recoverPath } from './server/recovery.js'; import { buildOreoMessages } from './server/oreo-system.js'; import { decorateSteps, composeSteps, EXPERTISE_LEVELS, expertiseFor, tuneGuide } from './public/path-engine.js'; @@ -581,10 +581,9 @@ function heuristicInsight(repo, metadata, files, mode) { }; } -async function callAiInsight(config, repo, metadata, files, mode, question, session) { - if (!config || !config.baseUrl || !config.apiKey || !config.model) return null; +async function callAiInsight(config, repo, metadata, files, mode, question, session, prepareOnly = false) { + if (!prepareOnly && (!config || !config.baseUrl || !config.apiKey || !config.model)) return null; const meta = INSIGHT_META[mode] || INSIGHT_META.recommendations; - const endpointUrl = buildProviderUrl(config.baseUrl, config.endpoint || '/chat/completions'); const modeBrief = mode === 'features' ? 'Propose 3–5 practical, creative feature extensions for a NEW fork. Each bullet: bold title, one plain sentence of what it does, one of who it delights. No jargon.' : mode === 'bugs' @@ -595,6 +594,9 @@ async function callAiInsight(config, repo, metadata, files, mode, question, sess // message on this chat path alone. Guide generation (callAi) and recovery // never see it. const messages = buildOreoMessages(prompt, session); + const request = { messages, options: { temperature: 0.35, max_tokens: 2200 } }; + if (prepareOnly) return request; + const endpointUrl = buildProviderUrl(config.baseUrl, config.endpoint || '/chat/completions'); let response; try { response = await fetchWithTimeout(endpointUrl, { @@ -609,7 +611,11 @@ async function callAiInsight(config, repo, metadata, files, mode, question, sess const detail = await response.text(); throw new Error(`AI provider returned ${response.status}${detail ? `: ${detail.slice(0, 180)}` : '. Check the base URL, endpoint, model, and key.'}`); } - const parsed = parseAiJson(extractAiText(await response.json())); + return insightFromProviderText(extractAiText(await response.json()), mode, meta); +} + +function insightFromProviderText(text, mode, meta = INSIGHT_META[mode] || INSIGHT_META.recommendations) { + const parsed = parseAiJson(String(text || '').slice(0, 300_000)); if (!parsed || !Array.isArray(parsed.bullets)) throw new Error('The AI insight response was not valid JSON. Try again.'); return { mode, @@ -721,16 +727,18 @@ function toProviderError(error, what = 'AI provider') { return error instanceof Error ? error : new Error(String(message || `${what} request failed.`)); } -async function callAi(config, repo, metadata, files) { - if (!config || !config.baseUrl || !config.apiKey || !config.model) return null; - const endpointUrl = buildProviderUrl(config.baseUrl, config.endpoint || '/chat/completions'); +async function callAi(config, repo, metadata, files, prepareOnly = false) { + if (!prepareOnly && (!config || !config.baseUrl || !config.apiKey || !config.model)) return null; const prompt = `You are Git-Up, a meticulous senior developer advocate who can also explain anything to a 12-year-old. Analyze this public GitHub repository and return only valid JSON. Do not invent commands or requirements. Use the repository files as your source of truth. If uncertain, say so in notes.\n\nJSON schema:\n{\"summary\":\"string\",\"confidence\":\"high|medium|low\",\"dependencies\":[{\"name\":\"string\",\"version\":\"string|null\",\"kind\":\"runtime|dev|tooling|service\"}],\"requirements\":[\"string\"],\"environment\":[\"ENV_KEY\"],\"steps\":[{\"id\":\"stable-id\",\"title\":\"action title\",\"command\":\"shell commands or empty string\",\"detail\":\"what to do\"}],\"explanations\":[{\"stepId\":\"matching step id\",\"title\":\"short title\",\"body\":\"why this matters\"}],\"notes\":[\"caveat\"],\"anticipatedFailures\":[{\"signature\":\"short label of the failure\",\"stepId\":\"which step id it breaks\",\"symptom\":\"error text people actually paste\",\"fix\":\"the command or action that avoids it\"}],\"plainOverview\":{\"analogy\":\"one vivid real-world analogy sentence\",\"problem\":\"what everyday problem this solves, zero jargon\",\"audience\":\"who benefits, in everyday roles\",\"howItWorks\":[\"step 1 in plain words\",\"step 2\",\"step 3\"]},\"followUps\":[\"contextual follow-up question 1\",\"question 2\",\"question 3\"]}\n\nInstall-path rules (never break these):\n- steps[].id must be chosen from this vocabulary when the action matches it: clone, toolchain, services, docker, dependencies, database, env, build, dev, run, verify. Reuse the same id for the same action across revisions so progress is not lost.\n- steps[].order is an integer 0-90 placing the step in the sequence; steps with the same action must keep the same order slot.\n- anticipatedFailures: the 2-4 ways installers most often break for THIS repository, each pointing at the step id it breaks. Base them on the README, lockfiles, and Dockerfile — not on generic advice. Return an empty array if nothing specific is known.\n\nSTRICT plainOverview rules (never break these):\n- ZERO technical jargon. Never use: API, endpoints, serialization, dependencies, CI/CD, manifest, lockfile, toolchain, middleware, SDK, CLI, interface, schema, runtime, framework, repository (say \"project\" instead).\n- Write at a 12-year-old reading level. Use real-world analogies (kitchen, recipe box, library, workshop, garden, lunchbox, toolbox).\n- Cover exactly: what everyday problem it solves, who benefits (parents, teachers, shop owners, students…), how it works from a visitor's point of view in 3 short steps.\n- followUps: 2–3 short clickable questions specific to THIS project (e.g. ideas to build next, what to be careful about). Never generic filler.\n\nRepository: ${repo.canonicalUrl}\nMetadata: ${JSON.stringify({ name: metadata.name, description: metadata.description, language: metadata.language, default_branch: metadata.default_branch, topics: metadata.topics })}\n\nFiles:\n${files.map((file) => `--- ${file.path}\n${compactText(file.content, 5000)}`).join('\n')}`; + const request = { messages: [{ role: 'user', content: prompt }], options: { temperature: 0.1, max_tokens: 6000 } }; + if (prepareOnly) return request; + const endpointUrl = buildProviderUrl(config.baseUrl, config.endpoint || '/chat/completions'); let response; try { response = await fetchWithTimeout(endpointUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` }, - body: JSON.stringify({ model: config.model, temperature: 0.1, messages: [{ role: 'user', content: prompt }] }), + body: JSON.stringify({ model: config.model, temperature: 0.1, messages: request.messages }), }, AI_CHAT_TIMEOUT_MS); } catch (error) { throw toProviderError(error); @@ -740,7 +748,11 @@ async function callAi(config, repo, metadata, files) { throw new Error(`AI provider returned ${response.status}${detail ? `: ${detail.slice(0, 180)}` : '. Check the base URL, endpoint, model, and key.'}`); } const payload = await response.json(); - const parsed = parseAiJson(extractAiText(payload)); + return guideFromProviderText(extractAiText(payload), repo, metadata, files); +} + +function guideFromProviderText(text, repo, metadata, files) { + const parsed = parseAiJson(String(text || '').slice(0, 750_000)); if (!parsed) throw new Error('The AI response was not valid installation-guide JSON. Try another model.'); return { ...parsed, source: 'ai', analyzedAt: new Date().toISOString(), repository: { ...repo, name: metadata.name, description: metadata.description, stars: metadata.stargazers_count, language: metadata.language, defaultBranch: metadata.default_branch }, files: files.map((file) => file.path) }; } @@ -774,12 +786,14 @@ async function analyzeRepository(body) { const { repo, metadata, files, fullTreePaths, rawBlocked } = await getRepoContext(body.repoUrl); let scanNotice = ''; if ((files.length <= 2) && fullTreePaths.length <= 2) scanNotice = ''; - // AI upgrades the guide but must never break it: any provider failure - // (bad key, wrong endpoint, rate limit, offline) falls back to the local - // scan with a visible warning instead of failing the whole analysis. + // Preserve the existing Custom API fallback: bad keys, endpoints, limits, or + // offline providers still yield a local scan with a visible warning. Browser + // provider results are validated explicitly so Free AI never silently changes modes. let aiGuide = null; let aiWarning = ''; - if (body.config) { + if (body.provider === 'pollinations') { + aiGuide = guideFromProviderText(body.providerResult, repo, metadata, files); + } else if (body.config) { try { aiGuide = await callAi(body.config, repo, metadata, files); } catch (error) { @@ -899,37 +913,44 @@ function sanitiseStepList(value) { * Feature 1 endpoint: a step failed, the user pasted what happened, and the path * from that step onward is rebuilt. Completed steps are never returned. */ -async function analyzeRecovery(body) { +async function recoveryInput(body) { const failedStepId = String(body?.failedStepId || '').slice(0, 60); const errorText = String(body?.errorText || '').slice(0, 12_000); if (!body?.repoUrl) throw new Error('A repository URL is required to recover a failed step.'); const { repo, metadata, files } = await getRepoContext(body.repoUrl, { refresh: Boolean(body.refresh) }); const guide = body.guide && typeof body.guide === 'object' ? body.guide : {}; - const completed = sanitiseStepList(body.completedSteps); + const completedSteps = sanitiseStepList(body.completedSteps); const remaining = sanitiseStepList(body.remainingSteps); - const failedStep = completed.find((entry) => entry.id === failedStepId) + const failedStep = completedSteps.find((entry) => entry.id === failedStepId) || remaining.find((entry) => entry.id === failedStepId) || sanitiseStepList(guide.steps).find((entry) => entry.id === failedStepId) || { id: failedStepId || 'failed-step', title: 'The failed step', command: '', detail: '' }; - const expertise = EXPERTISE_LEVELS.some((level) => level.id === body?.expertise) ? body.expertise : 'some'; - const recovery = await recoverPath({ - config: body.config?.apiKey && body.config?.model ? body.config : null, + return { repo, metadata, files, failedStep, errorText, remainingSteps: remaining.length ? remaining : [failedStep], - completedSteps: completed, + completedSteps, requirements: Array.isArray(guide.requirements) ? guide.requirements.slice(0, 8) : [], - expertise, + expertise: EXPERTISE_LEVELS.some((level) => level.id === body?.expertise) ? body.expertise : 'some', failureScan: guide.failureScan, + }; +} + +async function analyzeRecovery(body) { + const input = await recoveryInput(body); + const recovery = await recoverPath({ + ...input, + config: body.config?.apiKey && body.config?.model ? body.config : null, + providerResult: body.provider === 'pollinations' ? body.providerResult : undefined, }); return { ...recovery, - failedStep, + failedStep: input.failedStep, revision: Number(body?.revision || 1) + 1, - repository: { canonicalUrl: repo.canonicalUrl, name: metadata?.name || repo.repo }, + repository: { canonicalUrl: input.repo.canonicalUrl, name: input.metadata?.name || input.repo.repo }, generatedAt: new Date().toISOString(), }; } @@ -940,6 +961,12 @@ async function analyzeInsight(body) { if (!body.repoUrl) throw new Error('A repository URL is required for insights.'); const { repo, metadata, files } = await getRepoContext(body.repoUrl); const effectiveMode = mode === 'custom' ? (body.baseMode && INSIGHT_META[body.baseMode] ? body.baseMode : 'recommendations') : mode; + if (body.provider === 'pollinations') { + const ai = insightFromProviderText(body.providerResult, effectiveMode); + if (mode === 'custom' && question) ai.title = `Answer: ${question.slice(0, 80)}`; + ai.mode = mode; + return ai; + } if (body.config?.apiKey && body.config?.model) { try { const ai = await callAiInsight(body.config, repo, metadata, files, effectiveMode, question || undefined, body.session); @@ -966,6 +993,28 @@ async function analyzeInsight(body) { return fallback; } +async function prepareAiOperation(body) { + const operation = String(body?.operation || ''); + const payload = body?.payload && typeof body.payload === 'object' ? body.payload : {}; + if (operation === 'analyze') { + if (!payload.repoUrl) throw new Error('A repository URL is required for AI analysis.'); + const { repo, metadata, files } = await getRepoContext(payload.repoUrl); + return callAi(null, repo, metadata, files, true); + } + if (operation === 'insight') { + if (!payload.repoUrl) throw new Error('A repository URL is required for AI insights.'); + const mode = ['features', 'bugs', 'recommendations', 'custom'].includes(payload.mode) ? payload.mode : 'recommendations'; + const effectiveMode = mode === 'custom' && payload.baseMode && INSIGHT_META[payload.baseMode] ? payload.baseMode : (mode === 'custom' ? 'recommendations' : mode); + const question = typeof payload.question === 'string' ? payload.question.slice(0, 2000) : ''; + const { repo, metadata, files } = await getRepoContext(payload.repoUrl); + return callAiInsight(null, repo, metadata, files, effectiveMode, question || undefined, payload.session, true); + } + if (operation === 'recover') { + return buildRecoveryAiRequest(await recoveryInput(payload)); + } + throw new Error('Unsupported AI operation.'); +} + function safeUpstreamUrl(base, path = '') { let url; try { url = new URL(base); } catch { throw new Error('Enter a valid AI base URL.'); } @@ -1031,10 +1080,13 @@ async function analyzeStream(req, res, body) { let scanNotice = ''; if (!((files.length <= 2) && fullTreePaths.length <= 2)) scanNotice = ''; - // Same fallback as /api/analyze: a failing provider must not fail the stream. + // Custom API keeps its established local fallback. A supplied browser + // provider result is validated explicitly and never triggers a provider switch. let aiGuide = null; let aiWarning = ''; - if (body.config) { + if (body.provider === 'pollinations') { + aiGuide = guideFromProviderText(body.providerResult, repo, metadata, files); + } else if (body.config) { try { aiGuide = await callAi(body.config, repo, metadata, files); } catch (error) { @@ -1098,6 +1150,7 @@ async function handleApi(req, res, pathname) { // instead of escaping as an unhandled rejection that kills the server. if (req.method === 'POST' && pathname === '/api/analyze-stream') { await analyzeStream(req, res, body); return; } if (req.method === 'POST' && pathname === '/api/insight') return sendJson(res, 200, { ok: true, insight: await analyzeInsight(body) }); + if (req.method === 'POST' && pathname === '/api/ai/prepare') return sendJson(res, 200, { ok: true, request: await prepareAiOperation(body) }); if (req.method === 'POST' && pathname === '/api/models') return sendJson(res, 200, { ok: true, models: await fetchModels(body) }); if (req.method === 'POST' && pathname === '/api/recover') return sendJson(res, 200, { ok: true, recovery: await analyzeRecovery(body) }); if (req.method === 'GET' && pathname === '/api/health') return sendJson(res, 200, { ok: true, service: 'git-up', version: '2.0.0', features: FEATURE_FLAGS, githubToken: Boolean(process.env.GITHUB_TOKEN) }); diff --git a/server/recovery.js b/server/recovery.js index f125ea4..1b2d788 100644 --- a/server/recovery.js +++ b/server/recovery.js @@ -330,6 +330,22 @@ function aiPrompt({ repo, metadata, files, failedStep, errorText, remainingSteps ].join('\n'); } +export function buildRecoveryAiRequest(input) { + return { + messages: [{ role: 'user', content: aiPrompt(input) }], + options: { temperature: 0.15, max_tokens: 3200 }, + }; +} + +function parseAiPayload(value) { + if (value && typeof value === 'object') return value; + const cleaned = String(value || '').slice(0, 400_000).replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); + try { return JSON.parse(cleaned); } catch { /* fall through */ } + const match = cleaned.match(/\{[\s\S]*\}/); + if (!match) return null; + try { return JSON.parse(match[0]); } catch { return null; } +} + async function aiCall(config, prompt) { const base = String(config.baseUrl).trim().replace(/\/+$/, ''); const endpointValue = String(config.endpoint || '/chat/completions').trim() || '/chat/completions'; @@ -356,30 +372,33 @@ async function aiCall(config, prompt) { if (!response.ok) throw new Error(`AI provider returned ${response.status}: ${(await response.text()).slice(0, 160)}. Check the base URL, endpoint, model, and key.`); const payload = await response.json(); const text = payload?.choices?.[0]?.message?.content || payload?.choices?.[0]?.text || payload?.output_text || payload?.content || ''; - const cleaned = String(text).replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); - try { return JSON.parse(cleaned); } catch { /* fall through */ } - const match = cleaned.match(/\{[\s\S]*\}/); - if (!match) return null; - try { return JSON.parse(match[0]); } catch { return null; } + return parseAiPayload(text); } /** - * Recovery entry point. AI-first when configured, always landing on the local - * rules engine so the button never dead-ends without a key. + * Recovery entry point. The existing Custom API path keeps its local fallback. + * Browser-provider failures stay explicit so Git-Up never silently changes a + * user's selected provider. */ -export async function recoverPath({ config, repo, metadata, files, failedStep, errorText, remainingSteps, completedSteps, requirements, expertise, failureScan }) { +export async function recoverPath({ config, providerResult, repo, metadata, files, failedStep, errorText, remainingSteps, completedSteps, requirements, expertise, failureScan }) { const local = heuristicRecover({ failedStep, errorText, remainingSteps, completedSteps }); - if (!config?.apiKey || !config?.model || !config?.baseUrl) return local; + const browserProvider = providerResult !== undefined; + if (!browserProvider && (!config?.apiKey || !config?.model || !config?.baseUrl)) return local; let aiPayload = null; let aiError = ''; - try { - aiPayload = await aiCall(config, aiPrompt({ repo, metadata, files, failedStep, errorText, remainingSteps, completedSteps, requirements, expertise, failureScan })); - } catch (error) { - aiError = error.message || 'The AI provider could not answer.'; - } - if (!aiPayload?.correctedSteps?.length) { - if (aiError) local.note = `AI recovery was unavailable (${aiError.slice(0, 140)}). Applying the local fix instead.`; - return local; + if (browserProvider) { + aiPayload = parseAiPayload(providerResult); + if (!aiPayload?.correctedSteps?.length) throw new Error('Free AI returned an invalid recovery response. Please retry.'); + } else { + try { + aiPayload = await aiCall(config, aiPrompt({ repo, metadata, files, failedStep, errorText, remainingSteps, completedSteps, requirements, expertise, failureScan })); + } catch (error) { + aiError = error.message || 'The AI provider could not answer.'; + } + if (!aiPayload?.correctedSteps?.length) { + if (aiError) local.note = `AI recovery was unavailable (${aiError.slice(0, 140)}). Applying the local fix instead.`; + return local; + } } const remainingIds = new Set(remainingSteps.map((entry) => String(entry.id))); const corrected = aiPayload.correctedSteps.map((entry, index) => ({ diff --git a/tests/free-ai-provider.test.mjs b/tests/free-ai-provider.test.mjs new file mode 100644 index 0000000..f78da48 --- /dev/null +++ b/tests/free-ai-provider.test.mjs @@ -0,0 +1,297 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + AI_CONNECTION, + AI_PROVIDER, + activeConnectionState, + connectionPresentation, + normaliseAiProvider, +} from '../public/ai/ai-provider.js'; +import { createAiService } from '../public/ai/ai-service.js'; +import { + FreeAIError, + POLLINATIONS_API_BASE_URL, + POLLINATIONS_AUTH_BASE_URL, + POLLINATIONS_DEFAULT_MODEL, + POLLINATIONS_SESSION_KEY, + createPollinationsProvider, + extractPollinationsText, + freeAiMessage, +} from '../public/ai/pollinations-provider.js'; +import { buildRecoveryAiRequest, recoverPath } from '../server/recovery.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +function response(payload, ok = true, status = ok ? 200 : 400) { + return { ok, status, json: async () => payload }; +} + +function memoryStorage(initial = {}) { + const values = { ...initial }; + return { + values, + getItem: (key) => values[key] ?? null, + setItem: (key, value) => { values[key] = value; }, + removeItem: (key) => { delete values[key]; }, + }; +} + +test('provider state defaults unknown settings to Custom API and exposes explicit Free AI states', () => { + assert.equal(normaliseAiProvider(undefined), AI_PROVIDER.CUSTOM); + assert.equal(normaliseAiProvider('unknown'), AI_PROVIDER.CUSTOM); + assert.equal(normaliseAiProvider('pollinations'), AI_PROVIDER.POLLINATIONS); + assert.equal(activeConnectionState({ activeProvider: 'custom', customReady: true }), AI_CONNECTION.CUSTOM_CONNECTED); + assert.equal(activeConnectionState({ activeProvider: 'custom', customReady: false }), AI_CONNECTION.NO_PROVIDER); + assert.equal(activeConnectionState({ activeProvider: 'custom', customReady: true, customError: true }), AI_CONNECTION.CUSTOM_ERROR); + assert.equal(activeConnectionState({ activeProvider: 'pollinations', pollinationsStatus: AI_CONNECTION.POLLINATIONS_CONNECTED }), AI_CONNECTION.POLLINATIONS_CONNECTED); + assert.equal(activeConnectionState({ activeProvider: 'pollinations', pollinationsStatus: AI_CONNECTION.POLLINATIONS_CONNECTING }), AI_CONNECTION.POLLINATIONS_CONNECTING); + assert.equal(connectionPresentation(AI_CONNECTION.CUSTOM_CONNECTED).label, 'Custom API Connected'); + assert.equal(connectionPresentation(AI_CONNECTION.POLLINATIONS_CONNECTED).label, 'Free AI Connected'); + assert.equal(connectionPresentation(AI_CONNECTION.POLLINATIONS_CONNECTING).tone, 'pending'); + assert.equal(connectionPresentation(AI_CONNECTION.NO_PROVIDER).label, 'AI Not Connected'); +}); + +test('the central AI service routes only to the user-selected adapter', async () => { + const calls = []; + const custom = { + generate: async (request) => { calls.push(['custom', request.operation]); return 'custom-result'; }, + fetchModels: async () => ['custom-model'], + }; + const pollinations = { + generate: async (request) => { calls.push(['pollinations', request.operation]); return 'free-result'; }, + isConnected: async () => true, + connect: async () => true, + cancelConnection: () => true, + disconnect: async () => true, + testConnection: async () => true, + }; + const service = createAiService({ custom, pollinations }); + assert.equal(await service.generate({ provider: 'custom', operation: 'insight', payload: {} }), 'custom-result'); + assert.equal(await service.generate({ provider: 'pollinations', operation: 'recover', payload: {} }), 'free-result'); + assert.deepEqual(calls, [['custom', 'insight'], ['pollinations', 'recover']], 'there is no silent fallback to the other provider'); + assert.deepEqual(await service.fetchCustomModels({}), ['custom-model']); + assert.equal(service.cancelFreeAiConnection(), true); +}); + +test('device authorization is deduplicated, session-only, and powers a browser-side request', async () => { + const storage = memoryStorage(); + const requests = []; + const waits = []; + let tokenPolls = 0; + const delegatedToken = 'delegated-session-value'; + const fetchImpl = async (url, options = {}) => { + const body = options.body ? JSON.parse(options.body) : undefined; + requests.push({ url: String(url), method: options.method || 'GET', headers: { ...(options.headers || {}) }, body }); + if (url === `${POLLINATIONS_AUTH_BASE_URL}/api/device/code`) { + return response({ + device_code: 'temporary-device-code', + user_code: 'ABCD1234', + verification_uri_complete: `${POLLINATIONS_AUTH_BASE_URL}/device?user_code=ABCD1234`, + expires_in: 1800, + interval: 5, + }); + } + if (url === `${POLLINATIONS_AUTH_BASE_URL}/api/device/token`) { + tokenPolls += 1; + return tokenPolls === 1 + ? response({ error: 'authorization_pending' }, false, 400) + : response({ access_token: delegatedToken }); + } + if (url === `${POLLINATIONS_AUTH_BASE_URL}/api/device/userinfo`) return response({ sub: 'account-1' }); + if (url === `${POLLINATIONS_API_BASE_URL}/chat/completions`) { + const prompt = body?.messages?.[0]?.content || ''; + const content = prompt.includes('Reply with exactly') + ? 'Git-Up connection successful.' + : '{"provider":"pollinations"}'; + return response({ choices: [{ message: { content } }] }); + } + if (url === '/api/ai/prepare') { + return response({ ok: true, request: { messages: [{ role: 'user', content: 'Prepared public repository context' }], options: { temperature: 0.2, max_tokens: 80 } } }); + } + if (url === '/api/insight') return response({ ok: true, insight: { title: 'Pollinations result', bullets: [] } }); + return response({ ok: false, error: 'unexpected route' }, false, 404); + }; + const provider = createPollinationsProvider({ + storage, + fetchImpl, + wait: async (ms) => { waits.push(ms); }, + }); + + const authorizations = []; + const firstConnect = provider.connect({ onAuthorization: (value) => authorizations.push(value) }); + const secondConnect = provider.connect(); + assert.strictEqual(firstConnect, secondConnect, 'rapid Connect actions share one device flow'); + assert.equal(await firstConnect, true); + assert.equal(authorizations.length, 1); + assert.deepEqual(Object.keys(authorizations[0]).sort(), ['expiresAt', 'userCode', 'verificationUri']); + assert.equal(authorizations[0].userCode, 'ABCD1234'); + assert.match(authorizations[0].verificationUri, /^https:\/\/enter\.pollinations\.ai\/device/); + assert.deepEqual(waits, [5000, 5000], 'polling honors the provider interval'); + const codeRequest = requests.find((entry) => entry.url.endsWith('/api/device/code')); + assert.deepEqual(codeRequest.body, { scope: 'generate' }, 'authorization requests only generation scope and no app secret'); + const tokenRequests = requests.filter((entry) => entry.url.endsWith('/api/device/token')); + assert.equal(tokenRequests.every((entry) => entry.body.grant_type === 'urn:ietf:params:oauth:grant-type:device_code'), true); + assert.equal(tokenRequests.some((entry) => 'client_secret' in entry.body), false); + assert.equal(storage.values[POLLINATIONS_SESSION_KEY], delegatedToken, 'delegated authorization is kept in session storage'); + + assert.equal(await provider.testConnection(), true); + const insight = await provider.generate({ operation: 'insight', payload: { repoUrl: 'https://github.com/acme/widget' }, fetchImpl }); + assert.equal(insight.title, 'Pollinations result'); + + const chatRequests = requests.filter((entry) => entry.url === `${POLLINATIONS_API_BASE_URL}/chat/completions`); + assert.equal(chatRequests.length, 2, 'the explicit test and repository review each use Pollinations'); + assert.equal(chatRequests[0].body.max_tokens, 24, 'the connection test is deliberately small'); + const chatRequest = chatRequests.at(-1); + assert.equal(chatRequest.body.model, POLLINATIONS_DEFAULT_MODEL); + assert.equal(chatRequest.body.stream, false); + assert.equal(chatRequests.every((entry) => entry.headers.Authorization === `Bearer ${delegatedToken}`), true); + const finalRequest = requests.find((entry) => entry.url === '/api/insight'); + assert.equal(finalRequest.body.provider, 'pollinations'); + assert.equal(finalRequest.body.providerResult, '{"provider":"pollinations"}'); + assert.equal('apiKey' in finalRequest.body, false, 'Custom credentials are never mixed into the free-provider request'); + assert.equal('access_token' in finalRequest.body, false, 'the delegated token never reaches the Git-Up server'); + assert.equal(requests.some((entry) => entry.url.includes(delegatedToken)), false, 'the delegated token is never placed in a URL'); + + await provider.disconnect(); + assert.equal(storage.values[POLLINATIONS_SESSION_KEY], undefined); + await assert.rejects( + provider.generate({ operation: 'insight', payload: {}, fetchImpl }), + (error) => error?.code === 'auth-required', + 'a disconnected provider fails explicitly instead of silently using Custom API', + ); +}); + +test('device authorization supports cancellation, denial, and bounded expiry', async () => { + const codePayload = { + device_code: 'temporary-device-code', + user_code: 'ZXCV5678', + verification_uri_complete: `${POLLINATIONS_AUTH_BASE_URL}/device?user_code=ZXCV5678`, + expires_in: 1800, + interval: 5, + }; + + let releaseAuthorization; + const authorizationReady = new Promise((resolve) => { releaseAuthorization = resolve; }); + const cancelled = createPollinationsProvider({ + storage: memoryStorage(), + fetchImpl: async () => response(codePayload), + wait: (_ms, signal) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new FreeAIError('authorization-cancelled', 'cancelled')), { once: true }); + }), + }); + const cancelledConnection = cancelled.connect({ onAuthorization: releaseAuthorization }); + await authorizationReady; + assert.equal(cancelled.cancelConnection(), true); + await assert.rejects(cancelledConnection, (error) => error?.code === 'authorization-cancelled'); + + const denied = createPollinationsProvider({ + storage: memoryStorage(), + fetchImpl: async (url) => url.endsWith('/api/device/code') + ? response(codePayload) + : response({ error: 'access_denied' }, false, 400), + wait: async () => {}, + }); + await assert.rejects(denied.connect(), (error) => error?.code === 'authorization-denied'); + + let nowMs = 0; + let expiryPolls = 0; + const expiring = createPollinationsProvider({ + storage: memoryStorage(), + now: () => nowMs, + fetchImpl: async (url) => { + if (url.endsWith('/api/device/code')) return response({ ...codePayload, expires_in: 1 }); + expiryPolls += 1; + return response({ error: 'authorization_pending' }, false, 400); + }, + wait: async (ms) => { nowMs += ms; }, + }); + await assert.rejects(expiring.connect(), (error) => error?.code === 'authorization-expired'); + assert.equal(expiryPolls, 0, 'polling stops when the bounded code lifetime elapses'); +}); + +test('expired sessions, balance limits, rate limits, and network failures remain recoverable', async () => { + const expiredStorage = memoryStorage({ [POLLINATIONS_SESSION_KEY]: 'expired-session-value' }); + const expired = createPollinationsProvider({ + storage: expiredStorage, + fetchImpl: async () => response({ error: 'invalid_token' }, false, 401), + }); + await assert.rejects(expired.testConnection(), (error) => error?.code === 'auth-expired'); + assert.equal(expiredStorage.values[POLLINATIONS_SESSION_KEY], undefined, 'an invalid delegated session is removed'); + + const generationFailure = async (status, payload) => { + const provider = createPollinationsProvider({ + storage: memoryStorage({ [POLLINATIONS_SESSION_KEY]: 'active-session-value' }), + fetchImpl: async (url) => { + if (url === '/api/ai/prepare') return response({ ok: true, request: { messages: [{ role: 'user', content: 'review' }], options: {} } }); + if (url === `${POLLINATIONS_API_BASE_URL}/chat/completions`) return response(payload, false, status); + throw new Error('the selected provider must not fall back to another endpoint'); + }, + }); + return provider.generate({ operation: 'insight', payload: {} }); + }; + await assert.rejects(generationFailure(402, { error: { code: 'insufficient_balance' } }), (error) => error?.code === 'insufficient-balance'); + await assert.rejects(generationFailure(429, { error: { code: 'rate_limit' } }), (error) => error?.code === 'rate-limited'); + + const offline = createPollinationsProvider({ + storage: memoryStorage({ [POLLINATIONS_SESSION_KEY]: 'active-session-value' }), + fetchImpl: async () => { throw new TypeError('simulated network outage'); }, + }); + await assert.rejects(offline.testConnection(), (error) => error?.code === 'network-error'); + assert.match(freeAiMessage({ code: 'network-error' }), /network/i); +}); + +test('Pollinations response extraction handles OpenAI-compatible content shapes', () => { + assert.equal(extractPollinationsText('plain'), 'plain'); + assert.equal(extractPollinationsText({ choices: [{ message: { content: 'normalized' } }] }), 'normalized'); + assert.equal(extractPollinationsText({ choices: [{ message: { content: [{ type: 'text', text: 'block one' }, { type: 'text', text: ' block two' }] } }] }), 'block one block two'); + assert.equal(extractPollinationsText({}), ''); +}); + +test('browser-provided recovery uses the same normalized request and result layer', async () => { + const input = { + repo: { canonicalUrl: 'https://github.com/acme/widget' }, + metadata: { language: 'JavaScript', default_branch: 'main' }, + files: [{ path: 'package.json', content: '{"scripts":{"start":"node index.js"}}' }], + failedStep: { id: 'dependencies', title: 'Install', command: 'npm install', detail: '' }, + errorText: 'ERESOLVE dependency tree', + remainingSteps: [{ id: 'dependencies', title: 'Install', command: 'npm install' }], + completedSteps: [{ id: 'clone', title: 'Clone', command: 'git clone x' }], + requirements: [], expertise: 'some', failureScan: { patterns: [] }, + }; + const request = buildRecoveryAiRequest(input); + assert.equal(request.messages.length, 1); + assert.match(request.messages[0].content, /ERESOLVE dependency tree/); + const result = await recoverPath({ + ...input, + providerResult: JSON.stringify({ + diagnosis: 'The package ranges conflict.', + correctedSteps: [{ id: 'dependencies', title: 'Install compatible packages', command: 'npm install --legacy-peer-deps', detail: 'Accept the older peer range.', confidence: 'high' }], + checks: ['npm ls'], followUps: ['Did it install?'], confidence: 'high', + }), + }); + assert.equal(result.source, 'ai'); + assert.equal(result.correctedSteps[0].id, 'dependencies'); + assert.equal(result.correctedSteps[0].keptFromPath, true); +}); + +test('the key-free browser integration has no external AI SDK and keeps responsive provider styles', () => { + const html = fs.readFileSync(path.join(here, '..', 'public', 'index.html'), 'utf8'); + const css = fs.readFileSync(path.join(here, '..', 'public', 'styles.css'), 'utf8'); + const adapter = fs.readFileSync(path.join(here, '..', 'public', 'ai', 'pollinations-provider.js'), 'utf8'); + const server = fs.readFileSync(path.join(here, '..', 'server.js'), 'utf8'); + const aiFiles = fs.readdirSync(path.join(here, '..', 'public', 'ai')).sort(); + assert.deepEqual(aiFiles, ['ai-http.js', 'ai-provider.js', 'ai-service.js', 'custom-api-provider.js', 'pollinations-provider.js']); + assert.doesNotMatch(html, /]+id="[^"]*sdk/i); + assert.equal((server.match(/body\.provider === 'pollinations'/g) || []).length, 4, 'all browser-result server paths recognize Pollinations'); + assert.match(adapter, /https:\/\/enter\.pollinations\.ai/); + assert.match(adapter, /https:\/\/gen\.pollinations\.ai\/v1/); + assert.doesNotMatch(adapter, /\blocalStorage\b/); + assert.doesNotMatch(adapter, /console\./); + assert.match(css, /\.provider-options\s*\{[^}]*grid-template-columns:\s*repeat\(2/); + assert.match(css, /\.free-ai-authorization/); + assert.match(css, /@media \(max-width: 600px\)[\s\S]*\.model-row, \.provider-options\s*\{\s*grid-template-columns:\s*1fr/); +}); diff --git a/tests/free-ai-startup.test.mjs b/tests/free-ai-startup.test.mjs new file mode 100644 index 0000000..397cb76 --- /dev/null +++ b/tests/free-ai-startup.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadClient } from './helpers/dom.mjs'; +import { AI_CONNECTION, AI_PROVIDER } from '../public/ai/ai-provider.js'; + +const POLLINATIONS_AUTH_BASE_URL = 'https://enter.pollinations.ai'; +const POLLINATIONS_SESSION_KEY = 'git-up-pollinations-token'; +const saved = { + activeProvider: 'pollinations', + baseUrl: 'https://custom.example/v1', + endpoint: '/chat/completions', + model: 'custom-model', + modelOptions: ['custom-model'], +}; +const delegatedToken = 'restored-session-value'; +const requests = []; +const fetchImpl = async (url, options = {}) => { + requests.push({ url: String(url), headers: { ...(options.headers || {}) } }); + if (url === `${POLLINATIONS_AUTH_BASE_URL}/api/device/userinfo`) { + return { ok: true, status: 200, json: async () => ({ sub: 'account-1' }) }; + } + throw new Error(`unexpected startup request: ${url}`); +}; +const { state, render, captured, store, sessionStore } = await loadClient({ + 'git-up-settings': JSON.stringify(saved), + 'git-up-history': '[]', +}, { + sessionStore: { [POLLINATIONS_SESSION_KEY]: delegatedToken }, + fetchImpl, +}); +await new Promise((resolve) => setImmediate(resolve)); + +test('startup restores Pollinations as active and verifies the delegated browser session', () => { + assert.equal(state.ai.activeProvider, AI_PROVIDER.POLLINATIONS); + assert.equal(state.ai.pollinationsStatus, AI_CONNECTION.POLLINATIONS_CONNECTED); + assert.equal(state.settings.baseUrl, saved.baseUrl, 'inactive Custom API metadata remains loaded'); + assert.equal(state.settings.model, saved.model); + assert.match(captured.html, /Free AI Connected/); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, `${POLLINATIONS_AUTH_BASE_URL}/api/device/userinfo`); + assert.equal(requests[0].headers.Authorization, `Bearer ${delegatedToken}`); + assert.equal(requests[0].url.includes(delegatedToken), false); +}); + +test('startup keeps delegated authorization session-only and never renders a credential field', () => { + state.modal = 'settings'; + render(); + assert.match(captured.html, /Git-Up Free AI/); + assert.doesNotMatch(captured.html, /id="api-key"/); + assert.doesNotMatch(captured.html, new RegExp(delegatedToken)); + assert.match(captured.html, /delegated authorization stays in this browser session/); + assert.equal(sessionStore[POLLINATIONS_SESSION_KEY], delegatedToken); + assert.equal(JSON.stringify(store).includes(delegatedToken), false, 'local settings never receive the delegated value'); +}); diff --git a/tests/free-ai-ui.test.mjs b/tests/free-ai-ui.test.mjs new file mode 100644 index 0000000..e55c2a9 --- /dev/null +++ b/tests/free-ai-ui.test.mjs @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadClient } from './helpers/dom.mjs'; +import { AI_CONNECTION, AI_PROVIDER } from '../public/ai/ai-provider.js'; + +const legacySettings = { + baseUrl: 'https://provider.example/v1', + endpoint: '/chat/completions', + model: 'existing-model', + modelOptions: ['existing-model'], +}; +const { state, render, captured, store, switchAiProvider, aiConnection, disconnectFreeAi } = await loadClient({ + 'git-up-settings': JSON.stringify(legacySettings), + 'git-up-history': '[]', +}); +state.settings.apiKey = 'session-only-existing-key'; + +function showSettings() { + state.modal = 'settings'; + render(); + return captured.html; +} + +test('legacy Custom API config remains the default and keeps its recognizable form', () => { + assert.equal(state.ai.activeProvider, AI_PROVIDER.CUSTOM, 'old settings migrate without changing provider'); + assert.equal(aiConnection(), AI_CONNECTION.CUSTOM_CONNECTED); + const html = showSettings(); + assert.match(html, /name="ai-provider" value="custom" checked/); + assert.match(html, /name="ai-provider" value="pollinations"/); + assert.match(html, /id="base-url" value="https:\/\/provider\.example\/v1"/); + assert.match(html, /id="api-key" type="password" value="session-only-existing-key"/); + assert.match(html, /id="model"/); + assert.match(html, /existing-model/); + assert.match(html, /Don’t have your own API\?/); + assert.match(html, /No problem — Git-Up has a free AI option/); + assert.match(html, /data-action="use-free-ai"/); + assert.match(html, /Custom API Connected/, 'topbar identifies the active custom provider'); +}); + +test('switching to Free AI preserves every Custom API value and persists only the route choice', async () => { + showSettings(); + switchAiProvider(AI_PROVIDER.POLLINATIONS); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(state.ai.activeProvider, AI_PROVIDER.POLLINATIONS); + assert.equal(state.settings.baseUrl, legacySettings.baseUrl); + assert.equal(state.settings.endpoint, legacySettings.endpoint); + assert.equal(state.settings.model, legacySettings.model); + assert.equal(state.settings.apiKey, 'session-only-existing-key'); + + const saved = JSON.parse(store['git-up-settings']); + assert.equal(saved.activeProvider, AI_PROVIDER.POLLINATIONS); + assert.equal(saved.baseUrl, legacySettings.baseUrl); + assert.equal(saved.model, legacySettings.model); + assert.equal('apiKey' in saved, false, 'the existing key remains session-only'); + + const html = captured.html; + assert.match(html, /Git-Up Free AI/); + assert.match(html, /No key to paste/); + assert.match(html, /Powered by Pollinations/); + assert.match(html, /Pollinations model/); + assert.match(html, />openai { + state.ai.activeProvider = AI_PROVIDER.POLLINATIONS; + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_CONNECTING; + state.ai.busy = 'connect'; + state.ai.authorization = { + userCode: 'ABCD1234', + verificationUri: 'https://enter.pollinations.ai/device?user_code=ABCD1234', + expiresAt: Date.now() + 60_000, + }; + const html = showSettings(); + assert.match(html, /Authorization code/); + assert.match(html, /ABCD1234/); + assert.match(html, /href="https:\/\/enter\.pollinations\.ai\/device\?user_code=ABCD1234"/); + assert.match(html, /target="_blank" rel="noopener noreferrer"/); + assert.match(html, /data-copy="ABCD1234"/); + assert.match(html, /data-action="cancel-free-ai-connect"/); + assert.match(html, /Waiting for Pollinations approval/); + state.ai.busy = ''; + state.ai.authorization = null; +}); + +test('connected Free AI has meaningful status, testing, and disconnect controls', () => { + state.ai.activeProvider = AI_PROVIDER.POLLINATIONS; + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_CONNECTED; + state.ai.message = ''; + showSettings(); + const html = captured.html; + assert.match(html, /Free AI Connected/); + assert.match(html, /data-action="test-free-ai"/); + assert.match(html, /Test AI/); + assert.match(html, /data-action="disconnect-free-ai"/); + assert.match(html, /role="status" aria-live="polite"/); + assert.match(html, /aria-label="AI status: Free AI Connected\. Open AI settings"/); +}); + +test('disconnecting Free AI does not alter Custom API configuration', async () => { + state.ai.activeProvider = AI_PROVIDER.POLLINATIONS; + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_CONNECTED; + await disconnectFreeAi(); + assert.equal(state.ai.pollinationsStatus, AI_CONNECTION.POLLINATIONS_AUTH_REQUIRED); + assert.equal(state.settings.baseUrl, legacySettings.baseUrl); + assert.equal(state.settings.model, legacySettings.model); + assert.equal(state.settings.apiKey, 'session-only-existing-key'); +}); + +test('provider failures give specific recoverable status without changing providers', () => { + state.ai.activeProvider = AI_PROVIDER.POLLINATIONS; + state.ai.pollinationsStatus = AI_CONNECTION.POLLINATIONS_ERROR; + state.ai.message = 'Your Pollinations account does not have enough Pollen. Add or earn Pollen, then retry.'; + const html = showSettings(); + assert.match(html, /does not have enough Pollen/); + assert.match(html, /Retry Free AI/); + assert.match(html, /data-action="disconnect-free-ai"/); + assert.equal(state.ai.activeProvider, AI_PROVIDER.POLLINATIONS, 'an error never silently selects Custom API'); + switchAiProvider(AI_PROVIDER.CUSTOM); + switchAiProvider(AI_PROVIDER.POLLINATIONS); + assert.match(showSettings(), /does not have enough Pollen/, 'switching away and back does not erase the recoverable error'); +}); + +test('switching back restores the original Custom API form without data loss', () => { + switchAiProvider(AI_PROVIDER.CUSTOM); + assert.equal(state.ai.activeProvider, AI_PROVIDER.CUSTOM); + assert.equal(state.settings.apiKey, 'session-only-existing-key'); + const html = showSettings(); + assert.match(html, /value="https:\/\/provider\.example\/v1"/); + assert.match(html, /value="session-only-existing-key"/); + assert.match(html, /