feat: migrate frontend styling to SCSS with modular architecture

- Implement SCSS with 7 modular partials for better maintainability:
  - _variables.scss: Complete design token system
  - _typography.scss: Font hierarchy and styles
  - _animations.scss: Reusable keyframe animations
  - _components.scss: UI components (spinner, search, buttons)
  - _layout.scss: Page structure (hero, SWOT cards, status timeline)
  - _states.scss: Empty and error state components
  - _responsive.scss: Mobile-first breakpoints
- Add build scripts for SCSS compilation and watch mode
- Add EmptyState and ErrorState components for better UX
- Enhance templates with progressive loading and accessibility
- Configure npm with local registry override (.npmrc)
- Add app.js for progressive loading and smooth interactions

Benefits:
- Better code organization with partial separation
- CSS custom properties for runtime theming
- BEM naming convention for maintainability
- Compressed output for production (13KB)
- Easy development with watch mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 16:17:10 -05:00
parent 55d2abe4c7
commit 67fce2656b
18 changed files with 3763 additions and 509 deletions

2448
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
src/frontend/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"dependencies": {
"@creativebulma/bulma-collapsible": "^1.0.4",
"@vizuaalog/bulmajs": "^0.12.2",
"bulma": "^1.0.4",
"bulma-tagsinput": "^2.0.0",
"htmx.org": "^2.0.8",
"install": "^0.13.0",
"jquery": "^4.0.0",
"npm": "^11.8.0"
},
"description": "Pygentic AI Frontend - SWOT Analysis Interface",
"devDependencies": {
"sass": "^1.83.0"
},
"name": "pygentic-ai-frontend",
"scripts": {
"build:css": "sass scss/styles.scss static/css/pygentic_ai.css --style compressed",
"dev": "npm run watch:css",
"watch:css": "sass scss/styles.scss static/css/pygentic_ai.css --watch"
},
"version": "1.0.0"
}

View File

