diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..280854e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+# Ignore build outputs
+.gradle
+/local.properties
+/.idea
+/build
+**/build
+*.iml
+/.cxx
diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..97c041c
--- /dev/null
+++ b/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,17 @@
+Title: Scaffold: LoadAtom Neutron Editor (proot rootfs downloader)
+
+Summary: Adds an Android Studio scaffold for LoadAtom Neutron Editor. The app downloads a minimal Alpine rootfs and a proot binary on first run, then launches a shell in a proot container. Terminal-only UI for v0.1.
+
+Files added: Android project (app/), MainActivity, RootfsInstaller (download/extract), README, .gitignore.
+
+Important: The scaffold uses placeholder URLs for the rootfs zip and proot binaries in app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt. You must host valid artifacts and update those constants before the app can run.
+
+TODOs:
+- Host rootfs/proot binaries and replace placeholder URLs.
+- Add checksum verification for downloads.
+- Integrate a proper pty-based terminal emulator for full interactive experience.
+- Add multi-ABI proot binaries and fallback selection.
+
+Testing: Build in Android Studio, run on arm64/armv7 device/emulator, allow first-run download.
+
+License: MIT
diff --git a/README.md b/README.md
index be9b00c..3375705 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,29 @@
-# .github
+# LoadAtom Neutron Editor — scaffold
-These are the [default community health files](https://help.github.com/en/articles/creating-a-default-community-health-file-for-your-organization) for the MicrosoftDocs organization on GitHub.
+This scaffold creates an Android app that downloads a Linux rootfs and a proot binary on first run and launches a shell inside it.
-# Contributing
+Important notes (you must complete these before the app will work):
-Microsoft projects adopt the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
+1. Provide a rootfs zip and proot binaries
+ - The scaffold references placeholder URLs in app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt:
+ - ROOTFS_ZIP_URL
+ - PROOT_URL_ARM64
+ - PROOT_URL_ARM7
+ - Host a zip file containing a minimal rootfs (for example, Alpine minirootfs packed as a zip). The zip should contain the filesystem root entries (bin, lib, etc.).
+ - Provide statically-linked proot binaries for the target ABIs and point PROOT_URL_* at them.
+
+2. APK size and bandwidth
+ - By choosing "download on first run" the APK stays small. The first run will download the rootfs and proot binaries (~tens of MB).
+
+3. Terminal UI
+ - The scaffold implements a very simple terminal UI (TextView + EditText). For a better terminal experience, integrate an Android terminal emulator view (eg. Jackpal's Android-Terminal-Emulator) or Termux's terminal view.
+
+4. Build
+ - Open the project in Android Studio (recommended) and build. Ensure your SDK/NDK versions match the Gradle plugin.
+
+5. Improvements / TODOs
+ - Add checksum verification for downloaded artifacts.
+ - Support multiple ABI proot downloads and extraction.
+ - Use a proper pty-based terminal for interactive programs (vim, nano, ssh).
+
+License: MIT
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..76d735a
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,32 @@
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion 33
+ defaultConfig {
+ applicationId "com.nehtechnine.loadatom"
+ minSdkVersion 24
+ targetSdkVersion 33
+ versionCode 1
+ versionName "0.1"
+
+ ndk {
+ abiFilters "arm64-v8a", "armeabi-v7a"
+ }
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+}
+
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'androidx.core:core-ktx:1.9.0'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..cf4b3e9
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt b/app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt
new file mode 100644
index 0000000..ff07b00
--- /dev/null
+++ b/app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt
@@ -0,0 +1,115 @@
+package com.nehtechnine.loadatom
+
+import android.os.Bundle
+import android.widget.Button
+import android.widget.EditText
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import java.io.*
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var output: TextView
+ private lateinit var input: EditText
+ private lateinit var sendBtn: Button
+ private lateinit var scroll: ScrollView
+
+ private var process: Process? = null
+ private var processWriter: BufferedWriter? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ output = findViewById(R.id.output)
+ input = findViewById(R.id.input)
+ sendBtn = findViewById(R.id.sendBtn)
+ scroll = findViewById(R.id.scroll)
+
+ appendOutput("Preparing rootfs and proot (first run may download ~30-100MB)...\n")
+
+ GlobalScope.launch(Dispatchers.IO) {
+ try {
+ RootfsInstaller.ensureRootfs(applicationContext)
+ appendOutput("Rootfs ready. Launching shell...\n")
+ launchProotShell()
+ } catch (e: Exception) {
+ appendOutput("Error during setup: ${'$'}{e.message}\n")
+ }
+ }
+
+ sendBtn.setOnClickListener {
+ val text = input.text.toString()
+ input.setText("")
+ sendToProcess(text + "\n")
+ }
+ }
+
+ private fun appendOutput(s: String) {
+ runOnUiThread {
+ output.append(s)
+ scroll.post { scroll.fullScroll(ScrollView.FOCUS_DOWN) }
+ }
+ }
+
+ private fun launchProotShell() {
+ val filesDir = filesDir
+ val proot = File(filesDir, "proot")
+ val rootfs = File(filesDir, "rootfs")
+ if (!proot.exists() || !rootfs.exists()) {
+ appendOutput("Required files missing.\n")
+ return
+ }
+ proot.setExecutable(true)
+
+ val pb = ProcessBuilder(proot.absolutePath, "-S", rootfs.absolutePath, "/bin/sh")
+ pb.redirectErrorStream(true)
+ process = pb.start()
+
+ processWriter = BufferedWriter(OutputStreamWriter(process!!.outputStream))
+
+ // Read output
+ Thread {
+ val reader = BufferedReader(InputStreamReader(process!!.inputStream))
+ var line: String?
+ try {
+ while (reader.readLine().also { line = it } != null) {
+ appendOutput(line + "\n")
+ }
+ } catch (e: IOException) {
+ appendOutput("Process output read error: ${'$'}{e.message}\n")
+ }
+ }.start()
+
+ // Wait for process
+ Thread {
+ try {
+ val rc = process!!.waitFor()
+ appendOutput("Shell exited (rc=${'$'}rc)\n")
+ } catch (e: InterruptedException) {
+ appendOutput("Process was interrupted\n")
+ }
+ }.start()
+ }
+
+ private fun sendToProcess(s: String) {
+ GlobalScope.launch(Dispatchers.IO) {
+ try {
+ processWriter?.apply {
+ write(s)
+ flush()
+ } ?: run { appendOutput("Shell not ready.\n") }
+ } catch (e: IOException) {
+ appendOutput("Failed to send input: ${'$'}{e.message}\n")
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ process?.destroy()
+ }
+}
diff --git a/app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt b/app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt
new file mode 100644
index 0000000..093a568
--- /dev/null
+++ b/app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt
@@ -0,0 +1,88 @@
+package com.nehtechnine.loadatom
+
+import android.content.Context
+import android.util.Log
+import java.io.*
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+object RootfsInstaller {
+ private const val TAG = "RootfsInstaller"
+ // IMPORTANT: Replace these URLs with a hosted rootfs zip and a proot binary for each ABI.
+ // The scaffold uses placeholders. You'll need to host or point to real files.
+ private const val ROOTFS_ZIP_URL = "https://example.com/alpine-rootfs-arm64.zip"
+ private const val PROOT_URL_ARM64 = "https://example.com/proot-arm64"
+ private const val PROOT_URL_ARM7 = "https://example.com/proot-arm7"
+
+ fun ensureRootfs(ctx: Context) {
+ val filesDir = ctx.filesDir
+ val rootfsDir = File(filesDir, "rootfs")
+ val prootFile = File(filesDir, "proot")
+
+ if (!rootfsDir.exists()) {
+ // Download and extract rootfs zip
+ val zipFile = File(filesDir, "rootfs.zip")
+ downloadFile(ROOTFS_ZIP_URL, zipFile)
+ unzip(zipFile, rootfsDir)
+ zipFile.delete()
+ }
+
+ if (!prootFile.exists()) {
+ val abi = android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a"
+ val url = when {
+ abi.contains("arm64") -> PROOT_URL_ARM64
+ abi.contains("arm") -> PROOT_URL_ARM7
+ else -> PROOT_URL_ARM64
+ }
+ downloadFile(url, prootFile)
+ prootFile.setExecutable(true)
+ }
+ }
+
+ private fun downloadFile(urlStr: String, outFile: File) {
+ Log.i(TAG, "Downloading $urlStr -> ${outFile.absolutePath}")
+ val url = URL(urlStr)
+ val conn = url.openConnection() as HttpURLConnection
+ conn.connectTimeout = 15000
+ conn.readTimeout = 15000
+ conn.requestMethod = "GET"
+ conn.doInput = true
+ conn.connect()
+ if (conn.responseCode != HttpURLConnection.HTTP_OK) {
+ throw IOException("Server returned HTTP ${conn.responseCode} ${conn.responseMessage}")
+ }
+ val input = conn.inputStream
+ val output = FileOutputStream(outFile)
+ input.copyTo(output)
+ output.flush()
+ output.close()
+ input.close()
+ conn.disconnect()
+ }
+
+ private fun unzip(zipFile: File, targetDir: File) {
+ val buffer = ByteArray(4096)
+ ZipInputStream(FileInputStream(zipFile)).use { zis ->
+ var ze: ZipEntry? = zis.nextEntry
+ while (ze != null) {
+ val fileName = ze.name
+ val newFile = File(targetDir, fileName)
+ if (ze.isDirectory) {
+ newFile.mkdirs()
+ } else {
+ newFile.parentFile?.mkdirs()
+ FileOutputStream(newFile).use { fos ->
+ var len: Int
+ while (zis.read(buffer).also { len = it } > 0) {
+ fos.write(buffer, 0, len)
+ }
+ }
+ }
+ ze = zis.nextEntry
+ }
+ zis.closeEntry()
+ }
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..61c1fd4
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..5035bc7
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,16 @@
+// Top-level build.gradle (Groovy)
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:7.4.2'
+ }
+}
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..45dde8e
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,2 @@
+include ':app'
+rootProject.name = 'LoadAtomNeutronEditor'
diff --git a/weather-dashboard/.gitignore b/weather-dashboard/.gitignore
new file mode 100644
index 0000000..a167bc6
--- /dev/null
+++ b/weather-dashboard/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.env
+.DS_Store
diff --git a/weather-dashboard/README.md b/weather-dashboard/README.md
new file mode 100644
index 0000000..78dd5e6
--- /dev/null
+++ b/weather-dashboard/README.md
@@ -0,0 +1,26 @@
+# Weather Dashboard (React + Vite)
+
+This project is a small weather dashboard that uses OpenWeatherMap.
+
+Quick start (frontend-only)
+1. Copy or clone this folder.
+2. Create weather-dashboard/.env with:
+ VITE_OPENWEATHER_API_KEY=your_openweather_api_key
+3. cd weather-dashboard
+4. npm install
+5. npm run dev
+6. Open http://localhost:5173
+
+Recommended: run the included proxy to hide the API key on the server.
+Server quick start:
+1. create weather-dashboard/server/.env with:
+ OPENWEATHER_API_KEY=your_openweather_api_key
+2. from weather-dashboard run: node server/index.js
+3. set in weather-dashboard/.env: VITE_API_BASE=/api
+4. run frontend (npm run dev) and the app will call /api/* which the proxy serves.
+
+Notes:
+- The project uses OpenWeatherMap free APIs (current weather + 5-day forecast).
+- Consider adding caching on the server to avoid rate limits and reduce latency.
+
+License: MIT
diff --git a/weather-dashboard/index.html b/weather-dashboard/index.html
new file mode 100644
index 0000000..cda5c42
--- /dev/null
+++ b/weather-dashboard/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Weather Dashboard
+
+
+
+
+
+
diff --git a/weather-dashboard/package.json b/weather-dashboard/package.json
new file mode 100644
index 0000000..d4f5ccf
--- /dev/null
+++ b/weather-dashboard/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "weather-dashboard",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "server": "node server/index.js"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "express": "^4.18.2",
+ "dotenv": "^16.0.0"
+ },
+ "devDependencies": {
+ "vite": "^4.0.0",
+ "@vitejs/plugin-react": "^3.0.0"
+ }
+}
diff --git a/weather-dashboard/server/index.js b/weather-dashboard/server/index.js
new file mode 100644
index 0000000..e0a64bc
--- /dev/null
+++ b/weather-dashboard/server/index.js
@@ -0,0 +1,44 @@
+// Simple proxy to hide OpenWeatherMap key (uses global fetch on Node 18+)
+import express from 'express'
+import dotenv from 'dotenv'
+
+dotenv.config()
+const app = express()
+const PORT = process.env.PORT || 3000
+const KEY = process.env.OPENWEATHER_API_KEY
+if (!KEY) {
+ console.error('Set OPENWEATHER_API_KEY in server .env')
+ process.exit(1)
+}
+
+app.get('/api/weather', async (req, res) => {
+ const { city, lat, lon } = req.query
+ let target
+ if (city) {
+ target = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=metric&appid=${KEY}`
+ } else if (lat && lon) {
+ target = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${KEY}`
+ } else {
+ return res.status(400).json({ error: 'Provide city or lat+lon' })
+ }
+ const r = await fetch(target)
+ const body = await r.text()
+ res.status(r.status).send(body)
+})
+
+app.get('/api/forecast', async (req, res) => {
+ const { city, lat, lon } = req.query
+ let target
+ if (city) {
+ target = `https://api.openweathermap.org/data/2.5/forecast?q=${encodeURIComponent(city)}&units=metric&appid=${KEY}`
+ } else if (lat && lon) {
+ target = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${KEY}`
+ } else {
+ return res.status(400).json({ error: 'Provide city or lat+lon' })
+ }
+ const r = await fetch(target)
+ const body = await r.text()
+ res.status(r.status).send(body)
+})
+
+app.listen(PORT, () => console.log(`Proxy server listening on http://localhost:${PORT}`))
diff --git a/weather-dashboard/src/App.jsx b/weather-dashboard/src/App.jsx
new file mode 100644
index 0000000..63dd165
--- /dev/null
+++ b/weather-dashboard/src/App.jsx
@@ -0,0 +1,132 @@
+import React, { useState } from 'react'
+
+const API_BASE = import.meta.env.VITE_API_BASE ?? 'https://api.openweathermap.org'
+const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY ?? ''
+
+function formatDate(ts) {
+ const d = new Date(ts * 1000)
+ return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
+}
+
+async function fetchCurrentByCity(city) {
+ const url = `${API_BASE}/data/2.5/weather?q=${encodeURIComponent(city)}&units=metric&appid=${API_KEY}`
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('City not found or API error')
+ return res.json()
+}
+
+async function fetchForecastByCity(city) {
+ const url = `${API_BASE}/data/2.5/forecast?q=${encodeURIComponent(city)}&units=metric&appid=${API_KEY}`
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('Forecast API error')
+ return res.json()
+}
+
+async function fetchCurrentByCoords(lat, lon) {
+ const url = `${API_BASE}/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${API_KEY}`
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('Location API error')
+ return res.json()
+}
+
+async function fetchForecastByCoords(lat, lon) {
+ const url = `${API_BASE}/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${API_KEY}`
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('Forecast API error')
+ return res.json()
+}
+
+function CurrentCard({ data }) {
+ if (!data) return null
+ const icon = data.weather?.[0]?.icon
+ return (
+
+
+
+
+ {data.name}{data.sys?.country ? `, ${data.sys.country}` : ''}
+
+
{data.weather?.[0]?.description}
+
+
+
{Math.round(data.main.temp)}°C
+
Feels: {Math.round(data.main.feels_like)}°C
+
Humidity: {data.main.humidity}%
+
Wind: {data.wind.speed} m/s
+
+ {icon &&

}
+
+
+ )
+}
+
+function ForecastList({ forecast }) {
+ if (!forecast || !forecast.list) return null
+ const days = []
+ for (let i = 0; i < forecast.list.length; i += 8) {
+ days.push(forecast.list[i])
+ }
+ return (
+
+ {days.slice(0, 5).map((item) => (
+
+
{formatDate(item.dt)}
+
{item.weather?.[0]?.main}
+
{Math.round(item.main.temp)}°C
+

+
+ ))}
+
+ )
+}
+
+export default function App() {
+ const [city, setCity] = useState('London')
+ const [current, setCurrent] = useState(null)
+ const [forecast, setForecast] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+
+ async function loadByCity(c) {
+ setError(''); setLoading(true)
+ try {
+ const cur = await fetchCurrentByCity(c)
+ const f = await fetchForecastByCity(c)
+ setCurrent(cur); setForecast(f)
+ } catch (e) {
+ setError(e.message)
+ setCurrent(null); setForecast(null)
+ } finally { setLoading(false) }
+ }
+
+ async function useGeo() {
+ if (!navigator.geolocation) { setError('Geolocation not supported'); return }
+ setError(''); setLoading(true)
+ navigator.geolocation.getCurrentPosition(async (pos) => {
+ try {
+ const { latitude, longitude } = pos.coords
+ const cur = await fetchCurrentByCoords(latitude, longitude)
+ const f = await fetchForecastByCoords(latitude, longitude)
+ setCurrent(cur); setForecast(f)
+ } catch (e) {
+ setError(e.message)
+ setCurrent(null); setForecast(null)
+ } finally { setLoading(false) }
+ }, (err) => { setError(err.message); setLoading(false) })
+ }
+
+ return (
+
+
Weather Dashboard
+
+ setCity(e.target.value)} placeholder="City name" />
+
+
+
+ {loading &&
Loading…
}
+ {error &&
Error: {error}
}
+
+
+
+ )
+}
diff --git a/weather-dashboard/src/main.jsx b/weather-dashboard/src/main.jsx
new file mode 100644
index 0000000..e8f6991
--- /dev/null
+++ b/weather-dashboard/src/main.jsx
@@ -0,0 +1,6 @@
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import App from './App'
+import './styles.css'
+
+createRoot(document.getElementById('root')).render()
diff --git a/weather-dashboard/src/styles.css b/weather-dashboard/src/styles.css
new file mode 100644
index 0000000..08215de
--- /dev/null
+++ b/weather-dashboard/src/styles.css
@@ -0,0 +1,16 @@
+:root { --accent: #2563eb; --bg: #f9fafb; --card: #ffffff; }
+body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; margin: 0; padding: 1.5rem; background: var(--bg); color: #111827; }
+.container { max-width: 900px; margin: 0 auto; }
+.controls { display:flex; gap:0.5rem; margin-bottom:1rem; }
+.controls input { flex:1; padding:0.6rem; border-radius:6px; border:1px solid #e5e7eb; }
+.controls button { padding:0.6rem 0.9rem; border-radius:6px; border:0; background:var(--accent); color:white; cursor:pointer; }
+.card { background:var(--card); padding:1rem; border-radius:10px; box-shadow:0 1px 4px rgba(2,6,23,0.06); margin-bottom:1rem; display:flex; align-items:center; }
+.row { display:flex; align-items:center; gap:1rem; width:100%; }
+.title { font-weight:700; font-size:1.2rem; }
+.desc { color:#374151; text-transform:capitalize; }
+.temp { margin-left:auto; text-align:right; }
+.big { font-size:1.8rem; font-weight:700; }
+.forecast { display:flex; gap:0.5rem; flex-wrap:wrap; }
+.forecast-item { background:white; padding:0.5rem 0.75rem; border-radius:8px; width:140px; text-align:center; box-shadow:0 1px 3px rgba(2,6,23,0.04); }
+.error { color:#b91c1c; margin-bottom:0.5rem; }
+.info { color:#374151; margin-bottom:0.5rem; }
diff --git a/weather-dashboard/vite.config.js b/weather-dashboard/vite.config.js
new file mode 100644
index 0000000..eca6e2f
--- /dev/null
+++ b/weather-dashboard/vite.config.js
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()]
+})