diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..e001d3b --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,71 @@ +# Copyright (c) 2025-2026 Anton Kundenko +# All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# Deploys site/ to GitHub Pages on every push to main that touches it, +# and on manual dispatch. The site is purely static (HTML/CSS/JS, no +# build step), so the workflow just bundles the directory and hands it +# to the official actions/deploy-pages action. +# +# Prerequisite (one-time): in repo settings, Pages -> Source -> select +# "GitHub Actions". The first push after this lands the site at +# https://rayforcedb.github.io/raysense/. + +name: pages + +on: + push: + branches: [main] + paths: + - 'site/**' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Avoid concurrent deploys racing each other. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload site/ as Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..2348f79 --- /dev/null +++ b/site/README.md @@ -0,0 +1,58 @@ +# site/ + +Single-page landing site for raysense, deployed to GitHub Pages by +`.github/workflows/pages.yml`. Lives at +[https://rayforcedb.github.io/raysense/](https://rayforcedb.github.io/raysense/) +once the workflow runs (and after the one-time Pages-source step +described below). + +## Local preview + +Pure static HTML/CSS, no build step. Serve from this directory: + +```bash +cd site +python3 -m http.server 8000 +# open http://localhost:8000 +``` + +## Layout + +``` +site/ + index.html landing page + style.css all styles, single file + assets/ + favicon.svg + fonts/ Inter, Oswald, JetBrains Mono (woff2, latin) + img/ + treemap-mockup.svg PLACEHOLDER, swap with a real dashboard screenshot +``` + +## Replacing the treemap mockup + +The hero shows `assets/img/treemap-mockup.svg`, a stand-in. To replace +with a real dashboard capture: + +1. `cargo run -- . --ui` (or `cargo install raysense && raysense . --ui`). +2. Open `http://localhost:7000` in a browser, wait for the treemap to + settle, take a screenshot at ~1440x960 (so it scales cleanly to the + 720x480 hero slot). +3. Save as `site/assets/img/treemap.png` (or `.gif` for animated). +4. In `index.html`, change the `` from + `assets/img/treemap-mockup.svg` to `assets/img/treemap.png` and + update the `alt` text. Drop or keep the SVG mockup as a fallback. + +## One-time GitHub Pages setup + +The deploy workflow assumes Pages is configured to use GitHub Actions +as its source. In repo Settings -> Pages, set Source to "GitHub +Actions". After the first successful workflow run, the site URL +appears in the deploy step's output. + +## Editing copy + +`index.html` is the only page; the canonical wording mirrors the +project [README](../README.md). When the README changes, sync the +hero / problem / quality-signal / what-you-get sections so the two +stay aligned. The README is the source of truth. diff --git a/site/assets/favicon.svg b/site/assets/favicon.svg new file mode 100644 index 0000000..065cf85 --- /dev/null +++ b/site/assets/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/site/assets/fonts/inter-400.woff2 b/site/assets/fonts/inter-400.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/site/assets/fonts/inter-400.woff2 differ diff --git a/site/assets/fonts/inter-600.woff2 b/site/assets/fonts/inter-600.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/site/assets/fonts/inter-600.woff2 differ diff --git a/site/assets/fonts/inter-700.woff2 b/site/assets/fonts/inter-700.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/site/assets/fonts/inter-700.woff2 differ diff --git a/site/assets/fonts/jbmono-400.woff2 b/site/assets/fonts/jbmono-400.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/site/assets/fonts/jbmono-400.woff2 differ diff --git a/site/assets/fonts/jbmono-600.woff2 b/site/assets/fonts/jbmono-600.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/site/assets/fonts/jbmono-600.woff2 differ diff --git a/site/assets/fonts/oswald-600.woff2 b/site/assets/fonts/oswald-600.woff2 new file mode 100644 index 0000000..0211eba Binary files /dev/null and b/site/assets/fonts/oswald-600.woff2 differ diff --git a/site/assets/fonts/oswald-700.woff2 b/site/assets/fonts/oswald-700.woff2 new file mode 100644 index 0000000..0211eba Binary files /dev/null and b/site/assets/fonts/oswald-700.woff2 differ diff --git a/site/assets/img/treemap-mockup.svg b/site/assets/img/treemap-mockup.svg new file mode 100644 index 0000000..848c595 --- /dev/null +++ b/site/assets/img/treemap-mockup.svg @@ -0,0 +1,145 @@ + + Raysense treemap dashboard mockup + + + + + + + + + + + + + + + + + + + + + raysense . --ui · http://localhost:7000 + + + + + HEALTH + + + B+ + + + 78 / 100 + + + FILES + + + 142 + + + CYCLES + + + 2 + + + HOT + + + scanner.rs + + + BUS FACTOR + + + 1 + + + + + + + + + + scanner.rs + 2,158 LOC · cy 47 + load-bearing · churn high + + + + + + health.rs + 1,402 LOC + + + + + + memory.rs + 2,058 LOC + + + + + + mcp.rs + 2,409 LOC + + + + + + cli.rs + + + + + + graph.rs + + + + + + facts.rs + + + + profile.rs + + + + simulate.rs + + + + VENDOR · 38 files + excluded from health scoring + + + + baseline.rs + + + + sys.rs + + + + main.rs · lib.rs + + + + TESTS · inline mod · 123 tests · 0 failed + covered: 11/13 modules · gaps: profile.rs, simulate.rs + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..2832e28 --- /dev/null +++ b/site/index.html @@ -0,0 +1,268 @@ + + + + + + Raysense - structural X-ray for AI-edited codebases + + + + + + + + + + + + + + + + + +
+ +
+
+
+
LIVE · LOCAL · AGENT-READY
+

