diff --git a/.gitea/workflows/deploy-blog.yml b/.gitea/workflows/deploy-blog.yml index a8051b7..de3ce3c 100644 --- a/.gitea/workflows/deploy-blog.yml +++ b/.gitea/workflows/deploy-blog.yml @@ -2,7 +2,7 @@ name: Deploy blog static Worker preview on: push: - branches: [astro-platform-pilot] + branches: [astro-platform-pilot, markdown-content-pilot] paths: - .gitea/workflows/deploy-blog.yml - astro.config.mjs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 971e735..08f7925 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy primary static Worker preview on: push: - branches: [astro-platform-pilot] + branches: [astro-platform-pilot, markdown-content-pilot] paths: - .gitea/workflows/deploy.yml - astro.config.mjs diff --git a/README.md b/README.md index 3ed9c78..b32e2e0 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ Cloudflare Pages site remains the rollback target while this branch is tested. - `src/` contains shared Astro presentation code. - `schemas/` and `fixtures/` provide the Phase 1 JSON contract proof. -The blog is static JSON in this phase. Markdown content, CMS workflows, public -sign-up changes, n8n changes, and onboarding-worker changes are deliberately -out of scope. +Blog posts and primary-site FAQs are now static Markdown collections with +validated frontmatter and draft filtering. CMS workflows, public sign-up +changes, n8n changes, and onboarding-worker changes remain out of scope. ## Local checks @@ -33,8 +33,9 @@ routes, account IDs, or secrets. ## Preview deployment -The Gitea workflow deploys only a non-production Worker from the pilot branch: -`3031023-xyz-www-preview` by default, or the manually selected `blog` site. +The Gitea workflows deploy scoped non-production Workers from the pilot and +Markdown-test branches: `3031023-xyz-www-preview` for primary-site changes and +`3031023-xyz-blog-preview` for blog changes. It uses the existing `CF_API_TOKEN` and `CF_ACCOUNT_ID`. The token retains the Pages permissions needed for the `main` rollback target and now also permits the non-production Worker preview deployment. diff --git a/docs/content-model.md b/docs/content-model.md index d1c0256..ffa6d36 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -6,10 +6,21 @@ whether a physical-address map is displayed. Shared Astro components render that data without a per-site code fork. Current reusable primitives include the base SEO layout, header/footer, -full-page hero, section heading, CTA band, review cards, and optional location -map. The optional `faq` array provides accessible, static question/answer -accordions and stays empty until approved content exists. A real logo can be provided as `/images/.svg` or image asset; -until then the configured short mark and shared favicon are used. +full-page hero, section heading, CTA band, review cards, Markdown-backed FAQ +accordions, and an optional location map. A real logo can be provided as +`/images/.svg` or image asset; until then the configured short mark +and shared favicon are used. + +Phase 6 adds two validated Markdown collections: + +- `sites/blog/content/posts/*.md` produces the journal listing and individual + `/posts//` pages on `blog.3031023.xyz`. +- `sites/www/content/faq/*.md` produces the FAQ section on the primary-site + homepage. + +Both collections require frontmatter and support `draft: true`. Draft entries +are excluded from the rendered pages and sitemap. This is a Git-backed pilot; +it does not add a CMS or client-editing access. Images must live under `public/images/`, be referenced with descriptive alt text where rendered, and remain at or below the 500 KiB pilot limit. The build diff --git a/schemas/site.schema.json b/schemas/site.schema.json index c8fc059..e63a1c0 100644 --- a/schemas/site.schema.json +++ b/schemas/site.schema.json @@ -17,10 +17,8 @@ "features": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/card" } }, "services": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/service" } }, "reviews": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/review" } }, - "faq": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/faq" } }, "story": { "$ref": "#/$defs/story" }, - "team": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/team" } }, - "posts": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/post" } } + "team": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/team" } } }, "$defs": { "text": { "type": "string", "minLength": 1, "maxLength": 1000 }, @@ -34,9 +32,7 @@ "card": { "type": "object", "additionalProperties": false, "required": ["title", "description"], "properties": { "icon": { "type": "string", "maxLength": 8 }, "title": { "$ref": "#/$defs/text" }, "description": { "$ref": "#/$defs/text" } } }, "service": { "type": "object", "additionalProperties": false, "required": ["title", "description", "price", "items"], "properties": { "title": { "$ref": "#/$defs/text" }, "description": { "$ref": "#/$defs/text" }, "price": { "type": "string", "maxLength": 48 }, "items": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "$ref": "#/$defs/text" } } } }, "review": { "type": "object", "additionalProperties": false, "required": ["quote", "author", "rating"], "properties": { "quote": { "$ref": "#/$defs/text" }, "author": { "type": "string", "minLength": 1, "maxLength": 120 }, "rating": { "type": "integer", "minimum": 1, "maximum": 5 } } }, - "faq": { "type": "object", "additionalProperties": false, "required": ["question", "answer"], "properties": { "question": { "type": "string", "minLength": 1, "maxLength": 180 }, "answer": { "$ref": "#/$defs/text" } } }, "story": { "type": "object", "additionalProperties": false, "required": ["heading", "copy", "image", "imageAlt"], "properties": { "heading": { "$ref": "#/$defs/text" }, "copy": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" }, "imageAlt": { "$ref": "#/$defs/text" } } }, - "team": { "type": "object", "additionalProperties": false, "required": ["name", "role", "bio", "image"], "properties": { "name": { "$ref": "#/$defs/text" }, "role": { "$ref": "#/$defs/text" }, "bio": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" } } }, - "post": { "type": "object", "additionalProperties": false, "required": ["title", "excerpt", "date", "tag"], "properties": { "title": { "$ref": "#/$defs/text" }, "excerpt": { "$ref": "#/$defs/text" }, "date": { "$ref": "#/$defs/text" }, "tag": { "$ref": "#/$defs/text" } } } + "team": { "type": "object", "additionalProperties": false, "required": ["name", "role", "bio", "image"], "properties": { "name": { "$ref": "#/$defs/text" }, "role": { "$ref": "#/$defs/text" }, "bio": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" } } } } } diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index 45d33d2..61e9e8e 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -14,4 +14,21 @@ for (const { file, html } of pages) { if (!/[^<]+<\/title>/.test(html)) throw new Error(`${file} is missing a title.`); for (const match of html.matchAll(/(?:src|href)="(\/images\/[^"?#]+)"/g)) await access(resolve(root, `.${match[1]}`)); } + +const home = await readFile(resolve(root, 'index.html'), 'utf8'); +if (key === 'www') { + if (!home.includes('What services do you offer?') || !home.includes('How do I book a visit?')) throw new Error('Primary FAQ Markdown entries are missing from the homepage.'); + if (home.includes('What information should I bring to a meet and greet?')) throw new Error('Draft FAQ entry appeared in the homepage.'); +} +if (key === 'blog') { + const publishedPosts = ['first-walk-after-work', 'gentler-grooming-day', 'overnight-care-checklist']; + await Promise.all(publishedPosts.map((slug) => access(resolve(root, `posts/${slug}/index.html`)))); + if (home.includes('Draft only example for editorial review')) throw new Error('Draft blog post appeared in the journal.'); + try { + await access(resolve(root, 'posts/draft-only-example/index.html')); + throw new Error('Draft blog post generated a public page.'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} console.log(`Verified ${key}: ${required.join(', ')}`); diff --git a/sites/blog/content/posts/draft-only-example.md b/sites/blog/content/posts/draft-only-example.md new file mode 100644 index 0000000..b2ca516 --- /dev/null +++ b/sites/blog/content/posts/draft-only-example.md @@ -0,0 +1,9 @@ +--- +title: Draft only example for editorial review +description: A deliberately unpublished entry used to verify that drafts remain out of the generated journal and sitemap. +pubDate: 2026-09-03 +tags: [Editorial] +draft: true +--- + +This draft should never appear in the generated site until its frontmatter is changed to `draft: false`. diff --git a/sites/blog/content/posts/first-walk-after-work.md b/sites/blog/content/posts/first-walk-after-work.md new file mode 100644 index 0000000..139d2ef --- /dev/null +++ b/sites/blog/content/posts/first-walk-after-work.md @@ -0,0 +1,17 @@ +--- +title: A better first walk after a busy day +description: Three low-pressure ways to help your dog transition from indoor energy to an enjoyable neighborhood walk. +pubDate: 2026-09-02 +tags: [Walking, Routine] +draft: false +--- + +The first few minutes outside can set the tone for the whole walk. Start with a slower block, let your dog sniff, and save the brisk pace for after they have settled in. + +## Try this simple reset + +1. Fill the water bowl before leaving. +2. Give your dog a minute to look and sniff near home. +3. Keep the first turn predictable, then add a little variety later. + +Small routines make the end of a busy day feel calmer for everyone. diff --git a/sites/blog/content/posts/gentler-grooming-day.md b/sites/blog/content/posts/gentler-grooming-day.md new file mode 100644 index 0000000..bae7ca9 --- /dev/null +++ b/sites/blog/content/posts/gentler-grooming-day.md @@ -0,0 +1,11 @@ +--- +title: How to make grooming day feel gentler +description: A few familiar at-home routines can make a professional grooming appointment calmer for dogs and their people. +pubDate: 2026-08-26 +tags: [Grooming, Comfort] +draft: false +--- + +Grooming goes more smoothly when the experience is built from small, familiar steps. A short brush at home and a quiet arrival can help your dog know what to expect. + +Bring any care notes that matter to your dog, such as a sensitive spot or a favorite way to be greeted. Clear details help the grooming team work at your dog’s pace. diff --git a/sites/blog/content/posts/overnight-care-checklist.md b/sites/blog/content/posts/overnight-care-checklist.md new file mode 100644 index 0000000..fc2ff6d --- /dev/null +++ b/sites/blog/content/posts/overnight-care-checklist.md @@ -0,0 +1,18 @@ +--- +title: The overnight checklist we share with every family +description: What to pack and what to share with a sitter so your dog can keep a familiar rhythm while you are away. +pubDate: 2026-08-19 +tags: [Overnight care, Planning] +draft: false +--- + +The best overnight plan is usually the one that feels most like home. Pack enough food, a familiar blanket, and the small routine details that make your dog feel settled. + +## Share the essentials + +- Feeding and medication instructions +- Walk and sleep routines +- Your veterinarian and emergency contact +- A few things your dog enjoys and avoids + +This pilot post is sample content for testing the Markdown workflow. diff --git a/sites/blog/site.json b/sites/blog/site.json index a641e21..cd4af32 100644 --- a/sites/blog/site.json +++ b/sites/blog/site.json @@ -5,7 +5,5 @@ "branding": { "wordmark": "Happy Tails Journal", "mark": "HJ", "logo": null }, "seo": { "socialImage": "/images/puppy-playing.jpg" }, "nav": [{ "label": "Journal", "href": "/" }, { "label": "Happy Tails", "href": "https://3031023.xyz" }], - "hero": { "eyebrow": "From the Happy Tails pack", "headline": "Better days with your best dog.", "copy": "A small, static sample blog for tips, routines, and dog-care inspiration.", "image": "/images/puppy-playing.jpg", "imageAlt": "A playful puppy outdoors", "cta": "Read the journal", "ctaHref": "/" }, - "posts": [{ "title": "A better first walk after a busy day", "excerpt": "Three easy ways to help your dog shift from indoor energy to an enjoyable neighborhood walk.", "date": "September 2, 2026", "tag": "Walking" }, { "title": "How to make grooming day feel gentler", "excerpt": "Small routines at home can make a professional grooming appointment calmer for everyone.", "date": "August 26, 2026", "tag": "Grooming" }, { "title": "The overnight checklist we share with every family", "excerpt": "What to pack—and what to tell your sitter—so your dog keeps their familiar rhythm.", "date": "August 19, 2026", "tag": "Overnight care" }], - "faq": [] + "hero": { "eyebrow": "From the Happy Tails pack", "headline": "Better days with your best dog.", "copy": "A small, static Markdown-powered journal for tips, routines, and dog-care inspiration.", "image": "/images/puppy-playing.jpg", "imageAlt": "A playful puppy outdoors", "cta": "Read the journal", "ctaHref": "/" } } diff --git a/sites/www/content/faq/draft-only-answer.md b/sites/www/content/faq/draft-only-answer.md new file mode 100644 index 0000000..1d27a5d --- /dev/null +++ b/sites/www/content/faq/draft-only-answer.md @@ -0,0 +1,8 @@ +--- +question: What information should I bring to a meet and greet? +category: Booking +order: 30 +draft: true +--- + +This is a deliberately unpublished FAQ answer. It verifies that drafts do not appear on the public home page. diff --git a/sites/www/content/faq/how-do-i-book.md b/sites/www/content/faq/how-do-i-book.md new file mode 100644 index 0000000..3996889 --- /dev/null +++ b/sites/www/content/faq/how-do-i-book.md @@ -0,0 +1,8 @@ +--- +question: How do I book a visit? +category: Booking +order: 20 +draft: false +--- + +For this pilot, the contact form is intentionally disabled until the approved server-mapped contact workflow is connected. The page clearly explains that it cannot send a request yet. diff --git a/sites/www/content/faq/what-services-do-you-offer.md b/sites/www/content/faq/what-services-do-you-offer.md new file mode 100644 index 0000000..9aeffef --- /dev/null +++ b/sites/www/content/faq/what-services-do-you-offer.md @@ -0,0 +1,8 @@ +--- +question: What services do you offer? +category: Services +order: 10 +draft: false +--- + +Happy Tails offers sample **dog walking, grooming, and overnight care** options in this pilot site. A live client site would replace this answer with approved business details. diff --git a/sites/www/site.json b/sites/www/site.json index 8de6f57..098a649 100644 --- a/sites/www/site.json +++ b/sites/www/site.json @@ -11,7 +11,6 @@ "features": [{ "icon": "◎", "title": "Personalized care", "description": "Every visit follows your dog's pace, routine, and personality." }, { "icon": "↗", "title": "Updates you can trust", "description": "Photos and thoughtful notes keep you close to every adventure." }, { "icon": "✦", "title": "Experienced hands", "description": "A small, caring team with safety and comfort at the center." }], "services": [{ "title": "Dog walking", "description": "One-on-one neighborhood walks with photo updates and water breaks.", "price": "$25 / walk", "items": ["GPS-tracked routes", "Flexible scheduling", "Post-walk notes"] }, { "title": "Grooming", "description": "A calmer, kinder grooming day for dogs of every size.", "price": "From $40", "items": ["Bathing & blow-dry", "Nail and ear care", "Full groom available"] }, { "title": "Overnight care", "description": "A familiar, cozy routine while you are away.", "price": "$65 / night", "items": ["Evening & morning walks", "Feeding routine", "24-hour supervision"] }], "reviews": [{ "quote": "Marcus sends the best updates. Our dog recognizes his car and is ready at the door.", "author": "Tom N.", "rating": 5 }, { "quote": "The grooming team made bath time stress-free for the first time ever.", "author": "Jennifer W.", "rating": 5 }, { "quote": "We came home to a relaxed, happy puppy. That peace of mind is everything.", "author": "Lisa P.", "rating": 5 }], - "faq": [], "story": { "heading": "A smaller pack. A more personal kind of care.", "copy": "Happy Tails began with a few neighborhood walks and a belief that every dog deserves to be known—not just scheduled. Today, our close-knit team brings the same attentive care to every walk, groom, and sleepover.", "image": "/images/happy-tails-team.jpg", "imageAlt": "Happy Tails team with dogs in the park" }, "team": [{ "name": "Sarah Mitchell", "role": "Founder & Head Groomer", "bio": "A gentle grooming specialist with ten years of experience.", "image": "/images/sarah-mitchell.jpg" }, { "name": "Marcus Rivera", "role": "Lead Trainer", "bio": "Positive-reinforcement expert and champion pack leader.", "image": "/images/marcus-rivera.jpg" }, { "name": "Emily Chen", "role": "Pack Walker", "bio": "A marathon runner who keeps every pup moving and smiling.", "image": "/images/emily-chen.jpg" }, { "name": "David Okonkwo", "role": "Overnight Care", "bio": "The calm, caring presence behind cozy overnight stays.", "image": "/images/david-okonkwo.jpg" }] } diff --git a/src/components/Faq.astro b/src/components/Faq.astro index 9cdd3f3..51c0d8a 100644 --- a/src/components/Faq.astro +++ b/src/components/Faq.astro @@ -1,7 +1,9 @@ --- -const { heading = 'Frequently asked questions', items = [] } = Astro.props; +import { render } from 'astro:content'; +const { heading = 'Frequently asked questions', entries = [] } = Astro.props; +const items = await Promise.all(entries.map(async (entry) => ({ ...entry.data, Content: (await render(entry)).Content }))); if (!items.length) return; --- <section class="section faq-section" aria-labelledby="faq-heading"> - <div class="shell narrow"><p class="eyebrow">Helpful answers</p><h2 id="faq-heading">{heading}</h2><div class="faq-list">{items.map((item) => <details><summary>{item.question}</summary><p>{item.answer}</p></details>)}</div></div> + <div class="shell narrow"><p class="eyebrow">Helpful answers</p><h2 id="faq-heading">{heading}</h2><div class="faq-list">{items.map((item) => <details><summary>{item.question}</summary><div class="faq-answer"><item.Content /></div></details>)}</div></div> </section> diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..57d7a08 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,26 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const blog = defineCollection({ + loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }), + schema: z.object({ + title: z.string().min(12).max(100), + description: z.string().min(30).max(180), + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + tags: z.array(z.string().min(2).max(32)).min(1).max(4), + draft: z.boolean().default(false), + }), +}); + +const faq = defineCollection({ + loader: glob({ base: './sites/www/content/faq', pattern: '**/*.md' }), + schema: z.object({ + question: z.string().min(12).max(180), + category: z.string().min(2).max(48).default('General'), + order: z.number().int().min(0).max(99).default(0), + draft: z.boolean().default(false), + }), +}); + +export const collections = { blog, faq }; diff --git a/src/pages/index.astro b/src/pages/index.astro index a1f2afb..fde9143 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,14 +1,17 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; +import { getCollection } from 'astro:content'; import Faq from '../components/Faq.astro'; import Hero from '../components/Hero.astro'; import { isBlog, site } from '../lib/site-data.js'; +const posts = isBlog ? (await getCollection('blog', ({ data }) => !data.draft)).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) : []; +const faqs = !isBlog ? (await getCollection('faq', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order) : []; --- <BaseLayout> <Hero /> - {isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{site.posts.map((post) => <article class="post-card"><p class="tag">{post.tag}</p><h3>{post.title}</h3><p>{post.excerpt}</p><footer>{post.date}</footer></article>)}</div><p class="phase-note">This static sample deliberately uses JSON. Markdown publishing starts only in the later, separately approved content-update phase.</p></div></section> : <> + {isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{posts.map((post) => <article class="post-card"><p class="tag">{post.data.tags.join(' · ')}</p><h3><a href={`/posts/${post.id}/`}>{post.data.title}</a></h3><p>{post.data.description}</p><footer>{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}</footer></article>)}</div><p class="phase-note">This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.</p></div></section> : <> <section class="section"><div class="shell"><div class="section-heading split"><div><p class="eyebrow">The Happy Tails difference</p><h2>A familiar face, a thoughtful routine, a very happy dog.</h2></div><p>We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.</p></div><div class="feature-grid">{site.features.map((feature) => <article class="feature"><span>{feature.icon}</span><h3>{feature.title}</h3><p>{feature.description}</p></article>)}</div></div></section> <section class="section surface"><div class="shell story-grid"><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /><div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section> - <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq items={site.faq} /> + <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq entries={faqs} /> </>} </BaseLayout> diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro new file mode 100644 index 0000000..87d24a5 --- /dev/null +++ b/src/pages/posts/[slug].astro @@ -0,0 +1,17 @@ +--- +import { getCollection, render } from 'astro:content'; +import BaseLayout from '../../layouts/BaseLayout.astro'; + +export async function getStaticPaths() { + if (process.env.SITE_KEY !== 'blog') return []; + const posts = await getCollection('blog', ({ data }) => !data.draft); + return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); +} + +const { post } = Astro.props; +const { Content } = await render(post); +const published = post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); +--- +<BaseLayout title={`${post.data.title} | The Happy Tails Journal`} description={post.data.description}> + <article class="article"><div class="shell narrow"><p class="eyebrow">{post.data.tags.join(' · ')}</p><h1>{post.data.title}</h1><p class="lede">{post.data.description}</p><p class="article-date">Published {published}</p><div class="article-body"><Content /></div><a class="text-link" href="/">← Back to the journal</a></div></article> +</BaseLayout> diff --git a/src/styles/global.css b/src/styles/global.css index 5419797..4cec8d4 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -50,7 +50,7 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); } .centered { text-align: center; }.centered .lede { margin-inline: auto; } .contact-grid { align-items: start; }.contact-details { margin-top: 2rem; font-style: normal; color: #52616b; }.contact-details p { margin-bottom: .8rem; }.contact-details a { color: var(--ink); font-weight: 700; }.contact-form { display: grid; gap: 1rem; padding: 2rem; background: var(--surface); border-radius: .85rem; }.contact-form label { display: grid; gap: .4rem; font-size: .9rem; font-weight: 700; }.contact-form input,.contact-form textarea { width: 100%; padding: .8rem; font: inherit; border: 1px solid #cbd5d1; border-radius: .35rem; background: #fff; }.contact-form button:disabled { cursor: not-allowed; opacity: .6; }.contact-form p { margin-bottom: 0; color: #66747d; font-size: .85rem; } .location-map { display: grid; grid-template-columns: .75fr 1.25fr; overflow: hidden; border: 1px solid #dce4e1; border-radius: 1rem; background: #fff; }.map-copy { padding: clamp(2rem, 5vw, 4rem); }.map-copy h3 { font-family: 'DM Serif Display', Georgia, serif; font-size: 2.25rem; font-weight: 400; line-height: 1.05; }.map-copy p:not(.eyebrow) { color: #52616b; }.location-map iframe { display: block; min-height: 360px; width: 100%; border: 0; filter: saturate(.8) contrast(.96); } -.post-card { padding: 2rem; border-radius: .85rem; background: #fff; border: 1px solid #dce4e1; }.post-card h3 { font-family: 'DM Serif Display', Georgia, serif; font-size: 1.75rem; font-weight: 400; line-height: 1.1; }.post-card footer { color: #75828c; font-size: .85rem; }.not-found { min-height: 55vh; display: grid; place-items: center; } -.narrow { width: min(760px, calc(100% - 3rem)); }.faq-list { border-top: 1px solid #dce4e1; }.faq-list details { border-bottom: 1px solid #dce4e1; padding: 1.1rem 0; }.faq-list summary { cursor: pointer; font-weight: 700; list-style: none; padding-right: 2rem; position: relative; }.faq-list summary::after { content: '+'; position: absolute; right: .25rem; color: var(--accent); font-size: 1.3rem; top: -.3rem; }.faq-list details[open] summary::after { content: '−'; }.faq-list p { margin: 1rem 2rem .2rem 0; color: #52616b; } +.post-card { padding: 2rem; border-radius: .85rem; background: #fff; border: 1px solid #dce4e1; }.post-card h3 { font-family: 'DM Serif Display', Georgia, serif; font-size: 1.75rem; font-weight: 400; line-height: 1.1; }.post-card h3 a { text-decoration: none; }.post-card h3 a:hover { color: var(--accent); }.post-card footer { color: #75828c; font-size: .85rem; }.not-found { min-height: 55vh; display: grid; place-items: center; } +.narrow { width: min(760px, calc(100% - 3rem)); }.faq-list { border-top: 1px solid #dce4e1; }.faq-list details { border-bottom: 1px solid #dce4e1; padding: 1.1rem 0; }.faq-list summary { cursor: pointer; font-weight: 700; list-style: none; padding-right: 2rem; position: relative; }.faq-list summary::after { content: '+'; position: absolute; right: .25rem; color: var(--accent); font-size: 1.3rem; top: -.3rem; }.faq-list details[open] summary::after { content: '−'; }.faq-answer > :first-child { margin-top: 1rem; }.faq-answer > :last-child { margin-bottom: .2rem; }.faq-answer p,.faq-answer li { color: #52616b; }.article { padding: clamp(4rem, 8vw, 7rem) 0; }.article h1 { max-width: 14ch; font-size: clamp(3rem, 6vw, 5.8rem); }.article-date { color: #75828c; font-size: .9rem; }.article-body { margin: 2.5rem 0; color: #344650; font-size: 1.07rem; }.article-body h2 { margin-top: 2.5rem; font-size: clamp(1.8rem, 3vw, 2.8rem); }.article-body li { margin-bottom: .55rem; } .site-footer { padding: 4rem 0 1.25rem; background: var(--ink); color: #fff; }.site-footer .brand { color: #fff; }.site-footer p { color: #c5d0d6; max-width: 34ch; }.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 3rem; }.footer-link { display: block; margin: .6rem 0; color: #fff; text-decoration: none; }.footer-bottom { display: flex; justify-content: space-between; gap: 1rem; margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid #35505f; color: #c5d0d6; font-size: .85rem; } @media (max-width: 760px) { .shell { width: min(100% - 2rem, 1120px); }.menu-button { display: inline-flex; gap: .5rem; } .nav-wrap { min-height: 68px; } nav { display: none; position: absolute; top: 68px; right: 1rem; left: 1rem; padding: 1rem; background: #fff; border: 1px solid #e8ecea; border-radius: .6rem; box-shadow: 0 1rem 2rem #102a4320; } nav.is-open { display: grid; } .hero { background: var(--surface); }.hero-grid,.story-grid,.contact-grid,.section-heading.split,.page-intro-grid,.location-map { grid-template-columns: 1fr; gap: 2.5rem; }.page-intro img { max-width: 520px; }.hero-image { max-width: 520px; }.image-note { right: -.5rem; }.feature-grid,.review-grid,.service-grid,.post-grid { grid-template-columns: 1fr; }.service-card.featured,.service-card.featured:hover { transform: none; }.team-grid { grid-template-columns: 1fr; }.location-map iframe { min-height: 300px; }.footer-grid { grid-template-columns: 1fr; gap: 1.5rem; }.footer-bottom { display: block; }.footer-bottom span { display: block; margin-top: .5rem; } }