Improve Lighthouse Agentic Browsing readiness (#8)
## Summary - Add `robots.txt`, `sitemap.xml`, and `llms.txt` endpoints so crawlers and AI agents can discover public pages - Fix accessibility tree issues: contact form labels, semantic nav/profile dropdown buttons, cookie consent dialog ARIA, and dead `href="#"` links - Reduce layout shift on homepage/contact via hero min-heights, trusted-by marquee sizing, and fixed cookie banner - Add regression tests and `docs/agentic-browsing.md` for Lighthouse agentic-browsing audits Closes #5. Also addresses overlap with #3 (sitemap + robots.txt). ## Test plan - [x] `python manage.py test public.tests` (14 tests pass) - [ ] Verify `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200 in staging/prod - [ ] Confirm contact form labels visible and submittable on `/contact` - [ ] Run Lighthouse `--only-categories=agentic-browsing` on homepage and contact page - [ ] Smoke test mobile nav dropdown and cookie consent banner Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
# Agentic Browsing Readiness
|
||||||
|
|
||||||
|
This site targets Google's experimental **Agentic Browsing** Lighthouse category (v13.3+), which checks whether AI agents can read, navigate, and act on public pages.
|
||||||
|
|
||||||
|
PageSpeed Insights does not yet expose this category. Run audits locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx lighthouse@latest https://aimloperations.com \
|
||||||
|
--only-categories=agentic-browsing \
|
||||||
|
--chrome-flags="--enable-experimental-web-platform-features" \
|
||||||
|
--output=html --output-path=agentic-browsing-report.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Test at minimum:
|
||||||
|
|
||||||
|
- `/` (homepage)
|
||||||
|
- `/contact` (primary conversion page)
|
||||||
|
|
||||||
|
## What we ship
|
||||||
|
|
||||||
|
| Check | Implementation |
|
||||||
|
|-------|----------------|
|
||||||
|
| **llms.txt** | `GET /llms.txt` — machine-readable site summary |
|
||||||
|
| **robots.txt** | `GET /robots.txt` — crawl rules + sitemap reference |
|
||||||
|
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
|
||||||
|
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
|
||||||
|
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
|
||||||
|
|
||||||
|
WebMCP is intentionally deferred until browser and agent support stabilizes.
|
||||||
|
|
||||||
|
## Regression checklist
|
||||||
|
|
||||||
|
Before merging public-facing template or CSS changes:
|
||||||
|
|
||||||
|
1. Contact form fields have associated `<label>` elements (not placeholder-only).
|
||||||
|
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
|
||||||
|
3. Cookie consent banner stays `position: fixed` (no document flow shift).
|
||||||
|
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
|
||||||
|
5. Re-run Lighthouse agentic-browsing on homepage and contact page.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
|
||||||
|
- [llms.txt proposal](https://llmstxt.org/)
|
||||||
|
- Gitea issue #5
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Machine-readable site discovery endpoints for crawlers and AI agents."""
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
# Public marketing pages included in sitemap and llms.txt.
|
||||||
|
PUBLIC_PAGE_ENTRIES = (
|
||||||
|
("public_index", "Home", "weekly", "1.0"),
|
||||||
|
("forward_deployed", "Forward-Deployed AI", "monthly", "0.9"),
|
||||||
|
("bot", "AI Agents", "monthly", "0.9"),
|
||||||
|
("ml_model", "ML Models", "monthly", "0.8"),
|
||||||
|
("chat", "Secure AI Chat", "monthly", "0.8"),
|
||||||
|
("ai_sensor", "AI Sensor Algorithms", "monthly", "0.7"),
|
||||||
|
("ai_education", "AI Education", "monthly", "0.7"),
|
||||||
|
("computers", "Computer Builds", "monthly", "0.7"),
|
||||||
|
("file_hosting", "File Hosting", "monthly", "0.7"),
|
||||||
|
("web_design", "Web Design and Hosting", "monthly", "0.8"),
|
||||||
|
("contact", "Contact", "monthly", "0.9"),
|
||||||
|
("terms_of_service", "Terms of Service and Privacy", "yearly", "0.3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_url(request, url_name):
|
||||||
|
return request.build_absolute_uri(reverse(url_name))
|
||||||
|
|
||||||
|
|
||||||
|
def robots_txt(request):
|
||||||
|
sitemap_url = _absolute_url(request, "sitemap_xml")
|
||||||
|
content = render_to_string(
|
||||||
|
"public/robots.txt",
|
||||||
|
{"sitemap_url": sitemap_url},
|
||||||
|
)
|
||||||
|
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sitemap_xml(request):
|
||||||
|
pages = [
|
||||||
|
{
|
||||||
|
"loc": _absolute_url(request, url_name),
|
||||||
|
"changefreq": changefreq,
|
||||||
|
"priority": priority,
|
||||||
|
}
|
||||||
|
for url_name, _title, changefreq, priority in PUBLIC_PAGE_ENTRIES
|
||||||
|
]
|
||||||
|
content = render_to_string("public/sitemap.xml", {"pages": pages})
|
||||||
|
return HttpResponse(content, content_type="application/xml; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def llms_txt(request):
|
||||||
|
pages = [
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"url": _absolute_url(request, url_name),
|
||||||
|
}
|
||||||
|
for url_name, title, _changefreq, _priority in PUBLIC_PAGE_ENTRIES
|
||||||
|
]
|
||||||
|
content = render_to_string(
|
||||||
|
"public/llms.txt",
|
||||||
|
{
|
||||||
|
"site_url": request.build_absolute_uri("/"),
|
||||||
|
"contact_url": _absolute_url(request, "contact"),
|
||||||
|
"pages": pages,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||||
@@ -23,6 +23,7 @@ body {
|
|||||||
font-family: var(--font-main);
|
font-family: var(--font-main);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -81,6 +82,7 @@ nav {
|
|||||||
.hero-section {
|
.hero-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
min-height: 520px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -88,6 +90,11 @@ nav {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-section--compact {
|
||||||
|
height: 40vh;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
#hero-canvas {
|
#hero-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -95,6 +102,7 @@ nav {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
@@ -102,11 +110,14 @@ nav {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
min-height: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 4rem;
|
font-size: clamp(2.5rem, 8vw, 4rem);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
min-height: 1.1em;
|
||||||
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -282,6 +293,41 @@ nav {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -376,7 +422,29 @@ nav {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown>a::after {
|
.dropdown.dropdown-open .dropdown-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger:hover,
|
||||||
|
.nav-dropdown-trigger.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown .nav-dropdown-trigger::after {
|
||||||
content: ' ▼';
|
content: ' ▼';
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
@@ -387,12 +455,72 @@ nav {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-icon-link::after {
|
.profile-icon-link::after {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Trusted-by marquee */
|
||||||
|
.trusted-by-section {
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-label {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-marquee {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-marquee-row {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3rem;
|
||||||
|
padding-right: 3rem;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-logo img {
|
||||||
|
display: block;
|
||||||
|
height: 48px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.trusted-by-marquee-row {
|
||||||
|
animation: trusted-by-scroll 30s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes trusted-by-scroll {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-icon {
|
.profile-icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -599,6 +727,21 @@ input:focus, select:focus, textarea:focus {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-btn:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.footer-separator {
|
.footer-separator {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0 0.5rem;
|
margin: 0 0.5rem;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
const canvas = document.getElementById('hero-canvas');
|
const canvas = document.getElementById('hero-canvas');
|
||||||
|
if (!canvas) {
|
||||||
|
// Hero animation only runs on the homepage.
|
||||||
|
} else {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
let width, height;
|
let width, height;
|
||||||
@@ -109,3 +112,4 @@ function animate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
animate();
|
animate();
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,18 +49,18 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Navigation Bar -->
|
<!-- Navigation Bar -->
|
||||||
<nav>
|
<nav aria-label="Main navigation">
|
||||||
<a href="{% url 'public_index' %}" class="brand-logo">
|
<a href="{% url 'public_index' %}" class="brand-logo">
|
||||||
<img src="{% static 'public/img/logo.png' %}" alt="AI ML Operations" class="brand-logo-img" width="243" height="28">
|
<img src="{% static 'public/img/logo.png' %}" alt="AI ML Operations" class="brand-logo-img" width="243" height="28">
|
||||||
</a>
|
</a>
|
||||||
<button class="mobile-menu-btn" aria-label="Menu">☰</button>
|
<button type="button" class="mobile-menu-btn" aria-label="Open menu" aria-expanded="false" aria-controls="main-nav-links">☰</button>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links" id="main-nav-links">
|
||||||
<li><a href="{% url 'public_index' %}"
|
<li><a href="{% url 'public_index' %}"
|
||||||
class="{% if request.resolver_match.url_name == 'public_index' %}active{% endif %}">Home</a></li>
|
class="{% if request.resolver_match.url_name == 'public_index' %}active{% endif %}">Home</a></li>
|
||||||
<li class="dropdown">
|
<li class="dropdown">
|
||||||
<a href="#"
|
<button type="button" class="nav-dropdown-trigger{% if request.resolver_match.url_name in 'forward_deployed,ai_education,ai_sensor,bot,chat,computers,file_hosting,ml_model,web_design' %} active{% endif %}"
|
||||||
class="{% if request.resolver_match.url_name in 'forward_deployed,ai_education,ai_sensor,bot,chat,computers,file_hosting,ml_model,web_design' %}active{% endif %}">Services</a>
|
id="services-menu-button" aria-expanded="false" aria-haspopup="true" aria-controls="services-menu">Services</button>
|
||||||
<ul class="dropdown-content">
|
<ul class="dropdown-content" id="services-menu" role="menu" aria-labelledby="services-menu-button">
|
||||||
<li><a href="{% url 'forward_deployed' %}">Forward-Deployed AI</a></li>
|
<li><a href="{% url 'forward_deployed' %}">Forward-Deployed AI</a></li>
|
||||||
<li><a href="{% url 'bot' %}">AI Agents</a></li>
|
<li><a href="{% url 'bot' %}">AI Agents</a></li>
|
||||||
<li><a href="{% url 'ml_model' %}">ML Models</a></li>
|
<li><a href="{% url 'ml_model' %}">ML Models</a></li>
|
||||||
@@ -83,14 +83,15 @@
|
|||||||
class="{% if 'financial' in request.path %}active{% endif %}"
|
class="{% if 'financial' in request.path %}active{% endif %}"
|
||||||
data-tianji-event="nav_financials">Financials</a></li>
|
data-tianji-event="nav_financials">Financials</a></li>
|
||||||
<li class="dropdown" id="user-profile-dropdown">
|
<li class="dropdown" id="user-profile-dropdown">
|
||||||
<a href="#" class="profile-icon-link" title="{{ user.get_full_name|default:user.username }}">
|
<button type="button" class="profile-icon-link" aria-label="Account menu for {{ user.get_full_name|default:user.username }}"
|
||||||
|
aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
|
||||||
<svg class="profile-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
<svg class="profile-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="8" r="4" />
|
<circle cx="12" cy="8" r="4" />
|
||||||
<path d="M4 21v-1a6 6 0 0 1 12 0v1" />
|
<path d="M4 21v-1a6 6 0 0 1 12 0v1" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
<ul class="dropdown-content profile-dropdown-content">
|
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
|
||||||
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
|
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
|
||||||
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
||||||
<li>
|
<li>
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
<a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms & Privacy</a>
|
<a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms & Privacy</a>
|
||||||
{% if tianji_enabled %}
|
{% if tianji_enabled %}
|
||||||
<span class="footer-separator">|</span>
|
<span class="footer-separator">|</span>
|
||||||
<a href="#" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</a>
|
<button type="button" class="footer-link-btn" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="footer-text footer-text--muted">© 2023 -
|
<p class="footer-text footer-text--muted">© 2023 -
|
||||||
@@ -133,10 +134,11 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% if tianji_enabled %}
|
{% if tianji_enabled %}
|
||||||
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-live="polite"
|
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-modal="true"
|
||||||
aria-label="Cookie consent">
|
aria-labelledby="cookie-consent-title" aria-describedby="cookie-consent-description">
|
||||||
<div class="cookie-consent-content">
|
<div class="cookie-consent-content">
|
||||||
<p class="cookie-consent-text">
|
<h2 id="cookie-consent-title" class="visually-hidden">Cookie consent</h2>
|
||||||
|
<p class="cookie-consent-text" id="cookie-consent-description">
|
||||||
<span class="cookie-consent-text-full">We use analytics tracking to understand how visitors use our site. Tracking runs only if you accept.
|
<span class="cookie-consent-text-full">We use analytics tracking to understand how visitors use our site. Tracking runs only if you accept.
|
||||||
See our <a href="{% url 'terms_of_service' %}">Terms of Service & Privacy Policy</a> for details.</span>
|
See our <a href="{% url 'terms_of_service' %}">Terms of Service & Privacy Policy</a> for details.</span>
|
||||||
<span class="cookie-consent-text-short">We use analytics if you accept. <a href="{% url 'terms_of_service' %}">Privacy Policy</a></span>
|
<span class="cookie-consent-text-short">We use analytics if you accept. <a href="{% url 'terms_of_service' %}">Privacy Policy</a></span>
|
||||||
@@ -157,9 +159,41 @@
|
|||||||
|
|
||||||
if (mobileBtn && navLinks) {
|
if (mobileBtn && navLinks) {
|
||||||
mobileBtn.addEventListener('click', function () {
|
mobileBtn.addEventListener('click', function () {
|
||||||
navLinks.classList.toggle('active');
|
const isOpen = navLinks.classList.toggle('active');
|
||||||
|
mobileBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
mobileBtn.setAttribute('aria-label', isOpen ? 'Close menu' : 'Open menu');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDropdown(dropdown) {
|
||||||
|
dropdown.classList.remove('dropdown-open');
|
||||||
|
const trigger = dropdown.querySelector('.nav-dropdown-trigger, .profile-icon-link');
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.dropdown').forEach(function (dropdown) {
|
||||||
|
const trigger = dropdown.querySelector('.nav-dropdown-trigger, .profile-icon-link');
|
||||||
|
if (!trigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.addEventListener('click', function (event) {
|
||||||
|
if (window.matchMedia('(min-width: 769px)').matches && dropdown.querySelector('.nav-dropdown-trigger')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const isOpen = dropdown.classList.toggle('dropdown-open');
|
||||||
|
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (!event.target.closest('.dropdown')) {
|
||||||
|
document.querySelectorAll('.dropdown.dropdown-open').forEach(closeDropdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% block tracking_events %}{% endblock %}
|
{% block tracking_events %}{% endblock %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
<div class="hero-section hero-section--compact">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="hero-title">Get in Touch</h1>
|
<h1 class="hero-title">Get in Touch</h1>
|
||||||
<p class="hero-subtitle hero-subtitle--wide">Describe the workflow or bottleneck you want to automate—we will help you scope a forward-deployed solution.</p>
|
<p class="hero-subtitle hero-subtitle--wide">Describe the workflow or bottleneck you want to automate—we will help you scope a forward-deployed solution.</p>
|
||||||
@@ -32,24 +32,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h4 class="card-title" style="margin-bottom: 2rem;">Send Us a Message</h4>
|
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
|
||||||
<form action="{% url 'contact' %}" method="POST">
|
<form action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control" name="name" placeholder="Your Name">
|
<label class="form-label" for="contact-name">Your Name</label>
|
||||||
|
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="email" class="form-control" name="email" placeholder="Your Email">
|
<label class="form-label" for="contact-email">Your Email</label>
|
||||||
|
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control" name="subject" id="contact-subject" placeholder="Subject" value="{{ request.GET.subject|default:'' }}">
|
<label class="form-label" for="contact-subject">Subject</label>
|
||||||
|
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<textarea name="message" class="form-control" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea>
|
<label class="form-label" for="contact-message">Message</label>
|
||||||
|
<textarea name="message" class="form-control" id="contact-message" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -69,15 +73,15 @@
|
|||||||
<!-- Info Column -->
|
<!-- Info Column -->
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-title">Contact Information</h5>
|
<h3 class="card-title">Contact Information</h3>
|
||||||
<ul style="list-style: none;">
|
<ul class="contact-info-list">
|
||||||
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;">
|
<li class="contact-info-item">
|
||||||
<span style="color: var(--primary-color);">📞</span>
|
<span class="contact-info-icon" aria-hidden="true">📞</span>
|
||||||
<p style="color: var(--text-muted);">+1 (330) 402-2675</p>
|
<a href="tel:+13304022675">+1 (330) 402-2675</a>
|
||||||
</li>
|
</li>
|
||||||
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;">
|
<li class="contact-info-item">
|
||||||
<span style="color: var(--primary-color);">✉️</span>
|
<span class="contact-info-icon" aria-hidden="true">✉️</span>
|
||||||
<p style="color: var(--text-muted);">ryan@aimloperations.com</p>
|
<a href="mailto:ryan@aimloperations.com">ryan@aimloperations.com</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,4 +100,4 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -106,13 +106,16 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="form-group col-6" style="padding-left:1rem">
|
<div class="form-group col-6" style="padding-left:1rem">
|
||||||
<input type="email" class="form-control" name="email" placeholder="Your Email">
|
<label for="legacy-contact-email">Your Email</label>
|
||||||
|
<input type="email" class="form-control" name="email" id="legacy-contact-email" placeholder="Your Email">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-6" style="padding-right:1rem">
|
<div class="form-group col-6" style="padding-right:1rem">
|
||||||
<input type="text" class="form-control" name="name" placeholder="Your Name">
|
<label for="legacy-contact-name">Your Name</label>
|
||||||
|
<input type="text" class="form-control" name="name" id="legacy-contact-name" placeholder="Your Name">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-12" style="padding:1rem">
|
<div class="form-group col-12" style="padding:1rem">
|
||||||
<textarea name="message" type="text" class="form-control" rows="5" placeholder="Your message"></textarea>
|
<label for="legacy-contact-message">Your Message</label>
|
||||||
|
<textarea name="message" id="legacy-contact-message" class="form-control" rows="5" placeholder="Your message"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-12">
|
<div class="form-group col-12">
|
||||||
{% if capchaForm %}
|
{% if capchaForm %}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# AI ML Operations, LLC
|
||||||
|
|
||||||
|
> Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems.
|
||||||
|
|
||||||
|
AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web hosting.
|
||||||
|
|
||||||
|
## Key pages
|
||||||
|
|
||||||
|
{% for page in pages %}- [{{ page.title }}]({{ page.url }})
|
||||||
|
{% endfor %}
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- [Contact form]({{ contact_url }})
|
||||||
|
- Phone: +1 (330) 402-2675
|
||||||
|
- Email: ryan@aimloperations.com
|
||||||
|
|
||||||
|
## Site
|
||||||
|
|
||||||
|
{{ site_url }}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero-section">
|
<div class="hero-section">
|
||||||
<canvas id="hero-canvas"></canvas>
|
<canvas id="hero-canvas" aria-hidden="true"></canvas>
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="hero-title">AI ML Operations</h1>
|
<h1 class="hero-title">AI ML Operations</h1>
|
||||||
<p class="hero-subtitle hero-subtitle--wide">
|
<p class="hero-subtitle hero-subtitle--wide">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /accounts/
|
||||||
|
Disallow: /financial/
|
||||||
|
Disallow: /planning/
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: {{ sitemap_url }}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
{% for page in pages %}
|
||||||
|
<url>
|
||||||
|
<loc>{{ page.loc }}</loc>
|
||||||
|
<changefreq>{{ page.changefreq }}</changefreq>
|
||||||
|
<priority>{{ page.priority }}</priority>
|
||||||
|
</url>
|
||||||
|
{% endfor %}
|
||||||
|
</urlset>
|
||||||
@@ -97,7 +97,7 @@ visually stunning, functional websites tailored to your business.{% endblock %}
|
|||||||
<div class="pricing-toggle" role="group" aria-label="Billing period">
|
<div class="pricing-toggle" role="group" aria-label="Billing period">
|
||||||
<span class="pricing-toggle-label active" id="monthlyLabel">Monthly</span>
|
<span class="pricing-toggle-label active" id="monthlyLabel">Monthly</span>
|
||||||
<label class="pricing-switch" for="pricingToggle">
|
<label class="pricing-switch" for="pricingToggle">
|
||||||
<input type="checkbox" id="pricingToggle" role="switch" aria-labelledby="monthlyLabel yearlyLabel">
|
<input type="checkbox" id="pricingToggle" aria-label="Toggle between monthly and yearly billing">
|
||||||
<span class="pricing-switch-slider"></span>
|
<span class="pricing-switch-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<span class="pricing-toggle-label" id="yearlyLabel">Yearly</span>
|
<span class="pricing-toggle-label" id="yearlyLabel">Yearly</span>
|
||||||
@@ -119,7 +119,7 @@ visually stunning, functional websites tailored to your business.{% endblock %}
|
|||||||
<li style="margin-bottom: 0.5rem;">✓ Email Notifications</li>
|
<li style="margin-bottom: 0.5rem;">✓ Email Notifications</li>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Backend Admin Access</li>
|
<li style="margin-bottom: 0.5rem;">✓ Backend Admin Access</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="#" class="btn">Get Started</a>
|
<a href="{% url 'contact' %}?subject=Web%20Hosting%20Standard%20Plan" class="btn">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Yearly Card -->
|
<!-- Yearly Card -->
|
||||||
@@ -135,7 +135,7 @@ visually stunning, functional websites tailored to your business.{% endblock %}
|
|||||||
<li style="margin-bottom: 0.5rem;">✓ 2 Months Free (Yearly)</li>
|
<li style="margin-bottom: 0.5rem;">✓ 2 Months Free (Yearly)</li>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Priority Support</li>
|
<li style="margin-bottom: 0.5rem;">✓ Priority Support</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="#" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
|
<a href="{% url 'contact' %}?subject=Web%20Hosting%20Premium%20Plan" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ class ContactViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Send Us a Message")
|
self.assertContains(response, "Send Us a Message")
|
||||||
self.assertContains(response, 'name="name"')
|
self.assertContains(response, 'id="contact-name"')
|
||||||
self.assertContains(response, 'name="email"')
|
self.assertContains(response, 'for="contact-name"')
|
||||||
self.assertContains(response, 'name="subject"')
|
self.assertContains(response, 'id="contact-email"')
|
||||||
|
self.assertContains(response, 'for="contact-email"')
|
||||||
|
self.assertContains(response, 'id="contact-subject"')
|
||||||
|
self.assertContains(response, 'for="contact-subject"')
|
||||||
|
self.assertContains(response, 'id="contact-message"')
|
||||||
|
self.assertContains(response, 'for="contact-message"')
|
||||||
|
|
||||||
@patch("public.views.send_contact_email")
|
@patch("public.views.send_contact_email")
|
||||||
def test_contact_post_success_saves_contact_and_sends_email(self, mock_send_email):
|
def test_contact_post_success_saves_contact_and_sends_email(self, mock_send_email):
|
||||||
@@ -125,3 +130,39 @@ class TianjiTrackingTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotContains(response, "cookie-consent-banner")
|
self.assertNotContains(response, "cookie-consent-banner")
|
||||||
self.assertNotContains(response, "tianji-config")
|
self.assertNotContains(response, "tianji-config")
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticBrowsingSeoTests(TestCase):
|
||||||
|
def test_robots_txt_is_available(self):
|
||||||
|
response = self.client.get(reverse("robots_txt"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||||
|
self.assertContains(response, "User-agent: *")
|
||||||
|
self.assertContains(response, "Sitemap:")
|
||||||
|
|
||||||
|
def test_sitemap_xml_lists_public_pages(self):
|
||||||
|
response = self.client.get(reverse("sitemap_xml"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/xml; charset=utf-8")
|
||||||
|
self.assertContains(response, "<urlset")
|
||||||
|
self.assertContains(response, reverse("contact"))
|
||||||
|
self.assertContains(response, reverse("forward_deployed"))
|
||||||
|
|
||||||
|
def test_llms_txt_is_available(self):
|
||||||
|
response = self.client.get(reverse("llms_txt"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||||
|
self.assertContains(response, "# AI ML Operations, LLC")
|
||||||
|
self.assertContains(response, "## Key pages")
|
||||||
|
self.assertContains(response, reverse("contact"))
|
||||||
|
|
||||||
|
def test_homepage_uses_semantic_nav_controls(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'id="services-menu-button"')
|
||||||
|
self.assertContains(response, 'aria-haspopup="true"')
|
||||||
|
self.assertContains(response, 'aria-label="Main navigation"')
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import seo, views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("robots.txt", seo.robots_txt, name="robots_txt"),
|
||||||
|
path("sitemap.xml", seo.sitemap_xml, name="sitemap_xml"),
|
||||||
|
path("llms.txt", seo.llms_txt, name="llms_txt"),
|
||||||
path("", views.index, name="public_index"),
|
path("", views.index, name="public_index"),
|
||||||
path("chat", views.chat, name="chat"),
|
path("chat", views.chat, name="chat"),
|
||||||
path("ai_education", views.ai_education, name="ai_education"),
|
path("ai_education", views.ai_education, name="ai_education"),
|
||||||
|
|||||||
Reference in New Issue
Block a user