Build static Astro pilot for Happy Tails
@@ -1,93 +1,41 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
# 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.
|
||||
name: Validate and deploy static Worker preview
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [astro-platform-pilot]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# These will be replaced during site creation
|
||||
CLOUDFLARE_PROJECT_NAME: 3031023-xyz
|
||||
CLOUDFLARE_CUSTOM_DOMAIN: 3031023.xyz
|
||||
inputs:
|
||||
site_key:
|
||||
description: Site key to deploy to its non-production Worker
|
||||
required: true
|
||||
type: choice
|
||||
options: [www, blog]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Cloudflare Pages
|
||||
preview:
|
||||
name: Preview ${{ inputs.site_key || 'www' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
SITE_KEY: ${{ inputs.site_key || 'www' }}
|
||||
ASTRO_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: Checkout source
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Deploy to Cloudflare Pages (Direct Upload)
|
||||
run: npx wrangler pages deploy . --project-name=${{ env.CLOUDFLARE_PROJECT_NAME }} --branch=${GITHUB_REF#refs/heads/}
|
||||
- name: Use Node 22
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
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:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_WORKER_PREVIEW_TOKEN }}
|
||||
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 }}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.wrangler/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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
|
||||
- Edit HTML files in the root directory to update page content
|
||||
- Modify images in the `images/` directory
|
||||
- Update text content directly in HTML files
|
||||
## Local checks
|
||||
|
||||
### Styling
|
||||
- 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
|
||||
Use Node 22.
|
||||
|
||||
### 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
|
||||
git add .
|
||||
git commit -m "Initial commit"
|
||||
git push origin master
|
||||
npm ci
|
||||
SITE_KEY=www npm run build:site
|
||||
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
|
||||
- Update styling by modifying CSS variables in `css/style.css`
|
||||
- Add new images to the `images/` directory and reference them in HTML
|
||||
- For structural changes, modify the appropriate HTML template
|
||||
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.
|
||||
It uses repository secrets `CF_WORKER_PREVIEW_TOKEN` and `CF_ACCOUNT_ID`.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
build: { format: 'directory' },
|
||||
vite: { build: { sourcemap: false } }
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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." }]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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=()
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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 } } } }
|
||||
}
|
||||
}
|
||||
@@ -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"] } } }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.');
|
||||
@@ -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(', ')}`);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; } }
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||