Compare commits

...
6 Commits
Author SHA1 Message Date
platform-mcpandClaude Opus 4.8 01cafba6c1 feat: apply WorkRoot logo sitewide and as favicon
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 10m48s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 11m40s
E2E Test Suite / Form Interaction Tests (push) Failing after 12m32s
Ping Search Engines / Notify Search Engines (push) Failing after 11m26s
E2E Test Suite / Security Header Tests (push) Failing after 12m18s
E2E Test Suite / Mobile Device Tests (push) Failing after 13m10s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 14m3s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 14m55s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 22m34s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Test Report Summary (push) Failing after 14m58s
Deploy to Production / Post-Deploy Verification (push) Failing after 10m26s
Deploy to Production / Build & Verify (push) Failing after 14m49s
Deploy to Production / Pre-Deploy Tests (push) Failing after 14m58s
Deploy to Production / Deploy to Railway (push) Failing after 14m5s
Deploy to Production / Deploy to Render (push) Failing after 14m5s
Deploy to Production / Deploy to VPS (PM2) (push) Failing after 13m13s
Deploy to Production / Deploy to Fly.io (push) Failing after 13m13s
Deploy to Production / Notify on Failure (push) Failing after 13m53s
- Add WorkRoot Icon as public/favicon.svg, regenerate logo.png (512x512)
  and apple-touch-icon.png (180x180) from the brand vector; add
  favicon-32x32.png raster
- Register 32x32 PNG favicon link in BaseLayout <head> alongside the
  SVG and apple-touch-icon
- Replace placeholder "W" logo boxes with the WorkRoot mark in the
  header, mobile menu, and footer (preserving "WorkRoot" text spans
  required by the dark-mode contrast tests and the home link wrapper)

