Personal portfolio of a Full Stack Developer (React, TypeScript, ASP.NET Core, EF Core, SQL Server, PostgreSQL): projects with detailed case studies, experience and skills.
Live site: https://arjun-prakash.is-a.dev
- Overall flow
- Tech stack
- Getting started
- Scripts
- UI themes
- Environment variables
- Project structure
- Branch naming
- Commit messages
- Git hooks
- Prettier
- ESLint
- TypeScript
- Tests
- Build
- SonarCloud
- CI
- Versioning and releases
- Deployment
- Repository settings and secrets
- Day-to-day workflow
- Troubleshooting
feature branch ──PR──▶ main ──▶ semantic-release ──▶ Vercel deploy
(local hooks) (CI + SonarCloud) (version, tag, (arjun-prakash.is-a.dev)
CHANGELOG, release)
- Work on a
<type>/<description>branch. Git hooks lint, type-check and test locally. - Open a pull request to
main. CI and SonarCloud must pass. - Merge. The release workflow re-verifies everything, then semantic-release bumps the version, tags
vX.Y.Zand publishes a GitHub release. - Vercel deploys
main. The new version shows in the navbar.
main only accepts changes through pull requests.
| Area | Tools |
|---|---|
| App | React 19, TypeScript 6, React Router 7 |
| Build | Vite 8 (Rolldown), Tailwind CSS 3, PostCSS |
| Motion | framer-motion (reduced-motion aware) |
| Analytics | Google Analytics 4 via react-ga4 (opt-in) |
| Tests | Vitest 5, Testing Library, jsdom, v8 coverage |
| Quality | ESLint 10, Prettier 3, SonarCloud |
| Workflow | Bun, Husky, lint-staged, commitlint, semantic-release, GitHub Actions |
| Hosting | Vercel |
Requires Bun (npm also works) and Node.js 22+.
git clone https://github.com/APK-Arjun-Developer/Portfolio.git
cd Portfolio
bun install # also installs the Husky git hooks ("prepare" script)
cp .env.example .env # optional: theme and Google Analytics settings
bun run dev # http://localhost:5173 (classic theme)
bun run dev:signal # same, with the Coral Signal theme| Script | What it does |
|---|---|
dev / dev:signal |
Dev server (theme from .env, or Signal) |
build |
tsc -b + production build using VITE_UI_THEME (what Vercel runs) |
build:classic / build:signal |
Production build of one specific theme |
preview |
Serve the last build locally |
og:generate |
Re-render the social preview images in public/ |
format / format:check |
Prettier: rewrite files / check only |
lint / lint:fix |
ESLint: report / auto-fix |
typecheck |
tsc -b (no emit) |
test / test:watch |
Vitest once / in watch mode |
test:coverage |
Vitest with v8 coverage (coverage/, including lcov.info for SonarCloud) |
ci |
Everything CI runs: format:check → lint → typecheck → test:coverage → build:classic → build:signal |
release |
semantic-release (CI only) |
prepare |
Installs the Husky hooks (runs automatically on install) |
Run bun run ci before opening a pull request. If it passes locally, CI will pass too.
The site ships with two complete designs. One is chosen at build time. Only that theme's code, CSS, fonts, favicon, theme-color and social image end up in the bundle.
| Theme | Look |
|---|---|
classic (default) |
Dark "glass" design with blue and violet accents |
signal |
"Coral Signal": light/dark/system modes, flow-diagram case studies, motion system |
Switching the live site: in Vercel → Settings → Environment Variables, set VITE_UI_THEME to signal or classic, then redeploy. Saving the variable alone changes nothing.
Locally: set VITE_UI_THEME in .env and restart the dev server, or use dev:signal / build:signal / build:classic, which pass Vite's --mode and ignore .env.
How it works:
vite.config.tsresolves the theme (--modefirst, thenVITE_UI_THEME, elseclassic). An unknown value falls back toclassicwith a warning.- The alias
@theme-apppoints atsrc/themes/<theme>/App.tsx, whichsrc/App.tsximports. - The
ui-theme-headplugin injects that theme's fonts,theme-color, favicon, social image tags and, for Signal, the light/dark bootstrap script. tailwind.config.jsscans only the active theme's files, so the CSS has no leftovers from the other theme.
The Light / System / Dark toggle in the Signal navbar is a per-visitor setting stored in their browser. It doesn't switch between themes.
| Variable | Default | Description |
|---|---|---|
VITE_UI_THEME |
classic |
classic or signal, read at build time |
VITE_GA_ENABLED |
false |
Set to exactly true to send GA4 events |
VITE_GA_MEASUREMENT_ID |
(none) | GA4 measurement ID (G-XXXXXXXXXX) |
- Locally: copy
.env.exampleto.env(git-ignored). - Production: Vercel environment variables, followed by a redeploy.
- Every
VITE_*value is embedded in the public JavaScript bundle. Never put secrets in them.
.github/workflows/ ci.yml, sonarcloud.yml, branch-name.yml, release.yml
.husky/ pre-commit, commit-msg, pre-push
public/ favicons, og-image*.png, resume.pdf
scripts/og-image.mjs renders the social preview cards (Playwright)
src/
App.tsx router, scroll reset, GA page views; renders @theme-app
main.tsx entry point
data/personal.ts ALL content: profile, projects, experience, education, skills
lib/ analytics.ts, version.ts, utils.ts (shared)
components/ shared components (BrandIcons)
themes/
classic/ App, pages, components, styles.css
signal/ App, pages, components, motion.ts, content.ts, theme.ts, styles.css
__tests__/ Vitest suites + setup.ts + helpers.tsx
To change content, edit src/data/personal.ts. Signal-only copy (headline, skill groups, flow diagrams) lives in src/themes/signal/content.ts.
<type>/<kebab-case-description>
| Rule | Value |
|---|---|
| Valid types | feat fix ci chore docs refactor test perf hotfix |
| Description | lowercase letters, digits and hyphens: [a-z0-9][a-z0-9-]* |
| Exempt | main |
| Enforced by | .husky/pre-push locally, and .github/workflows/branch-name.yml on every PR to main (catches --no-verify pushes and branches created on github.com) |
Examples: feat/contact-form, fix/sonar-classic-issues, docs/full-readme.
Conventional Commits, enforced by commitlint (.commitlintrc.json, extends @commitlint/config-conventional):
<type>(<optional scope>): <subject>
<optional body>
| Rule | Value |
|---|---|
| Types | feat fix ci chore docs refactor test perf hotfix |
| Subject | entirely lowercase |
| Header | at most 72 characters |
| Body lines | over 100 characters produce a warning only |
| Message | Result |
|---|---|
feat: add contact form |
✅ valid, minor release |
fix(classic): correct footer link |
✅ valid, patch release |
Added contact form |
❌ missing type |
fix: Resolve SonarCloud issues |
❌ subject has capitals |
The type also decides the next version. See Versioning and releases.
Husky installs these on bun install / npm install (via the prepare script). They live in .husky/.
| Hook | Runs on | What it does | Fails when |
|---|---|---|---|
| pre-commit | git commit |
lint-staged on staged files only (see below) |
ESLint errors in staged files |
| commit-msg | git commit |
commitlint | message breaks the rules above |
| pre-push | git push |
branch-name check, then lint → typecheck → test → build |
any step fails |
lint-staged configuration (in package.json):
| Staged files | Commands |
|---|---|
*.{ts,tsx} |
eslint, then prettier --write |
*.{js,json,css,html,md,yml,yaml} |
prettier --write |
The release job sets HUSKY=0, so the hooks don't run in CI.
Config: prettier.config.js. Ignored: .prettierignore (node_modules, dist, coverage, bun.lock, *.min.js, generated CHANGELOG.md).
| Option | Value |
|---|---|
semi |
true |
singleQuote |
true |
trailingComma |
'all' |
printWidth |
100 |
tabWidth |
2 |
arrowParens |
'always' |
endOfLine |
'lf' |
.gitattributes (* text=auto eol=lf) makes Git check files out with LF everywhere, so endOfLine: 'lf' passes on Windows too.
Run bun run format to fix everything, or bun run format:check to check (as CI does).
Config: eslint.config.js (flat config). Ignored: dist, coverage.
| Applied to | Rule sets |
|---|---|
**/*.{ts,tsx} |
@eslint/js recommended, typescript-eslint recommended, react-hooks recommended, react-refresh (Vite) |
src/__tests__/** |
react-refresh/only-export-components turned off (test helpers mix components and functions) |
Run bun run lint, or bun run lint:fix to auto-fix.
tsconfig.json holds only project references:
| Project | Covers | Key options |
|---|---|---|
tsconfig.app.json |
src/ (app + tests) |
strict, target: es2023, moduleResolution: bundler, jsx: react-jsx, noUnusedLocals, noUnusedParameters, erasableSyntaxOnly, verbatimModuleSyntax, noFallthroughCasesInSwitch |
tsconfig.node.json |
vite.config.ts, vitest.config.ts |
Node-side config files |
Global types for src/vite-env.d.ts:
__APP_VERSION__: injected at build timeImportMetaEnv:VITE_*variables- the
@theme-appmodule
bun run typecheck runs tsc -b. It runs in pre-push, CI, the SonarCloud job and the release job.
| Item | Details |
|---|---|
| Runner | Vitest 5 with jsdom (vitest.config.ts) |
| Libraries | @testing-library/react, user-event, jest-dom, jest-axe (accessibility) |
| Location | src/__tests__/**/*.test.{ts,tsx} |
| Setup | src/__tests__/setup.ts: stubs matchMedia, IntersectionObserver, clipboard and scrollTo; blocks jsdom navigation for external links |
| Helpers | src/__tests__/helpers.tsx: renderTheme(App, path), waitForPage(), currentPath() |
| Coverage | v8 provider; reporters text, json, html, lcov; main.tsx and vite-env.d.ts excluded |
| Thresholds | lines ≥ 80%, branches ≥ 80%. The run fails below either. |
| Theme alias | @theme-app resolves to the classic theme in tests; both themes are also tested directly |
| Suite | Covers |
|---|---|
data.test.ts |
years-of-experience maths, project ids, flow diagrams |
lib.test.ts |
analytics events on/off, cn, version label |
App.test.tsx |
root app, GA initialisation and page views |
classic/ClassicApp.test.tsx |
every classic route, navbar, mobile menu, project filter, links |
signal/SignalApp.test.tsx |
every Signal route, navigation, filter, case studies, contact footer |
signal/theme.test.tsx |
light/dark/system preference, OS changes, blocked storage |
a11y.test.tsx |
axe-core audit of every route in both themes (toHaveNoViolations); colour contrast is skipped in jsdom |
| Item | Details |
|---|---|
| Tool | Vite 8 (vite.config.ts) with @vitejs/plugin-react |
| Output | dist/, a static single-page app |
| Code splitting | each route page is lazy-loaded |
| Version | package.json version injected as __APP_VERSION__ and shown in the navbar chip |
| Theme | see UI themes |
| Social images | public/og-image.png (classic), public/og-image-signal.png (Signal), 1200×630, absolute URLs in meta tags |
Regenerate the social images after changing the name, role or headline:
bun run og:generate # uses your installed Chrome via Playwright| Item | Details |
|---|---|
| Config | sonar-project.properties, workflow .github/workflows/sonarcloud.yml |
| Project | APK-Arjun-Developer_Portfolio, organization apk-arjun-developer |
| Runs on | pushes to main and pull requests into main |
| Steps | install → typecheck → test:coverage → SonarCloud scan |
| Sources | src/ (tests and vite-env.d.ts excluded) |
| Coverage | coverage/lcov.info; main.tsx excluded from coverage |
| Duplication | src/data/** excluded from copy-paste detection: the project objects share the same shape |
| Gate | sonar.qualitygate.wait=true: the job fails, and blocks the PR, when the quality gate fails |
| Editor | .vscode/settings.json binds SonarLint (connected mode) to the project |
| Requirements | SONAR_TOKEN secret; Automatic Analysis turned off in SonarCloud (CI analysis only) |
.github/workflows/ci.yml runs on pushes and pull requests to main, using Bun.
| Job | Steps |
|---|---|
| Format, Lint, Typecheck & Test | format:check → lint → typecheck → test:coverage → bun audit (informational, never fails) |
| Build (classic) / Build (signal) | run after the first job, in parallel: build:classic and build:signal |
Both themes must build on every change, even though only one is deployed.
.github/workflows/branch-name.yml also runs on every pull request to main and fails when the source branch doesn't match the naming rule.
.github/workflows/release.yml + .releaserc.json (semantic-release).
Trigger: every push to main, which normally means merging a pull request.
Steps: format → lint → typecheck → test → build classic → build signal → semantic-release.
How the version is chosen from the commits since the last tag:
| Commits contain | Release | Example |
|---|---|---|
fix: |
patch | 1.0.2 → 1.0.3 |
feat: |
minor | 1.0.2 → 1.1.0 |
feat!: / fix!: or a BREAKING CHANGE: footer |
major | 1.0.2 → 2.0.0 |
only chore docs ci test refactor perf |
none | (none) |
What a release does:
- Writes the new version to
package.json(npm publishing disabled:npmPublish: false). - Updates
CHANGELOG.md. - Commits
chore(release): x.y.z [skip ci]tomain. - Creates and pushes the tag
vX.Y.Z. - Publishes a GitHub release with notes, and comments on the released PRs.
Secrets: GH_TOKEN, a personal access token of a ruleset bypass actor (falls back to GITHUB_TOKEN).
| Item | Details |
|---|---|
| Host | Vercel, connected to this repository |
| Trigger | every push to main, including the release commit |
| Command | bun run build |
| Routing | vercel.json rewrites all paths to index.html (client-side routing) |
| Theme | Vercel env var VITE_UI_THEME (unset means classic) |
| Domain | https://arjun-prakash.is-a.dev |
A merge produces two deployments: the merge commit, then the release commit. The second one carries the new version number.
| Where | Setting | Purpose |
|---|---|---|
GitHub ruleset on main |
changes must go through a pull request | protects main |
| Ruleset bypass list | Repository admin (always) | lets the release commit and tag through |
Actions secret GH_TOKEN |
personal access token (Contents, Issues, Pull requests: read & write) | release commit, tag, GitHub release |
Actions secret SONAR_TOKEN |
SonarCloud token | SonarCloud scan |
| SonarCloud project | Automatic Analysis: off | CI-based analysis only |
| Vercel environment variables | VITE_UI_THEME, VITE_GA_ENABLED, VITE_GA_MEASUREMENT_ID |
theme and analytics |
When GH_TOKEN expires, releases fail. Renew it and update the secret (it's shared with mui-schema-form-builder).
git switch main && git pull
git switch -c feat/my-change # valid branch name
# ...edit...
bun run ci # optional: everything CI will run
git add <files>
git commit -m "feat: describe the change" # lint-staged + commitlint
git push -u origin feat/my-change # branch check + lint + typecheck + test + build
# Open a PR to main → CI + SonarCloud must pass → merge
# → release workflow tags vX.Y.Z → Vercel deploys → navbar shows the new version| Problem | Cause / fix |
|---|---|
| Push rejected: "Branch name … is invalid" | Rename the branch: git branch -m <type>/<description> |
| Commit rejected by commitlint | Use type: lowercase subject within 72 characters |
format:check fails on every file (Windows) |
CRLF line endings. Run bun run format; .gitattributes keeps new checkouts in LF |
Dev server: "The bg-sg-bg class does not exist" |
Tailwind config cached from before a theme change. Restart the dev server |
Theme didn't change after editing .env / Vercel |
Restart the dev server locally, or redeploy on Vercel |
| SonarCloud: "CI analysis while Automatic Analysis is enabled" | Turn off Automatic Analysis in the SonarCloud project settings |
| Release: "GH013 … Changes must be made through a pull request" | Ruleset bypass missing, or GH_TOKEN missing or expired |
| Old favicon or link preview still showing | Browser and social caches: hard refresh (Ctrl+Shift+R), or LinkedIn Post Inspector |