From 498f1d70924bbca1123407fd898a813746bdada2 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 1 Oct 2026 11:59:07 +0100 Subject: [PATCH] Refine trades starter template --- README.md | 10 ++ assets/icons/favicon-16x16.png | Bin 0 -> 687 bytes css/base.css | 9 +- css/components.css | 247 ++++++++++++++++++++++++++++++++- css/theme.css | 4 +- index.html | 1 + js/main.js | 90 +++++++++--- 7 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 assets/icons/favicon-16x16.png diff --git a/README.md b/README.md index 7013755..e19841b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ Then visit . 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. diff --git a/assets/icons/favicon-16x16.png b/assets/icons/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..bbc0e55a3ced2315a5cf71b7cb273f6cd496e36b GIT binary patch literal 687 zcmV;g0#N;lP)_x)Q{twBSM% ziZ0ZRN(Ec3Ra?}Bf)+tq4Jk2@Uni5KN#=Rqdts! zT@r&^pGqng3mdS7!1qjD#ieNo^f?04i@Lf@#y7b!pRZ#!r3uoXBl40QkC-k7&>^8f zZ$NioF6;Us`u{uVUd*PnU@z^b+i&3W-x`_-Iy%sbXd7P<2)8h$6m>e0o91-g%&HdL z)^4c$)^-=hj`-0`)6He=6@dyBs@nn!1>LUCtp!s>y|5qIIxG^4br|@)RL6z!qS{Mi zgsX2Y9DJ0=WP+5a*7Nx)PCP5%?$-)=z^}ZG{)ail?&om%wS|H+Di>f1kuYr}OrgNi zRH%_JX)@Ar-Xdws1v)N-U=>b%3=2o(e_}1C!x{bu00960Hj$1&00006NklP V@SRyfr0M_w002ovPDHLkV1gw^IGg|g literal 0 HcmV?d00001 diff --git a/css/base.css b/css/base.css index 525d261..b13daa7 100644 --- a/css/base.css +++ b/css/base.css @@ -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 { diff --git a/css/components.css b/css/components.css index 9b080e3..663bdfb 100644 --- a/css/components.css +++ b/css/components.css @@ -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; } } diff --git a/css/theme.css b/css/theme.css index 7bcc766..b8f5bb0 100644 --- a/css/theme.css +++ b/css/theme.css @@ -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; diff --git a/index.html b/index.html index 91cb1a7..03efb8c 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@ Local Service Business + 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");