JSON-LD/SEO logo (/logo.png) and PWA manifest icons now resolve to the
real brand assets. Build passes; BUG-5 dark-mode and navigation tests
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:08:31 +05:30
platform-mcp 19a0720fe8 pre-agent: Apply WorkRoot logo sitewide and as favicon 2026-06-17 15:03:27 +05:30
platform-mcp 5c05779541 agent: Fix mobile responsiveness and conversion across pa 2026-06-17 14:51:43 +05:30
platform-mcp ce67f90f06 pre-agent: Fix mobile responsiveness and conversion across pa 2026-06-17 14:42:24 +05:30
platform-mcp fb5e1ea281 agent: Improve blog content and lead funnel 2026-06-17 14:41:57 +05:30
platform-mcp 03c37a623f pre-agent: Improve blog content and lead funnel 2026-06-17 14:34:26 +05:30
16 changed files with 1003 additions and 497 deletions
+39 -34
View File
@@ -1,11 +1,11 @@
# Graph Report - 2e709500-79ab-403f-9ca0-3f027ac9f36e (2026-06-17)
## Corpus Check
- 57 files · ~323,606 words
- 58 files · ~324,818 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 522 nodes · 567 edges · 93 communities (86 shown, 7 thin omitted)
- 536 nodes · 580 edges · 94 communities (87 shown, 7 thin omitted)
- Extraction: 99% EXTRACTED · 1% INFERRED · 0% AMBIGUOUS · INFERRED: 4 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
@@ -23,9 +23,9 @@
- [[_COMMUNITY_Community 5|Community 5]]
- [[_COMMUNITY_Community 6|Community 6]]
- [[_COMMUNITY_Community 7|Community 7]]
- [[_COMMUNITY_Community 9|Community 9]]
- [[_COMMUNITY_Community 8|Community 8]]
- [[_COMMUNITY_Community 10|Community 10]]
- [[_COMMUNITY_Community 12|Community 12]]
- [[_COMMUNITY_Community 11|Community 11]]
- [[_COMMUNITY_Community 13|Community 13]]
- [[_COMMUNITY_Community 14|Community 14]]
- [[_COMMUNITY_Community 15|Community 15]]
@@ -49,7 +49,8 @@
- [[_COMMUNITY_Community 33|Community 33]]
- [[_COMMUNITY_Community 34|Community 34]]
- [[_COMMUNITY_Community 35|Community 35]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 36|Community 36]]
- [[_COMMUNITY_Community 41|Community 41]]
## God Nodes (most connected - your core abstractions)
1. `Performance Testing Scripts` - 12 edges
@@ -73,7 +74,7 @@
- `POST()` --calls--> `logApiRequest()` [INFERRED]
src/pages/api/newsletter.ts → src/utils/logger.ts
## Communities (93 total, 7 thin omitted)
## Communities (94 total, 7 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
@@ -105,90 +106,94 @@ Nodes (15): 1. Modular Monolith Over Premature Microservices, 2. Role-Based Acce
### Community 7 - "Community 7"
Cohesion: 0.14
Nodes (13): 1. Multi-District Deployment Complexity, 2. Security and Compliance, 3. Reliability Under Constraint, Architecture Decisions That Paid Off, code:java (@Configuration), code:java (@Service), code:java (@Component), code:yaml (# District-specific feature configuration) (+5 more)
Nodes (13): 1. Database-per-district with central aggregation, 2. Offline-first for field-critical modules, 3. Audit logging baked into the data layer, Building something similar?, code:yaml (# Per-district deployment descriptor), code:java (@Scheduled(fixedDelay = 300_000) // every 5 minutes), code:yaml (features:), The Architecture We Committed To (+5 more)
### Community 9 - "Community 9"
Cohesion: 0.17
Nodes (11): 1. Customer Service Automation, 2. Predictive Analytics, 3. Process Automation, 4. Intelligent Document Processing, code:python (from transformers import pipeline), code:python (import pandas as pd), code:python (# Example: Extracting structured data from forms), Implementation Considerations (+3 more)
### Community 8 - "Community 8"
Cohesion: 0.14
Nodes (13): 1. Multi-District Deployment Complexity, 2. Security and Compliance, 3. Reliability Under Constraint, Architecture Decisions That Paid Off, code:java (@Configuration), code:java (@Service), code:java (@Component), code:yaml (# District-specific feature configuration) (+5 more)
### Community 10 - "Community 10"
Cohesion: 0.17
Nodes (11): 1. Customer Service Automation, 2. Predictive Analytics, 3. Process Automation, 4. Intelligent Document Processing, code:python (from transformers import pipeline), code:python (import pandas as pd), code:python (# Example: Extracting structured data from forms), Implementation Considerations (+3 more)
### Community 11 - "Community 11"
Cohesion: 0.17
Nodes (11): Code Example, code:astro (---), code:javascript (// Traditional SPA: Everything loads upfront), code:typescript (// src/content/config.ts), code:bash (npm create astro@latest), Content Collections: Type-Safe Content, Getting Started, How Astro Differs from Traditional Frameworks (+3 more)
### Community 13 - "Community 13"
### Community 14 - "Community 14"
Cohesion: 0.18
Nodes (10): code:hcl (# Define AWS EC2 instance), code:dockerfile (# Multi-stage build for a Go application), code:yaml (# AWS Cost allocation tags), Common Pitfalls, Conclusion, Containerization as a Migration Strategy, Cost Optimization Strategies, Infrastructure as Code (+2 more)
### Community 17 - "Community 17"
### Community 18 - "Community 18"
Cohesion: 0.43
Nodes (6): trackCaseStudyView(), trackEvent(), trackExternalLink(), trackFormSubmit(), trackNewsletterSignup(), trackPortfolioFilter()
### Community 18 - "Community 18"
### Community 19 - "Community 19"
Cohesion: 0.36
Nodes (7): extract_scores(), get_issues(), main(), Run Lighthouse audit on a URL, Extract key scores from Lighthouse data, Extract issues from Lighthouse audit, run_lighthouse()
### Community 19 - "Community 19"
### Community 20 - "Community 20"
Cohesion: 0.46
Nodes (7): captureScreenshot(), ensureDir(), generateIndex(), main(), screenshotPath(), setTheme(), waitForPageReady()
### Community 20 - "Community 20"
### Community 21 - "Community 21"
Cohesion: 0.36
Nodes (4): deleteFromDB(), getAllFromDB(), openDB(), processSyncQueue()
### Community 21 - "Community 21"
### Community 22 - "Community 22"
Cohesion: 0.46
Nodes (7): checkRateLimit(), corsHeaders(), escapeHtml(), OPTIONS(), POST(), sendContactEmail(), validateContactForm()
### Community 22 - "Community 22"
### Community 23 - "Community 23"
Cohesion: 0.43
Nodes (6): trackCaseStudyView(), trackEvent(), trackExternalLink(), trackFormSubmit(), trackNewsletterSignup(), trackPortfolioFilter()
### Community 23 - "Community 23"
### Community 24 - "Community 24"
Cohesion: 0.36
Nodes (7): extract_scores(), get_issues(), main(), Run Lighthouse audit on a URL, Extract key scores from Lighthouse data, Extract issues from Lighthouse audit, run_lighthouse()
### Community 24 - "Community 24"
### Community 25 - "Community 25"
Cohesion: 0.46
Nodes (7): captureScreenshot(), ensureDir(), generateIndex(), main(), screenshotPath(), setTheme(), waitForPageReady()
### Community 25 - "Community 25"
### Community 26 - "Community 26"
Cohesion: 0.36
Nodes (4): deleteFromDB(), getAllFromDB(), openDB(), processSyncQueue()
### Community 26 - "Community 26"
Cohesion: 0.48
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
### Community 27 - "Community 27"
Cohesion: 0.48
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
### Community 28 - "Community 28"
Cohesion: 0.48
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
### Community 29 - "Community 29"
Cohesion: 0.48
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
### Community 30 - "Community 30"
Cohesion: 0.48
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
### Community 31 - "Community 31"
Cohesion: 0.47
Nodes (5): main(), measure_response_time(), print_stats(), Measure response time for a URL over multiple iterations, Print statistics for response times
### Community 32 - "Community 32"
### Community 33 - "Community 33"
Cohesion: 0.47
Nodes (5): main(), measure_response_time(), print_stats(), Measure response time for a URL over multiple iterations, Print statistics for response times
### Community 34 - "Community 34"
Cohesion: 0.6
Nodes (3): formatUptime(), GET(), percentile()
### Community 35 - "Community 35"
Cohesion: 0.6
Nodes (3): formatUptime(), GET(), percentile()
### Community 36 - "Community 36"
Cohesion: 0.6
Nodes (3): formatUptime(), GET(), percentile()
## Knowledge Gaps
- **111 isolated node(s):** `Run Lighthouse audit on a URL`, `Extract key scores from Lighthouse data`, `Extract issues from Lighthouse audit`, `Measure response time for a URL over multiple iterations`, `Print statistics for response times` (+106 more)
- **120 isolated node(s):** `Run Lighthouse audit on a URL`, `Extract key scores from Lighthouse data`, `Extract issues from Lighthouse audit`, `Measure response time for a URL over multiple iterations`, `Print statistics for response times` (+115 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **7 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
@@ -198,7 +203,7 @@ _Questions this graph is uniquely positioned to answer:_
- **Are the 2 inferred relationships involving `captureException()` (e.g. with `sendContactEmail()` and `subscribeToNewsletter()`) actually correct?**
_`captureException()` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `Run Lighthouse audit on a URL`, `Extract key scores from Lighthouse data`, `Extract issues from Lighthouse audit` to the rest of the system?**
_111 weakly-connected nodes found - possible documentation gaps or missing edges._
_120 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.05 - nodes in this community are weakly interconnected._
- **Should `Community 1` be split into smaller, more focused modules?**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+583 -327
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -6
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 410 B

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+1 -3
View File
@@ -44,9 +44,7 @@ const currentYear = new Date().getFullYear();
<!-- Company Info -->
<div class="lg:col-span-1">
<a href="/" class="inline-flex items-center gap-2 mb-4 group" aria-label="WorkRoot IT Solutions LLP - Home">
<div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:scale-105" aria-hidden="true">
<span class="text-white font-bold text-lg">W</span>
</div>
<img src="/favicon.svg" alt="WorkRoot" width="40" height="40" class="w-10 h-10 transition-transform group-hover:scale-105" />
<span class="text-xl font-bold text-white">WorkRoot</span>
</a>
<p class="text-secondary-400 text-sm leading-relaxed mb-6">
+42 -23
View File
@@ -33,9 +33,13 @@ const currentPath = Astro.url.pathname;
<nav class="flex items-center justify-between h-16 lg:h-20" aria-label="Main navigation">
<!-- Logo -->
<a href="/" class="flex items-center gap-2 group" aria-label="WorkRoot IT Solutions LLP - Home">
<div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:scale-105">
<span class="text-white font-bold text-xl">W</span>
</div>
<img
src="/favicon.svg"
alt="WorkRoot"
width="40"
height="40"
class="w-10 h-10 transition-transform group-hover:scale-105"
/>
<div class="hidden sm:block">
<span class="font-bold text-xl text-secondary dark:text-white">WorkRoot</span>
<span class="text-primary font-semibold text-sm block -mt-1">IT Solutions</span>
@@ -85,23 +89,40 @@ const currentPath = Astro.url.pathname;
</a>
</div>
<!-- Mobile Menu Button -->
<button
type="button"
class="lg:hidden relative w-10 h-10 flex items-center justify-center text-secondary-600 dark:text-secondary-400 hover:text-primary dark:hover:text-primary-400 transition-colors"
id="mobile-menu-toggle"
aria-label="Toggle mobile menu"
aria-expanded="false"
aria-controls="mobile-menu"
>
<span class="sr-only">Open main menu</span>
<!-- Hamburger Icon -->
<div class="hamburger-icon" aria-hidden="true">
<span class="hamburger-line hamburger-line-1"></span>
<span class="hamburger-line hamburger-line-2"></span>
<span class="hamburger-line hamburger-line-3"></span>
</div>
</button>
<!-- Mobile actions: prominent click-to-call + hamburger (shown <lg on every page) -->
<div class="flex items-center gap-1 lg:hidden">
<!-- Click-to-call — always visible in the mobile header so visitors on
any page can reach us in one tap without opening the menu. -->
<a
href="tel:+919561417403"
data-cta="header-mobile-call"
aria-label="Call WorkRoot at +91 95614 17403"
class="inline-flex items-center gap-1.5 h-10 px-3 rounded-lg bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 font-semibold text-sm hover:bg-primary-100 dark:hover:bg-primary-900/50 active:scale-95 transition-all focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span class="hidden xs:inline">Call</span>
</a>
<!-- Mobile Menu Button -->
<button
type="button"
class="relative w-10 h-10 flex items-center justify-center text-secondary-600 dark:text-secondary-400 hover:text-primary dark:hover:text-primary-400 transition-colors"
id="mobile-menu-toggle"
aria-label="Toggle mobile menu"
aria-expanded="false"
aria-controls="mobile-menu"
>
<span class="sr-only">Open main menu</span>
<!-- Hamburger Icon -->
<div class="hamburger-icon" aria-hidden="true">
<span class="hamburger-line hamburger-line-1"></span>
<span class="hamburger-line hamburger-line-2"></span>
<span class="hamburger-line hamburger-line-3"></span>
</div>
</button>
</div>
</nav>
</div>
@@ -124,9 +145,7 @@ const currentPath = Astro.url.pathname;
<!-- Mobile Menu Header -->
<div class="flex items-center justify-between p-4 border-b border-secondary-100 dark:border-secondary-800">
<a href="/" class="flex items-center gap-2" aria-label="WorkRoot IT Solutions LLP - Home">
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center" aria-hidden="true">
<span class="text-white font-bold text-lg">W</span>
</div>
<img src="/favicon.svg" alt="WorkRoot" width="32" height="32" class="w-8 h-8" />
<span class="font-bold text-lg text-secondary dark:text-white">WorkRoot</span>
</a>
<button
@@ -0,0 +1,123 @@
---
title: "How We Deployed a Police Management System Across 11 Bihar Districts"
description: "A field-tested account of rolling out a Police Management System to 15,000+ officers across 11 districts in Bihar — the architecture, the rollout plan, the failures, and the numbers that came out the other side."
pubDate: 2026-06-10
heroImage: "/images/blog/govt-systems.jpg"
category: "DevOps"
tags: ["Government IT", "Case Study", "Deployment", "Spring Boot", "Police Management System", "Bihar"]
author:
name: "Ajay"
avatar: "/images/team/ajay.jpg"
draft: false
---
When a state police department asks you to digitise case management, personnel tracking, and resource allocation across **11 districts** and put it in front of **15,000+ officers**, the hard part is not the code. It is the rollout. This is the story of how we took a Police Management System (PMS) from a single pilot district in Bihar to a statewide deployment without taking public-safety operations offline — what worked, what broke, and the numbers we measured along the way.
> **The headline:** 11 districts live in 14 weeks, zero unplanned downtime during cutover, and FIR registration time cut from an average of 38 minutes (paper) to under 6 minutes (digital).
## The Starting Point
Before the project, every district ran on paper and a patchwork of disconnected spreadsheets. A case opened in one district was invisible to the next. Personnel rosters lived in filing cabinets. Resource allocation — vehicles, equipment, duty assignments — was reconciled by hand at the end of each shift.
The brief was deceptively simple: one system, every district, no disruption to live policing. The constraints were not:
- **Heterogeneous infrastructure.** Some district HQs had fibre; rural outposts shared a single intermittent mobile connection.
- **Zero tolerance for data leakage between jurisdictions.** District A must never see District B's open cases.
- **Officers with mixed digital literacy.** The system had to be usable by someone who had never filed anything on a computer.
- **A non-negotiable audit trail.** Every read and write on sensitive records had to be traceable.
## The Architecture We Committed To
We built the backend on **Spring Boot** as a modular monolith — clean module boundaries (cases, personnel, resources, reporting, auth) without the operational tax of running dozens of microservices across 11 sites. The frontend was an **Angular** SPA with an offline-resilient shell so officers could keep working through connectivity drops.
Three decisions did most of the heavy lifting:
### 1. Database-per-district with central aggregation
Each district ran its own operational database, with a central read-only warehouse for state-level analytics. This gave us hard data isolation *for free* — a query in one district physically cannot reach another district's records — and meant one district's database incident never cascaded.
```yaml
# Per-district deployment descriptor
district:
code: "D04"
deployment-mode: "standard" # high-load | standard | minimal
database:
isolation: dedicated # no cross-district connection strings exist
pool-size: 20
sync:
central-warehouse: nightly
```
### 2. Offline-first for field-critical modules
FIR registration and duty logging had to work when the network did not. We queued writes locally and synced on a fixed cadence, with conflict resolution on the server. An officer in a rural outpost never sees a spinner waiting on a flaky uplink.
```java
@Scheduled(fixedDelay = 300_000) // every 5 minutes
public void syncPendingRecords() {
for (PendingRecord record : localStore.getPending()) {
try {
centralServer.submit(record);
localStore.markSynced(record.getId());
} catch (ConnectException e) {
log.warn("Uplink down — {} records still queued", localStore.pendingCount());
break; // stop and retry next cycle; never block the officer
}
}
}
```
### 3. Audit logging baked into the data layer
Every access to a sensitive record writes an immutable audit entry with user, action, district, timestamp, and source IP. Critical actions trigger real-time alerts. This was not a reporting feature bolted on at the end — it was a cross-cutting concern in the shared library every module depended on.
## The Rollout Plan That Actually Held Up
We did **not** big-bang 11 districts. We sequenced them.
1. **Pilot (Weeks 14): one district.** We picked a mid-sized district with reasonable connectivity and a station house officer who was enthusiastic about the project. Everything we learned here rewrote the playbook for the rest.
2. **Hardening (Weeks 57): two adjacent districts.** This proved the multi-district data isolation under real concurrent load and exposed our first nasty bug (see below).
3. **Wave rollout (Weeks 814): the remaining eight, two at a time.** Each wave used **feature flags** so a district could go live with the core modules first and switch on biometric auth or mobile reporting only once its officers were trained.
```yaml
features:
biometric-auth:
enabled: true
districts: ["D01", "D02", "D05"]
mobile-reporting:
enabled: true
districts: ["D01", "D02", "D03", "D04", "D05",
"D06", "D07", "D08", "D09", "D10", "D11"]
```
Cutover for each district used **blue-green deployment**: stand up the new version alongside the old, run smoke tests, switch the load balancer, and keep the previous environment warm for 24 hours as an instant rollback path. No district ever experienced a maintenance window during working hours.
## What Broke (Because Something Always Does)
**The sync storm.** When we brought districts three and four online, a power restoration after an outage caused hundreds of field devices to flush their queues simultaneously. The central server buckled under the thundering herd. The fix: jittered, exponentially backed-off sync windows so devices never resync in lockstep. It is now standard in every offline module we build.
**The "helpful" copy-paste.** During training we discovered officers pasting entire case narratives that included pasted formatting payloads. Our early sanitisation was too permissive. We tightened input handling and added server-side normalisation before this ever reached production data.
**Training was half the project.** We budgeted for software and under-budgeted for people. The districts that went smoothly were the ones where we ran hands-on sessions with real (anonymised) cases, not slide decks. We rebuilt our rollout checklist to make on-site training a gating requirement, not an afterthought.
## The Numbers
After all 11 districts were live for one quarter:
- **38 min → under 6 min** average FIR registration time.
- **11/11 districts** on a single, consistent platform with strict jurisdiction isolation.
- **15,000+ officers** onboarded.
- **Zero** unplanned downtime during any district cutover.
- **100%** of sensitive-record access captured in a tamper-evident audit trail.
## What We'd Tell Anyone Attempting This
1. **Sequence, never big-bang.** A pilot that teaches you something is worth more than a launch that impresses someone.
2. **Design for the worst network in the state, not the best.** If it breaks at 500ms latency, it will break in the field.
3. **Make isolation structural, not procedural.** If a query *can* cross jurisdictions, eventually it will. Remove the possibility at the schema and connection layer.
4. **Automate every deployment.** With 11+ environments, manual steps are how outages happen. We used Ansible so every cutover was reproducible.
5. **Treat training as engineering work.** Budget for it, measure it, and gate go-live on it.
## Building something similar?
We have shipped systems like this across multiple Indian states and institutions — police, universities, and government departments — under exactly these constraints. If you are scoping a multi-district government platform, a custom ERP, or any system where downtime is not an option, [get a free proposal](/contact) and we will share what we learned the expensive way so you do not have to.
+1
View File
@@ -49,6 +49,7 @@ const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- PWA Manifest -->
+132 -98
View File
@@ -80,6 +80,23 @@ const trustStats = [
{ value: '< 24h', label: 'Response Time' },
{ value: '3+', label: 'Years Experience' },
];
// Service options — rendered as large, tappable radio tiles (replacing the
// old native <select>) so the choice is thumb-friendly on mobile. `value`
// is the code submitted to /api/contact (unchanged from the previous
// <option> values); `label` is the display text.
const serviceOptions = [
{ value: 'erp-solutions', label: 'ERP / Enterprise Systems' },
{ value: 'government-systems', label: 'Government IT' },
{ value: 'web-development', label: 'Web Development' },
{ value: 'mobile-development', label: 'Mobile Development' },
{ value: 'ui-ux', label: 'UI/UX Design' },
{ value: 'consulting', label: 'IT Consulting / DevOps' },
{ value: 'other', label: 'Other / General Inquiry' },
];
// Budget options — also rendered as large tappable tiles.
const budgetOptions = ['< ₹1L', '₹1L ₹5L', '₹5L ₹15L', '₹15L+', 'Not sure yet'];
---
<BaseLayout
@@ -273,107 +290,84 @@ const trustStats = [
</div>
</div>
<!-- Phone + Subject row -->
<div class="grid sm:grid-cols-2 gap-5">
<!-- Phone Field -->
<div class="form-group">
<label for="phone" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-1.5">
Phone Number
<span class="text-secondary-400 font-normal">(optional)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none" aria-hidden="true">
<svg class="w-4 h-4 text-secondary-400 field-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<input
type="tel"
id="phone"
name="phone"
autocomplete="tel"
aria-describedby="phone-error"
class="form-input w-full pl-10 pr-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-sm bg-secondary-50 dark:bg-secondary-700 focus:bg-white dark:focus:bg-secondary-700"
placeholder="+1 (555) 123-4567"
/>
<div class="valid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Valid">
<svg class="w-4 h-4 text-green-700 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="invalid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Invalid">
<svg class="w-4 h-4 text-red-800 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<!-- Phone Field (full width) -->
<div class="form-group">
<label for="phone" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-1.5">
Phone Number
<span class="text-secondary-400 font-normal">(optional)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none" aria-hidden="true">
<svg class="w-4 h-4 text-secondary-400 field-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="phone-error" data-error="phone" role="alert">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
Please enter a valid phone number.
</p>
</div>
<!-- Subject Field -->
<div class="form-group">
<label for="subject" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-1.5">
Service Needed <span class="text-red-500" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none" aria-hidden="true">
<svg class="w-4 h-4 text-secondary-400 field-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<select
id="subject"
name="subject"
required
aria-required="true"
aria-describedby="subject-error"
class="form-input w-full pl-10 pr-8 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 text-secondary-900 dark:text-secondary-100 text-sm bg-secondary-50 dark:bg-secondary-700 focus:bg-white dark:focus:bg-secondary-700 appearance-none cursor-pointer"
>
<option value="">Select a service...</option>
<option value="erp-solutions">ERP / Enterprise Systems</option>
<option value="government-systems">Government IT</option>
<option value="web-development">Web Development</option>
<option value="mobile-development">Mobile Development</option>
<option value="ui-ux">UI/UX Design</option>
<option value="consulting">IT Consulting / DevOps</option>
<option value="other">Other / General Inquiry</option>
</select>
<div class="valid-check absolute inset-y-0 right-9 flex items-center hidden" aria-label="Valid">
<svg class="w-4 h-4 text-green-700 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="invalid-check absolute inset-y-0 right-9 flex items-center hidden" aria-label="Invalid">
<svg class="w-4 h-4 text-red-800 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none" aria-hidden="true">
<svg class="w-4 h-4 text-secondary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<input
type="tel"
id="phone"
name="phone"
autocomplete="tel"
aria-describedby="phone-error"
class="form-input w-full pl-10 pr-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-sm bg-secondary-50 dark:bg-secondary-700 focus:bg-white dark:focus:bg-secondary-700"
placeholder="+1 (555) 123-4567"
/>
<div class="valid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Valid">
<svg class="w-4 h-4 text-green-700 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="invalid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Invalid">
<svg class="w-4 h-4 text-red-800 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="subject-error" data-error="subject" role="alert">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
Please select a service.
</p>
</div>
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="phone-error" data-error="phone" role="alert">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
Please enter a valid phone number.
</p>
</div>
<!-- Budget range (new field) -->
<!-- Service Needed — large tappable radio tiles (replaces the
old native <select>). Renders 2 columns on mobile (each tile
a comfortable thumb target) and 3 from sm up. The hidden
radios share name="subject" so the form submission and
analytics read the same value the <select> used to provide. -->
<div class="form-group">
<span id="subject-label" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
Service Needed <span class="text-red-500" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</span>
<div
id="subject-group"
role="radiogroup"
aria-labelledby="subject-label"
aria-describedby="subject-error"
class="grid grid-cols-2 sm:grid-cols-3 gap-2.5"
>
{serviceOptions.map((opt) => (
<label class="subject-option group flex items-center justify-center text-center min-h-[52px] px-3 py-2.5 rounded-xl border border-secondary-200 dark:border-secondary-600 text-sm font-medium text-secondary-700 dark:text-secondary-300 cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-all duration-150 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-primary-700 dark:has-[:checked]:text-primary-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/40">
<input type="radio" name="subject" value={opt.value} class="sr-only" aria-describedby="subject-error" />
<span class="leading-snug">{opt.label}</span>
</label>
))}
</div>
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="subject-error" data-error="subject" role="alert">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
Please select a service.
</p>
</div>
<!-- Project Budget — large tappable radio tiles. 2 cols on mobile
so each is a full thumb target; auto-fit row from sm up. -->
<div class="form-group">
<label class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
Project Budget
<span class="text-secondary-400 dark:text-secondary-500 font-normal">(optional)</span>
</label>
<div class="flex flex-wrap gap-2" role="group" aria-label="Budget range options">
{['< ₹1L', '₹1L ₹5L', '₹5L ₹15L', '₹15L+', 'Not sure yet'].map((range, i) => (
<label class="budget-option flex items-center gap-1.5 px-3 py-2 rounded-lg border border-secondary-200 dark:border-secondary-600 text-sm text-secondary-600 dark:text-secondary-400 cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-all duration-150 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-primary-700 dark:has-[:checked]:text-primary-300">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5" role="group" aria-label="Budget range options">
{budgetOptions.map((range) => (
<label class="budget-option flex items-center justify-center text-center min-h-[48px] px-3 py-2.5 rounded-xl border border-secondary-200 dark:border-secondary-600 text-sm font-medium text-secondary-600 dark:text-secondary-400 cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-all duration-150 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-primary-700 dark:has-[:checked]:text-primary-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/40">
<input type="radio" name="budget" value={range} class="sr-only" />
{range}
</label>
@@ -806,6 +800,13 @@ const trustStats = [
color: #0e7490;
}
/* Service tiles: invalid-group highlight (shown only after a failed submit
attempt with no service chosen). Subtle red ring around the whole group. */
.subject-group-invalid {
border-radius: 0.75rem;
box-shadow: 0 0 0 2px rgb(220 38 38 / 0.35);
}
/* FAQ item */
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::marker { display: none; }
@@ -909,7 +910,7 @@ const trustStats = [
});
}
// ── Budget toggle (radio-as-chip) ──────────────────────────
// ── Budget toggle (radio-as-tile) ──────────────────────────
const budgetOptions = document.querySelectorAll('.budget-option');
budgetOptions.forEach((opt) => {
const radio = opt.querySelector('input[type="radio"]') as HTMLInputElement;
@@ -921,6 +922,31 @@ const trustStats = [
}
});
// ── Service tiles (radio group replacing the old native <select>) ──
// The hidden radios share name="subject" so `form.elements.subject` resolves
// to a RadioNodeList whose `.value` is the checked option — exactly what the
// submit handler and analytics previously read from the <select>.
const subjectErrorEl = document.querySelector('[data-error="subject"]');
const subjectGroupEl = document.getElementById('subject-group');
const subjectRadios = Array.from(
document.querySelectorAll<HTMLInputElement>('input[name="subject"]')
);
function getSubjectValue(): string {
return subjectRadios.find((r) => r.checked)?.value ?? '';
}
function validateSubject(touched = true): boolean {
const valid = getSubjectValue() !== '';
if (subjectErrorEl) subjectErrorEl.classList.toggle('hidden', valid || !touched);
subjectGroupEl?.classList.toggle('subject-group-invalid', !valid && touched);
return valid;
}
subjectRadios.forEach((radio) => {
radio.addEventListener('change', () => validateSubject());
});
// ── Form Validation ────────────────────────────────────────
const form = document.getElementById('contact-form') as HTMLFormElement;
const successState = document.getElementById('success-state');
@@ -1012,18 +1038,25 @@ const trustStats = [
return;
}
// Validate all fields
// Validate all fields. `subject` is now a radio group, so it is checked
// separately from the text/textarea fields handled by validateField.
let isValid = true;
['name', 'email', 'phone', 'subject', 'message'].forEach((fieldName) => {
['name', 'email', 'phone', 'message'].forEach((fieldName) => {
const field = form.elements.namedItem(fieldName) as HTMLInputElement;
if (field && !validateField(field)) isValid = false;
});
if (!validateSubject()) isValid = false;
if (!isValid) {
showToast('Please fix the highlighted fields before submitting.', 'error');
// Focus first invalid field
// Focus the first invalid text field, or the service tiles if that's
// the only thing missing.
const firstInvalid = form.querySelector('.is-invalid') as HTMLElement | null;
firstInvalid?.focus();
if (firstInvalid) {
firstInvalid.focus();
} else if (getSubjectValue() === '') {
subjectRadios[0]?.focus();
}
return;
}
@@ -1057,7 +1090,7 @@ const trustStats = [
name: (form.elements.namedItem('name') as HTMLInputElement)?.value.trim(),
email: (form.elements.namedItem('email') as HTMLInputElement)?.value.trim(),
phone: (form.elements.namedItem('phone') as HTMLInputElement)?.value.trim(),
subject: (form.elements.namedItem('subject') as HTMLSelectElement)?.value,
subject: getSubjectValue(),
message: (form.elements.namedItem('message') as HTMLTextAreaElement)?.value.trim(),
website: honeypot,
}),
@@ -1087,7 +1120,7 @@ const trustStats = [
}
// Track analytics
const subject = (form.elements.namedItem('subject') as HTMLSelectElement)?.value;
const subject = getSubjectValue();
trackFormSubmit('contact', success);
if (success) trackEvent('contact_form_submit', { subject_category: subject });
@@ -1122,6 +1155,7 @@ const trustStats = [
form.querySelectorAll('[data-error]').forEach((el) => el.classList.add('hidden'));
if (charCount) charCount.textContent = '0 / 2000';
budgetOptions.forEach((o) => o.classList.remove('selected'));
subjectGroupEl?.classList.remove('subject-group-invalid');
});
}
}
+46
View File
@@ -400,6 +400,52 @@ const techStack = [
</div>
</section>
<!-- Mid-page Conversion CTA — sits directly after the Services section so
mobile visitors hit the funnel early instead of having to scroll past
How-It-Works, testimonials, the tech stack and the FAQ to reach the
inline lead form. Primary action jumps straight to the on-page inline
lead form (#inline-lead-form, smooth-scrolled by the anchor handler at
the bottom of this page); secondary action is a one-tap phone call. -->
<section
aria-labelledby="midpage-cta-heading"
class="bg-gradient-to-r from-primary-600 to-primary-700 dark:from-primary-700 dark:to-primary-800"
>
<div class="container-wrapper py-10 sm:py-12">
<div class="flex flex-col items-center gap-5 text-center md:flex-row md:justify-between md:text-left md:gap-8 max-w-5xl mx-auto">
<div class="md:flex-1">
<h2 id="midpage-cta-heading" class="text-2xl sm:text-3xl font-bold text-white leading-tight">
Have a project in mind? Get a free proposal in 24 hours.
</h2>
<p class="mt-2 text-primary-100 text-sm sm:text-base">
No obligation · Free 30-min discovery call · NDA on request.
</p>
</div>
<div class="flex w-full flex-col sm:flex-row md:w-auto md:flex-shrink-0 gap-3">
<a
href="#inline-lead-form"
data-cta="midpage-primary"
class="inline-flex items-center justify-center rounded-xl bg-white px-7 py-4 text-base font-bold text-primary-700 shadow-lg transition-all hover:bg-primary-50 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-700"
>
Get my free proposal
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a
href="tel:+919561417403"
data-cta="midpage-call"
class="inline-flex items-center justify-center rounded-xl border-2 border-white/40 bg-white/5 px-7 py-4 text-base font-bold text-white transition-all hover:bg-white/15 hover:border-white/60 focus:outline-none focus:ring-2 focus:ring-white/60 focus:ring-offset-2 focus:ring-offset-primary-700"
>
<svg class="mr-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Call now
</a>
</div>
</div>
</div>
</section>
<!-- How It Works Section - NEW -->
<section class="py-24 bg-gradient-to-br from-secondary-900 via-secondary-800 to-primary-900 relative overflow-hidden">
<!-- Background pattern -->
+29 -2
View File
@@ -348,8 +348,11 @@ const featuredProjects = projects.filter(p => p.featured);
</div>
</div>
<!-- Category Filter Tabs -->
<div class="flex flex-wrap justify-center gap-3" role="tablist" aria-label="Filter projects by category">
<!-- Category Filter Tabs
Mobile (<sm): a single horizontally-scrollable row so the five
chips never wrap into a tall stack that pushes the project grid
below the fold. ≥sm: centered wrapping row as before. -->
<div class="filter-scroll flex flex-nowrap gap-3 overflow-x-auto pb-1 snap-x sm:flex-wrap sm:justify-center sm:overflow-visible sm:pb-0" role="tablist" aria-label="Filter projects by category">
<button
data-filter="all"
class="filter-btn active rounded-full bg-primary px-6 py-2.5 font-semibold text-white transition-all duration-300 hover:shadow-lg hover:shadow-primary/25 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
@@ -1190,4 +1193,28 @@ const featuredProjects = projects.filter(p => p.featured);
display: flex;
flex-wrap: wrap;
}
/* Horizontally-scrollable category filter row on mobile.
Chips keep their intrinsic width (no shrinking) and snap as the user
swipes; the scrollbar is hidden for a clean native-app feel. At >=sm the
row wraps normally so the scroll affordance is mobile-only. */
.filter-scroll {
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
}
.filter-scroll > .filter-btn {
flex: 0 0 auto;
white-space: nowrap;
scroll-snap-align: start;
}
/* Hide the scrollbar (still scrollable) only while in the mobile scroll mode */
@media (max-width: 639px) {
.filter-scroll {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge legacy */
}
.filter-scroll::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
}
</style>