Add dashboard UI foundation and map export workflow

This commit is contained in:
b0txec
2026-08-18 21:44:09 +03:00
parent 90631a7586
commit bb1d9fc78c
13 changed files with 90 additions and 87 deletions
+7 -7
View File
@@ -37,20 +37,20 @@ Status: in progress
## Phase 1 — Behavior discovery and bug inventory ## Phase 1 — Behavior discovery and bug inventory
Status: pending Status: in progress
- [ ] Walk through every page with synthetic data. - [ ] Walk through every page with synthetic data.
- [ ] Document the actual purpose and intended users of each workflow. - [ ] Document the actual purpose and intended users of each workflow.
- [ ] Separate analytical dashboard features from broadcast-graphic authoring tools. - [x] Separate analytical dashboard features from broadcast-graphic authoring tools on the city-results map.
- [ ] Record unclear controls, missing units, broken states, and layout problems. - [ ] Record unclear controls, missing units, broken states, and layout problems.
- [ ] Fix the `atmPressire` frontend field typo. - [ ] Fix the `atmPressire` frontend field typo.
- [ ] Fix shifted Database export columns caused by duplicated `tempMax`. - [ ] Fix shifted Database export columns caused by duplicated `tempMax`.
- [ ] Correct malformed integer route handling. - [ ] Correct malformed integer route handling.
- [ ] Add consistent loading, empty, and error states. - [ ] Add consistent loading, empty, and error states. (City selection empty state completed.)
## Phase 2 — Test safety net ## Phase 2 — Test safety net
Status: pending Status: in progress
- [ ] Add backend route tests for representative station and country queries. - [ ] Add backend route tests for representative station and country queries.
- [ ] Add database integration tests for aggregation and export behavior. - [ ] Add database integration tests for aggregation and export behavior.
@@ -114,10 +114,10 @@ Status: pending
- [ ] Separate historical analysis, live station monitoring, database inspection, HARMONIE visualization, and broadcast graphics. - [ ] Separate historical analysis, live station monitoring, database inspection, HARMONIE visualization, and broadcast graphics.
- [ ] Replace technical/internal labels with task-oriented language. - [ ] Replace technical/internal labels with task-oriented language.
- [ ] Replace the character-based weather-icon entry workflow with a visual picker or automatic mapping. - [ ] Replace the character-based weather-icon entry workflow with a visual picker or automatic mapping.
- [ ] Explain or automate manual wind and weather-icon inputs. - [x] Explain and visually separate manual wind and weather-icon inputs from queried data.
- [ ] Add units, legends, contextual help, and clear date semantics. - [ ] Add units, legends, contextual help, and clear date semantics.
- [ ] Establish a responsive layout, typography, spacing, and component system. - [x] Establish the first responsive layout, typography, spacing, and component-system foundation.
- [ ] Design explicit export/download workflows for broadcast assets. - [x] Add an explicit PNG download workflow for broadcast map assets.
- [ ] Test target resolutions and real workplace display conditions. - [ ] Test target resolutions and real workplace display conditions.
- [ ] Check keyboard navigation, contrast, focus states, and screen-reader labeling. - [ ] Check keyboard navigation, contrast, focus states, and screen-reader labeling.
+18 -19
View File
@@ -1,5 +1,5 @@
import { Component } from 'solid-js' import { Component } from 'solid-js'
import { Route, Router } from '@solidjs/router' import { A, Route, Router } from '@solidjs/router'
import styles from './css/menu.module.css'; import styles from './css/menu.module.css';
import { Country } from './pages/country/Country'; import { Country } from './pages/country/Country';
@@ -10,36 +10,35 @@ import { apiHost } from './consts';
import { WeatherIconStub } from './components/weatherIcons/WeatherIconStub'; import { WeatherIconStub } from './components/weatherIcons/WeatherIconStub';
import { Harmonie } from './pages/harmonie/Harmonie' import { Harmonie } from './pages/harmonie/Harmonie'
import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast' import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast'
import { Home } from './pages/home/Home'
console.log("env:", import.meta.env.MODE) console.log("env:", import.meta.env.MODE)
console.log("api host:", apiHost) console.log("api host:", apiHost)
const App: Component = () => { const App: Component = () => {
const pages = ['station', 'cities', 'latvia', 'database', 'harmonie', 'lvgmc-forecast'] const pages = [
['/station', 'Stations'], ['/cities', 'City analysis'], ['/latvia', 'Latvia overview'],
['/database', 'Data archive'], ['/harmonie', 'HARMONIE'], ['/lvgmc-forecast', 'LVĢMC forecast'],
]
return ( return (
<div> <Router>
<div class="grid-1-1"> <div class="appShell">
<div><WeatherIconStub /></div> <WeatherIconStub />
<div> <header class={styles.header}>
<ul class={styles.menu}> <A class={styles.brand} href="/"><span class={styles.brandMark}>W</span><span><strong>WeatherTool</strong><small>Weather data workspace</small></span></A>
{ pages.map(page => <nav aria-label="Main navigation"><ul class={styles.menu}>{pages.map(([path, label]) => <li><A href={path} activeClass={styles.active}>{label}</A></li>)}</ul></nav>
<li> </header>
<a href={page}>{page}</a> <main class="appContent">
</li> <Route path="/" component={Home} />
)}
</ul>
</div>
</div>
<Router>
<Route path="/" component={Harmonie} />
<Route path="/harmonie" component={Harmonie} /> <Route path="/harmonie" component={Harmonie} />
<Route path="/lvgmc-forecast" component={LvgmcForecast} /> <Route path="/lvgmc-forecast" component={LvgmcForecast} />
<Route path="/station" component={Station} /> <Route path="/station" component={Station} />
<Route path="/cities" component={Cities} /> <Route path="/cities" component={Cities} />
<Route path="/latvia" component={Country} /> <Route path="/latvia" component={Country} />
<Route path="/database" component={Database} /> <Route path="/database" component={Database} />
</Router> </main>
</div> </div>
</Router>
); );
}; };
+17 -7
View File
@@ -8,6 +8,7 @@ import { cityCoords } from "../cityCoords";
import { MapResolution, resolutionProps, windProps, WindProps } from "./mapConsts"; import { MapResolution, resolutionProps, windProps, WindProps } from "./mapConsts";
import { CityData, drawOnMap } from "./canvasDraw"; import { CityData, drawOnMap } from "./canvasDraw";
import { IconInputs } from "../weatherIcons/IconInputs"; import { IconInputs } from "../weatherIcons/IconInputs";
import { download } from '../../helpers/download'
export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[] }> = ({ type, data }) => { export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[] }> = ({ type, data }) => {
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>(); const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
@@ -75,13 +76,19 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
}); });
const getStyleSize = () => { const getStyleSize = () => {
return props.width === 1920 return { "width": "100%", "height": "auto" };
? { "width": "1000px", "height": "562px" } }
: { "width": "1000px", "height": "374px" };
function downloadPng() {
getCanvas()!.toBlob(blob => blob && download(blob, `weather-${type}.png`), 'image/png')
} }
return ( return (
<> <div class="mapWorkspace">
<div class="mapToolbar"><div><strong>Map preview</strong><span>{props.width} × {props.height} px export</span></div><button class="primary" onClick={downloadPng}>Download PNG</button></div>
<details class="broadcastControls">
<summary><span><strong>Broadcast overlay controls</strong><small>Optional manual wind and weather-symbol overrides</small></span></summary>
<p class="controlHelp">These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.</p>
<div class="grid-1-2"> <div class="grid-1-2">
<div> <div>
<div> <div>
@@ -98,12 +105,15 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
</div> </div>
<div><IconInputs weatherIconsSingal={weatherIconsSingal} /></div> <div><IconInputs weatherIconsSingal={weatherIconsSingal} /></div>
</div> </div>
</details>
<div class="canvasFrame">
<canvas <canvas
ref={setCanvas} ref={setCanvas}
width={props.width+"px"} width={props.width}
height={props.height+"px"} height={props.height}
style={getStyleSize()} style={getStyleSize()}
/> />
</> </div>
</div>
); );
} }
@@ -18,6 +18,7 @@ export const IconInputs: Component<{ weatherIconsSingal: Signal<{[key: string]:
return ( return (
<> <>
<div> <div>
<strong>Weather symbols</strong><p class="fieldHint">Click a symbol to copy it, then paste it into a city field below.</p>
{ weatherIcons.map(icon => { weatherIcons.map(icon =>
<span class="weatherIconButton" onClick={() => copyToClipboard(icon)}>{ icon }</span>) <span class="weatherIconButton" onClick={() => copyToClipboard(icon)}>{ icon }</span>)
} }
+1
View File
@@ -4,6 +4,7 @@
justify-content: space-between; justify-content: space-between;
padding: 10px 0; padding: 10px 0;
} }
.resultTitle>span:first-child{display:flex;flex-wrap:wrap;gap:7px}.resultTitle>span:first-child br{display:none}.resultTitle input{margin:0}.grid-view{margin-top:12px}.grid-view .item,.list-view .item{border:1px solid var(--border);border-radius:12px;background:var(--surface)!important;box-shadow:var(--shadow-sm)}
.resultTitle h3 { .resultTitle h3 {
margin: 0; margin: 0;
+2 -1
View File
@@ -11,4 +11,5 @@
.timeRangeTitle div { .timeRangeTitle div {
font-size: 11px; font-size: 11px;
} }
.pageWorkspace{max-width:1800px;margin:auto;padding:34px 28px 70px}.pageHeading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.pageHeading h1{margin:5px 0 6px;font-size:34px}.pageHeading p{margin:0;color:var(--text-muted)}.selectionCount{padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--surface);font-weight:700}.aggregator>.container{display:grid;grid-template-columns:240px 300px minmax(0,1fr);gap:18px;align-items:start}.panel{padding:20px;border:1px solid var(--border);border-radius:16px;background:var(--surface);box-shadow:var(--shadow-sm)}.panel h2{margin:0 0 18px;font-size:16px}.cityPanel{max-height:calc(100vh - 160px);overflow:auto}.cityPanel ul{margin:12px 0;padding:0;columns:1}.cityPanel li{padding:3px 0}.queryPanel h4{margin:22px 0 9px}.queryPanel p{margin:10px 0}.resultsPanel{min-width:0}.emptyState{display:flex;min-height:260px;align-items:center;justify-content:center;flex-direction:column;gap:8px;margin-top:18px;padding:30px;border:1px dashed #bac6d4;border-radius:16px;background:rgba(255,255,255,.55);color:var(--text-muted);text-align:center}.emptyState strong{color:var(--text);font-size:18px}.mapWorkspace{margin-top:14px}.mapToolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.mapToolbar strong,.mapToolbar span{display:block}.mapToolbar span{margin-top:3px;color:var(--text-muted);font-size:12px}.broadcastControls{margin-bottom:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.broadcastControls summary{padding:14px 16px;cursor:pointer}.broadcastControls summary span,.broadcastControls summary strong,.broadcastControls summary small{display:block}.broadcastControls summary small,.controlHelp,.fieldHint{color:var(--text-muted);font-size:12px}.broadcastControls[open] summary{border-bottom:1px solid var(--border)}.broadcastControls .grid-1-2{padding:16px}.controlHelp{margin:16px 16px 0}.canvasFrame{overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#d8dee6;box-shadow:var(--shadow-sm)}.canvasFrame canvas{display:block}.weatherIconButton{border-radius:7px}.fieldHint{margin:4px 0 10px}@media(max-width:1200px){.aggregator>.container{grid-template-columns:220px 1fr}.resultsPanel{grid-column:1/-1}.cityPanel{max-height:none}}@media(max-width:700px){.pageWorkspace{padding:24px 14px}.pageHeading{align-items:start;flex-direction:column}.aggregator>.container{grid-template-columns:1fr}.resultsPanel{grid-column:auto}}
+10 -9
View File
@@ -1,6 +1,9 @@
:root{--text:#182230;--text-muted:#667085;--surface:#fff;--surface-soft:#f8fafc;--surface-strong:#edf1f5;--border:#dfe5ec;--accent:#2477c5;--accent-strong:#185a98;--accent-soft:#91bfe8;--accent-pale:#e9f3fc;--success:#269368;--danger:#c84646;--shadow-sm:0 5px 16px rgba(16,24,40,.05);--shadow-md:0 16px 36px rgba(16,24,40,.10)}
*{box-sizing:border-box}
body { body {
margin: 0; margin: 0;
font-family: Arial, sans-serif; font-family: Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
color:var(--text);background:#f3f5f8;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -31,17 +34,14 @@ thead {
font-display: swap; font-display: swap;
} }
.primary { .primary,.secondary,button {
background-color: #ffcc66; min-height:38px;background-color:var(--surface-strong);border:1px solid transparent;border-radius:9px;padding:8px 14px;
border: none;
padding: 5px 10px;
cursor: pointer; cursor: pointer;
font-family: 'Rubik', sans-serif; font-family: 'Rubik', sans-serif;
} }
.primary:hover { .primary{background:var(--accent);color:#fff}.primary:hover{background:var(--accent-strong)}
background-color: #ffb92d; input[type=text],input[type=date],input[type=datetime-local],select{min-height:38px;padding:7px 10px;border:1px solid #cfd7e2;border-radius:8px;background:#fff}button:disabled,input:disabled{cursor:not-allowed;opacity:.48}.appContent{min-height:calc(100vh - 73px)}
}
table td { table td {
padding: 3px 7px; padding: 3px 7px;
@@ -81,4 +81,5 @@ table tr:hover {
display: grid; display: grid;
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2fr;
gap: 10px; gap: 10px;
} }
.weatherIconStub{position:fixed!important;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none}
+3 -28
View File
@@ -1,28 +1,3 @@
.menu { .header{position:sticky;top:0;z-index:100;display:flex;align-items:center;justify-content:space-between;gap:28px;min-height:72px;padding:0 30px;border-bottom:1px solid var(--border);background:rgba(249,250,252,.92);backdrop-filter:blur(16px)}
padding: 8px 20px; .brand{display:flex;align-items:center;gap:11px;color:var(--text);text-decoration:none}.brandMark{display:grid;place-items:center;width:38px;height:38px;border-radius:11px;background:var(--accent);color:#fff;font-family:Rubik,sans-serif;font-size:19px}.brand strong,.brand small{display:block}.brand small{margin-top:2px;color:var(--text-muted);font-size:11px}.menu{display:flex;align-items:center;gap:4px;margin:0;padding:0;list-style:none}.menu li{margin:0;padding:0}.menu a{display:block;padding:10px 12px;border-radius:9px;color:var(--text-muted);font-size:13px;font-weight:700;text-decoration:none}.menu a:hover{background:var(--surface-strong);color:var(--text)}.menu a.active{background:var(--accent-pale);color:var(--accent-strong)}
text-align: right; @media(max-width:980px){.header{align-items:flex-start;flex-direction:column;padding:14px 18px}.menu{max-width:100%;overflow-x:auto}.menu a{white-space:nowrap}}
}
.menu li{
display: inline;
padding: 10px 0 10px 10px;
cursor: pointer;
}
.menu li:not(:first-child)::before {
content: '•';
margin-right: 1ch;
}
.menu li a {
color: black;
text-decoration: none;
}
.menu li a:hover {
text-decoration: underline;
}
.active {
font-weight: bold;
}
+11 -8
View File
@@ -21,12 +21,15 @@ export function Cities() {
const [getGranularity, setGranularity] = createSignal("hour"); const [getGranularity, setGranularity] = createSignal("hour");
return ( return (
<div class="aggregator"> <div class="aggregator pageWorkspace">
<div class="pageHeading"><div><span class="eyebrow">Historical observations</span><h1>City analysis</h1><p>Choose stations and a calculation, then compare results or prepare a map graphic.</p></div><div class="selectionCount">{getCities().size} cities selected</div></div>
<div class="container"> <div class="container">
<div class="column"> <aside class="column panel cityPanel">
<h2>1. Choose cities</h2>
<SelectCity getCities={getCities} setCities={setCities} /> <SelectCity getCities={getCities} setCities={setCities} />
</div> </aside>
<div class="column"> <aside class="column panel queryPanel">
<h2>2. Configure query</h2>
<div> <div>
<SelectTimeRange <SelectTimeRange
getStart={getStart} getStart={getStart}
@@ -43,8 +46,8 @@ export function Cities() {
<SelectKey getKey={getKey} setKey={setKey} /> <SelectKey getKey={getKey} setKey={setKey} />
<SelectGranularity getGranularity={getGranularity} setGranularity={setGranularity} /> <SelectGranularity getGranularity={getGranularity} setGranularity={setGranularity} />
</div> </div>
</div> </aside>
<div class="column"> <section class="column resultsPanel">
<QueryView <QueryView
getCities={getCities} getCities={getCities}
getStart={getStart} getStart={getStart}
@@ -53,8 +56,8 @@ export function Cities() {
getKey={getKey} getKey={getKey}
getGranularity={getGranularity} getGranularity={getGranularity}
/> />
</div> </section>
</div> </div>
</div> </div>
); );
} }
+4 -5
View File
@@ -21,7 +21,7 @@ export const QueryView: Component<{
const [getTimestamp, setTimestamp] = createSignal<number>(0); const [getTimestamp, setTimestamp] = createSignal<number>(0);
const fetchQuery = async (timestamp: number) => { const fetchQuery = async (timestamp: number) => {
if (cities() === "") return new Error("ERROR: Select cities!"); if (cities() === "") return undefined;
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS)) await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS))
const response = await fetch(`${apiHost}/api/query/city/${cities()}/${queryStart()}-${queryEnd()}/${props.getGranularity()}/${props.getField()}/${props.getKey()}`); const response = await fetch(`${apiHost}/api/query/city/${cities()}/${queryStart()}-${queryEnd()}/${props.getGranularity()}/${props.getField()}/${props.getKey()}`);
const json = await response.json(); const json = await response.json();
@@ -36,18 +36,17 @@ export const QueryView: Component<{
type="button" type="button"
class="primary" class="primary"
value="Query Data" value="Query Data"
disabled={cities() === ""}
onClick={() => setTimestamp(Date.now())} onClick={() => setTimestamp(Date.now())}
/> />
{ queryResource.loading && <LoadingSpinner text="Loading query" /> } { queryResource.loading && <LoadingSpinner text="Loading query" /> }
{ queryResource.error && ( { queryResource.error && (
<div>Error while querying: ${queryResource.error}</div> <div>Error while querying: ${queryResource.error}</div>
)} )}
{ queryResource() && queryResource() instanceof Error && { cities() === "" && <div class="emptyState"><strong>Select at least one city</strong><span>Your results and map options will appear here after you choose cities and run the query.</span></div> }
<div>{ queryResource().message }</div>
}
{ queryResource() && isQueryResult( queryResource() ) && { queryResource() && isQueryResult( queryResource() ) &&
<ResultView result={queryResource} /> <ResultView result={queryResource} />
} }
</div> </div>
); );
} }
+3 -3
View File
@@ -9,6 +9,7 @@ import { GridResult } from "./GridResult";
type ResultView = "grid" | "list" | "map_1920x1080" | "map_1920x1080_wind" | "map_3840x1440" | "map_3840x1440_wind"; type ResultView = "grid" | "list" | "map_1920x1080" | "map_1920x1080_wind" | "map_3840x1440" | "map_3840x1440_wind";
const resultViews: Array<ResultView> = ["grid", "list", "map_1920x1080", "map_1920x1080_wind", "map_3840x1440", "map_3840x1440_wind"]; const resultViews: Array<ResultView> = ["grid", "list", "map_1920x1080", "map_1920x1080_wind", "map_3840x1440", "map_3840x1440_wind"];
const viewLabels: Record<ResultView,string> = {grid:'Cards',list:'List',map_1920x1080:'HD map',map_1920x1080_wind:'HD map + wind',map_3840x1440:'Ultrawide map',map_3840x1440_wind:'Ultrawide + wind'}
export const ResultView: Component<{ export const ResultView: Component<{
result: Resource<QueryResult> result: Resource<QueryResult>
@@ -40,11 +41,10 @@ export const ResultView: Component<{
<input <input
type="button" type="button"
class="secondary" class="secondary"
value={viewName} value={viewLabels[viewName]}
disabled={!showViewButton(viewName)} disabled={!showViewButton(viewName)}
onClick={() => setResultView(viewName)} onClick={() => setResultView(viewName)}
/> />
&nbsp; | &nbsp;
</> </>
)} )}
</span> </span>
@@ -88,4 +88,4 @@ export const ResultView: Component<{
</div> </div>
</div> </div>
); );
} }
+12
View File
@@ -0,0 +1,12 @@
import { A } from '@solidjs/router'
import './home.css'
const tools = [
['/station','Monitor','Stations','Inspect recent measurements on a map and open detailed station charts.','◉'],
['/cities','Analyse & export','City analysis','Compare cities, aggregate values, and prepare map graphics.','▦'],
['/latvia','Summarise','Latvia overview','Review country-wide minimum, maximum, average, and total values.','⌁'],
['/database','Browse','Data archive','Find available dates and inspect individual hourly records.','▤'],
['/harmonie','Forecast model','HARMONIE','Render forecast fields from locally stored HARMONIE GRIB files.','◫'],
['/lvgmc-forecast','Forecast products','LVĢMC forecast','Open prepared LVĢMC city forecasts and broadcast tables.','☁'],
]
export function Home() { return <section class="homePage"><div class="homeHero"><div><p class="eyebrow">Weather data and production tools</p><h1>Choose a workspace</h1><p class="heroCopy">Explore station history, compare cities, inspect forecasts, or prepare weather graphics from one place.</p></div><div class="environmentBadge"><span class="statusDot"/>Development dataset<small>Synthetic station observations are active</small></div></div><div class="toolGrid">{tools.map(([path,eyebrow,title,description,icon]) => <A class="toolCard" href={path}><span class="toolIcon">{icon}</span><span class="eyebrow">{eyebrow}</span><h2>{title}</h2><p>{description}</p><span class="cardAction">Open workspace </span></A>)}</div></section> }
+1
View File
@@ -0,0 +1 @@
.homePage{max-width:1440px;margin:auto;padding:56px 32px 80px}.homeHero{display:flex;justify-content:space-between;align-items:end;gap:48px;margin-bottom:40px}.homeHero h1{margin:8px 0 14px;font-size:clamp(38px,5vw,68px);line-height:.98;letter-spacing:-.045em}.heroCopy{max-width:720px;margin:0;color:var(--text-muted);font-size:18px;line-height:1.6}.eyebrow{color:var(--accent-strong);font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.environmentBadge{min-width:250px;padding:16px 18px;border:1px solid var(--border);border-radius:14px;background:var(--surface);font-weight:700;box-shadow:var(--shadow-sm)}.environmentBadge small{display:block;margin:5px 0 0 18px;color:var(--text-muted);font-weight:400}.statusDot{display:inline-block;width:9px;height:9px;margin-right:9px;border-radius:50%;background:var(--success);box-shadow:0 0 0 4px rgba(38,147,104,.12)}.toolGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.toolCard{position:relative;min-height:230px;padding:26px;overflow:hidden;border:1px solid var(--border);border-radius:18px;background:var(--surface);color:var(--text);text-decoration:none;box-shadow:var(--shadow-sm);transition:.18s}.toolCard:hover{transform:translateY(-3px);border-color:var(--accent-soft);box-shadow:var(--shadow-md)}.toolCard h2{margin:28px 0 10px;font-size:24px}.toolCard p{max-width:90%;margin:0;color:var(--text-muted);line-height:1.55}.toolIcon{position:absolute;top:20px;right:22px;color:var(--accent);font-size:31px}.cardAction{position:absolute;bottom:24px;left:26px;font-weight:700}@media(max-width:960px){.toolGrid{grid-template-columns:repeat(2,1fr)}.homeHero{align-items:start;flex-direction:column}}@media(max-width:640px){.homePage{padding:36px 18px}.toolGrid{grid-template-columns:1fr}}