@ -0,0 +1,96 @@
// ============================================
// ANIMATIONS & KEYFRAMES
// ============================================
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba($brand-primary, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba($brand-primary, 0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
@keyframes iconPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes containerFadeIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes statusFadeIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes statusPulse {
0%, 100% {
box-shadow: $shadow-md;
}
50% {
box-shadow: 0 4px 20px rgba($swot-weakness, 0.3);
}
}

View File

@ -0,0 +1,176 @@
// ============================================
// COMPONENTS
// Reusable UI components
// ============================================
// Loading Spinner
// ===================================
.spinner-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-content {
text-align: center;
}
.loader {
border: 4px solid rgba($brand-primary, 0.1);
border-top: 4px solid $brand-primary;
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
.loading-text {
margin-top: 1.5rem;
h3 {
font-size: 1.25rem;
font-weight: 600;
color: $neutral-900;
margin-bottom: 0.5rem;
}
p {
font-size: 0.875rem;
color: $neutral-600;
}
}
// Search Form
// ===================================
.search-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.search-form {
width: 100%;
}
.search-input-group {
display: flex;
align-items: center;
background: white;
border-radius: $radius-full;
padding: 0.5rem;
box-shadow: $shadow-xl;
transition: all $transition-base;
&:focus-within {
box-shadow: 0 20px 40px rgba($brand-primary, 0.2);
transform: translateY(-2px);
}
}
.search-icon {
padding: 0 1rem;
color: $neutral-400;
font-size: 1.25rem;
}
.search-input {
flex: 1;
border: none;
outline: none;
padding: 0.875rem 1rem;
font-size: 1rem;
background: transparent;
color: $neutral-900;
&::placeholder {
color: $neutral-400;
}
}
.search-button {
background: $brand-primary;
color: white;
border: none;
border-radius: $radius-full;
padding: 0.875rem 2rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all $transition-base;
white-space: nowrap;
&:hover {
background: $brand-primary-dark;
transform: translateX(2px);
box-shadow: $shadow-lg;
}
&:active {
transform: scale(0.95);
}
&:focus {
outline: 2px solid $brand-primary-light;
outline-offset: 2px;
}
&.is-loading {
position: relative;
color: transparent;
pointer-events: none;
&::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
top: 50%;
left: 50%;
margin-left: -8px;
margin-top: -8px;
border: 2px solid transparent;
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
}
}
.search-help {
margin-top: 1rem;
text-align: center;
color: $neutral-600;
font-size: 0.875rem;
i {
margin-right: 0.25rem;
color: $brand-primary;
}
}
// Smooth transitions for all interactive elements
button,
a,
input,
.swot-card,
.swot-card__icon {
transition: all $transition-base;
}
// Focus visible styles for accessibility
:focus-visible {
outline: 2px solid $brand-primary;
outline-offset: 2px;
}

View File

@ -0,0 +1,269 @@
// ============================================
// LAYOUT
// Page structure and major sections
// ============================================
// Hero Section
// ===================================
.gradient-hero {
background: $gradient-hero !important;
position: relative;
overflow: hidden;
}
.hero-icon {
img {
filter: brightness(0) invert(1);
animation: float 3s ease-in-out infinite;
&:hover {
animation: float 1s ease-in-out infinite;
filter: brightness(0) invert(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.5));
}
}
}
.hero-cta .button {
animation: pulse 2s infinite;
}
// SWOT Cards
// ===================================
.swot-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: $spacing-lg;
margin-top: $spacing-xl;
}
.swot-card {
background: white;
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-lg;
transition: all $transition-base;
border-top: 4px solid var(--card-color);
animation: slideUp 0.5s ease-out backwards;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-2xl;
.swot-card__icon {
animation: iconPulse 0.5s ease-out;
}
}
// Card variants
&--strength {
--card-color: #{$swot-strength};
}
&--weakness {
--card-color: #{$swot-weakness};
}
&--opportunity {
--card-color: #{$swot-opportunity};
}
&--threat {
--card-color: #{$swot-threat};
}
}
.swot-card__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid $neutral-200;
}
.swot-card__icon {
width: 48px;
height: 48px;
border-radius: $radius-md;
background: var(--card-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
flex-shrink: 0;
}
.swot-card__title {
font-weight: 600;
font-size: 1.375rem;
color: $neutral-900;
flex-grow: 1;
margin: 0;
}
.swot-card__count {
background: $neutral-100;
padding: 0.25rem 0.75rem;
border-radius: $radius-xl;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-700;
flex-shrink: 0;
}
.swot-card__body {
overflow: hidden;
}
.swot-list {
list-style: none;
padding: 0;
margin: 0;
}
.swot-list__item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid $neutral-100;
animation: fadeIn 0.3s ease-out backwards;
&:last-child {
border-bottom: none;
}
// Stagger animation for list items
@for $i from 1 through 10 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 0.1}s;
}
}
}
.swot-list__bullet {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--card-color);
margin-top: 0.5rem;
flex-shrink: 0;
}
.swot-list__text {
flex: 1;
color: $neutral-700;
line-height: 1.6;
}
// Stagger card animations
@for $i from 1 through 4 {
.swot-card:nth-child(#{$i}) {
animation-delay: #{$i * 0.1}s;
}
}
// Result container entrance
#result-container.animate-in {
animation: containerFadeIn 0.6s ease-out;
}
// Status Timeline
// ===================================
.status-timeline {
position: relative;
padding-left: 2rem;
&::before {
content: '';
position: absolute;
left: 14px;
top: 24px;
bottom: 24px;
width: 2px;
background: $neutral-200;
}
}
.status-item {
position: relative;
padding: 1rem 0 1rem 2rem;
animation: statusFadeIn 0.3s ease-out;
// Status variants
&--info {
.status-item__indicator,
.status-item__header {
color: $swot-opportunity;
}
}
&--loading {
.status-item__indicator,
.status-item__header {
color: $swot-weakness;
}
.status-item__content {
animation: statusPulse 2s ease-in-out infinite;
}
}
&--success {
.status-item__indicator,
.status-item__header {
color: $swot-strength;
}
}
&--error {
.status-item__indicator,
.status-item__header {
color: $swot-threat;
}
}
}
.status-item__indicator {
position: absolute;
left: 0;
top: 1.25rem;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
background: white;
box-shadow: 0 0 0 4px white;
z-index: 1;
}
.status-item__content {
background: white;
border-radius: $radius-md;
padding: 1rem 1.25rem;
box-shadow: $shadow-md;
transition: all $transition-base;
}
.status-item:hover .status-item__content {
box-shadow: $shadow-lg;
transform: translateX(4px);
}
.status-item__header {
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.status-item__message {
color: $neutral-700;
line-height: 1.6;
font-size: 0.9375rem;
}

View File

@ -0,0 +1,74 @@
// ============================================
// RESPONSIVE DESIGN
// Mobile-first breakpoints
// ============================================
// Tablet and below (768px)
@media (max-width: $breakpoint-tablet) {
.cell.is-col-span-2 {
grid-column: span 1;
}
.swot-grid {
grid-template-columns: 1fr;
}
.hero-title {
font-size: 2rem;
}
.search-input-group {
flex-direction: column;
gap: 0.5rem;
border-radius: $radius-xl;
padding: 1rem;
}
.search-input {
width: 100%;
text-align: center;
}
.search-button {
width: 100%;
justify-content: center;
}
.swot-card__header {
flex-wrap: wrap;
}
.swot-card__count {
order: -1;
margin-left: auto;
}
}
// Mobile (480px)
@media (max-width: $breakpoint-mobile) {
.swot-card {
padding: 1rem;
}
.swot-card__icon {
width: 40px;
height: 40px;
font-size: 1.25rem;
}
.swot-card__title {
font-size: 1.125rem;
}
.hero-title {
font-size: 1.75rem;
}
.status-timeline {
padding-left: 1.5rem;
}
.status-item {
padding-left: 1.5rem;
}
}

View File

@ -0,0 +1,169 @@
// ============================================
// STATES
// Empty and error states
// ============================================
// Empty State
// ===================================
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 3rem 1.5rem;
}
.empty-state__content {
text-align: center;
max-width: 500px;
}
.empty-state__icon {
width: 120px;
height: 120px;
margin: 0 auto 2rem;
border-radius: 50%;
background: linear-gradient(135deg, $neutral-100 0%, $neutral-200 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: $neutral-400;
animation: float 3s ease-in-out infinite;
}
.empty-state__title {
font-size: 1.75rem;
font-weight: 600;
color: $neutral-800;
margin-bottom: 0.75rem;
}
.empty-state__description {
font-size: 1.125rem;
color: $neutral-600;
line-height: 1.6;
margin-bottom: 1.5rem;
}
.empty-state__cta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 2rem;
background: $brand-primary;
color: white;
border-radius: $radius-full;
text-decoration: none;
font-weight: 600;
transition: all $transition-base;
box-shadow: $shadow-md;
&:hover {
background: $brand-primary-dark;
transform: translateY(-2px);
box-shadow: $shadow-lg;
color: white;
}
}
// Error State
// ===================================
.error-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 3rem 1.5rem;
}
.error-state__content {
text-align: center;
max-width: 500px;
}
.error-state__icon {
width: 120px;
height: 120px;
margin: 0 auto 2rem;
border-radius: 50%;
background: linear-gradient(135deg, #FEE2E2 0%, #FEF2F2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: $swot-threat;
animation: shake 0.5s ease-in-out;
}
.error-state__title {
font-size: 1.75rem;
font-weight: 600;
color: $neutral-800;
margin-bottom: 0.75rem;
}
.error-state__description {
font-size: 1.125rem;
color: $neutral-600;
line-height: 1.6;
margin-bottom: 1.5rem;
}
.error-state__details {
background: $neutral-100;
border-radius: $radius-md;
padding: 1rem;
margin: 1.5rem 0;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: $neutral-700;
text-align: left;
overflow-x: auto;
}
.error-state__actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.error-state__button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 2rem;
border-radius: $radius-full;
font-weight: 600;
transition: all $transition-base;
box-shadow: $shadow-md;
cursor: pointer;
border: none;
text-decoration: none;
&--primary {
background: $brand-primary;
color: white;
&:hover {
background: $brand-primary-dark;
transform: translateY(-2px);
box-shadow: $shadow-lg;
color: white;
}
}
&--secondary {
background: white;
color: $neutral-700;
border: 2px solid $neutral-300;
&:hover {
background: $neutral-100;
border-color: $neutral-400;
transform: translateY(-2px);
color: $neutral-800;
}
}
}

