Refine trades starter template
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 1m29s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 36s

This commit is contained in:
2026-10-01 11:59:07 +01:00
parent f2cc99dd94
commit 498f1d7092
7 changed files with 336 additions and 25 deletions
+10
View File
@@ -23,6 +23,16 @@ Then visit <http://localhost:8000>.
The sample business and hero photo are demo content. Replace both before publishing. The sample photo is remotely hosted; use a local image if the demo needs to work without an internet connection.
## Data contract
`data/business.json` is the content contract between the business-generation workflow and this template.
Templates may change their layout, styling, sections, and components, but should continue to accept the same JSON structure.
Optional fields may be empty. The template should render gracefully when optional content such as logos, testimonials, opening hours, or images is unavailable.
Do not add business facts to the template itself. Business-specific content belongs in `data/business.json`.
## Files
- `index.html` contains the page structure.
Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

+7 -2
View File
@@ -18,6 +18,11 @@ body {
-webkit-font-smoothing: antialiased;
}
/* Empty optional content is hidden by js/main.js; keep it hidden over any display rule. */
[hidden] {
display: none !important;
}
img {
display: block;
max-width: 100%;
@@ -40,8 +45,8 @@ a:focus-visible {
}
::selection {
background: var(--brand-accent);
color: var(--brand-primary);
background: var(--brand-primary);
color: white;
}
.page-width {
+246 -1
View File
@@ -1,43 +1,65 @@
.site-header {
position: absolute;
z-index: 5;
inset: 0 0 auto;
color: #fff;
}
.header-inner {
display: flex;
min-height: 92px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgb(255 255 255 / 24%);
}
.brand,
.footer-brand {
display: inline-flex;
align-items: center;
gap: 12px;
font-family: var(--font-display);
font-size: 17px;
font-weight: 800;
letter-spacing: 0;
}
.brand-logo {
width: 42px;
height: 42px;
object-fit: contain;
}
.main-nav {
display: flex;
align-items: center;
gap: 36px;
margin-left: auto;
margin-right: 48px;
color: rgb(255 255 255 / 82%);
font-size: 13px;
font-weight: 500;
}
@@ -54,61 +76,88 @@
.header-call {
display: grid;
gap: 3px;
text-align: right;
}
.header-call-label {
color: rgb(255 255 255 / 65%);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.header-phone {
font-size: 14px;
font-weight: 700;
}
.menu-toggle {
display: none;
width: 42px;
height: 42px;
padding: 12px 9px;
border: 1px solid rgb(255 255 255 / 45%);
background: transparent;
color: #fff;
cursor: pointer;
}
.menu-toggle span {
display: block;
height: 1px;
margin: 5px 0;
background: currentColor;
}
.hero {
position: relative;
display: flex;
min-height: min(760px, 100svh);
align-items: center;
overflow: hidden;
background: #292e2b;
color: #fff;
}
.hero-image,
.hero-shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hero-image {
object-fit: cover;
object-position: center 48%;
animation: hero-arrive 900ms ease-out both;
}
@@ -118,9 +167,13 @@
.hero-content {
position: relative;
z-index: 1;
padding-top: 108px;
padding-bottom: 92px;
animation: content-arrive 650ms 100ms ease-out both;
}
@@ -130,35 +183,54 @@
.hero-title {
max-width: 790px;
margin-bottom: 22px;
font-size: clamp(48px, 7vw, 88px);
font-weight: 600;
letter-spacing: 0;
line-height: 0.99;
white-space: pre-line;
}
.hero-description {
max-width: 500px;
margin-bottom: 32px;
color: rgb(255 255 255 / 78%);
font-size: 17px;
line-height: 1.7;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.button {
display: inline-flex;
min-height: 52px;
align-items: center;
justify-content: center;
padding: 0 23px;
font-size: 13px;
font-weight: 700;
transition:
background 160ms ease,
color 160ms ease,
@@ -167,7 +239,8 @@
.button-accent {
background: var(--brand-accent);
color: #171b1b;
color: #fff;
}
.button-accent:hover {
@@ -176,59 +249,85 @@
.button-outline {
border: 1px solid rgb(255 255 255 / 50%);
color: #fff;
}
.button-outline:hover {
border-color: #fff;
background: rgb(255 255 255 / 10%);
}
.hero-proof {
display: flex;
flex-wrap: wrap;
gap: 0;
margin-top: 64px;
color: rgb(255 255 255 / 85%);
font-size: 12px;
}
.hero-proof span {
padding-inline: 18px;
border-left: 1px solid rgb(255 255 255 / 35%);
}
.hero-proof span:first-child {
padding-left: 0;
border-left: 0;
}
.hero-scroll {
position: absolute;
z-index: 1;
right: max(24px, calc((100vw - 1200px) / 2));
bottom: 39px;
width: 22px;
height: 36px;
border: 1px solid rgb(255 255 255 / 65%);
border-radius: 12px;
}
.hero-scroll span {
position: absolute;
top: 7px;
left: 9px;
width: 2px;
height: 7px;
background: var(--brand-accent);
animation: scroll-hint 1.6s infinite;
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 40px;
margin-bottom: 48px;
}
@@ -236,10 +335,15 @@
.about-title,
.contact-title {
margin-bottom: 0;
font-size: clamp(34px, 4vw, 52px);
font-weight: 600;
letter-spacing: 0;
line-height: 1.08;
white-space: pre-line;
}
.section-heading .eyebrow,
@@ -250,50 +354,71 @@
.section-intro {
max-width: 360px;
margin: 0 0 3px;
color: var(--brand-muted);
font-size: 14px;
line-height: 1.75;
}
.service-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-top: 1px solid #d9d9d6;
}
.service-item {
min-height: 210px;
padding: 25px 28px 26px 0;
border-bottom: 1px solid #d9d9d6;
}
.service-item:not(:nth-child(3n + 1)) {
padding-left: 28px;
border-left: 1px solid #d9d9d6;
}
.service-index {
display: block;
margin-bottom: 28px;
color: var(--brand-accent);
font-family: var(--font-display);
font-size: 12px;
font-weight: 800;
}
.service-item h3 {
margin: 0 0 9px;
font-family: var(--font-display);
font-size: 19px;
font-weight: 700;
}
.service-item p {
max-width: 310px;
margin: 0;
color: var(--brand-muted);
font-size: 13px;
line-height: 1.7;
}
@@ -303,25 +428,37 @@
.about-inner {
display: grid;
grid-template-columns: 0.45fr 1.4fr 0.9fr;
align-items: start;
gap: 54px;
}
.about-marker {
display: flex;
align-items: center;
gap: 16px;
padding-top: 7px;
color: var(--brand-accent);
font-family: var(--font-display);
font-size: 13px;
font-weight: 800;
}
.about-rule {
width: 48px;
height: 1px;
background: var(--brand-accent);
}
@@ -331,25 +468,41 @@
.about-description {
max-width: 530px;
margin: 24px 0 20px;
color: #545a5c;
font-size: 15px;
line-height: 1.8;
}
.about-description[hidden] + .about-link {
margin-top: 24px;
}
.text-link {
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 13px;
font-weight: 700;
}
.text-link::after {
width: 25px;
height: 1px;
background: var(--brand-accent);
content: "";
transition: width 160ms ease;
}
@@ -359,41 +512,61 @@
.about-note {
position: relative;
margin-top: 43px;
padding: 28px 0 0 25px;
border-left: 1px solid #c7c5be;
}
.note-mark {
position: absolute;
top: -17px;
left: 21px;
color: var(--brand-accent);
font-family: Georgia, serif;
font-size: 45px;
line-height: 1;
}
.about-quote {
margin-bottom: 18px;
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
line-height: 1.65;
}
.about-quote-byline {
color: var(--brand-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.contact-inner {
display: grid;
grid-template-columns: 1.5fr 1fr 0.9fr;
align-items: center;
gap: 50px;
}
@@ -403,20 +576,27 @@
.contact-description {
max-width: 490px;
margin: 18px 0 0;
color: var(--brand-muted);
font-size: 14px;
line-height: 1.7;
}
.contact-actions {
display: grid;
justify-items: start;
gap: 15px;
}
.button-dark {
background: var(--brand-primary);
color: #fff;
}
@@ -426,24 +606,35 @@
.contact-email {
font-size: 13px;
font-weight: 600;
}
.contact-location {
display: grid;
gap: 8px;
padding-left: 26px;
border-left: 1px solid #d9d9d6;
font-size: 13px;
line-height: 1.5;
}
.contact-location-label {
margin-bottom: 4px;
color: var(--brand-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
@@ -453,14 +644,19 @@
.site-footer {
background: var(--brand-primary);
color: #fff;
}
.footer-inner {
display: flex;
min-height: 88px;
align-items: center;
justify-content: space-between;
gap: 20px;
}
@@ -471,7 +667,9 @@
.footer-location,
.footer-copyright {
margin: 0;
color: rgb(255 255 255 / 60%);
font-size: 11px;
}
@@ -479,6 +677,7 @@
from {
transform: scale(1.04);
}
to {
transform: scale(1);
}
@@ -487,10 +686,13 @@
@keyframes content-arrive {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -499,10 +701,13 @@
0%,
100% {
transform: translateY(0);
opacity: 1;
}
65% {
transform: translateY(9px);
opacity: 0;
}
}
@@ -514,24 +719,31 @@
.main-nav {
gap: 22px;
margin-right: 25px;
}
.about-inner {
grid-template-columns: 0.25fr 1.3fr 0.85fr;
gap: 28px;
}
.contact-inner {
grid-template-columns: 1.2fr 0.8fr;
gap: 36px;
}
.contact-location {
grid-column: 2;
padding-top: 20px;
padding-left: 0;
border-top: 1px solid #d9d9d6;
border-left: 0;
}
}
@@ -543,13 +755,17 @@
.brand {
position: relative;
z-index: 2;
max-width: calc(100% - 105px);
font-size: 15px;
}
.brand-logo {
width: 34px;
height: 34px;
}
@@ -559,18 +775,27 @@
.menu-toggle {
position: relative;
z-index: 2;
display: block;
}
.main-nav {
position: absolute;
inset: 0 0 auto;
display: none;
align-items: stretch;
gap: 0;
margin: 0;
padding: 82px 24px 18px;
background: var(--brand-primary);
}
@@ -580,12 +805,15 @@
.main-nav a {
padding: 15px 0;
border-top: 1px solid rgb(255 255 255 / 17%);
}
.hero {
min-height: 700px;
min-height: max(680px, 100svh);
align-items: end;
}
@@ -599,36 +827,43 @@
.hero-content {
padding-top: 120px;
padding-bottom: 74px;
}
.hero-title {
max-width: 500px;
font-size: clamp(46px, 13vw, 68px);
}
.hero-description {
max-width: 420px;
font-size: 15px;
}
.hero-proof {
gap: 12px 0;
margin-top: 42px;
}
.hero-proof span {
padding-inline: 10px;
font-size: 10px;
}
.hero-scroll {
right: 20px;
bottom: 23px;
}
.section-heading {
display: block;
margin-bottom: 32px;
}
@@ -649,7 +884,9 @@
.service-item,
.service-item:not(:nth-child(3n + 1)) {
min-height: 0;
padding: 20px 0 22px;
border-left: 0;
}
@@ -659,6 +896,7 @@
.about-inner {
grid-template-columns: 1fr;
gap: 23px;
}
@@ -668,24 +906,31 @@
.about-note {
max-width: 420px;
margin: 16px 0 0;
}
.contact-inner {
grid-template-columns: 1fr;
gap: 28px;
}
.contact-location {
grid-column: auto;
padding-top: 20px;
}
.footer-inner {
min-height: 0;
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding-block: 25px;
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
:root {
--brand-primary: #111827;
--brand-accent: #f97316;
--brand-accent: #ad4f08;
--brand-bg: #ffffff;
--brand-text: #111111;
--brand-muted: #687078;
--brand-muted: #666d74;
--brand-soft: #f3f1ed;
--font-body: "DM Sans", sans-serif;
--font-display: "Manrope", sans-serif;
+1
View File
@@ -11,6 +11,7 @@
<title>Local Service Business</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" sizes="16x16" href="./assets/icons/favicon-16x16.png" />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
+70 -20
View File
@@ -1,13 +1,25 @@
const hasValue = (value) => typeof value === "string" && value.trim() !== "";
const setHidden = (selector, hidden) => {
document.querySelectorAll(selector).forEach((element) => {
element.hidden = hidden;
});
};
// Fills an element and hides it when the value is empty.
const text = (selector, value) => {
const element = document.querySelector(selector);
if (element) element.textContent = value ?? "";
if (!element) return;
element.textContent = value ?? "";
element.hidden = !hasValue(value);
};
const setLink = (selector, href, label) => {
const element = document.querySelector(selector);
if (!element) return;
element.href = href;
element.textContent = label;
element.textContent = label ?? "";
element.hidden = !hasValue(label);
};
const addText = (parent, tag, className, value) => {
@@ -34,44 +46,66 @@ const renderBusiness = (business) => {
const brand = document.querySelector(".brand");
brand.setAttribute("aria-label", business.homeLabel);
const logo = document.querySelector(".brand-logo");
if (business.logo) {
if (hasValue(business.logo)) {
logo.src = business.logo;
logo.alt = business.logoAlt || business.name;
logo.hidden = false;
// A logo that fails to load falls back to the business name.
logo.addEventListener("error", () => {
logo.hidden = true;
});
}
const phoneHref = `tel:${(business.phone ?? "").replace(/[^\d+]/g, "")}`;
document.querySelectorAll("[data-phone-link]").forEach((link) => {
link.href = `tel:${business.phone.replace(/[^\d+]/g, "")}`;
link.href = phoneHref;
});
setLink(
".hero-primary",
`tel:${business.phone.replace(/[^\d+]/g, "")}`,
business.hero.primaryAction,
);
setLink(".hero-primary", phoneHref, business.hero.primaryAction);
setLink(".hero-secondary", "#services", business.hero.secondaryAction);
text(".hero-eyebrow", business.hero.eyebrow);
text(".hero-title", business.hero.title);
text(".hero-description", business.hero.description);
text(".hero-proof", "");
const proof = document.querySelector(".hero-proof");
business.highlights.forEach((item) => addText(proof, "span", "", item));
const proof = document.querySelector(".hero-proof");
const highlights = (business.highlights ?? []).filter(hasValue);
proof.textContent = "";
highlights.forEach((item) => addText(proof, "span", "", item));
proof.hidden = highlights.length === 0;
// Without a usable image the hero keeps its plain dark background.
const heroImage = document.querySelector(".hero-image");
heroImage.src = business.hero.image;
heroImage.alt = business.hero.imageAlt;
if (hasValue(business.hero.image)) {
heroImage.addEventListener("error", () => {
heroImage.hidden = true;
});
heroImage.src = business.hero.image;
heroImage.alt = business.hero.imageAlt ?? "";
} else {
heroImage.hidden = true;
}
text(".services-eyebrow", business.servicesSection.eyebrow);
text(".services-title", business.servicesSection.title);
text(".services-intro", business.servicesSection.description);
const serviceList = document.querySelector(".service-list");
business.services.forEach((service, index) => {
const services = (business.services ?? []).filter((service) =>
hasValue(service.name),
);
services.forEach((service, index) => {
const item = document.createElement("article");
item.className = "service-item";
addText(item, "span", "service-index", String(index + 1).padStart(2, "0"));
addText(item, "h3", "", service.name);
addText(item, "p", "", service.description);
if (hasValue(service.description))
addText(item, "p", "", service.description);
serviceList.append(item);
});
if (services.length === 0) {
setHidden(
"#services, [data-nav-services], .hero-secondary, .hero-scroll",
true,
);
}
text(".about-marker .about-number", business.about.number);
text(".about-eyebrow", business.about.eyebrow);
@@ -81,18 +115,34 @@ const renderBusiness = (business) => {
text(".about-quote", business.about.quote);
text(".about-quote-byline", business.about.quoteByline);
// The quote treatment needs a quote; the section needs a title, a description, or a quote.
const hasQuote = hasValue(business.about.quote);
setHidden(".about-note", !hasQuote);
const hasAbout =
hasValue(business.about.title) ||
hasValue(business.about.description) ||
hasQuote;
if (!hasAbout) setHidden("#about, [data-nav-about]", true);
text(".contact-eyebrow", business.contact.eyebrow);
text(".contact-title", business.contact.title);
text(".contact-description", business.contact.description);
const contactPhone = document.querySelector(".contact-phone");
contactPhone.href = `tel:${business.phone.replace(/[^\d+]/g, "")}`;
contactPhone.textContent = business.contact.phoneLabel;
setLink(".contact-email", `mailto:${business.email}`, business.email);
setLink(".contact-phone", phoneHref, business.contact.phoneLabel);
setLink(".contact-email", `mailto:${business.email ?? ""}`, business.email);
text(".contact-location-label", business.contact.locationLabel);
text(".contact-address", business.location);
text(".contact-hours", business.hours);
text(".footer-location", business.location);
text(".footer-copyright", `© ${new Date().getFullYear()} ${business.name}`);
if (!hasValue(business.phone)) {
setHidden("[data-phone-link], .hero-primary", true);
}
const hasLocationBlock =
hasValue(business.contact.locationLabel) ||
hasValue(business.location) ||
hasValue(business.hours);
setHidden(".contact-location", !hasLocationBlock);
};
const menuToggle = document.querySelector(".menu-toggle");