Build static Astro pilot for Happy Tails

This commit is contained in:
Kevin
2026-09-02 00:54:33 -04:00
parent cfe0a7d98d
commit 56a80951b7
42 changed files with 7171 additions and 134 deletions
+30 -82
View File
@@ -1,93 +1,41 @@
name: Deploy to Cloudflare Pages name: Validate and deploy static Worker preview
# Preview deployments are created for pushes to any non-master branch.
# Pushing to master creates the production deployment (served on custom domain).
# Non-master branches get a unique preview URL for client review.
on: on:
push: push:
branches: [astro-platform-pilot]
workflow_dispatch: workflow_dispatch:
inputs:
env: site_key:
# These will be replaced during site creation description: Site key to deploy to its non-production Worker
CLOUDFLARE_PROJECT_NAME: 3031023-xyz required: true
CLOUDFLARE_CUSTOM_DOMAIN: 3031023.xyz type: choice
options: [www, blog]
jobs: jobs:
deploy: preview:
name: Deploy to Cloudflare Pages name: Preview ${{ inputs.site_key || 'www' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
env:
SITE_KEY: ${{ inputs.site_key || 'www' }}
ASTRO_TELEMETRY_DISABLED: '1'
steps: steps:
- name: Checkout code - name: Checkout source
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Use Node 22
- name: Deploy to Cloudflare Pages (Direct Upload) uses: https://github.com/actions/setup-node@v4
run: npx wrangler pages deploy . --project-name=${{ env.CLOUDFLARE_PROJECT_NAME }} --branch=${GITHUB_REF#refs/heads/} with:
node-version: 22
cache: npm
- name: Install locked dependencies
run: npm ci
- name: Validate and build selected site
run: npm run build:site
- name: Verify selected output
run: npm run verify:build
- name: Deploy non-production Worker
run: npx wrangler deploy --config "wrangler/${SITE_KEY}.jsonc"
env: env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CF_WORKER_PREVIEW_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Attach custom domain (main only)
if: github.ref == 'refs/heads/main'
run: |
echo "Adding domain ${CLOUDFLARE_CUSTOM_DOMAIN} to project ${CLOUDFLARE_PROJECT_NAME}..."
BODY=$(printf '{"name": "%s"}' "${CLOUDFLARE_CUSTOM_DOMAIN}")
HTTP_STATUS=$(curl -s -o /tmp/cf_response.txt -w "%{http_code}" -X POST \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${CLOUDFLARE_PROJECT_NAME}/domains" \
-H "Authorization: Bearer *** " \
-H "Content-Type: application/json" \
-d "${BODY}")
echo "HTTP Status: ${HTTP_STATUS}"
echo "Response: $(cat /tmp/cf_response.txt)"
if [ "${HTTP_STATUS}" -ge 200 ] && [ "${HTTP_STATUS}" -lt 300 ]; then
echo "Domain attached successfully"
else
echo "::warning::Domain attach returned ${HTTP_STATUS} - check domain and permissions"
fi
env:
CR_AUTH: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Create DNS record for custom domain
if: github.ref == 'refs/heads/main'
run: |
# Extract root zone from the custom domain (e.g. product-example.sloanops.com -> sloanops.com)
ROOT_ZONE="${CLOUDFLARE_CUSTOM_DOMAIN#*.}"
echo "Root zone: ${ROOT_ZONE}"
# Look up the Cloudflare zone ID
ZONE_RESPONSE=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${ROOT_ZONE}" \
-H "Authorization: Bearer *** " \
-H "Content-Type: application/json")
ZONE_ID=$(echo "$ZONE_RESPONSE" | python3 -c "import sys,json; data=json.load(sys.stdin); print(data.get('result',[{}])[0].get('id',''))" 2>/dev/null || echo "")
if [ -z "$ZONE_ID" ]; then
echo "::warning::Could not find zone ID for ${ROOT_ZONE} - check Cloudflare token has Zone:Zone:Read permission"
exit 0
fi
echo "Zone ID: ${ZONE_ID}"
# Create CNAME record (skip if it already exists - idempotent)
RECORDS_RESPONSE=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?type=CNAME&name=${CLOUDFLARE_CUSTOM_DOMAIN}" \
-H "Authorization: Bearer *** " \
-H "Content-Type: application/json")
EXISTING=$(echo "$RECORDS_RESPONSE" | python3 -c "import sys,json; data=json.load(sys.stdin); print(len(data.get('result',[])))" 2>/dev/null || echo "0")
if [ "$EXISTING" -gt 0 ]; then
echo "DNS record already exists, skipping creation"
else
BODY=$(printf '{"type":"CNAME","name":"%s","content":"%s.pages.dev","proxied":true}' "${CLOUDFLARE_CUSTOM_DOMAIN}" "${CLOUDFLARE_PROJECT_NAME}")
HTTP_STATUS=$(curl -s -o /tmp/cf_dns_response.txt -w "%{http_code}" -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer *** " \
-H "Content-Type: application/json" \
-d "${BODY}")
echo "DNS record HTTP Status: ${HTTP_STATUS}"
echo "Response: $(cat /tmp/cf_dns_response.txt)"
if [ "${HTTP_STATUS}" -ge 200 ] && [ "${HTTP_STATUS}" -lt 300 ]; then
echo "DNS record created successfully"
else
echo "::warning::DNS record creation returned ${HTTP_STATUS} - ensure token has Zone:DNS:Edit permission"
fi
fi
env:
CR_AUTH: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CLOUDFLARE_CUSTOM_DOMAIN: ${{ env.CLOUDFLARE_CUSTOM_DOMAIN }}
CLOUDFLARE_PROJECT_NAME: ${{ env.CLOUDFLARE_PROJECT_NAME }}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.astro/
.wrangler/
.env
.env.*
!.env.example
+31 -52
View File
@@ -1,64 +1,43 @@
# Happy Tails Pet Care # Happy Tails Astro pilot
A professional website created for Happy Tails Pet Care using SloanOps template system. This repository is a static Astro pilot for `3031023.xyz`. The existing
Cloudflare Pages site remains the rollback target while this branch is tested.
## Overview ## Repository layout
This site was generated using the SloanOps client onboarding system and is designed for deployment to Cloudflare Pages via Gitea Actions CI/CD. - `sites/www/` is the primary-site content location; it serves `3031023.xyz`.
- `sites/blog/` is the separate `blog.3031023.xyz` sample site.
- `sites.json` is the operator-controlled manifest. It is not public intake.
- `src/` contains shared Astro presentation code.
- `schemas/` and `fixtures/` provide the Phase 1 JSON contract proof.
## Customization 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.
### Content Updates ## Local checks
- Edit HTML files in the root directory to update page content
- Modify images in the `images/` directory
- Update text content directly in HTML files
### Styling Use Node 22.
- All colors, fonts, and spacing are controlled via CSS variables in `css/style.css`
- To rebrand: modify the `:root` variables at the top of `css/style.css`
- Font families can be changed in the HTML `<head>` section
### Deployment
Pushes to the `master` branch trigger automatic deployment to Cloudflare Pages.
Preview deployments are created for all other branches.
## Structure
```
3031023-xyz/
├── index.html # Home page
├── about.html # About page (if included)
├── services.html # Services page (if included)
├── products.html # Products page (if included)
├── contact.html # Contact page
├── css/
│ └── style.css # All styles with CSS variables
├── js/
│ └── main.js # Interactive components (mobile menu, etc.)
├── images/ # Image assets
└── .gitea/
└── workflows/
└── deploy.yml # Cloudflare Pages CI/CD configuration
```
## Deployment Setup
1. Ensure Cloudflare API token and account ID are configured as secrets in your Gitea repository:
- `CF_API_TOKEN`: Your Cloudflare API token
- `CF_ACCOUNT_ID`: Your Cloudflare account ID
2. Push to the repository to trigger deployment:
```bash ```bash
git add . npm ci
git commit -m "Initial commit" SITE_KEY=www npm run build:site
git push origin master SITE_KEY=www npm run verify:build
SITE_KEY=blog npm run build:site
SITE_KEY=blog npm run verify:build
``` ```
3. The site will be available at: https://3031023.xyz Each selected site builds to `dist/<site-key>/`. The Worker configurations use
only static assets and a custom 404 page. They contain no custom domains,
routes, account IDs, or secrets.
## Maintenance ## Preview deployment
- Update content by editing HTML files The Gitea workflow deploys only a non-production Worker from the pilot branch:
- Update styling by modifying CSS variables in `css/style.css` `3031023-xyz-www-preview` by default, or the manually selected `blog` site.
- Add new images to the `images/` directory and reference them in HTML It uses repository secrets `CF_WORKER_PREVIEW_TOKEN` and `CF_ACCOUNT_ID`.
- For structural changes, modify the appropriate HTML template Routine jobs do not configure DNS or custom domains.
Production hostname attachment and the `www` redirect are manual,
operator-controlled cutover steps. See `docs/pilot-baseline.md` for the
rollback record.
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static',
build: { format: 'directory' },
vite: { build: { sourcemap: false } }
});
+58
View File
@@ -0,0 +1,58 @@
# Astro pilot baseline and rollback record
Captured: 2026-09-02 00:42 EDT
## Protected rollback target
The existing Cloudflare Pages site remains the production rollback target until
the Worker cutover is accepted. No Pages project, Pages deployment, DNS record,
or custom-domain attachment has been changed by this pilot.
| Item | Recorded value |
| --- | --- |
| Repository baseline | `cfe0a7d98d087b702ba8878c972bbba893c387c6` on `main` |
| Pilot branch | `astro-platform-pilot` |
| Existing deployment model | Cloudflare Pages direct upload from Gitea Actions |
| Existing Pages project | `3031023-xyz` (from committed workflow) |
| Production hostname | `3031023.xyz` |
| Canonical policy | `3031023.xyz`; `www.3031023.xyz` will redirect to the apex after approved cutover |
| Existing alternate hostname | `www.3031023.xyz` did not resolve at baseline capture |
| Current public response | `https://3031023.xyz/` returned HTTP 200 through Cloudflare |
| Analytics measurement ID | `G-Z15EG3SFPD` |
## Rendered-page reference
The current production site returned HTTP 200 for `/`, `/about`, `/services`,
and `/contact`. The legacy `.html` routes also remain reachable. An unknown
route returned the home page with HTTP 200; the Astro Worker will instead use a
real HTTP 404 page.
| Page | Baseline title |
| --- | --- |
| `/` | Happy Tails Pet Care |
| `/about` | About \| Happy Tails Pet Care |
| `/services` | Services \| Happy Tails Pet Care |
| `/contact` | Contact \| Happy Tails Pet Care |
The source pages, ten local images, legacy CSS, and browser scripts are
retained on the baseline commit. The Astro implementation keeps the same page
coverage and existing imagery while intentionally modernizing the presentation.
## Contact-form baseline
The legacy client script prevents the form from delivering a message and shows
a local success state. That behavior must not be carried forward. The Astro
contact UI is presentational until the separately approved n8n
`client-contact` route is ready; it must clearly state that submission is not
active rather than pretending delivery succeeded.
## Cutover and reversal procedure
Before a domain change, the operator must record the live Pages deployment ID,
custom-domain attachment, and DNS values from Cloudflare. The approved Worker
version and its `workers.dev` preview must be recorded as well.
If the Worker cutover fails, restore the recorded Pages custom-domain/DNS
configuration, verify `https://3031023.xyz` externally, and retain the failed
Worker deployment and logs as evidence. Routine CI deployments must never add,
remove, or mutate production domains or DNS.
+20
View File
@@ -0,0 +1,20 @@
# Preview deployment and later cutover controls
The preview workflow deploys static output only. It has two deliberately
separate Worker names:
| Site key | Preview Worker | Future hostname |
| --- | --- | --- |
| `www` | `3031023-xyz-www-preview` | `3031023.xyz` |
| `blog` | `3031023-xyz-blog-preview` | `blog.3031023.xyz` |
`workers.dev` is the only preview destination. Neither Wrangler configuration
contains a route, custom domain, zone ID, account ID, or token. Cloudflare
credentials remain Gitea repository secrets.
The primary Worker must pass its public preview test matrix before an operator
records the exact Pages deployment/DNS state and requests production-cutover
approval. A production deployment should use a separately named approved
Worker or an explicitly promoted Worker version; it must not be an ordinary
push job. Attaching `3031023.xyz`, configuring the `www.3031023.xyz` redirect,
and attaching `blog.3031023.xyz` each remain manual approval-boundary actions.
+7
View File
@@ -0,0 +1,7 @@
{
"schemaVersion": "1.0",
"submission": { "type": "client-signup", "submittedAt": "not-a-date" },
"client": { "businessName": "", "contactEmail": "invalid" },
"domain": { "requestedRootDomain": "not a domain", "management": "unknown" },
"site": {}
}
+17
View File
@@ -0,0 +1,17 @@
{
"schemaVersion": "1.0",
"submission": { "type": "client-signup", "submittedAt": "2026-09-02T04:42:47Z" },
"client": { "businessName": "Happy Tails Pet Care", "contactEmail": "hello@3031023.xyz" },
"domain": { "requestedRootDomain": "3031023.xyz", "management": "client_managed" },
"site": {
"title": "Happy Tails Pet Care",
"description": "Professional dog walking, grooming, and pet care services for Springfield families.",
"brand": { "primaryColor": "#155e75", "secondaryColor": "#f59e0b", "headingFont": "DM Serif Display", "bodyFont": "DM Sans" },
"contact": { "publicEmail": "hello@3031023.xyz", "phone": "+1 (555) 742-9283" },
"hero": { "headline": "Care that feels like family.", "subheadline": "Personal dog care for every walk, stay, and wag." },
"features": [{ "title": "Personal care", "description": "Tailored to every dog." }],
"services": [{ "title": "Dog walking", "description": "One-on-one neighborhood walks." }],
"team": [{ "title": "Sarah Mitchell", "description": "Founder and Head Groomer." }],
"testimonials": [{ "title": "Jennifer Walsh", "description": "Our dog loves Happy Tails." }]
}
}
+6647
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "3031023-xyz",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": { "node": ">=22.0.0 <23" },
"scripts": {
"validate": "node scripts/validate.mjs",
"build": "npm run validate && ASTRO_TELEMETRY_DISABLED=1 astro build",
"build:site": "node scripts/build-site.mjs",
"verify:build": "node scripts/verify-build.mjs",
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview"
},
"devDependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"astro": "^6.0.0",
"wrangler": "^4.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN
Permissions-Policy: geolocation=(), microphone=(), camera=()
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sloanops.com/schemas/intake.v1.schema.json",
"title": "SloanOps approved intake v1",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "submission", "client", "domain", "site"],
"properties": {
"schemaVersion": { "const": "1.0" },
"submission": { "type": "object", "additionalProperties": false, "required": ["type", "submittedAt"], "properties": { "type": { "const": "client-signup" }, "submittedAt": { "type": "string", "format": "date-time" } } },
"client": { "type": "object", "additionalProperties": false, "required": ["businessName", "contactEmail"], "properties": { "businessName": { "type": "string", "minLength": 1, "maxLength": 120 }, "contactEmail": { "type": "string", "format": "email", "maxLength": 254 } } },
"domain": { "type": "object", "additionalProperties": false, "required": ["requestedRootDomain", "management"], "properties": { "requestedRootDomain": { "type": "string", "pattern": "^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$" }, "management": { "enum": ["client_managed", "sloanops_managed"] } } },
"site": { "type": "object", "additionalProperties": false, "required": ["title", "description", "brand", "contact", "hero", "features", "services", "team", "testimonials"], "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "description": { "type": "string", "minLength": 1, "maxLength": 200 }, "brand": { "$ref": "#/$defs/brand" }, "contact": { "$ref": "#/$defs/contact" }, "hero": { "$ref": "#/$defs/hero" }, "features": { "$ref": "#/$defs/contentItems" }, "services": { "$ref": "#/$defs/contentItems" }, "team": { "$ref": "#/$defs/contentItems" }, "testimonials": { "$ref": "#/$defs/contentItems" } } }
},
"$defs": {
"brand": { "type": "object", "additionalProperties": false, "required": ["primaryColor", "secondaryColor", "headingFont", "bodyFont"], "properties": { "primaryColor": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, "secondaryColor": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, "headingFont": { "type": "string", "minLength": 1, "maxLength": 80 }, "bodyFont": { "type": "string", "minLength": 1, "maxLength": 80 } } },
"contact": { "type": "object", "additionalProperties": false, "required": ["publicEmail", "phone"], "properties": { "publicEmail": { "type": "string", "format": "email", "maxLength": 254 }, "phone": { "type": "string", "pattern": "^[+0-9(). -]{7,30}$" } } },
"hero": { "type": "object", "additionalProperties": false, "required": ["headline", "subheadline"], "properties": { "headline": { "type": "string", "minLength": 1, "maxLength": 160 }, "subheadline": { "type": "string", "minLength": 1, "maxLength": 280 } } },
"contentItems": { "type": "array", "maxItems": 24, "items": { "type": "object", "additionalProperties": true, "required": ["title", "description"], "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "description": { "type": "string", "minLength": 1, "maxLength": 1000 } } } }
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sloanops.com/schemas/sites.schema.json",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "rootDomain", "repository", "sites"],
"properties": {
"schemaVersion": { "const": "1.0" },
"rootDomain": { "const": "3031023.xyz" },
"repository": { "const": "3031023-xyz" },
"sites": { "type": "object", "minProperties": 1, "additionalProperties": { "$ref": "#/$defs/site" } }
},
"$defs": {
"site": { "type": "object", "additionalProperties": false, "required": ["hostname", "workerName", "contactSiteKey", "status"], "properties": { "hostname": { "type": "string", "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$" }, "alternateHostnames": { "type": "array", "items": { "type": "string" }, "maxItems": 4 }, "workerName": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$" }, "contactSiteKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$" }, "status": { "enum": ["preview", "approved", "production"] } } }
}
}
+13
View File
@@ -0,0 +1,13 @@
import { existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
const key = process.env.SITE_KEY;
if (!key || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(key) || !existsSync(`sites/${key}/site.json`)) {
throw new Error('SITE_KEY must be a manifest-defined site directory.');
}
const result = spawnSync('npm', ['run', 'build', '--', '--outDir', `dist/${key}`], {
env: { ...process.env, SITE_KEY: key },
stdio: 'inherit'
});
if (result.status !== 0) process.exit(result.status ?? 1);
+22
View File
@@ -0,0 +1,22 @@
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { readFile } from 'node:fs/promises';
const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const [intakeSchema, sitesSchema, validIntake, invalidIntake, sites] = await Promise.all([
readJson('schemas/intake.v1.schema.json'),
readJson('schemas/sites.schema.json'),
readJson('fixtures/intake.v1.valid.json'),
readJson('fixtures/intake.v1.invalid.json'),
readJson('sites.json')
]);
const validIntakeCheck = ajv.compile(intakeSchema);
const sitesCheck = ajv.compile(sitesSchema);
if (!validIntakeCheck(validIntake)) throw new Error(`Valid intake rejected: ${ajv.errorsText(validIntakeCheck.errors)}`);
if (validIntakeCheck(invalidIntake)) throw new Error('Invalid intake fixture was accepted');
if (!sitesCheck(sites)) throw new Error(`Sites manifest rejected: ${ajv.errorsText(sitesCheck.errors)}`);
console.log('Schema fixtures and sites manifest are valid.');
+17
View File
@@ -0,0 +1,17 @@
import { access, readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const key = process.env.SITE_KEY;
if (!key) throw new Error('SITE_KEY is required.');
const root = resolve(`dist/${key}`);
const required = ['index.html', '404.html'];
if (key === 'www') required.push('about/index.html', 'services/index.html', 'contact/index.html');
for (const file of required) await access(resolve(root, file));
const pages = await Promise.all(required.filter((file) => file.endsWith('.html')).map(async (file) => ({ file, html: await readFile(resolve(root, file), 'utf8') })));
for (const { file, html } of pages) {
if (html.includes('{{') || html.includes('{%')) throw new Error(`${file} contains an unresolved template placeholder.`);
if (!/<title>[^<]+<\/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]}`));
}
console.log(`Verified ${key}: ${required.join(', ')}`);
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": "1.0",
"rootDomain": "3031023.xyz",
"repository": "3031023-xyz",
"sites": {
"www": {
"hostname": "3031023.xyz",
"alternateHostnames": ["www.3031023.xyz"],
"workerName": "3031023-xyz-www-preview",
"contactSiteKey": "3031023-xyz-www",
"status": "preview"
},
"blog": {
"hostname": "blog.3031023.xyz",
"workerName": "3031023-xyz-blog-preview",
"contactSiteKey": "3031023-xyz-blog",
"status": "preview"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "The Happy Tails Journal",
"description": "Simple, useful dog-care ideas from the Happy Tails pack.",
"theme": { "accent": "#0f766e", "ink": "#102a43", "surface": "#f3f8f6" },
"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" }]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Happy Tails Pet Care",
"description": "Personal dog walking, grooming, and overnight care for Springfield families.",
"analyticsId": "G-Z15EG3SFPD",
"theme": { "accent": "#f59e0b", "ink": "#102a43", "surface": "#f7f5ef" },
"nav": [{ "label": "Home", "href": "/" }, { "label": "About", "href": "/about" }, { "label": "Services", "href": "/services" }, { "label": "Contact", "href": "/contact" }],
"contact": { "email": "hello@3031023.xyz", "phone": "(555) 742-9283", "phoneHref": "+15557429283", "address": "742 Evergreen Terrace, Springfield, IL 62704", "hours": "Mon–Fri · 7am–6pm CST" },
"hero": { "eyebrow": "Springfield's neighborhood dog care", "headline": "Care that feels like family.", "copy": "Thoughtful walks, gentle grooming, and cozy overnight stays for the dog you love most.", "image": "/images/hero-dogs.jpg", "imageAlt": "Two happy dogs playing outdoors", "cta": "Meet the pack", "ctaHref": "/contact" },
"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 }],
"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" }]
}
+11
View File
@@ -0,0 +1,11 @@
---
import { isBlog, site } from '../lib/site-data.js';
---
<footer class="site-footer">
<div class="shell footer-grid">
<div><a class="brand" href="/"><span class="brand-mark" aria-hidden="true">✦</span><span>{isBlog ? 'Happy Tails Journal' : 'Happy Tails'}</span></a><p>{site.description}</p></div>
<div><p class="eyebrow">Explore</p>{site.nav.map((item) => <a class="footer-link" href={item.href}>{item.label}</a>)}</div>
{!isBlog && <address><p class="eyebrow">Say hello</p><a class="footer-link" href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a><a class="footer-link" href={`mailto:${site.contact.email}`}>{site.contact.email}</a></address>}
</div>
<div class="shell footer-bottom">© {new Date().getFullYear()} {site.name} <span>Built with care by SloanOps</span></div>
</footer>
+22
View File
@@ -0,0 +1,22 @@
---
import { isBlog, site } from '../lib/site-data.js';
const path = Astro.url.pathname;
---
<header class="site-header">
<div class="shell nav-wrap">
<a class="brand" href="/" aria-label={`${site.name} home`}><span class="brand-mark" aria-hidden="true">✦</span><span>{isBlog ? 'Happy Tails Journal' : 'Happy Tails'}</span></a>
<button class="menu-button" aria-expanded="false" aria-controls="main-menu">Menu <span aria-hidden="true">+</span></button>
<nav id="main-menu" aria-label="Main navigation">
{site.nav.map((item) => <a href={item.href} aria-current={path === item.href ? 'page' : undefined}>{item.label}</a>)}
</nav>
</div>
</header>
<script>
const button = document.querySelector('.menu-button');
const menu = document.querySelector('#main-menu');
button?.addEventListener('click', () => {
const open = button.getAttribute('aria-expanded') !== 'true';
button.setAttribute('aria-expanded', String(open));
menu?.classList.toggle('is-open', open);
});
</script>
+9
View File
@@ -0,0 +1,9 @@
---
import { site } from '../lib/site-data.js';
---
<section class="hero">
<div class="shell hero-grid">
<div class="hero-copy"><p class="eyebrow">{site.hero.eyebrow}</p><h1>{site.hero.headline}</h1><p class="lede">{site.hero.copy}</p><a class="button" href={site.hero.ctaHref}>{site.hero.cta} <span aria-hidden="true">→</span></a></div>
<div class="hero-image"><img src={site.hero.image} alt={site.hero.imageAlt} fetchpriority="high" /><div class="image-note">Kind care. Every day.</div></div>
</div>
</section>
+31
View File
@@ -0,0 +1,31 @@
---
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { deployment, isProduction, site } from '../lib/site-data.js';
const { title = site.name, description = site.description } = Astro.props;
const canonical = `https://${deployment.hostname}${Astro.url.pathname}`;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet" />
{isProduction && site.analyticsId && <script async src={`https://www.googletagmanager.com/gtag/js?id=${site.analyticsId}`}></script>}
{isProduction && site.analyticsId && <script define:vars={{ analyticsId: site.analyticsId }}>window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', analyticsId);</script>}
</head>
<body style={`--accent:${site.theme.accent};--ink:${site.theme.ink};--surface:${site.theme.surface}`}>
<Header />
<main><slot /></main>
<Footer />
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import manifest from '../../sites.json';
import www from '../../sites/www/site.json';
import blog from '../../sites/blog/site.json';
const content = { www, blog };
const key = process.env.SITE_KEY || 'www';
if (!Object.hasOwn(manifest.sites, key) || !Object.hasOwn(content, key)) {
throw new Error(`Unknown SITE_KEY: ${key}`);
}
export const siteKey = key;
export const deployment = manifest.sites[key];
export const site = content[key];
export const isBlog = key === 'blog';
export const isProduction = process.env.DEPLOY_ENV === 'production';
+4
View File
@@ -0,0 +1,4 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Page not found"><section class="page-intro not-found"><div class="shell"><p class="eyebrow">404</p><h1>That trail ends here.</h1><p class="lede">The page you are looking for is not available.</p><a class="button" href="/">Return home →</a></div></section></BaseLayout>
+6
View File
@@ -0,0 +1,6 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/');
---
<BaseLayout title={`About | ${site.name}`}><section class="page-intro"><div class="shell"><p class="eyebrow">Our pack</p><h1>We know that great care starts with knowing your dog.</h1><p class="lede">The Happy Tails team brings patience, playfulness, and real experience to every visit.</p></div></section><section class="section"><div class="shell story-grid"><div><p class="eyebrow">How we work</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><p>Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.</p></div><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /></div></section><section class="section surface"><div class="shell"><div class="section-heading"><p class="eyebrow">Meet the team</p><h2>Good people for good dogs.</h2></div><div class="team-grid">{site.team.map((member) => <article class="team-card"><img src={member.image} alt={member.name} loading="lazy" /><div><p class="eyebrow">{member.role}</p><h3>{member.name}</h3><p>{member.bio}</p></div></article>)}</div></div></section></BaseLayout>
+6
View File
@@ -0,0 +1,6 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/');
---
<BaseLayout title={`Contact | ${site.name}`}><section class="page-intro"><div class="shell"><p class="eyebrow">Get in touch</p><h1>Let's make your dog's day.</h1><p class="lede">Reach out to ask a question or start planning the right care routine.</p></div></section><section class="section"><div class="shell contact-grid"><div><p class="eyebrow">Happy Tails Pet Care</p><h2>We would love to hear from you.</h2><address class="contact-details"><p>{site.contact.address}</p><p><a href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a></p><p><a href={`mailto:${site.contact.email}`}>{site.contact.email}</a></p><p>{site.contact.hours}</p></address></div><form class="contact-form"><label>Name<input type="text" autocomplete="name" disabled /></label><label>Email<input type="email" autocomplete="email" disabled /></label><label>How can we help?<textarea rows="5" disabled></textarea></label><button class="button" type="button" disabled>Contact route coming soon</button><p>This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.</p></form></div></section></BaseLayout>
+13
View File
@@ -0,0 +1,13 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Hero from '../components/Hero.astro';
import { isBlog, site } from '../lib/site-data.js';
---
<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> : <>
<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>
</>}
</BaseLayout>
+6
View File
@@ -0,0 +1,6 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/');
---
<BaseLayout title={`Services | ${site.name}`}><section class="page-intro"><div class="shell"><p class="eyebrow">Services</p><h1>Everything your dog needs for a brighter day.</h1><p class="lede">Flexible, personal care—with no one-size-fits-all routines.</p></div></section><section class="section"><div class="shell service-grid">{site.services.map((service, index) => <article class:list={['service-card', { featured: index === 1 }]}><p class="eyebrow">{service.price}</p><h2>{service.title}</h2><p>{service.description}</p><ul>{service.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.title.toLowerCase()} →</a></article>)}</div></section><section class="section surface"><div class="shell centered"><p class="eyebrow">A simple start</p><h2>Let's meet your dog first.</h2><p class="lede">Tell us a little about their routine, personality, and what would make their day better.</p><a class="button" href="/contact">Book a meet and greet →</a></div></section></BaseLayout>
+53
View File
@@ -0,0 +1,53 @@
:root { color-scheme: light; font-family: 'DM Sans', sans-serif; color: var(--ink); background: #fff; }
* { box-sizing: border-box; }
body { margin: 0; line-height: 1.55; }
a { color: inherit; }
img { display: block; max-width: 100%; }
h1,h2,h3,p { margin-top: 0; }
h1,h2 { font-family: 'DM Serif Display', Georgia, serif; font-weight: 400; line-height: 1.03; letter-spacing: -.035em; }
h1 { font-size: clamp(3.1rem, 7vw, 6.7rem); max-width: 10ch; margin-bottom: 1.5rem; }
h2 { font-size: clamp(2.25rem, 4vw, 4.1rem); margin-bottom: 1.25rem; }
h3 { font-size: 1.2rem; margin-bottom: .5rem; }
.shell { width: min(1120px, calc(100% - 3rem)); margin-inline: auto; }
.site-header { position: relative; z-index: 5; background: #fff; border-bottom: 1px solid #e8ecea; }
.nav-wrap { min-height: 82px; display: flex; align-items: center; justify-content: space-between; gap: 2rem; }
.brand { display: inline-flex; align-items: center; gap: .5rem; text-decoration: none; font-size: 1.15rem; font-weight: 700; letter-spacing: -.03em; }
.brand-mark { display: grid; place-items: center; width: 28px; height: 28px; color: #fff; background: var(--accent); border-radius: 50%; font-size: .85rem; }
nav { display: flex; align-items: center; gap: 1.5rem; }
nav a { color: #52616b; text-decoration: none; font-size: .92rem; font-weight: 600; }
nav a[aria-current='page'], nav a:hover { color: var(--ink); }
.menu-button { display: none; font: inherit; font-weight: 600; color: var(--ink); background: transparent; border: 0; }
.hero { padding: clamp(4rem, 9vw, 8rem) 0; overflow: hidden; background: linear-gradient(110deg, var(--surface) 0%, var(--surface) 68%, #fff 68%); }
.hero-grid,.story-grid,.contact-grid { display: grid; grid-template-columns: 1.02fr .98fr; align-items: center; gap: clamp(3rem, 7vw, 8rem); }
.hero-copy { padding: 1rem 0; }
.eyebrow,.tag { color: color-mix(in srgb, var(--accent), var(--ink) 25%); text-transform: uppercase; letter-spacing: .13em; font-size: .73rem; font-weight: 700; margin-bottom: 1rem; }
.lede { color: #52616b; font-size: clamp(1.1rem, 2vw, 1.32rem); max-width: 48ch; }
.button { display: inline-flex; gap: .65rem; align-items: center; margin-top: 1.4rem; padding: .9rem 1.2rem; border-radius: 999px; border: 1px solid var(--ink); background: var(--ink); color: #fff; text-decoration: none; font-weight: 700; transition: transform .2s, background .2s; }
.button:hover { transform: translateY(-2px); background: var(--accent); border-color: var(--accent); }
.hero-image { position: relative; }
.hero-image img { aspect-ratio: 4 / 4.6; width: 100%; object-fit: cover; border-radius: 1.25rem; }
.image-note { position: absolute; right: -1.5rem; bottom: 1.5rem; padding: .8rem 1rem; background: var(--accent); color: #102a43; font-weight: 700; transform: rotate(-4deg); }
.section { padding: clamp(4rem, 8vw, 7.5rem) 0; }
.surface { background: var(--surface); }
.section-heading { max-width: 720px; margin-bottom: 3rem; }
.section-heading.split { max-width: none; display: grid; grid-template-columns: 1.25fr .75fr; gap: 2rem; }
.section-heading.split > p { color: #52616b; font-size: 1.06rem; align-self: end; }
.feature-grid,.review-grid,.service-grid,.post-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; }
.feature { padding: 2rem; background: #fff; border: 1px solid #e8ecea; border-radius: .85rem; }
.feature > span { display: inline-grid; place-items: center; width: 2.75rem; height: 2.75rem; margin-bottom: 1.5rem; border-radius: 50%; background: var(--surface); color: var(--accent); font-size: 1.4rem; }
.feature p,.review p,.team-card p,.post-card p,.service-card > p { color: #52616b; }
.story-grid img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 1.1rem; }
.text-link { display: inline-block; margin-top: .7rem; color: var(--ink); font-weight: 700; text-decoration-thickness: 2px; text-underline-offset: 4px; }
.review { margin: 0; padding: 1.75rem; border-radius: .8rem; background: #fff; border: 1px solid #e8ecea; }
.review .stars { color: var(--accent); font-size: .85rem; letter-spacing: .12em; }
.review blockquote { margin: 0; }
.review footer { font-weight: 700; }.review footer span { display: block; margin-top: .15rem; color: #75828c; font-size: .82rem; font-weight: 400; }
.review-disclosure,.phase-note { margin: 1.8rem auto 0; color: #66747d; font-size: .86rem; max-width: 75ch; }
.page-intro { padding: clamp(4rem, 8vw, 7rem) 0 2rem; background: var(--surface); }.page-intro .lede { max-width: 55ch; }
.team-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }.team-card { display: grid; grid-template-columns: 150px 1fr; overflow: hidden; background: #fff; border: 1px solid #e8ecea; border-radius: .85rem; }.team-card img { width: 100%; height: 100%; object-fit: cover; }.team-card div { padding: 1.5rem; }
.service-card { padding: 2rem; border: 1px solid #dce4e1; border-radius: .85rem; background: #fff; }.service-card.featured { border: 2px solid var(--accent); transform: translateY(-.5rem); }.service-card h2 { font-size: 2.2rem; }.service-card ul { padding: 0; list-style: none; }.service-card li { padding: .4rem 0; border-top: 1px solid #e8ecea; }.service-card li::before { content: '✓'; color: var(--accent); font-weight: 700; margin-right: .55rem; }
.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; }
.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; }
.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 { grid-template-columns: 1fr; gap: 2.5rem; }.hero-image { max-width: 520px; }.image-note { right: -.5rem; }.feature-grid,.review-grid,.service-grid,.post-grid { grid-template-columns: 1fr; }.service-card.featured { transform: none; }.team-grid { grid-template-columns: 1fr; }.footer-grid { grid-template-columns: 1fr; gap: 1.5rem; }.footer-bottom { display: block; }.footer-bottom span { display: block; margin-top: .5rem; } }
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../node_modules/wrangler/config-schema.json",
"name": "3031023-xyz-blog-preview",
"compatibility_date": "2026-09-02",
"workers_dev": true,
"assets": { "directory": "../dist/blog", "not_found_handling": "404-page" }
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../node_modules/wrangler/config-schema.json",
"name": "3031023-xyz-www-preview",
"compatibility_date": "2026-09-02",
"workers_dev": true,
"assets": { "directory": "../dist/www", "not_found_handling": "404-page" }
}