View File

@ -0,0 +1,30 @@
// ============================================
// TYPOGRAPHY
// ============================================
body {
font-family: $font-body;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.title,
.subtitle,
h1, h2, h3, h4, h5, h6 {
font-family: $font-heading;
letter-spacing: -0.02em;
font-weight: 600;
}
// Hero Title with Gradient
.hero-title {
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 700;
background: $gradient-hero-alt;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1rem;
line-height: 1.1;
}

View File

@ -0,0 +1,77 @@
// ============================================
// PYGENTIC AI - SCSS VARIABLES
// Design tokens and configuration
// ============================================
// Font Imports
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
// Font Families
$font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
$font-heading: 'Space Grotesk', 'Inter', sans-serif;
// Brand Colors (from purple.svg logo)
$brand-primary: #8B5CF6;
$brand-primary-light: #A78BFA;
$brand-primary-dark: #7C3AED;
$brand-primary-darker: #6D28D9;
// Semantic SWOT Colors
$swot-strength: #10B981;
$swot-strength-light: #34D399;
$swot-weakness: #F59E0B;
$swot-weakness-light: #FBBF24;
$swot-opportunity: #3B82F6;
$swot-opportunity-light: #60A5FA;
$swot-threat: #EF4444;
$swot-threat-light: #F87171;
// Neutral Palette
$neutral-50: #F9FAFB;
$neutral-100: #F3F4F6;
$neutral-200: #E5E7EB;
$neutral-300: #D1D5DB;
$neutral-400: #9CA3AF;
$neutral-500: #6B7280;
$neutral-600: #4B5563;
$neutral-700: #374151;
$neutral-800: #1F2937;
$neutral-900: #111827;
// Gradients
$gradient-hero: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
$gradient-hero-alt: linear-gradient(135deg, $brand-primary 0%, $brand-primary-darker 100%);
$gradient-card: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
// Shadows
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
// Transitions
$transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
$transition-base: 300ms cubic-bezier(0.4, 0, 0.2, 1);
$transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
// Breakpoints
$breakpoint-mobile: 480px;
$breakpoint-tablet: 768px;
$breakpoint-desktop: 1024px;
$breakpoint-widescreen: 1216px;
// Spacing
$spacing-xs: 0.25rem;
$spacing-sm: 0.5rem;
$spacing-md: 1rem;
$spacing-lg: 1.5rem;
$spacing-xl: 2rem;
$spacing-2xl: 3rem;
// Border Radius
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 20px;
$radius-full: 9999px;

View File

@ -0,0 +1,64 @@
/*!
* Pygentic AI - Main Stylesheet
* Compiled from SCSS partials
* Version: 1.0.0
*/
// Import order matters!
// 1. Variables first (used by all other partials)
// 2. Typography (base styles)
// 3. Animations (keyframes)
// 4. Components (reusable UI elements)
// 5. Layout (page structure)
// 6. States (empty/error states)
// 7. Responsive (media queries last)
@import 'variables';
@import 'typography';
@import 'animations';
@import 'components';
@import 'layout';
@import 'states';
@import 'responsive';
// ============================================
// CSS CUSTOM PROPERTIES
// For runtime theming support
// ============================================
:root {
// Brand Colors
--brand-primary: #{$brand-primary};
--brand-primary-light: #{$brand-primary-light};
--brand-primary-dark: #{$brand-primary-dark};
// SWOT Colors
--swot-strength: #{$swot-strength};
--swot-weakness: #{$swot-weakness};
--swot-opportunity: #{$swot-opportunity};
--swot-threat: #{$swot-threat};
// Neutrals
--neutral-50: #{$neutral-50};
--neutral-100: #{$neutral-100};
--neutral-200: #{$neutral-200};
--neutral-300: #{$neutral-300};
--neutral-400: #{$neutral-400};
--neutral-500: #{$neutral-500};
--neutral-600: #{$neutral-600};
--neutral-700: #{$neutral-700};
--neutral-800: #{$neutral-800};
--neutral-900: #{$neutral-900};
// Shadows
--shadow-sm: #{$shadow-sm};
--shadow-md: #{$shadow-md};
--shadow-lg: #{$shadow-lg};
--shadow-xl: #{$shadow-xl};
--shadow-2xl: #{$shadow-2xl};
// Transitions
--transition-fast: #{$transition-fast};
--transition-base: #{$transition-base};
--transition-slow: #{$transition-slow};
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["../../scss/styles.scss","../../scss/_variables.scss","../../scss/_typography.scss","../../scss/_animations.scss","../../scss/_components.scss","../../scss/_layout.scss","../../scss/_states.scss","../../scss/_responsive.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAAA;AAAA,GCMQ,gICFR,KACE,YDIU,8FCHV,kDACA,mCACA,kCAGF,mCAGE,YDJa,mCCKb,uBACA,gBAIF,YACE,iCACA,gBACA,WDmBkB,kDClBlB,6BACA,sCACA,qBACA,mBACA,gBCxBF,gBACE,KACE,uBAEF,GACE,0BAIJ,iBACE,QACE,0BAEF,IACE,6BAIJ,iBACE,QACE,uCAEF,IACE,0CAIJ,mBACE,KACE,UACA,2BAEF,GACE,UACA,yBAIJ,kBACE,KACE,UAEF,GACE,WAIJ,iBACE,gCACA,gCACA,gCAGF,qBACE,QACE,mBAEF,IACE,sBAIJ,2BACE,KACE,UACA,2BAEF,GACE,UACA,yBAIJ,wBACE,KACE,UACA,4BAEF,GACE,UACA,yBAIJ,uBACE,QACE,WF3CQ,6DE6CV,IACE,2CCtFJ,iBACE,eACA,MACA,OACA,WACA,YACA,qCACA,0BACA,aACA,uBACA,mBACA,aAGF,iBACE,kBAGF,QACE,qCACA,6BACA,kBACA,WACA,YACA,mCACA,cAGF,cACE,kBAEA,iBACE,kBACA,gBACA,MHHU,QGIV,oBAGF,gBACE,kBACA,MHZU,QGkBd,kBACE,gBACA,cACA,kBAGF,aACE,WAGF,oBACE,aACA,mBACA,gBACA,cHSY,OGRZ,cACA,WHpBU,iEGqBV,kDAEA,iCACE,2CACA,2BAIJ,aACE,eACA,MH/CY,QGgDZ,kBAGF,cACE,OACA,YACA,aACA,qBACA,eACA,yBACA,MHrDY,QGuDZ,2BACE,MH7DU,QGiEd,eACE,WHtFc,QGuFd,WACA,YACA,cH1BY,OG2BZ,qBACA,gBACA,eACA,eACA,aACA,mBACA,UACA,kDACA,mBAEA,qBACE,WHnGiB,QGoGjB,0BACA,WHpEQ,+DGuEV,sBACE,sBAGF,qBACE,0BACA,mBAGF,0BACE,kBACA,oBACA,oBAEA,iCACE,WACA,kBACA,WACA,YACA,QACA,SACA,iBACA,gBACA,+BACA,sBACA,kBACA,mCAKN,aACE,gBACA,kBACA,MHtHY,QGuHZ,kBAEA,eACE,oBACA,MHjJY,QGsJhB,2CAKE,kDAIF,eACE,0BACA,mBCvKF,eACE,wEACA,kBACA,gBAIA,eACE,+BACA,wCAEA,qBACE,wCACA,8EAKN,kBACE,4BAKF,WACE,aACA,2DACA,IJiCW,OIhCX,WJiCW,KI9Bb,WACE,gBACA,cJkCU,KIjCV,QJ0BW,OIzBX,WJMU,+DILV,kDACA,uCACA,yCAEA,iBACE,2BACA,WJCS,kCICT,kCACE,iCAKJ,qBACE,sBAGF,qBACE,sBAGF,wBACE,sBAGF,mBACE,sBAIJ,mBACE,aACA,mBACA,WACA,mBACA,oBACA,gCAGF,iBACE,WACA,YACA,cJbU,KIcV,6BACA,aACA,mBACA,uBACA,WACA,iBACA,cAGF,kBACE,gBACA,mBACA,MJ7DY,QI8DZ,YACA,SAGF,kBACE,WJ3EY,QI4EZ,sBACA,cJhCU,KIiCV,kBACA,gBACA,MJ1EY,QI2EZ,cAGF,iBACE,gBAGF,WACE,gBACA,UACA,SAGF,iBACE,aACA,uBACA,WACA,iBACA,gCACA,wCAEA,4BACE,mBAKA,8BACE,qBADF,8BACE,qBADF,8BACE,qBADF,8BACE,qBADF,8BACE,qBADF,8BACE,qBADF,8BACE,qBADF,8BACE,qBADF,8BACE,qBADF,+BACE,mBAKN,mBACE,UACA,WACA,kBACA,6BACA,iBACA,cAGF,iBACE,OACA,MJvHY,QIwHZ,gBAKA,wBACE,qBADF,wBACE,qBADF,wBACE,qBADF,wBACE,qBAKJ,6BACE,uCAKF,iBACE,kBACA,kBAEA,yBACE,WACA,kBACA,UACA,SACA,YACA,UACA,WJzJU,QI6Jd,aACE,kBACA,yBACA,oCAIE,mFAEE,MJ9Ka,QImLf,yFAEE,MJvLU,QI0LZ,4CACE,8CAKF,yFAEE,MJpMU,QIyMZ,qFAEE,MJrMQ,QI0Md,wBACE,kBACA,OACA,YACA,WACA,YACA,kBACA,aACA,mBACA,uBACA,eACA,gBACA,0BACA,UAGF,sBACE,gBACA,cJ5KU,KI6KV,qBACA,WJxMU,6DIyMV,kDAGF,yCACE,WJ5MU,+DI6MV,0BAGF,qBACE,gBACA,kBACA,yBACA,qBACA,oBAGF,sBACE,MJrOY,QIsOZ,gBACA,mBCpQF,aACE,aACA,mBACA,uBACA,iBACA,oBAGF,sBACE,kBACA,gBAGF,mBACE,YACA,aACA,mBACA,kBACA,6DACA,aACA,mBACA,uBACA,eACA,MLGY,QKFZ,wCAGF,oBACE,kBACA,gBACA,cACA,qBAGF,0BACE,mBACA,MLRY,QKSZ,gBACA,qBAGF,kBACE,oBACA,mBACA,UACA,qBACA,WLxCc,QKyCd,WACA,cLqBY,OKpBZ,qBACA,gBACA,kDACA,WLZU,6DKcV,wBACE,WL/CiB,QKgDjB,2BACA,WLhBQ,+DKiBR,WAMJ,aACE,aACA,mBACA,uBACA,iBACA,oBAGF,sBACE,kBACA,gBAGF,mBACE,YACA,aACA,mBACA,kBACA,6DACA,aACA,mBACA,uBACA,eACA,MLrEY,QKsEZ,gCAGF,oBACE,kBACA,gBACA,MLhEY,QKiEZ,qBAGF,0BACE,mBACA,MLxEY,QKyEZ,gBACA,qBAGF,sBACE,WLnFY,QKoFZ,cLzCU,KK0CV,aACA,gBACA,oCACA,kBACA,MLnFY,QKoFZ,gBACA,gBAGF,sBACE,aACA,SACA,uBACA,eAGF,qBACE,oBACA,mBACA,UACA,qBACA,cL5DY,OK6DZ,gBACA,kDACA,WL5FU,6DK6FV,eACA,YACA,qBAEA,8BACE,WLpIY,QKqIZ,WAEA,oCACE,WLtIe,QKuIf,2BACA,WLvGM,+DKwGN,WAIJ,gCACE,gBACA,ML1HU,QK2HV,yBAEA,sCACE,WLpIQ,QKqIR,aLlIQ,QKmIR,2BACA,MLhIQ,QM/Bd,yBACE,oBACE,mBAGF,WACE,0BAGF,YACE,eAGF,oBACE,sBACA,UACA,cNqDQ,KMpDR,aAGF,cACE,WACA,kBAGF,eACE,WACA,uBAGF,mBACE,eAGF,kBACE,SACA,kBAKJ,yBACE,WACE,aAGF,iBACE,WACA,YACA,kBAGF,kBACE,mBAGF,YACE,kBAGF,iBACE,oBAGF,aACE,qBP3CJ,MAEE,yBACA,+BACA,8BAGA,yBACA,yBACA,4BACA,uBAGA,sBACA,uBACA,uBACA,uBACA,uBACA,uBACA,uBACA,uBACA,uBACA,uBAGA,6CACA,mFACA,qFACA,uFACA,oDAGA,sDACA,sDACA","file":"pygentic_ai.css"}

View File

@ -0,0 +1,224 @@
/**
* Pygentic AI - Frontend Application
* Progressive loading and enhanced UX interactions
*/
(function() {
'use strict';
// Progressive loading messages
const LOADING_MESSAGES = [
'Fetching URL content...',
'Analyzing page structure...',
'Extracting key information...',
'Identifying patterns...',
'Generating SWOT analysis...',
'Finalizing insights...',
'Almost there...'
];
let loadingMessageIndex = 0;
let loadingInterval = null;
let pollCount = 0;
let pollInterval = 1000; // Start at 1s
const MAX_POLL_INTERVAL = 5000; // Max 5s
/**
* Update loading message progressively
*/
function updateLoadingMessage() {
const statusElement = document.getElementById('loading-status');
if (!statusElement) return;
if (loadingMessageIndex < LOADING_MESSAGES.length - 1) {
loadingMessageIndex++;
}
statusElement.textContent = LOADING_MESSAGES[loadingMessageIndex];
statusElement.style.animation = 'fadeIn 0.3s ease-in';
}
/**
* Start progressive loading messages
*/
function startLoadingMessages() {
loadingMessageIndex = 0;
pollCount = 0;
pollInterval = 1000;
const statusElement = document.getElementById('loading-status');
if (statusElement) {
statusElement.textContent = LOADING_MESSAGES[0];
}
// Update message every 3 seconds
if (loadingInterval) {
clearInterval(loadingInterval);
}
loadingInterval = setInterval(updateLoadingMessage, 3000);
}
/**
* Stop loading messages
*/
function stopLoadingMessages() {
if (loadingInterval) {
clearInterval(loadingInterval);
loadingInterval = null;
}
loadingMessageIndex = 0;
}
/**
* Smooth scroll to results
*/
function scrollToResults() {
const resultsSection = document.getElementById('result-container');
if (resultsSection) {
resultsSection.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
/**
* Exponential backoff for polling
*/
function calculateNextPollInterval() {
pollCount++;
if (pollCount > 3) {
pollInterval = Math.min(pollInterval * 1.5, MAX_POLL_INTERVAL);
}
return pollInterval;
}
/**
* Initialize form submission handler
*/
function initializeForm() {
const form = document.getElementById('swotSearch');
if (!form) return;
form.addEventListener('submit', function(e) {
// Start loading messages when form is submitted
startLoadingMessages();
// Show spinner
const spinner = document.getElementById('spinner');
if (spinner) {
spinner.classList.remove('is-hidden');
}
});
}
/**
* Monitor for analysis completion
*/
function monitorAnalysisCompletion() {
const resultBox = document.getElementById('result');
if (!resultBox) return;
// Use MutationObserver to watch for content changes
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && resultBox.innerHTML.trim().length > 0) {
// Analysis complete
stopLoadingMessages();
// Hide spinner
const spinner = document.getElementById('spinner');
if (spinner) {
spinner.classList.add('is-hidden');
}
// Scroll to results after a brief delay
setTimeout(scrollToResults, 500);
// Add success class for animation
const resultContainer = document.getElementById('result-container');
if (resultContainer) {
resultContainer.classList.add('animate-in');
}
}
});
});
observer.observe(resultBox, {
childList: true,
subtree: true
});
}
/**
* Add button press effects
*/
function initializeButtonEffects() {
const buttons = document.querySelectorAll('.search-button, .error-state__button');
buttons.forEach(button => {
button.addEventListener('mousedown', function() {
this.style.transform = 'scale(0.95)';
});
button.addEventListener('mouseup', function() {
this.style.transform = '';
});
button.addEventListener('mouseleave', function() {
this.style.transform = '';
});
});
}
/**
* Initialize smooth anchor scrolling
*/
function initializeSmoothScrolling() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {
const href = this.getAttribute('href');
if (href === '#' || !href) return;
e.preventDefault();
const target = document.querySelector(href);
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
}
/**
* Initialize all features on DOM ready
*/
function initialize() {
initializeForm();
monitorAnalysisCompletion();
initializeButtonEffects();
initializeSmoothScrolling();
console.log('✨ Pygentic AI initialized');
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
// Export functions for external use if needed
window.PygenticAI = {
startLoadingMessages,
stopLoadingMessages,
scrollToResults
};
})();