A structural X-ray for the codebases AI agents are writing.

+

+ Raysense reads your repository as a graph: who imports who, where the cycles are, which files are now load-bearing, what tends to change together. It runs locally, refreshes on save, and serves the whole picture to your coding agent over MCP. Before an edit, the agent can ask what depends on this file. After a chunk of edits, it can ask did this regress anything. +

+ +
+ + +
+
+
+ +
+
+
WHY
+

An agent reads source one file at a time.

+

+ The shape of the project (its modules, its layers, its cycles, the files that always change together) never reaches its working memory. Reviewers operate on diffs, and a diff hides structure by definition. So architectural drift is invisible until it shows up as a production bug, a regression, or a refactor that takes a week. Raysense closes that gap by computing the shape once and serving it back to everyone who can act on it. +

+
+
+ +
+
+
GRADING MODEL
+

Six graded dimensions, one aggregate.

+

+ Each dimension grades A through F against the dependency graph and commit history of the repo. The overall score, 0 to 100, is their weighted aggregate. The score moves with structure, not with cosmetics: adding tests or shuffling files around will not lift it. +

+ +
+
+
A
+
Modularity
+

How cleanly modules separate.

+
+
+
B
+
Acyclicity
+

How much the dependency graph really is a graph.

+
+
+
C
+
Depth
+

How layered (or how flat-and-tangled) the code is.

+
+
+
D
+
Equality
+

How evenly responsibility is distributed.

+
+
+
E
+
Redundancy
+

How much logic is duplicated.

+
+
+
F
+
Uniformity
+

How consistent the patterns are.

+
+
+
+
+ +
+
+
INSTALL
+

One binary. A few flags.

+ +
+
+
install
+
$ cargo install raysense
+
+ +
+
use
+
$ raysense .              # health report
+$ raysense . --check      # CI gate, exits non-zero on rule violations
+$ raysense . --watch      # rescan + reprint on a 2s loop
+$ raysense . --ui         # live dashboard at http://localhost:7000
+$ raysense --mcp          # stdio MCP server for agents
+
+
+
+
+ +
+
+
AGENT INTEGRATION
+

Ships as a Claude Code plugin.

+

+ Install the plugin and the agent gets four phase-scoped skills it can pick up at the right moments in its edit cycle. Project state lives in <repo>/.raysense/, never in a global registry, so two sessions on two repositories stay strictly independent. +

+ +
+
install
+
/plugin marketplace add RayforceDB/raysense
+/plugin install raysense
+
+ +
+
+
1
+

Bootstrap

+

At session start. Scan, save baseline, materialize the splayed-table memory.

+
+
+
2
+

Impact

+

Before non-trivial edits. Blast radius, coupling, cycle exposure.

+
+
+
3
+

Verify

+

After edits. Rescan, rule check, baseline diff.

+
+
+
4
+

Audit

+

On request. Architecture, DSM, evolution, test gaps.

+
+
+
+
+ +
+
+
CAPABILITIES
+

Beyond the score.

+ +
+
+

Live treemap dashboard

+

Every file, every metric, every cycle. Open in your browser while you work, refreshes on save.

+
+
+

Baselines and what-if

+

Diff against a saved snapshot. Simulate an edit (delete a file, break a cycle) before touching the working tree.

+
+
+

Splayed-table agent memory

+

Scan results materialized as columnar tables. An agent's follow-up questions are instant reads, not re-scans.

+
+
+

Test gap detection

+

Files without nearby tests, ranked by structural risk.

+
+
+

Evolution signal

+

Bus factor, change-coupling pairs, temporal hotspots (churn x complexity), file age windows.

+
+
+

45 languages

+

Rust, Python, TypeScript, C, C++ via tree-sitter. 40 more (Go, Java, Kotlin, Swift, Ruby, Elixir, Haskell, Clojure, Zig, ...) via configurable plugins.