View File

@ -1,3 +1,4 @@
<Js url="{{ url_for('static', path="/js/htmx.js") }}">type="modules"</Js>
<Js url="{{ url_for('static', path="/js/bulma.js") }}"></Js>
<Js url="{{ url_for('static', path="/js/bulma-collapsible.min.js") }}"></Js>
<Js url="{{ url_for('static', path="/js/bulma-collapsible.min.js") }}"></Js>
<Js url="{{ url_for('static', path="/js/app.js") }}"></Js>

View File

@ -0,0 +1,16 @@
{# def
title: str = "No Analysis Yet",
description: str = "Enter a URL above to get started with your first SWOT analysis",
icon: str = "fa-chart-simple"
#}
<div class="empty-state">
<div class="empty-state__content">
<div class="empty-state__icon">
<i class="fas {{ icon }}"></i>
</div>
<h3 class="empty-state__title">{{ title }}</h3>
<p class="empty-state__description">{{ description }}</p>
{{ content }}
</div>
</div>

View File

@ -0,0 +1,39 @@
{# def
title: str = "Analysis Failed",
description: str = "We couldn't analyze this URL. Please check the URL and try again.",
error_details: str = None,
show_retry: bool = True
#}
<div class="error-state">
<div class="error-state__content">
<div class="error-state__icon">
<i class="fas fa-triangle-exclamation"></i>
</div>
<h3 class="error-state__title">{{ title }}</h3>
<p class="error-state__description">{{ description }}</p>
{% if error_details %}
<div class="error-state__details">
{{ error_details }}
</div>
{% endif %}
<div class="error-state__actions">
{% if show_retry %}
<button class="error-state__button error-state__button--primary"
onclick="location.reload()">
<i class="fas fa-rotate-right"></i>
<span>Try Again</span>
</button>
{% endif %}
<a href="/"
class="error-state__button error-state__button--secondary">
<i class="fas fa-home"></i>
<span>Go Home</span>
</a>
</div>
{{ content }}
</div>
</div>

View File

@ -29,8 +29,10 @@
action={{ url_for('analyze_url') }}
target="status"
method="post">
<div class="search-input-group">
<span class="search-icon">
<div class="search-input-group"
role="search">
<span class="search-icon"
aria-hidden="true">
<i class="fas fa-link"></i>
</span>
<input type="url"
@ -38,11 +40,14 @@
id="url"
name="url"
placeholder="Enter a URL to analyze (e.g., https://example.com)"
aria-label="Enter URL to analyze"
aria-describedby="search-help"
required
pattern="https?://.+"
autocomplete="url" />
<button type="submit"
class="search-button"
aria-label="Analyze URL"
hx-indicator='#spinner'
hx-on:click="
const [status, result, spinner] = ['#status', '#result', '#spinner'].map(id => document.querySelector(id));
@ -51,7 +56,8 @@
result.style.display = 'none';
">
<span class="button-text">Analyze</span>
<span class="button-icon">
<span class="button-icon"
aria-hidden="true">
<i class="fas fa-arrow-right"></i>
</span>
</button>

View File

@ -1,39 +1,46 @@
{% if messages %}
<section class="section"
id="status-container">
<div class="container">
{% for message in messages %}
{% set is_error = message.startswith('Error:') %}
{% set is_loading = loop.last and not result %}
{% set is_tool_message = message.startswith('Using tool') %}
<div class="box">
{% if is_error %}
{% set bg_color = 'danger' %}
{% set header_content = 'Error' %}
{% set content = message %}
{% elif is_loading and "analysis complete" not in message.lower() %}
{% set bg_color = 'warning' %}
{% set content = message %}
{% set header_content = 'In Progress' %}
<section class="section" id="status-container">
<div class="container" style="max-width: 800px;">
<div class="status-timeline">
{% for message in messages %}
{% set is_error = message.startswith('Error:') %}
{% set is_loading = loop.last and not result %}
{% set is_tool_message = message.startswith('Using tool') %}
{% set is_complete = "complete" in message.lower() or "done" in message.lower() %}
{% elif is_tool_message and "analysis complete" not in message.lower() %}
{% set bg_color = 'info '%}
{% set header_content, content = message.split(' ', 2)[2].split('...', 1) %}
{% elif "analyzing..." in message.lower() %}
{% set bg_color = 'link' %}
{% set content = 'message' %}
{% set header_content = "Starting"%}
{% else %}
{% set bg_color = 'success' %}
{% set content = message %}
{% set header_content = 'Done!' %}
{% endif %}
<StatusResult div_class={{ bg_color }}
header_content={{ header_content }}>{{ message }}
</StatusResult>
<div class="status-item {% if is_error %}status-item--error{% elif is_complete %}status-item--success{% elif is_loading %}status-item--loading{% else %}status-item--info{% endif %}">
<div class="status-item__indicator">
{% if is_error %}
<i class="fas fa-circle-xmark"></i>
{% elif is_complete %}
<i class="fas fa-circle-check"></i>
{% elif is_loading %}
<i class="fas fa-circle-notch fa-spin"></i>
{% else %}
<i class="fas fa-circle"></i>
{% endif %}
</div>
<div class="status-item__content">
<div class="status-item__header">
{% if is_error %}
Error
{% elif is_complete %}
Complete
{% elif is_loading %}
In Progress
{% elif is_tool_message %}
Processing
{% else %}
Status
{% endif %}
</div>
<div class="status-item__message">
{{ message }}
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}