+
+
+
+
+ +
+ +
+ + + + + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..896ded7 --- /dev/null +++ b/site/style.css @@ -0,0 +1,552 @@ +/* ───────── self-hosted fonts (latin subset) ───────── */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('assets/fonts/inter-400.woff2') format('woff2'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('assets/fonts/inter-600.woff2') format('woff2'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('assets/fonts/inter-700.woff2') format('woff2'); +} +@font-face { + font-family: 'Oswald'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('assets/fonts/oswald-600.woff2') format('woff2'); +} +@font-face { + font-family: 'Oswald'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('assets/fonts/oswald-700.woff2') format('woff2'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('assets/fonts/jbmono-400.woff2') format('woff2'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('assets/fonts/jbmono-600.woff2') format('woff2'); +} + +/* ───────── tokens ───────── */ +:root { + --bg: #0a0e13; + --bg-section: #0d1219; + --bg-card: #111820; + --bg-card-2: #161e28; + + --border: #1f2933; + --border-hover: #2a3a4a; + + --text: #e6edf3; + --text-mute: #9eaab8; + --text-dim: #5b6671; + + --accent: #5eead4; /* teal, primary brand mark */ + --accent-blue: #60a5fa; /* secondary accent */ + --accent-orange:#e9a033; /* shared with rayforce, used sparingly */ + + --font: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-display: 'Oswald', 'Inter', ui-sans-serif, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + + --container-w: 1180px; +} + +/* ───────── reset + base ───────── */ +*, *::before, *::after { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + font-family: var(--font); + font-size: 16px; + line-height: 1.6; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); letter-spacing: -0.02em; line-height: 1.1; } +p { margin: 0; } +a { color: var(--accent); text-decoration: none; transition: color 0.15s; } +a:hover { color: var(--text); } +img { max-width: 100%; height: auto; display: block; } +code { + font-family: var(--font-mono); + font-size: 0.92em; + background: rgba(94, 234, 212, 0.08); + color: var(--text); + padding: 0.1em 0.35em; + border-radius: 4px; +} +em { font-style: normal; color: var(--accent); font-weight: 600; } + +/* ───────── layout primitives ───────── */ +.container { + max-width: var(--container-w); + margin: 0 auto; + padding: 0 24px; +} +.container.narrow { + max-width: 820px; +} + +/* ───────── nav ───────── */ +.nav { + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: blur(8px); + background: rgba(10, 14, 19, 0.78); + border-bottom: 1px solid var(--border); +} +.nav-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 64px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--font-display); + font-weight: 700; + font-size: 1.15rem; + letter-spacing: 0.01em; + color: var(--text); +} +.brand:hover { color: var(--text); } +.brand-mark { display: inline-flex; line-height: 0; } +.brand-name { font-family: var(--font-display); } + +.nav-links { + display: flex; + align-items: center; + gap: 28px; +} +.nav-links a { + color: var(--text-mute); + font-size: 0.92rem; + font-weight: 500; +} +.nav-links a:hover { color: var(--text); } +.nav-link-strong { + border: 1px solid var(--border); + padding: 8px 14px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.02); +} +.nav-link-strong:hover { border-color: var(--border-hover); background: rgba(255, 255, 255, 0.04); } + +@media (max-width: 720px) { + .nav-links { gap: 16px; } + .nav-links a:not(.nav-link-strong) { display: none; } +} + +/* ───────── hero ───────── */ +.hero { + position: relative; + padding: 96px 0 80px; + overflow: hidden; + border-bottom: 1px solid var(--border); +} +.hero-bg { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; +} +.hero-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgba(94, 234, 212, 0.04) 1px, transparent 1px), + linear-gradient(to bottom, rgba(94, 234, 212, 0.04) 1px, transparent 1px); + background-size: 80px 80px; + mask-image: radial-gradient(ellipse at center, #000 30%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse at center, #000 30%, transparent 75%); +} +.hero-glow { + position: absolute; + top: -200px; + left: 50%; + transform: translateX(-50%); + width: 800px; + height: 600px; + background: radial-gradient(ellipse at center, rgba(94, 234, 212, 0.13) 0%, transparent 60%); + filter: blur(40px); +} + +.hero .container { position: relative; z-index: 1; } +.hero-grid-2 { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 56px; + align-items: center; +} +@media (max-width: 980px) { + .hero { padding: 64px 0 48px; } + .hero-grid-2 { grid-template-columns: 1fr; gap: 40px; } +} + +.eyebrow { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.16em; + color: var(--accent); + background: rgba(94, 234, 212, 0.08); + padding: 6px 12px; + border-radius: 999px; + margin-bottom: 24px; +} +.hero h1 { + font-size: clamp(2.4rem, 5vw, 3.8rem); + font-weight: 700; + margin-bottom: 24px; +} +.hero h1 .highlight { + color: var(--accent); + background: linear-gradient(135deg, var(--accent), #7df0d6); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} +.hero .lead { + font-size: 1.12rem; + line-height: 1.65; + color: var(--text-mute); + margin-bottom: 32px; + max-width: 560px; +} +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +/* ───────── buttons ───────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 22px; + border-radius: var(--radius-sm); + font-family: var(--font); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.1s ease, background 0.15s, border-color 0.15s, color 0.15s; +} +.btn:active { transform: translateY(1px); } +.btn-primary { + background: var(--accent); + color: #06251f; + border: 1px solid var(--accent); +} +.btn-primary:hover { background: #7df0d6; color: #06251f; } +.btn-ghost { + background: transparent; + border: 1px solid var(--border-hover); + color: var(--text); +} +.btn-ghost:hover { background: rgba(255, 255, 255, 0.04); border-color: var(--accent); color: var(--text); } + +/* ───────── hero aside (treemap) ───────── */ +.hero-aside { display: flex; flex-direction: column; align-items: stretch; gap: 12px; } +.device-frame { + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-card); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.03) inset, + 0 30px 60px -20px rgba(0, 0, 0, 0.55); +} +.device-frame img { display: block; width: 100%; height: auto; } +.hero-aside-caption { + font-size: 0.85rem; + color: var(--text-dim); + line-height: 1.5; +} + +/* ───────── section base ───────── */ +.section { + padding: 96px 0; + border-bottom: 1px solid var(--border); +} +.section-alt { background: var(--bg-section); } +.section-eyebrow { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.18em; + color: var(--accent); + margin-bottom: 16px; +} +.section h2 { + font-size: clamp(1.8rem, 3.5vw, 2.6rem); + font-weight: 700; + margin-bottom: 20px; + max-width: 800px; +} +.section-lead { + color: var(--text-mute); + font-size: 1.08rem; + line-height: 1.65; + max-width: 720px; + margin-bottom: 40px; +} + +@media (max-width: 720px) { + .section { padding: 64px 0; } +} + +/* ───────── signal grid ───────── */ +.signal-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 24px; +} +@media (max-width: 880px) { .signal-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 540px) { .signal-grid { grid-template-columns: 1fr; } } +.signal-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 28px 24px; + transition: border-color 0.2s, transform 0.1s; + position: relative; + overflow: hidden; +} +.signal-card:hover { border-color: var(--border-hover); } +.signal-letter { + font-family: var(--font-display); + font-weight: 700; + font-size: 2.4rem; + line-height: 1; + color: var(--accent); + margin-bottom: 16px; +} +.signal-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.15rem; + letter-spacing: -0.01em; + color: var(--text); + margin-bottom: 8px; +} +.signal-card p { + color: var(--text-mute); + font-size: 0.94rem; + line-height: 1.55; +} + +/* ───────── code blocks ───────── */ +.code-grid { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 20px; +} +@media (max-width: 880px) { .code-grid { grid-template-columns: 1fr; } } + +.code-block { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + display: flex; + flex-direction: column; +} +.code-block-wide { margin-top: 12px; } +.code-block-head { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.16em; + color: var(--text-dim); + background: var(--bg-card-2); + padding: 10px 18px; + border-bottom: 1px solid var(--border); + text-transform: uppercase; +} +.code-block pre { + margin: 0; + padding: 20px 22px; + overflow-x: auto; +} +.code-block code { + background: none; + padding: 0; + font-family: var(--font-mono); + font-size: 0.88rem; + line-height: 1.7; + color: var(--text); +} +.hl-cmt { color: var(--text-dim); } + +/* ───────── phase grid (for-agents) ───────── */ +.phase-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-top: 32px; +} +@media (max-width: 880px) { .phase-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 480px) { .phase-grid { grid-template-columns: 1fr; } } +.phase-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 22px 20px; + position: relative; +} +.phase-step { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + color: var(--accent); + letter-spacing: 0.12em; + margin-bottom: 8px; +} +.phase-card h3 { + font-family: var(--font-display); + font-size: 1.15rem; + margin-bottom: 8px; + color: var(--text); +} +.phase-card p { + font-size: 0.92rem; + color: var(--text-mute); + line-height: 1.55; +} + +/* ───────── feature grid (what you get) ───────── */ +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 32px; +} +@media (max-width: 880px) { .feature-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 540px) { .feature-grid { grid-template-columns: 1fr; } } +.feature-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 28px 24px; + transition: border-color 0.2s, background 0.15s; +} +.feature-card:hover { + border-color: var(--border-hover); + background: var(--bg-card-2); +} +.feature-card h3 { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.15rem; + margin-bottom: 12px; + color: var(--text); +} +.feature-card p { + font-size: 0.95rem; + color: var(--text-mute); + line-height: 1.6; +} + +/* ───────── built on rayforce ───────── */ +.section-rayforce { background: var(--bg-section); } +.rayforce-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 24px; + padding: 48px 32px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + transition: border-color 0.2s, transform 0.15s; + color: var(--text); +} +.rayforce-card:hover { + border-color: var(--accent); + color: var(--text); +} +.rayforce-card img { max-width: 280px; height: auto; } +.rayforce-card p { + color: var(--text-mute); + font-size: 1rem; + line-height: 1.65; + max-width: 640px; +} +.rayforce-cta { + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--accent); + font-weight: 600; + letter-spacing: 0.04em; +} + +/* ───────── footer ───────── */ +.footer { + padding: 40px 0; + background: var(--bg); +} +.footer-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 18px; +} +.footer-brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--font-display); + font-weight: 700; + color: var(--text); +} +.footer-links { + display: flex; + gap: 24px; + font-size: 0.92rem; +} +.footer-links a { color: var(--text-mute); } +.footer-links a:hover { color: var(--accent); } +.footer-meta { + font-size: 0.82rem; + color: var(--text-dim); +} + +/* ───────── selection ───────── */ +::selection { background: rgba(94, 234, 212, 0.32); color: var(--text); }