
Citycraft
- 4 installs
- 2 repo stars
- Updated April 14, 2026
- can4hou6joeng4/landing-craft
Builds a visually bold multi-file landing page with GSAP scroll animations, SVG elements, and clip-path dividers from 57 city-inspired styles.
About
Generates an audacious single-page landing site by letting the user pick from 57 city-inspired styles plus typography, nav, and section variants, then outputs HTML, CSS, JS, and an SVG sprite. A developer uses it to produce a striking marketing, product, or campaign page.
- 57 city-style previews plus interactive nav/hero/features pickers
- Bundled GSAP snippets, clip-path dividers, and texture CSS
Citycraft by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,817 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/can4hou6joeng4/landing-craft --skill citycraftAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 2 |
| Last updated | April 14, 2026 |
| Repository | can4hou6joeng4/landing-craft ↗ |
What it does
Builds a visually bold multi-file landing page with GSAP scroll animations, SVG elements, and clip-path dividers from 57 city-inspired styles.
Files
Landing Page Builder
Philosophy
This skill produces landing pages that are visually audacious — not the typical nested-container, card-grid, predictable web template. Every output should feel like a deliberate design artifact:
- 布局大胆 — Clip-path dividers instead of horizontal lines. Type bleeding off-screen. Elements breaking out of their grid. Full-viewport headlines.
- SVG 作为设计核心 — Not decorative sprinkles. SVG paths animate on scroll, icons have personality, background textures from
<feTurbulence>noise. - 层次感与深度 — GSAP ScrollTrigger creates genuine spatial depth: sections that overlap and cover the previous one, sticky panels pinned while content slides over them, parallax on separate z-layers.
- 背景质感 — Every section has texture. Never a flat solid background.
- 导航惊喜 — The nav is never a standard horizontal bar.
---
Bundled Assets (USE THESE — do not reinvent)
This skill comes with pre-built assets. Read and use them directly:
| File | What's in it | When to use |
|---|---|---|
assets/style-preview-template.html | 57-city style preview cards | Step 2: sed fill __PRODUCT_NAME__ + __PRODUCT_HEADLINE__, save as style-preview.html, open |
assets/options-preview-template.html | Interactive demos: nav styles, color variants, transition styles, hero/features/testimonials variants | Step 3: sed fill city color tokens + product name, save as options-preview.html, open |
assets/textures.css | 6 CSS texture classes (.texture-kyoto, .texture-paris, .texture-tokyo, etc.) | Copy the matching class into style.css |
assets/gsap-snippets.js | 9 GSAP animation + utility functions (blur entrance, line reveal, parallax, sticky steps, blast menu, magnetic pill, hero demo stepper, animated count, dark mode toggle) | Copy the relevant functions into main.js |
assets/clip-paths.css | 8 clip-path divider classes (.clip-diagonal-br, .clip-parallelogram, .clip-arc-bottom, etc.) | Use at least 2 in style.css for section dividers |
assets/sections/hero-variants.html | 7 Hero section templates (全屏铺张/分屏张力/极简下降/产品演示/文字爆炸/杂志撕裂/弹出卡片) | Step 4: pick the variant matching the user's typography preference, copy and adapt |
assets/sections/features-variants.html | 6 Features section templates (大数字/交替展示/时间线/本托格子/水平滚动/问答展开) | Step 4: pick based on content type (stats → big number, how-it-works → timeline) |
assets/sections/testimonial-variants.html | 6 Testimonials templates (紧凑卡片/单列引用/马赛克拼贴/滚动横条/对话气泡/头像墙) | Step 4: pick based on testimonial volume and visual style preference |
assets/sections/conversion-variants.html | Pricing table, FAQ, brand wall, power CTA | Step 4: copy relevant section, all use CSS custom properties |
assets/sections/footer-variants.html | 3 Footer templates (极简单行/多列链接/杂志编辑) | Step 4: pick based on page complexity and brand tone |
assets/sections/page-variants.html | 3 Sub-page templates (关于我们/联系方式/博客列表) | Step 4: when user requests multi-page site |
assets/sections/form-variants.html | 3 Form section templates (邮件订阅/等待列表/内嵌联系表单) | Step 4: when page needs a form section |
assets/sections/extra-variants.html | 6 Extra section templates (团队/数据统计/Logo滚动/作品集/技术集成/时间线) | Step 4: pick based on product type and content needs |
references/product-demo-hero.md | Product demo hero principles + scene design guide | Read when user wants to show product workflow in hero (see Step 3/4) |
The quality guarantee of this skill comes from using these assets. They encode specific design decisions that make outputs distinct. Don't describe what to do — copy the code and adapt it.
---
The Workflow
Step 1: Understand the Product
Use AskUserQuestion to ask:
"告诉我你的落地页是关于什么的——产品/服务名称,以及一句话介绍。"
Wait for the answer before proceeding.
Detect the conversation language: If the user wrote their answer in English, set LANG_VALUE=en for all subsequent preview commands. If in Chinese, use LANG_VALUE=zh. This determines the UI language of the preview pages.
Check if the user needs sub-pages: After getting the product info, ask:
"除了主页之外,是否还需要其他页面?可以选择:关于我们 / 联系方式 / 博客列表,或者只需要一个单页落地页就够了。"
Record the user's choice. If they want sub-pages, note which ones (about / contact / blog). This will be used in Step 4 to generate additional HTML files.
Step 2: Generate the Style Preview
Fill in the two placeholders and open the result — do NOT read the template file into context:
PRODUCT_NAME→ the product name from Step 1PRODUCT_HEADLINE→ a short punchy phrase (3–5 words) that captures the product's essence
_SKILL_DIR=$(ls -d ~/.agents/skills/citycraft 2>/dev/null || ls -d ~/.claude/skills/citycraft 2>/dev/null)
PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "")
if [ -n "$PYTHON" ]; then
# Python available (macOS / Linux / WSL / Windows with Python)
# LANG_VALUE detected in Step 1 (zh or en)
"$PYTHON" "$_SKILL_DIR/assets/scripts/run_preview.py" \
--template "$_SKILL_DIR/assets/style-preview-template.html" \
--output ./style-preview.html \
--port 17433 \
--timeout 300 \
"LANG=$LANG_VALUE" \
"PRODUCT_NAME=ACTUAL_PRODUCT_NAME" \
"PRODUCT_HEADLINE=ACTUAL_HEADLINE" \
"RECEIVER_PORT=17433"
else
# No Python — substitute via sed and open as a local file.
# The submit button falls back to clipboard copy automatically.
sed "s/__PRODUCT_NAME__/ACTUAL_PRODUCT_NAME/g; s/__PRODUCT_HEADLINE__/ACTUAL_HEADLINE/g" \
"$_SKILL_DIR/assets/style-preview-template.html" > ./style-preview.html
open ./style-preview.html 2>/dev/null || xdg-open ./style-preview.html 2>/dev/null \
|| echo "Open in browser: $(pwd)/style-preview.html"
echo "Python not found — no live bridge. The submit button will copy the city name to clipboard. Paste it here."
fiReplace ACTUAL_PRODUCT_NAME and ACTUAL_HEADLINE with the real values from Step 1 in the script arguments.
Windows PowerShell (no WSL/Git Bash): Runrun_preview.ps1directly — it has the same interface. Seeassets/scripts/run_preview.ps1for usage.
Tell the user: "我在浏览器里打开了57种城市风格的预览卡片,每个都是真实渲染效果。向下滚动可以看到全部——从京都到拉各斯到棕榈泉,再到伊斯坦布尔、迈阿密、成都、哥本哈根、维也纳、开普敦、波哥大、阿姆斯特丹、贝鲁特、波特兰,以及上海、北京、重庆、西安、杭州、深圳夜、敦煌、苏州、拉萨、罗马、布拉格、墨尔本、雅典、卡萨布兰卡、釜山、巴厘岛、多伦多、特拉维夫、华沙、孟买夜,还有新加入的大阪、清迈、米兰、台北、新奥尔良、苏黎世、温哥华。选好之后直接点卡片发送给我;如果本地桥接没有连上,也可以复制城市名告诉我。如果57个城市都不对,直接用自己的语言描述给我也行。"
Step 3: Open the Interactive Options Preview
The user has chosen their city. Now open the visual options preview so they can feel the layout and nav choices instead of reading descriptions.
If the user clicked "让 AI 来选" (city = `__AI_CHOOSE__`): Skip the preview entirely. Instead, look back at the conversation to understand the product's audience, industry, and tone. Then pick the single most fitting city from references/city-styles.md and briefly explain why (2–3 sentences). Confirm with the user: "我为你选了 [城市]——[理由]. 继续吗?" Then proceed to Step 3 with that city.3a — Get city color tokens (script, not file read)
Run get_city_tokens.py to extract just the 5 color values — do NOT read city-styles.md into context:
_SKILL_DIR=$(ls -d ~/.agents/skills/citycraft 2>/dev/null || ls -d ~/.claude/skills/citycraft 2>/dev/null)
PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "")
# Outputs CITY_BG=, CITY_SURFACE=, CITY_INK=, CITY_MUTED=, CITY_ACCENT=
eval $("$PYTHON" "$_SKILL_DIR/assets/scripts/get_city_tokens.py" "ACTUAL_CITY_NAME")Replace ACTUAL_CITY_NAME with the city name the user chose (Chinese or English). The script handles both. If the city isn't found, it exits 1 — in that case fall back to reading the ### Colors section of references/city-styles.md manually.
3b — Generate and open options-preview.html
Use the $CITY_* variables from 3a. Do NOT read the template file into context:
if [ -n "$PYTHON" ]; then
# LANG_VALUE detected in Step 1 (zh or en)
"$PYTHON" "$_SKILL_DIR/assets/scripts/run_preview.py" \
--template "$_SKILL_DIR/assets/options-preview-template.html" \
--output ./options-preview.html \
--port 17432 \
--timeout 300 \
"LANG=$LANG_VALUE" \
"PRODUCT_NAME=ACTUAL_PRODUCT_NAME" \
"PRODUCT_HEADLINE=ACTUAL_HEADLINE" \
"CITY_NAME=ACTUAL_CITY_NAME" \
"CITY_BG=$CITY_BG" \
"CITY_SURFACE=$CITY_SURFACE" \
"CITY_INK=$CITY_INK" \
"CITY_MUTED=$CITY_MUTED" \
"CITY_ACCENT=$CITY_ACCENT" \
"CITY_DARK_BG=#0e0c09" \
"CITY_DARK_SURFACE=#1e1b16" \
"CITY_DARK_INK=#f2ede4" \
"CITY_DARK_ACCENT=$CITY_ACCENT" \
"CITY_BRIGHT_BG=#fdf9f2" \
"CITY_BRIGHT_SURFACE=#fffdf8" \
"CITY_BRIGHT_INK=#1a1510" \
"CITY_BRIGHT_ACCENT=$CITY_ACCENT" \
"RECEIVER_PORT=17432"
else
sed \
-e "s/__PRODUCT_NAME__/ACTUAL_PRODUCT_NAME/g" \
-e "s/__PRODUCT_HEADLINE__/ACTUAL_HEADLINE/g" \
-e "s/__CITY_NAME__/ACTUAL_CITY_NAME/g" \
-e "s/__CITY_BG__/$CITY_BG/g" \
-e "s/__CITY_SURFACE__/$CITY_SURFACE/g" \
-e "s/__CITY_INK__/$CITY_INK/g" \
-e "s/__CITY_MUTED__/$CITY_MUTED/g" \
-e "s/__CITY_ACCENT__/$CITY_ACCENT/g" \
-e "s/__CITY_DARK_BG__/#0e0c09/g" \
-e "s/__CITY_DARK_SURFACE__/#1e1b16/g" \
-e "s/__CITY_DARK_INK__/#f2ede4/g" \
-e "s/__CITY_DARK_ACCENT__/$CITY_ACCENT/g" \
-e "s/__CITY_BRIGHT_BG__/#fdf9f2/g" \
-e "s/__CITY_BRIGHT_SURFACE__/#fffdf8/g" \
-e "s/__CITY_BRIGHT_INK__/#1a1510/g" \
-e "s/__CITY_BRIGHT_ACCENT__/$CITY_ACCENT/g" \
"$_SKILL_DIR/assets/options-preview-template.html" > ./options-preview.html
open ./options-preview.html 2>/dev/null || xdg-open ./options-preview.html 2>/dev/null \
|| echo "Open in browser: $(pwd)/options-preview.html"
echo "Python not found — no live bridge. Use the copy button in the preview and paste the result here."
fiThe dark variant (__CITY_DARK_*) is always the luxury/night treatment — near-black bg, warm light text, same accent. The bright variant is always the fresh/modern treatment — near-white bg, dark text, same accent. The city's identity comes from the base colors and accent, not from the dark/bright shell.
Windows PowerShell (no WSL/Git Bash): Runrun_preview.ps1directly. Seeassets/scripts/run_preview.ps1for usage.
When the script exits, it prints the result JSON to stdout. If it times out, ask the user to type their choice manually before proceeding.
Tell the user: "在浏览器里打开了一个互动选择页——有排版、导航的实际演示效果,还有三种色调的对比,以及板块间过渡风格的可视化预览。可以点击全屏菜单看它怎么爆开,把光标移近底部胶囊感受磁性效果。全部选好之后,点底部的「告诉 Agent →」按钮,我会自动收到结果并继续生成;如果本地桥接没有连上,再把复制结果贴给我就可以。"
If the user chose a non-city description (scene, era, material, emotion): read references/imagery-derivation.md to derive the design token system first, use those derived colors as the CITY_* arg values above, then proceed normally.
If the script prints JSON, parse it directly and continue to Step 4 with city, layout, nav, tone, transitions, hero, features, and sections. If it times out, ask the user to paste their choices manually before proceeding.
Additional choices not in the preview JSON — these are determined by the agent based on Step 1 and the product context:
footer: Pick A (minimal), B (multi-column), or C (editorial) fromfooter-variants.htmlbased on page complexityform: Pick NEWSLETTER, WAITLIST, or CONTACT_INLINE fromform-variants.htmlif the user's sections include a form need, or skip if notpages: List of sub-pages from Step 1 (e.g.["about", "contact"]), or empty if single-page
Step 4: Generate the Landing Page
Output into {product-name}-landing/:
{product-name}-landing/
├── index.html
├── style.css
├── main.js
├── about.html ← optional sub-pages
├── contact.html ← optional sub-pages
├── blog.html ← optional sub-pages
└── assets/
└── icons.svgMulti-page support: If the user requests sub-pages (about, contact, blog), generate them as separate HTML files in the same directory. Each sub-page:
- Shares the same
style.css,main.js, andassets/icons.svgasindex.html - Uses the same nav (with links updated to point to sibling pages) and footer
- Content comes from
assets/sections/page-variants.html(variants:ABOUT,CONTACT,BLOG) - Has its own
<title>and<meta>tags following the SEO baseline (Design Law 11)
Extract sub-page content in the Bash Assembly step:
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/page-variants.html" ABOUT >> "$_OUT/_pages.html"
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/page-variants.html" CONTACT >> "$_OUT/_pages.html"Read _pages.html alongside other staging files, then write each sub-page as a complete HTML document wrapping the nav + page content + footer.
4a — Bash Assembly (do this first, before writing any file)
The goal is to avoid outputting bundled asset code as model tokens. Instead, use Bash to copy and pipe the ready-made assets into the output files. Only product-specific content (copy, tokens, overrides) is written by the model.
Step 1 — Set up directories
_SKILL_DIR=$(ls -d ~/.agents/skills/citycraft 2>/dev/null || ls -d ~/.claude/skills/citycraft 2>/dev/null)
_OUT="./{product-name}-landing"
mkdir -p "$_OUT/assets"Step 2 — Assemble section variants into a staging file
Plan which variants to use (see table below), then pipe each into a staging file:
# Replace B / C / B / PRICING with the user's actual choices
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/hero-variants.html" B > "$_OUT/_sections.html"
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/features-variants.html" C >> "$_OUT/_sections.html"
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/testimonial-variants.html" B >> "$_OUT/_sections.html"
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/conversion-variants.html" PRICING >> "$_OUT/_sections.html"
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/conversion-variants.html" CTA >> "$_OUT/_sections.html"
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/footer-variants.html" B >> "$_OUT/_sections.html"
# If user needs a form section, add it:
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/form-variants.html" NEWSLETTER >> "$_OUT/_sections.html"
# If user requested sub-pages, extract them:
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/page-variants.html" ABOUT >> "$_OUT/_pages.html"
python3 "$_SKILL_DIR/assets/scripts/extract_variant.py" \
"$_SKILL_DIR/assets/sections/page-variants.html" CONTACT >> "$_OUT/_pages.html"For testimonials use: A, B, C, D, E, F For conversion sections use: PRICING, COMPARE_PRICING, BRAND_WALL, CTA, FAQ_A, FAQ_B For footer use: A (minimal), B (multi-column), C (editorial) For forms use: NEWSLETTER, WAITLIST, CONTACT_INLINE For sub-pages use: ABOUT, CONTACT, BLOG
Step 3 — Copy bundled CSS utilities into style.css base
# Texture: use --texture flag (never read full city-styles.md)
python3 "$_SKILL_DIR/assets/scripts/get_city_tokens.py" "CITY_NAME" --texture > "$_OUT/_texture.css"
# Clip-paths: copy all classes, model picks which to apply via class names
cat "$_SKILL_DIR/assets/clip-paths.css" >> "$_OUT/_texture.css"Step 4 — Copy GSAP snippets into main.js base
cat "$_SKILL_DIR/assets/gsap-snippets.js" > "$_OUT/_gsap-base.js"Now read the staging files (_sections.html, _texture.css, _gsap-base.js, and _pages.html if sub-pages were requested) to understand what's available, then write the final output files.
4b — Section Variant Reference
Common page sequences:
- SaaS tool: Hero D → Features C → Features A → Testimonials A → Pricing → FAQ A → Newsletter → CTA → Footer B
- Agency portfolio: Hero A → Features B → Testimonials C → Contact Inline → CTA → Footer C
- Developer tool: Hero B → Features C → Features A → Testimonials A → Pricing → FAQ B → Newsletter → CTA → Footer B
- Luxury product: Hero C → Features B → Testimonials B → CTA → Footer C
- B2C app: Hero E → Features E → Testimonials F → Waitlist → CTA → Footer A
- Startup pre-launch: Hero G → Features D → Waitlist → FAQ A → Footer A
| If the user needs... | Use this template | Variant |
|---|---|---|
| Hero — massive bold statement | 全屏铺张 | A |
| Hero — product visual + headline | 分屏张力 | B |
| Hero — elegant, story-first | 极简下降 | C |
| Hero — product has a workflow to show | 产品演示型 | D (also read references/product-demo-hero.md) |
| Hero — typography-led, high-impact | 文字爆炸型 | E |
| Hero — editorial storytelling | 杂志撕裂型 | F |
| Hero — playful product launch | 弹出卡片型 | G |
| Features — data/metrics focus | 大数字 | A |
| Features — product screenshots | 交替展示 | B |
| How it works — step-by-step | 时间线 | C |
| Features — modular story blocks | 本托格子型 | D |
| Features — browseable capability ribbon | 水平滚动卡带型 | E |
| Features — objection handling | 问答展开型 | F |
| Pricing table | 定价表 | PRICING |
| Pricing comparison with toggle | 对比定价表 | COMPARE_PRICING |
| Testimonials — compact grid | 紧凑卡片网格 | A |
| Testimonials — magazine style | 单列引用墙 | B |
| Testimonials — masonry layout | 马赛克拼贴 | C |
| Testimonials — horizontal scroll | 滚动横条 | D |
| Testimonials — conversation bubbles | 对话气泡 | E |
| Testimonials — avatar wall | 视频头像墙 | F |
| Trusted brand logos | 品牌墙 | BRAND_WALL |
| Final CTA | 强力CTA区 | CTA |
| FAQ — editorial layout | 编辑排版型 | FAQ_A |
| FAQ — card grid | 全宽焦点型 | FAQ_B |
| Footer — minimal single row | 极简单行 | A (footer-variants.html) |
| Footer — multi-column links | 多列链接 | B (footer-variants.html) |
| Footer — editorial with watermark | 杂志编辑型 | C (footer-variants.html) |
| Sub-page — About / Brand story | 关于我们 | ABOUT (page-variants.html) |
| Sub-page — Contact with form | 联系方式 | CONTACT (page-variants.html) |
| Sub-page — Blog post listing | 博客列表 | BLOG (page-variants.html) |
| Form — Newsletter subscribe | 邮件订阅 | NEWSLETTER (form-variants.html) |
| Form — Waitlist signup | 等待列表 | WAITLIST (form-variants.html) |
| Form — Inline contact form | 内嵌联系表单 | CONTACT_INLINE (form-variants.html) |
| Team member cards | 团队成员 | TEAM (extra-variants.html) |
| Stats / Numbers showcase | 数据统计 | STATS (extra-variants.html) |
| Logo infinite scroll | 品牌滚动墙 | LOGO_SCROLL (extra-variants.html) |
| Gallery / Portfolio grid | 作品集展示 | GALLERY (extra-variants.html) |
| Integrations / Tech stack | 技术集成 | INTEGRATIONS (extra-variants.html) |
| Timeline / Milestones | 时间线 | TIMELINE (extra-variants.html) |
4c — Write the Output Files
index.html — Write with the Write tool. No markdown block, directly to file.
- Document structure:
<html>,<head>(Google Fonts, GSAP CDN, link to style.css + main.js),<body> - Nav HTML matching the selected nav style
- Paste
<style>blocks and<section>HTML from_sections.htmlin page order - Replace all placeholder copy with real product copy — fit the city aesthetic's tone of voice
- No Lorem Ipsum, no placeholder text
- Break template uniformity: vary visual weight across items within each section. The template shows a repeating pattern — make one item the focal point (accent background, larger card, featured badge, different internal layout) and let others recede. No section should look like a grid of clones.
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>style.css — Write with the Write tool. Structure: 1. :root block — full design token system: colors from $CITY_* tokens, chosen fonts, spacing scale, radii. This is the only part the model writes from scratch. 2. [data-theme="dark"] block — dark variant tokens using $CITY_DARK_* values from Step 3. This enables runtime dark mode toggle. 3. Paste texture + clip-path CSS from _texture.css 4. Nav CSS matching the selected nav style (from references/nav-catalog.md) 5. Layout and typography overrides — only what differs from section defaults
clamp() for ALL headline font sizes:
- Hero 主标题:
clamp(2.8rem, 7vw, 7rem)— 铺张型也不超过 7rem - Section 标题:
clamp(2rem, 4vw, 4rem) - 大数字/装饰数字:
clamp(4rem, 10vw, 9rem) - 副标题/说明文字:
clamp(1rem, 1.4vw, 1.2rem)
Do NOT add comments to style.css — they consume tokens and add no value to the output file.
main.js — Write with the Write tool. Structure: 1. gsap.registerPlugin(ScrollTrigger) 2. Paste the relevant functions from _gsap-base.js — required minimum:
initHeroEntrance()— stagger hero elements ininitParallax()— at least 2 layers at different speeds- Section heading reveals on viewport entry
initStickySteps()ORinitBlastMenu()ORinitMagneticPill()— matching nav/layout
3. initDarkModeToggle(".theme-toggle") — enable dark/light mode switching 4. Call sequence at bottom; ScrollTrigger.refresh() after fonts load
Do NOT add comments to main.js.
assets/icons.svg — SVG sprite with <symbol> elements. At minimum: logo mark, nav toggle, arrow, checkmark, 2–3 product-relevant feature icons. Icon style must match the city aesthetic's stroke weight and geometry.
After all files are written, delete the staging files:
rm -f "$_OUT/_sections.html" "$_OUT/_texture.css" "$_OUT/_gsap-base.js" "$_OUT/_pages.html"Step 5: Preview the Result
After all files are written, open the generated page in the browser so the user sees it immediately:
open "$_OUT/index.html" 2>/dev/null || xdg-open "$_OUT/index.html" 2>/dev/null || echo "Preview ready: $(cd "$_OUT" && pwd)/index.html"Tell the user: "落地页已生成并在浏览器中打开。如果需要调整任何细节,随时告诉我。"
Step 6: Deploy (Optional)
After the user confirms the page looks good, ask if they want to deploy it online:
"需要我帮你把这个站点部署到线上吗?支持 Netlify、Vercel 和 GitHub Pages 三种方式。"
Option A: Netlify
cd "$_OUT"
npx netlify deploy --dir=. --message="Landing page deploy from citycraft"This creates a draft deploy with a preview URL. If confirmed:
npx netlify deploy --dir=. --prod --message="Production deploy from citycraft"Option B: Vercel
cd "$_OUT"
npx vercel --yesThis deploys a preview. If confirmed:
npx vercel --prod --yesOption C: GitHub Pages
cd "$_OUT"
git init && git add -A && git commit -m "deploy: landing page"
gh repo create {product-name}-site --public --source=. --push
gh api -X POST repos/{owner}/{product-name}-site/pages -f source='{"branch":"main","path":"/"}'Then the site will be live at https://{owner}.github.io/{product-name}-site/.
Notes:
- First-time users will be prompted to log in to the chosen platform via browser
- All three platforms deploy static files directly, no build step needed
- If the user doesn't specify a platform, default to Netlify
---
Design Laws (Never Break These)
1. No `#ffffff` backgrounds. Not on sections, not on cards. Warm neutrals: #f5ede0. Cool: #edf0ee. Dark: #08060f. Cards get a slight tint, never pure white. 2. No `#6366f1`. Color comes from the city style palette. 3. Decide the transition type independently for each section boundary. Every pair of adjacent sections has its own visual relationship — don't reuse the same clip-path class everywhere. The user chooses a transition direction (geometric sharp / organic soft / cover-blend / minimal line / AI auto) in the options preview. Use that direction to constrain which classes you pick: GEOMETRIC → diagonals, chamfers, steps; ORGANIC → curves, scallops, arcs; BLEND → cover overlays and gradient dissolves; MINIMAL → hairline rules. If __AI_CHOOSE__, scan the selected city's entry in references/city-styles.md for divider/transition language first. Then apply per-boundary logic: consider what the two sections are (hero → features, features → pricing, etc.), their relative energy, and the overall page rhythm. Available classes in assets/clip-paths.css: diagonal (clip-diagonal-*, clip-parallelogram), curved (clip-round-bottom, clip-scallop, clip-arc-bottom), gradient dissolve (section-dissolve), flat rule (section-rule). 4. No generic icons. Match the city style's stroke weight and geometry. 5. Two typefaces minimum. Display/serif for headlines + clean sans for body. From references/city-styles.md. Decorative/script accent fonts (when a city style mentions one) go on watermarks, pull quotes, or ornamental elements — never on buttons, nav, or body copy. 6. Nav must surprise. Use the chosen nav from references/nav-catalog.md with its full surprise element implemented. 7. Use bundled assets. The texture, GSAP snippets, and clip-paths must come from the skill's asset files — not reimplemented from scratch. 8. `.line-wrap` CJK fix. Whenever the page has Chinese or Japanese text and uses .line-wrap { overflow: hidden } for line reveal animations, add padding-top: 0.15em; margin-top: -0.15em; to prevent CJK ascenders from being clipped at the top. 9. No clone grids. When a section contains multiple repeating items (feature rows, pricing cards, FAQ items, testimonials), they must not all share the same visual treatment. One item should be the focal point — larger, accented, or with a distinct layout — while others form the supporting cast. A section should feel like a poster with visual hierarchy, not a spreadsheet of identical rows. This is the single biggest cause of pages looking "templated" rather than designed. 10. Responsive by default. Every page must work on mobile (375px) through desktop (1440px). Rules:
- Hero text: use
clamp()for all font sizes, never fixedpx/remalone - Grid layouts: collapse to single column below 768px via
@media (max-width: 768px) - Navigation: pill nav shrinks gap/padding on mobile; side nav hides on mobile with a toggle
- Touch targets: all buttons and links minimum 44px height
- No horizontal scroll: test with
overflow-x: hiddenonbody, but fix the root cause - Images/SVGs: use
max-width: 100%andaspect-ratioto prevent layout shift
11. SEO baseline. Every generated index.html must include:
<title>with product name and value proposition (under 60 chars)<meta name="description">with a compelling summary (under 155 chars)<meta property="og:title">,og:description,og:type(website)<meta name="viewport" content="width=device-width, initial-scale=1.0">- Semantic HTML: one
<h1>, sequential heading hierarchy (h1→h2→h3),<nav>,<main>,<footer> langattribute on<html>matching the page language
12. Footer required. Every page must have a <footer> with at minimum: brand name, copyright year, and a link back to top or to the product. No page ends abruptly after the last section. 13. Accessibility baseline. Every generated page must:
- Use the
prefersReducedMotionguard fromgsap-snippets.js(function 0) - Include
<a class="skip-link" href="#main">for keyboard users - Ensure all interactive elements have visible
:focus-visiblestyles - Use
aria-expandedon FAQ accordion triggers and nav toggles
---
Reference Files
references/city-styles.md— Exact design parameters (fonts, colors, textures, motion, icons) for each city aestheticreferences/nav-catalog.md— 4 nav styles with full implementation notes and GSAP codereferences/imagery-derivation.md— How to translate any non-city description (scene, era, material, emotion) into a concrete design token system. Read this whenever the user describes something that isn't one of the 57 city cards.references/product-demo-hero.md— When and how to build a time-driven product workflow demo in the Hero (Variant D). Includes scene design guide, 3-act structure, onEnter() callback patterns, and product-type → scene mapping table. Read this whenever the user wants to show their product's process in the hero.
github: [can4hou6joeng4]
问题描述 简要说明遇到了什么问题。
复现步骤 1. 使用命令 '...' 2. 选择 '...' 3. 出现错误
期望行为 描述你期望发生什么。
实际行为 描述实际发生了什么。
环境信息
- 操作系统:
- Python 版本:
- Claude Code 版本:
- 选择的城市风格:
截图或日志 如有相关截图或错误日志请粘贴在此。
功能描述 简要描述你希望添加的功能。
使用场景 这个功能在什么场景下会被用到?
期望方案 描述你理想中的实现方式。
备选方案 是否考虑过其他替代方案?
补充信息 其他相关截图、链接或说明。
概要
<!-- 用 1-3 句话描述这个 PR 做了什么 -->
变更类型
- [ ] 新功能
- [ ] Bug 修复
- [ ] 文档更新
- [ ] 重构
- [ ] 其他
变更内容
<!-- 列出主要的改动点 -->
-
测试
<!-- 描述你是如何验证这些改动的 -->
- [ ] 本地测试通过
- [ ] 相关脚本运行正常(
get_city_tokens.py/extract_variant.py/run_preview.py)
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Validate Python script syntax
run: |
for f in assets/scripts/*.py; do
python -c "import ast; ast.parse(open('$f').read())" && echo "OK: $f" || exit 1
done
- name: Validate city token extraction
run: |
python assets/scripts/get_city_tokens.py "Kyoto" > /dev/null
python assets/scripts/get_city_tokens.py "東京" > /dev/null
python assets/scripts/get_city_tokens.py "Seoul" --texture > /dev/null
echo "City token extraction: OK"
- name: Validate all variant extraction
run: |
PASS=0
TOTAL=0
for v in A B C D E F G; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/hero-variants.html $v > /dev/null && PASS=$((PASS+1))
done
for v in A B C D E F; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/features-variants.html $v > /dev/null && PASS=$((PASS+1))
done
for v in A B C D E F; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/testimonial-variants.html $v > /dev/null && PASS=$((PASS+1))
done
for v in PRICING COMPARE_PRICING BRAND_WALL CTA FAQ_A FAQ_B; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/conversion-variants.html $v > /dev/null && PASS=$((PASS+1))
done
for v in A B C; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/footer-variants.html $v > /dev/null && PASS=$((PASS+1))
done
for v in ABOUT CONTACT BLOG; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/page-variants.html $v > /dev/null && PASS=$((PASS+1))
done
for v in NEWSLETTER WAITLIST CONTACT_INLINE; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/form-variants.html $v > /dev/null && PASS=$((PASS+1))
done
for v in TEAM STATS LOGO_SCROLL GALLERY INTEGRATIONS TIMELINE; do
TOTAL=$((TOTAL+1))
python assets/scripts/extract_variant.py assets/sections/extra-variants.html $v > /dev/null && PASS=$((PASS+1))
done
echo "$PASS/$TOTAL variants OK"
[ "$PASS" -eq "$TOTAL" ] || exit 1
- name: Validate evals.json
run: |
python -c "
import json
d = json.load(open('evals/evals.json'))
assert d['skill_name'] == 'citycraft', f'Wrong skill name: {d[\"skill_name\"]}'
assert len(d['evals']) >= 9, f'Too few evals: {len(d[\"evals\"])}'
print(f'Evals: {len(d[\"evals\"])} cases OK')
"
- name: Validate SKILL.md frontmatter
run: |
python -c "
text = open('SKILL.md').read()
assert text.startswith('---'), 'Missing frontmatter'
assert 'name: citycraft' in text[:500], 'Missing name field'
assert 'description:' in text[:500], 'Missing description field'
print('SKILL.md frontmatter: OK')
"
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Editors
.vscode/
.idea/
*.swp
*.swo
*~
# Generated preview files (user workspace, not source)
style-preview.html
options-preview.html
*-landing/
# Python
__pycache__/
*.pyc
*.pyo
# Temp
/tmp/
*.tmp
citycraft_result.json
citycraft_selection.json
# Logs
*.log
/* ============================================================
LANDING PAGE SKILL — CLIP-PATH DIVIDER LIBRARY
Apply to section elements to create non-rectangular dividers.
Combine top/bottom cuts from different sections to create
"interlocking" edges between adjacent sections.
============================================================ */
/* ── DIAGONAL CUTS ─────────────────────────────────────────── */
/* Section with a diagonal bottom-right cut */
.clip-diagonal-br {
clip-path: polygon(0 0, 100% 0, 100% 88%, 0 100%);
padding-bottom: calc(var(--section-pad, 120px) + 8vw);
}
/* Section with a diagonal bottom-left cut */
.clip-diagonal-bl {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 88%);
padding-bottom: calc(var(--section-pad, 120px) + 8vw);
}
/* Section with opposing diagonal (parallelogram feel) */
.clip-parallelogram {
clip-path: polygon(0 6vw, 100% 0, 100% calc(100% - 6vw), 0 100%);
padding: calc(var(--section-pad, 120px) + 6vw) 0;
margin-top: -6vw;
}
/* ── ANGLED CORNER CUTS (chamfer effect) ────────────────────── */
/* Bottom-right corner chamfered */
.clip-chamfer-br {
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 60px), calc(100% - 60px) 100%, 0 100%);
}
/* All four corners chamfered (hexagonal softness) */
.clip-chamfer-all {
clip-path: polygon(40px 0, calc(100% - 40px) 0, 100% 40px, 100% calc(100% - 40px), calc(100% - 40px) 100%, 40px 100%, 0 calc(100% - 40px), 0 40px);
}
/* ── WAVE CUTS ──────────────────────────────────────────────── */
/* Gentle wave at the bottom */
.clip-wave-bottom {
clip-path: ellipse(55% 100% at 50% 0%);
/* Use as a decorative top element overlapping the previous section */
}
/* Arc cut (bowl shape at bottom) */
.clip-arc-bottom {
clip-path: polygon(0 0, 100% 0, 100% 85%, 50% 100%, 0 85%);
padding-bottom: calc(var(--section-pad, 120px) + 10vw);
}
/* ── STEPPED CUTS ───────────────────────────────────────────── */
/* Staircase cut bottom-right */
.clip-step-br {
clip-path: polygon(0 0, 100% 0, 100% 60%, 75% 60%, 75% 80%, 50% 80%, 50% 100%, 0 100%);
}
/* ── UTILITY — apply to a sticky overlay section ───────────── */
/* The next section "covers" the previous one from below */
.clip-cover-prev {
position: relative;
z-index: 2;
clip-path: polygon(0 48px, 100% 0, 100% 100%, 0 100%);
margin-top: -48px;
}
/* ── GRADIENT / DISSOLVE TRANSITIONS ────────────────────────── */
/*
渐变过渡 — The section's top edge dissolves in from transparent,
overlapping the previous section. The gradient fades from transparent
to var(--bg), so the section blends into what came before.
Usage: add to the SECOND section in a pair.
The previous section needs no special class.
*/
.section-dissolve {
position: relative;
z-index: 2;
margin-top: -120px;
padding-top: calc(120px + var(--section-pad, 120px));
}
.section-dissolve::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 140px;
background: linear-gradient(to bottom, transparent, var(--bg, #f5ede0));
pointer-events: none;
z-index: -1;
}
/* ── FLAT / RULE TRANSITIONS ─────────────────────────────────── */
/*
平直过渡 — Clean straight edge, no clip. An accent-colored ruled line
visually separates sections without a geometric cut. Understated.
Usage: add to the section that receives the rule at its top.
*/
.section-rule {
position: relative;
padding-top: calc(var(--section-pad, 120px) + 32px);
}
.section-rule::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(
to right,
transparent,
var(--accent, #8b4513) 25%,
var(--accent, #8b4513) 75%,
transparent
);
opacity: 0.5;
}
/* ── SMOOTH CURVE TRANSITIONS ────────────────────────────────── */
/*
曲线过渡 — Smooth rounded bottom edge using border-radius.
The section background is clipped to an elliptical curve at the bottom.
Combine with the next section using a negative margin-top to interlock.
Usage: add to the FIRST section in a pair.
Next section: add z-index: 2 and margin-top: -6vw.
*/
.clip-round-bottom {
border-radius: 0 0 50% 50% / 0 0 5vw 5vw;
overflow: hidden;
padding-bottom: calc(var(--section-pad, 120px) + 6vw);
}
/* Concave wave — a scalloped bottom cut (multi-point polygon curve) */
.clip-scallop {
clip-path: polygon(
0 0, 100% 0, 100% 82%,
92% 84%, 84% 88%, 75% 93%, 66% 97%, 58% 99%,
50% 100%, 42% 99%, 34% 97%, 25% 93%, 16% 88%,
8% 84%, 0 82%
);
padding-bottom: calc(var(--section-pad, 120px) + 12vw);
}
/* ============================================================
LANDING PAGE SKILL — GSAP ANIMATION SNIPPETS
These are proven patterns. Copy, adapt variable names,
and plug into your main.js. Do NOT reinvent these.
============================================================ */
// ─── 0. REDUCED MOTION GUARD ─────────────────────────────────
// Check once, use everywhere. When true, skip all non-essential animations.
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ─── 1. BLUR-STAGGER ENTRANCE (hero text reveal) ──────────────
// Usage: call once on DOMContentLoaded
function initHeroEntrance(containerSelector, itemSelector) {
if (prefersReducedMotion) { gsap.set(itemSelector || `${containerSelector} > *`, { opacity: 1 }); return; }
gsap.from(itemSelector || `${containerSelector} > *`, {
y: 32,
opacity: 0,
filter: "blur(6px)",
duration: 0.9,
ease: "power3.out",
stagger: 0.12,
clearProps: "filter",
});
}
// Example: initHeroEntrance(".hero-text", ".hero-text > *")
// ─── 2. CLIP-MASK LINE REVEAL (Paris / editorial style) ────────
// Each heading line slides up from a clip container.
// Wrap each line: <span class="line-wrap"><span class="line-inner">text</span></span>
//
// ⚠️ CJK CLIPPING FIX (required when page includes Chinese/Japanese):
// .line-wrap { overflow: hidden } clips tall CJK ascenders at the top.
// Always add this compensation in style.css:
// .line-wrap { overflow: hidden; padding-top: 0.15em; margin-top: -0.15em; }
function initLineReveal(headingSelector) {
if (prefersReducedMotion) return;
const headings = document.querySelectorAll(headingSelector);
headings.forEach((heading) => {
const lines = heading.querySelectorAll(".line-inner");
gsap.from(lines, {
y: "110%",
duration: 0.85,
ease: "expo.out",
stagger: 0.08,
scrollTrigger: {
trigger: heading,
start: "top 80%",
toggleActions: "play none none none",
},
});
});
}
// CSS required:
// .line-wrap { display: block; overflow: hidden; }
// .line-inner { display: block; }
// ─── 3. PARALLAX LAYERS (depth on scroll) ──────────────────────
// Pass an array of { selector, speed } — speed 0.5 = half rate, 1.5 = faster
function initParallax(layers) {
if (prefersReducedMotion) return;
layers.forEach(({ selector, speed }) => {
gsap.to(selector, {
y: () => window.innerHeight * (speed - 1) * -0.4,
ease: "none",
scrollTrigger: {
trigger: "body",
start: "top top",
end: "bottom bottom",
scrub: speed,
},
});
});
}
// Example:
// initParallax([
// { selector: ".hero-bg-glow", speed: 0.6 },
// { selector: ".hero-phone", speed: 1.2 },
// { selector: ".hero-particles", speed: 0.8 },
// ]);
// ─── 4. STICKY STEP-THROUGH (how it works / theatrical) ────────
// Pins a container, steps through items as user scrolls.
// HTML: <div class="steps-wrapper">
// <div class="steps-panel sticky-panel"> ← pinned
// <div class="steps-content"> ← scroll height
// <div class="step" data-step="0">
function initStickySteps(wrapperSelector, panelSelector, stepSelector) {
if (prefersReducedMotion) { document.querySelectorAll(stepSelector).forEach(s => s.classList.add("active")); return; }
const steps = document.querySelectorAll(stepSelector);
const total = steps.length;
ScrollTrigger.create({
trigger: wrapperSelector,
start: "top top",
end: `+=${total * 100}vh`,
pin: panelSelector,
scrub: false,
onUpdate(self) {
const idx = Math.min(Math.floor(self.progress * total), total - 1);
steps.forEach((s, i) => s.classList.toggle("active", i === idx));
},
});
}
// Example:
// initStickySteps(".how-wrapper", ".how-panel", ".how-step")
// ─── 5. FULLSCREEN BLAST MENU ──────────────────────────────────
// Open/close animation for a full-viewport nav overlay.
// HTML: <div class="nav-overlay"> <a class="nav-item">...</a> </div>
function initBlastMenu(triggerSelector, overlaySelector, itemSelector) {
const trigger = document.querySelector(triggerSelector);
const overlay = document.querySelector(overlaySelector);
const items = document.querySelectorAll(itemSelector);
let isOpen = false;
if (prefersReducedMotion) {
gsap.set(overlay, { display: "none" });
trigger.addEventListener("click", () => {
isOpen = !isOpen;
trigger.setAttribute("aria-expanded", isOpen);
document.body.style.overflow = isOpen ? "hidden" : "";
overlay.style.display = isOpen ? "flex" : "none";
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && isOpen) { isOpen = false; overlay.style.display = "none"; document.body.style.overflow = ""; }
});
return;
}
// Overlay starts: clip-path: inset(0% 0% 100% 0%)
gsap.set(overlay, { clipPath: "inset(0% 0% 100% 0%)", display: "flex" });
const openTl = gsap.timeline({ paused: true })
.to(overlay, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.6, ease: "power4.inOut" })
.from(items, { y: 64, opacity: 0, stagger: 0.07, duration: 0.5, ease: "power3.out" }, "-=0.25");
trigger.addEventListener("click", () => {
isOpen = !isOpen;
trigger.setAttribute("aria-expanded", isOpen);
document.body.style.overflow = isOpen ? "hidden" : "";
isOpen ? openTl.play() : openTl.reverse();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && isOpen) { isOpen = false; openTl.reverse(); document.body.style.overflow = ""; }
});
}
// CSS required:
// .nav-overlay { position: fixed; inset: 0; z-index: 200; display: none; }
// Call: initBlastMenu(".menu-toggle", ".nav-overlay", ".nav-item")
// ─── 6. MAGNETIC PILL NAV ──────────────────────────────────────
// Bottom pill floats toward the cursor when nearby.
function initMagneticPill(pillSelector, radius, strength) {
if (prefersReducedMotion) return;
radius = radius || 120;
strength = strength || 0.28;
const pill = document.querySelector(pillSelector);
if (!pill) return;
document.addEventListener("mousemove", (e) => {
const r = pill.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.hypot(dx, dy);
if (dist < radius) {
const pull = 1 - dist / radius;
gsap.to(pill, { x: dx * pull * strength, y: dy * pull * strength, duration: 0.4, ease: "power2.out" });
} else {
gsap.to(pill, { x: 0, y: 0, duration: 0.8, ease: "elastic.out(1, 0.5)" });
}
});
}
// Example: initMagneticPill(".pill-nav")
// ─── 7. HERO DEMO STEPPER (time-driven product walkthrough) ────
// A self-running, scene-by-scene product demo inside the Hero.
// NOT scroll-driven — advances on a timer, resets when scrolled away.
//
// Required HTML structure:
// <div class="demo-wrapper"> ← IntersectionObserver target
// <div class="demo-step-badge">
// <span class="demo-step-num"></span>
// <span class="demo-step-label"></span>
// </div>
// <div class="demo-panel"> ← needs overflow: visible in CSS
// <div class="demo-slot" data-step="0"></div>
// <div class="demo-slot" data-step="1"></div>
// <div class="demo-slot" data-step="2"></div>
// </div>
// <div class="demo-progress-dots">
// <span class="step-dot active" data-target="0"></span>
// <span class="step-dot" data-target="1"></span>
// <span class="step-dot" data-target="2"></span>
// </div>
// <!-- Optional synced subtitle — text updates per step -->
// <p class="demo-synced-text"
// data-step-0="Step 0 subtitle"
// data-step-1="Step 1 subtitle"
// data-step-2="Step 2 subtitle"></p>
// </div>
//
// stepDefs: array of { num, label, onEnter(slotEl) }
// onEnter receives the slot DOM element — run your GSAP timeline inside it.
//
// Design law: each onEnter should animate (1) one hero element first,
// (2) supporting elements with stagger delay, (3) one breakout element last.
// Read references/product-demo-hero.md before writing these callbacks.
function initHeroDemoStepper(wrapperSel, stepDefs, intervalMs) {
const wrapper = document.querySelector(wrapperSel);
if (!wrapper) return;
intervalMs = intervalMs || 3200;
const slots = wrapper.querySelectorAll('.demo-slot');
const numEl = wrapper.querySelector('.demo-step-num');
const labelEl = wrapper.querySelector('.demo-step-label');
const dots = wrapper.querySelectorAll('.step-dot');
const syncText = wrapper.querySelector('.demo-synced-text');
let current = -1;
let timer = null;
function updateBadge(idx) {
const def = stepDefs[idx];
if (numEl) {
gsap.to(numEl, { opacity: 0, y: -6, duration: 0.18, onComplete: () => {
numEl.textContent = def.num;
gsap.to(numEl, { opacity: 1, y: 0, duration: 0.22 });
}});
}
if (labelEl) {
gsap.to(labelEl, { opacity: 0, duration: 0.15, onComplete: () => {
labelEl.textContent = def.label;
gsap.to(labelEl, { opacity: 1, duration: 0.2 });
}});
}
if (syncText) {
gsap.to(syncText, { opacity: 0, y: 4, duration: 0.18, onComplete: () => {
syncText.textContent = syncText.dataset['step' + idx] || '';
gsap.to(syncText, { opacity: 1, y: 0, duration: 0.25 });
}});
}
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
}
function enterSlot(slot, idx) {
gsap.set(slot, { display: 'block', opacity: 0 });
gsap.to(slot, { opacity: 1, duration: 0.22 });
if (stepDefs[idx].onEnter) stepDefs[idx].onEnter(slot);
}
function goTo(idx) {
const prev = current;
current = idx;
updateBadge(idx);
if (prev >= 0 && slots[prev]) {
gsap.to(slots[prev], { opacity: 0, duration: 0.2, onComplete: () => {
gsap.set(slots[prev], { display: 'none' });
enterSlot(slots[idx], idx);
}});
} else {
enterSlot(slots[idx], idx);
}
}
function startTimer() {
clearInterval(timer);
timer = setInterval(() => goTo((current + 1) % stepDefs.length), intervalMs);
}
dots.forEach((dot, i) => {
dot.addEventListener('click', () => { clearInterval(timer); goTo(i); startTimer(); });
});
// Start on viewport entry, pause on exit, reset when re-entering
const io = new IntersectionObserver((entries) => {
entries.forEach(({ isIntersecting }) => {
if (isIntersecting) {
slots.forEach(s => gsap.set(s, { display: 'none', opacity: 0 }));
current = -1;
goTo(0);
startTimer();
} else {
clearInterval(timer);
}
});
}, { threshold: 0.3 });
io.observe(wrapper);
}
// Example:
// initHeroDemoStepper(".demo-wrapper", [
// { num: "01", label: "Setup", onEnter(slot) { /* gsap animations */ } },
// { num: "02", label: "Running", onEnter(slot) { animateCount(slot.querySelector('.big-num'), 4200, 1.5); } },
// { num: "03", label: "Result", onEnter(slot) { /* gsap animations */ } },
// ]);
// ─── 8. ANIMATED COUNT (use inside initHeroDemoStepper onEnter) ─
// Counts from 0 → target inside an element. Shows velocity and scale.
function animateCount(el, target, duration, suffix) {
if (!el) return;
suffix = suffix || '';
duration = duration || 1.6;
if (prefersReducedMotion) { el.textContent = Math.round(target).toLocaleString() + suffix; return; }
const obj = { val: 0 };
gsap.to(obj, {
val: target,
duration: duration,
ease: 'power3.out',
onUpdate: () => { el.textContent = Math.round(obj.val).toLocaleString() + suffix; },
});
}
// Example: animateCount(slot.querySelector('.views-num'), 84201, 1.8)
// Example: animateCount(slot.querySelector('.pct'), 96, 1.2, '%')
// ─── 9. DARK MODE TOGGLE ─────────────────────────────────────
// Toggles between light/dark color schemes by swapping CSS custom properties.
// Requires: :root with light tokens, [data-theme="dark"] with dark tokens in style.css.
// Persists choice to localStorage.
//
// CSS required in style.css:
// :root { --bg: #fdf9f2; --bg-surface: #f7f0e6; --ink: #1a1510; ... }
// [data-theme="dark"] { --bg: #0e0c09; --bg-surface: #1e1b16; --ink: #f2ede4; ... }
//
// HTML: <button class="theme-toggle" aria-label="Toggle dark mode">
// <svg class="icon-sun" ...></svg>
// <svg class="icon-moon" ...></svg>
// </button>
function initDarkModeToggle(toggleSelector) {
const toggle = document.querySelector(toggleSelector);
if (!toggle) return;
const root = document.documentElement;
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initial = stored || (prefersDark ? 'dark' : 'light');
root.setAttribute('data-theme', initial);
toggle.addEventListener('click', () => {
const current = root.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
}
// Example: initDarkModeToggle(".theme-toggle")
#!/usr/bin/env python3
from __future__ import annotations
import re
import sys
from pathlib import Path
START_RE = re.compile(r"^\s*<!--\s*@@VARIANT:([A-Za-z0-9_]+)\s*-->\s*$")
END_RE = re.compile(r"^\s*<!--\s*@@/VARIANT:([A-Za-z0-9_]+)\s*-->\s*$")
STYLE_OPEN_RE = re.compile(r"^\s*<style\b[^>]*>\s*$", re.IGNORECASE)
STYLE_CLOSE_RE = re.compile(r"^\s*</style>\s*$", re.IGNORECASE)
def fail(message: str) -> None:
print(f"Error: {message}", file=sys.stderr)
print("Usage: python3 extract_variant.py <file> <variant>", file=sys.stderr)
print("Example: python3 extract_variant.py hero-variants.html A", file=sys.stderr)
print("Example: python3 extract_variant.py conversion-variants.html PRICING", file=sys.stderr)
raise SystemExit(1)
def resolve_path(raw_path: str) -> Path:
candidate = Path(raw_path).expanduser()
if candidate.exists():
return candidate.resolve()
script_dir = Path(__file__).resolve().parent
fallbacks = [
script_dir.parent / "sections" / raw_path,
script_dir / raw_path,
Path.cwd() / raw_path,
]
for fallback in fallbacks:
if fallback.exists():
return fallback.resolve()
fail(f"file not found: {raw_path}")
def parse_segments(lines: list[str]) -> tuple[list[tuple[int, int]], list[dict[str, object]]]:
style_blocks: list[tuple[int, int]] = []
segments: list[dict[str, object]] = []
inside_style = False
style_block_id = -1
current: dict[str, object] | None = None
for idx, line in enumerate(lines):
if STYLE_OPEN_RE.search(line):
inside_style = True
style_block_id += 1
style_blocks.append((idx, -1))
start_match = START_RE.match(line)
if start_match:
if current is not None:
fail("nested variant markers are not supported")
current = {
"variant": start_match.group(1).upper(),
"start_marker": idx,
"content_start": idx + 1,
"inside_style": inside_style,
"style_block_id": style_block_id if inside_style else None,
}
continue
end_match = END_RE.match(line)
if end_match:
if current is None:
fail(f"unexpected closing marker for {end_match.group(1)}")
if current["variant"] != end_match.group(1).upper():
fail(
f"marker mismatch: expected {current['variant']} but found {end_match.group(1).upper()}"
)
current["end_marker"] = idx
current["content_end"] = idx
segments.append(current)
current = None
continue
if STYLE_CLOSE_RE.search(line):
if style_block_id < 0:
fail("found </style> before <style>")
start_idx, _ = style_blocks[style_block_id]
style_blocks[style_block_id] = (start_idx, idx)
inside_style = False
if current is not None:
fail(f"unterminated variant marker for {current['variant']}")
unresolved = [block for block in style_blocks if block[1] == -1]
if unresolved:
fail("unterminated <style> block")
return style_blocks, segments
def build_style_output(lines, style_blocks, segments, variant):
outputs = []
full_style_segments = [
segment
for segment in segments
if segment["variant"] == variant
and "<style" in "".join(lines[segment["content_start"]:segment["content_end"]])
]
for segment in full_style_segments:
content = "".join(lines[segment["content_start"]:segment["content_end"]]).strip()
if content:
outputs.append((int(segment["start_marker"]), content))
style_segment_groups = {}
for segment in segments:
if segment["variant"] != variant:
continue
if not segment["inside_style"]:
continue
content = "".join(lines[segment["content_start"]:segment["content_end"]])
if "<style" in content:
continue
style_segment_groups.setdefault(int(segment["style_block_id"]), []).append(segment)
block_segments = {}
for segment in segments:
if segment["inside_style"] and segment["style_block_id"] is not None:
block_segments.setdefault(int(segment["style_block_id"]), []).append(segment)
for block_id in style_segment_groups:
start_idx, end_idx = style_blocks[block_id]
all_block_segments = block_segments.get(block_id, [])
marker_lines = {int(segment["start_marker"]) for segment in all_block_segments} | {
int(segment["end_marker"]) for segment in all_block_segments
}
occupied_lines = {}
for segment in all_block_segments:
for line_idx in range(int(segment["content_start"]), int(segment["content_end"])):
occupied_lines[line_idx] = str(segment["variant"])
rendered = []
for line_idx in range(start_idx, end_idx + 1):
if line_idx in marker_lines:
continue
owner = occupied_lines.get(line_idx)
if owner is not None and owner != variant:
continue
rendered.append(lines[line_idx])
content = "".join(rendered).strip()
if content:
outputs.append((start_idx, content))
outputs.sort(key=lambda item: item[0])
return outputs
def build_markup_output(lines, segments, variant):
outputs = []
for segment in segments:
if segment["variant"] != variant:
continue
content = "".join(lines[segment["content_start"]:segment["content_end"]])
stripped = content.strip()
if not stripped:
continue
if STYLE_OPEN_RE.match(stripped.splitlines()[0]):
continue
if segment["inside_style"]:
continue
outputs.append((int(segment["start_marker"]), stripped))
outputs.sort(key=lambda item: item[0])
return outputs
def main():
if len(sys.argv) != 3:
fail("expected a file path and a variant name")
file_path = resolve_path(sys.argv[1])
variant = sys.argv[2].strip().upper()
if not variant:
fail("variant name cannot be empty")
lines = file_path.read_text().splitlines(keepends=True)
style_blocks, segments = parse_segments(lines)
if not any(segment["variant"] == variant for segment in segments):
fail(f"variant not found: {variant}")
style_outputs = [content for _, content in build_style_output(lines, style_blocks, segments, variant)]
markup_outputs = [content for _, content in build_markup_output(lines, segments, variant)]
parts = [part for part in style_outputs + markup_outputs if part.strip()]
if not parts:
fail(f"no extractable content found for variant: {variant}")
sys.stdout.write("\n\n".join(parts).rstrip() + "\n")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
get_city_tokens.py — Extract color tokens (and optionally texture CSS) for a city.
Usage:
python3 get_city_tokens.py "京都" # color tokens (shell eval-friendly)
python3 get_city_tokens.py "Kyoto" # case-insensitive, Chinese or English
python3 get_city_tokens.py "京都" --texture # print only the CSS texture block
Color output (one token per line, for shell eval):
CITY_BG=#f7f0e6
CITY_SURFACE=#ede4d6
CITY_INK=#2a1f14
CITY_MUTED=#7a6655
CITY_ACCENT=#8b4513
Exit 0 on success, 1 if city not found.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
CITY_STYLES = Path(__file__).parent.parent.parent / "references" / "city-styles.md"
# Map CSS variable names → output key names
VAR_MAP = {
"--bg": "CITY_BG",
"--bg-surface": "CITY_SURFACE",
"--ink": "CITY_INK",
"--ink-muted": "CITY_MUTED",
"--accent": "CITY_ACCENT",
}
CJK_ALIASES = {
"東京": "东京", "東京夜": "东京夜",
"パリ": "巴黎", "ソウル": "首尔", "ベイルート": "贝鲁特",
"キョウト": "���都", "ニューヨーク": "纽约", "ベルリン": "柏林",
"シンガポール": "新加坡", "ドバイ": "迪拜", "ムンバイ": "孟买",
"香港": "香港", "台北": "台北", "大阪": "大阪",
}
def find_city_section(text: str, query: str) -> str | None:
"""Return the markdown text of the matching city section, or None."""
query_clean = query.strip()
query_lower = query_clean.lower()
# Resolve CJK variant aliases (e.g. Japanese 東京 → simplified 东京)
resolved = CJK_ALIASES.get(query_clean, query_clean)
candidates = {query_lower, resolved.lower()}
# Split on ## headings (city sections)
sections = re.split(r"\n(?=## \d+\.)", text)
for section in sections:
first_line = section.split("\n")[0].lower()
if any(c in first_line for c in candidates):
return section
return None
def extract_colors(section: str) -> dict:
"""Parse the ### Colors block and return {VAR_MAP key: value}."""
result = {}
in_colors = False
for line in section.splitlines():
if re.match(r"^### Colors", line):
in_colors = True
continue
if in_colors and re.match(r"^### ", line):
break # next subsection
if in_colors:
for css_var, out_key in VAR_MAP.items():
# Match: --bg: #xxx or --bg: rgba(...)
pattern = rf"^\s*{re.escape(css_var)}\s*:\s*([#\w(),.\s%]+)"
m = re.match(pattern, line)
if m:
value = m.group(1).split("/*")[0].strip().rstrip(",")
result[out_key] = value
return result
def extract_texture(section: str) -> str:
"""Return the CSS inside the ### Texture code block, or empty string."""
m = re.search(r"### Texture.*?```css\s*(.*?)```", section, re.S)
return m.group(1).strip() if m else ""
def main():
if len(sys.argv) < 2:
print("Usage: get_city_tokens.py <city-name> [--texture]", file=sys.stderr)
sys.exit(1)
query = sys.argv[1]
want_texture = "--texture" in sys.argv
text = CITY_STYLES.read_text(encoding="utf-8")
section = find_city_section(text, query)
if not section:
print(f"City not found: {query}", file=sys.stderr)
sys.exit(1)
if want_texture:
css = extract_texture(section)
if css:
print(css)
else:
print(f"No texture block found for '{query}'", file=sys.stderr)
sys.exit(1)
return
colors = extract_colors(section)
missing = [k for k in VAR_MAP.values() if k not in colors]
if missing:
print(f"Warning: could not parse {missing} for '{query}'", file=sys.stderr)
for key in VAR_MAP.values():
if key in colors:
print(f"{key}={colors[key]}")
if __name__ == "__main__":
main()
<#
.SYNOPSIS
Windows PowerShell preview launcher for citycraft.
.DESCRIPTION
Substitutes __KEY__ placeholders in the template, serves the filled HTML
on localhost, opens the default browser, waits for POST /submit, and
prints the result JSON to stdout.
Requires: PowerShell 5.1+ (Windows built-in) or PowerShell 7+.
No external dependencies — uses System.Net.HttpListener from .NET.
.PARAMETER Template
Path to the HTML template file.
.PARAMETER Output
Path to write the filled HTML.
.PARAMETER Port
HTTP port to listen on (default: 17432).
.PARAMETER Result
Path to write the submitted JSON (default: %TEMP%\citycraft_result.json).
.PARAMETER Timeout
Seconds to wait for submission (default: 300).
.PARAMETER Sub
One or more KEY=VALUE substitution pairs. Replaces __KEY__ in the template.
Repeat the flag for multiple pairs: -Sub "K1=V1" -Sub "K2=V2"
.EXAMPLE
.\run_preview.ps1 `
-Template "$env:USERPROFILE\.agents\skills\citycraft\assets\style-preview-template.html" `
-Output .\style-preview.html `
-Port 17433 `
-Timeout 300 `
-Sub "PRODUCT_NAME=My Product" `
-Sub "PRODUCT_HEADLINE=Build faster" `
-Sub "RECEIVER_PORT=17433"
#>
param(
[Parameter(Mandatory)][string] $Template,
[Parameter(Mandatory)][string] $Output,
[int] $Port = 17432,
[string] $Result = (Join-Path $env:TEMP "citycraft_result.json"),
[int] $Timeout = 300,
[string[]] $Sub = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ── 1. Template substitution ──────────────────────────────────────────────────
$templateFull = (Resolve-Path $Template).Path
$content = [System.IO.File]::ReadAllText($templateFull, [System.Text.Encoding]::UTF8)
foreach ($pair in $Sub) {
$idx = $pair.IndexOf("=")
if ($idx -lt 1) { continue }
$key = "__$($pair.Substring(0, $idx).Trim())__"
$value = $pair.Substring($idx + 1)
$content = $content.Replace($key, $value)
}
$outputFull = Join-Path (Get-Location) $Output
[System.IO.File]::WriteAllText($outputFull, $content, [System.Text.Encoding]::UTF8)
# ── 2. Start HTTP listener ────────────────────────────────────────────────────
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add("http://localhost:$Port/")
$listener.Start()
# ── 3. Open browser ───────────────────────────────────────────────────────────
Start-Process "http://localhost:$Port"
# ── 4. Request loop ───────────────────────────────────────────────────────────
$startTime = [DateTime]::UtcNow
$submitted = $false
$htmlBytes = $null # lazy-load on first GET
try {
while ($listener.IsListening) {
$elapsed = ([DateTime]::UtcNow - $startTime).TotalSeconds
if ($elapsed -ge $Timeout) { break }
# Non-blocking wait: up to 1 s per iteration
$async = $listener.BeginGetContext($null, $null)
if (-not $async.AsyncWaitHandle.WaitOne(1000)) { continue }
$ctx = $listener.EndGetContext($async)
$req = $ctx.Request
$resp = $ctx.Response
$resp.Headers.Add("Access-Control-Allow-Origin", "*")
$resp.Headers.Add("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
$resp.Headers.Add("Access-Control-Allow-Headers", "Content-Type")
$route = "$($req.HttpMethod) $($req.Url.LocalPath)"
switch ($route) {
"OPTIONS /" {
$resp.StatusCode = 204
}
"GET /" {
if ($null -eq $htmlBytes) {
$htmlBytes = [System.IO.File]::ReadAllBytes($outputFull)
}
$resp.ContentType = "text/html; charset=utf-8"
$resp.ContentLength64 = $htmlBytes.Length
$resp.OutputStream.Write($htmlBytes, 0, $htmlBytes.Length)
}
"POST /submit" {
$reader = [System.IO.StreamReader]::new(
$req.InputStream, [System.Text.Encoding]::UTF8)
$body = $reader.ReadToEnd()
[System.IO.File]::WriteAllText(
$Result, $body, [System.Text.Encoding]::UTF8)
$ok = [System.Text.Encoding]::UTF8.GetBytes('{"ok":true}')
$resp.ContentType = "application/json; charset=utf-8"
$resp.ContentLength64 = $ok.Length
$resp.OutputStream.Write($ok, 0, $ok.Length)
$submitted = $true
}
default {
$resp.StatusCode = 404
}
}
$resp.Close()
if ($submitted) { break }
}
} finally {
$listener.Stop()
}
# ── 5. Output ─────────────────────────────────────────────────────────────────
if ($submitted) {
$data = [System.IO.File]::ReadAllText($Result, [System.Text.Encoding]::UTF8)
Remove-Item $Result -ErrorAction SilentlyContinue
Write-Output $data
} else {
Write-Error "No submission in ${Timeout}s. Ask the user to type their choice manually."
exit 1
}
#!/usr/bin/env python3
"""
run_preview.py — Cross-platform preview launcher for citycraft.
Replaces the sed + receiver.py + shell-polling combo with a single script
that works on macOS, Linux, and Windows (Python 3.6+, stdlib only).
What it does:
1. Reads the template and substitutes __KEY__ placeholders.
2. Writes the filled HTML to --output.
3. Starts a one-shot HTTP server on --port (GET / → HTML, POST /submit → result).
4. Opens the browser via webbrowser.open() (cross-platform).
5. Blocks until POST /submit arrives or --timeout seconds elapse.
6. Prints the submitted JSON to stdout and exits 0.
On timeout: prints a message to stderr and exits 1.
Usage:
python3 run_preview.py \\
--template /path/to/template.html \\
--output ./preview.html \\
--port 17433 \\
--timeout 300 \\
PRODUCT_NAME="My Product" \\
PRODUCT_HEADLINE="Build faster" \\
RECEIVER_PORT=17433
Each positional KEY=VALUE arg replaces __KEY__ in the template.
"""
import argparse
import os
import sys
import tempfile
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
def parse_args():
p = argparse.ArgumentParser(description="citycraft preview launcher")
p.add_argument("--template", required=True, help="Path to HTML template")
p.add_argument("--output", required=True, help="Path to write filled HTML")
p.add_argument("--port", type=int, default=17432, help="HTTP port (default 17432)")
p.add_argument("--result", default=None,
help="Path to write submitted JSON (default: OS temp dir)")
p.add_argument("--timeout", type=int, default=300,
help="Seconds to wait for submission (default 300)")
p.add_argument("subs", nargs="*",
help="Substitution pairs: KEY=VALUE (replaces __KEY__ in template)")
return p.parse_args()
def substitute(template_path, output_path, subs):
text = Path(template_path).read_text(encoding="utf-8")
for pair in subs:
if "=" not in pair:
continue
key, _, value = pair.partition("=")
text = text.replace("__{key}__".format(key=key.strip()), value)
Path(output_path).write_text(text, encoding="utf-8")
def make_handler(html_path, result_path):
"""Return a BaseHTTPRequestHandler subclass bound to html_path / result_path."""
class _Handler(BaseHTTPRequestHandler):
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
if self.path != "/":
self.send_response(404)
self._cors()
self.end_headers()
return
body = Path(html_path).read_bytes()
self.send_response(200)
self._cors()
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != "/submit":
self.send_response(404)
self._cors()
self.end_headers()
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
Path(result_path).write_bytes(body)
self.send_response(200)
self._cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(b'{"ok":true}')
threading.Thread(target=self.server.shutdown, daemon=True).start()
def log_message(self, *_):
pass # suppress request logs
return _Handler
def find_free_port(preferred, max_attempts=10):
"""Try preferred port, then increment until a free one is found."""
import socket
for offset in range(max_attempts):
port = preferred + offset
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
return None
def main():
args = parse_args()
result_path = Path(
args.result if args.result
else os.path.join(tempfile.gettempdir(), "citycraft_result.json")
)
# Remove stale result from a previous run
try:
result_path.unlink()
except OSError:
pass
# 1. Fill template
substitute(args.template, args.output, args.subs)
# 2. Find available port and start HTTP server
port = find_free_port(args.port)
if port is None:
sys.stderr.write(
"Could not find a free port in range {p}–{e}.\n"
.format(p=args.port, e=args.port + 9)
)
sys.exit(1)
if port != args.port:
sys.stderr.write(
"Port {p} in use, using {a} instead.\n"
.format(p=args.port, a=port)
)
html_abs = str(Path(args.output).resolve())
Handler = make_handler(html_abs, str(result_path))
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
# 3. Open browser (cross-platform: macOS / Linux / Windows)
webbrowser.open("http://localhost:{port}".format(port=port))
# 4. Poll for result
for _ in range(args.timeout):
if result_path.exists():
data = result_path.read_text(encoding="utf-8")
try:
result_path.unlink()
except OSError:
pass
server.shutdown()
print(data)
return
time.sleep(1)
# Timeout
server.shutdown()
sys.stderr.write(
"No submission in {t}s. Ask the user to type their choice manually.\n"
.format(t=args.timeout)
)
sys.exit(1)
if __name__ == "__main__":
main()
<!-- @@VARIANT:TEAM -->
<style>
.section-team {
padding: 6rem 5vw;
background: var(--bg);
font-family: var(--font-body);
color: var(--ink);
}
.team-inner {
max-width: 1400px;
margin: 0 auto;
}
.team-inner h2 {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3.5rem);
font-weight: 400;
text-align: center;
margin-bottom: 1rem;
}
.team-inner > p {
text-align: center;
color: var(--ink-muted);
font-size: 1.05rem;
margin-bottom: 3rem;
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 2rem;
}
.team-card {
text-align: center;
padding: 2rem 1.5rem;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--bg-surface);
transition: box-shadow 0.3s, transform 0.3s;
}
.team-card:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
transform: translateY(-4px);
}
.team-card:first-child {
border-color: var(--accent);
}
.team-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 1.5rem;
margin: 0 auto 1rem;
}
.team-card h3 {
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 400;
margin-bottom: 0.25rem;
}
.team-role {
font-size: 0.85rem;
color: var(--accent);
margin-bottom: 0.75rem;
font-weight: 500;
}
.team-bio {
font-size: 0.9rem;
color: var(--ink-muted);
line-height: 1.5;
}
</style>
<section class="section-team">
<div class="team-inner">
<h2>Meet the team</h2>
<p>Replace with a short intro about your team culture.</p>
<div class="team-grid">
<div class="team-card">
<div class="team-avatar">JD</div>
<h3>Jane Doe</h3>
<div class="team-role">Founder & CEO</div>
<p class="team-bio">Replace with a 1-2 sentence bio.</p>
</div>
<div class="team-card">
<div class="team-avatar">AS</div>
<h3>Alex Smith</h3>
<div class="team-role">CTO</div>
<p class="team-bio">Replace with bio.</p>
</div>
<div class="team-card">
<div class="team-avatar">MK</div>
<h3>Maria Kim</h3>
<div class="team-role">Head of Design</div>
<p class="team-bio">Replace with bio.</p>
</div>
<div class="team-card">
<div class="team-avatar">RL</div>
<h3>Ryan Lee</h3>
<div class="team-role">Lead Engineer</div>
<p class="team-bio">Replace with bio.</p>
</div>
</div>
</div>
</section>
<!-- @@/VARIANT:TEAM -->
<!-- @@VARIANT:STATS -->
<style>
.section-stats {
padding: 5rem 5vw;
background: var(--bg-surface);
font-family: var(--font-body);
color: var(--ink);
}
.stats-inner {
max-width: 1200px;
margin: 0 auto;
text-align: center;
}
.stats-inner h2 {
font-family: var(--font-display);
font-size: clamp(1.8rem, 3.5vw, 2.8rem);
font-weight: 400;
margin-bottom: 3rem;
}
.stats-inner h2 span { color: var(--accent); font-style: italic; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
}
.stat-item {
padding: 2rem 1rem;
}
.stat-num {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4rem);
line-height: 1;
color: var(--ink);
margin-bottom: 0.5rem;
}
.stat-num .stat-accent { color: var(--accent); }
.stat-label {
font-size: 0.95rem;
color: var(--ink-muted);
line-height: 1.4;
}
.stat-divider {
width: 40px;
height: 2px;
background: var(--accent);
margin: 0.75rem auto 0;
opacity: 0.5;
}
</style>
<section class="section-stats">
<div class="stats-inner">
<h2>Numbers that <span>speak</span></h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-num">10<span class="stat-accent">K+</span></div>
<div class="stat-label">Active users</div>
<div class="stat-divider"></div>
</div>
<div class="stat-item">
<div class="stat-num">99<span class="stat-accent">%</span></div>
<div class="stat-label">Uptime SLA</div>
<div class="stat-divider"></div>
</div>
<div class="stat-item">
<div class="stat-num">50<span class="stat-accent">M</span></div>
<div class="stat-label">Requests per day</div>
<div class="stat-divider"></div>
</div>
<div class="stat-item">
<div class="stat-num">4.9<span class="stat-accent">/5</span></div>
<div class="stat-label">Average rating</div>
<div class="stat-divider"></div>
</div>
</div>
</div>
</section>
<!-- @@/VARIANT:STATS -->
<!-- @@VARIANT:LOGO_SCROLL -->
<style>
.section-logo-scroll {
padding: 3rem 0;
background: var(--bg);
overflow: hidden;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.logo-scroll-label {
text-align: center;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--ink-muted);
margin-bottom: 2rem;
font-weight: 500;
}
.logo-track {
display: flex;
width: max-content;
animation: logoScroll 25s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.logo-track { animation: none; flex-wrap: wrap; width: 100%; justify-content: center; gap: 2rem; padding: 0 5vw; }
}
@keyframes logoScroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.logo-item {
flex-shrink: 0;
padding: 0 2.5rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.4;
transition: opacity 0.3s;
}
.logo-item:hover { opacity: 0.8; }
.logo-item svg {
height: 28px;
width: auto;
fill: var(--ink);
}
</style>
<section class="section-logo-scroll">
<div class="logo-scroll-label">Trusted by teams at</div>
<div class="logo-track">
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand A</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand B</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand C</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand D</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand E</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand F</text></svg></div>
<!-- Duplicate set for seamless loop -->
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand A</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand B</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand C</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand D</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand E</text></svg></div>
<div class="logo-item"><svg viewBox="0 0 120 28"><rect width="120" height="28" rx="4" fill="currentColor" opacity="0.15"/><text x="60" y="18" text-anchor="middle" font-size="12" fill="currentColor">Brand F</text></svg></div>
</div>
</section>
<!-- @@/VARIANT:LOGO_SCROLL -->
<!-- @@VARIANT:GALLERY -->
<style>
.section-gallery {
padding: 6rem 5vw;
background: var(--bg);
font-family: var(--font-body);
color: var(--ink);
}
.gallery-inner {
max-width: 1400px;
margin: 0 auto;
}
.gallery-inner h2 {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3.5rem);
font-weight: 400;
text-align: center;
margin-bottom: 3rem;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 250px;
gap: 1rem;
}
@media (max-width: 768px) {
.gallery-grid { grid-template-columns: 1fr 1fr; grid-auto-rows: 200px; }
}
@media (max-width: 480px) {
.gallery-grid { grid-template-columns: 1fr; }
}
.gallery-grid .gi:nth-child(1) { grid-row: span 2; }
.gallery-grid .gi:nth-child(4) { grid-column: span 2; }
@media (max-width: 768px) {
.gallery-grid .gi:nth-child(1) { grid-row: span 1; }
.gallery-grid .gi:nth-child(4) { grid-column: span 1; }
}
.gi {
border-radius: 12px;
overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.3s;
position: relative;
}
.gi:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.1); }
.gi svg {
width: 40%;
height: 40%;
opacity: 0.12;
stroke: var(--ink);
fill: none;
stroke-width: 1;
}
.gi-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 50%);
opacity: 0;
transition: opacity 0.3s;
display: flex;
align-items: flex-end;
padding: 1.25rem;
}
.gi:hover .gi-overlay { opacity: 1; }
.gi-overlay span {
color: #fff;
font-size: 0.9rem;
font-weight: 500;
}
</style>
<section class="section-gallery">
<div class="gallery-inner">
<h2>Our work</h2>
<div class="gallery-grid">
<div class="gi">
<svg viewBox="0 0 100 100"><rect x="10" y="10" width="80" height="80" rx="4"/></svg>
<div class="gi-overlay"><span>Project Alpha</span></div>
</div>
<div class="gi">
<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="35"/></svg>
<div class="gi-overlay"><span>Project Beta</span></div>
</div>
<div class="gi">
<svg viewBox="0 0 100 100"><polygon points="50,15 90,85 10,85"/></svg>
<div class="gi-overlay"><span>Project Gamma</span></div>
</div>
<div class="gi">
<svg viewBox="0 0 100 100"><rect x="20" y="20" width="60" height="60" rx="8" transform="rotate(45 50 50)"/></svg>
<div class="gi-overlay"><span>Project Delta</span></div>
</div>
<div class="gi">
<svg viewBox="0 0 100 100"><path d="M20 80 Q50 20 80 80"/></svg>
<div class="gi-overlay"><span>Project Epsilon</span></div>
</div>
</div>
</div>
</section>
<!-- @@/VARIANT:GALLERY -->
<!-- @@VARIANT:INTEGRATIONS -->
<style>
.section-integrations {
padding: 6rem 5vw;
background: var(--bg-surface);
font-family: var(--font-body);
color: var(--ink);
}
.integ-inner {
max-width: 1000px;
margin: 0 auto;
text-align: center;
}
.integ-inner h2 {
font-family: var(--font-display);
font-size: clamp(1.8rem, 3.5vw, 2.8rem);
font-weight: 400;
margin-bottom: 0.75rem;
}
.integ-inner > p {
color: var(--ink-muted);
font-size: 1.05rem;
margin-bottom: 3rem;
}
.integ-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1.5rem;
}
.integ-item {
padding: 1.5rem 1rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
transition: border-color 0.3s, box-shadow 0.3s;
}
.integ-item:hover {
border-color: var(--accent);
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
}
.integ-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--accent-soft);
display: flex;
align-items: center;
justify-content: center;
}
.integ-icon svg {
width: 20px;
height: 20px;
stroke: var(--accent);
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.integ-name {
font-size: 0.85rem;
font-weight: 500;
}
</style>
<section class="section-integrations">
<div class="integ-inner">
<h2>Works with your stack</h2>
<p>Replace with a description of your integration ecosystem.</p>
<div class="integ-grid">
<div class="integ-item">
<div class="integ-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg></div>
<span class="integ-name">Platform A</span>
</div>
<div class="integ-item">
<div class="integ-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/></svg></div>
<span class="integ-name">Platform B</span>
</div>
<div class="integ-item">
<div class="integ-icon"><svg viewBox="0 0 24 24"><polygon points="12,2 22,22 2,22"/></svg></div>
<span class="integ-name">Platform C</span>
</div>
<div class="integ-item">
<div class="integ-icon"><svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></div>
<span class="integ-name">Platform D</span>
</div>
<div class="integ-item">
<div class="integ-icon"><svg viewBox="0 0 24 24"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/></svg></div>
<span class="integ-name">Platform E</span>
</div>
<div class="integ-item">
<div class="integ-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg></div>
<span class="integ-name">Platform F</span>
</div>
</div>
</div>
</section>
<!-- @@/VARIANT:INTEGRATIONS -->
<!-- @@VARIANT:TIMELINE -->
<style>
.section-timeline {
padding: 6rem 5vw;
background: var(--bg);
font-family: var(--font-body);
color: var(--ink);
}
.timeline-inner {
max-width: 800px;
margin: 0 auto;
}
.timeline-inner h2 {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3.5rem);
font-weight: 400;
text-align: center;
margin-bottom: 4rem;
}
.tl-list {
position: relative;
padding-left: 2.5rem;
}
.tl-list::before {
content: '';
position: absolute;
left: 7px;
top: 8px;
bottom: 8px;
width: 2px;
background: linear-gradient(to bottom, var(--accent), var(--line));
}
.tl-item {
position: relative;
padding-bottom: 3rem;
}
.tl-item:last-child { padding-bottom: 0; }
.tl-dot {
position: absolute;
left: -2.5rem;
top: 6px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg);
border: 3px solid var(--accent);
}
.tl-item:first-child .tl-dot {
background: var(--accent);
}
.tl-date {
font-size: 0.8rem;
color: var(--accent);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
.tl-item h3 {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 400;
margin-bottom: 0.5rem;
}
.tl-item p {
font-size: 0.95rem;
color: var(--ink-muted);
line-height: 1.6;
max-width: 560px;
}
</style>
<section class="section-timeline">
<div class="timeline-inner">
<h2>Our journey</h2>
<div class="tl-list">
<div class="tl-item">
<div class="tl-dot"></div>
<div class="tl-date">2026</div>
<h3>Replace with milestone title</h3>
<p>Replace with what happened at this milestone.</p>
</div>
<div class="tl-item">
<div class="tl-dot"></div>
<div class="tl-date">2025</div>
<h3>Replace with milestone title</h3>
<p>Replace with description.</p>
</div>
<div class="tl-item">
<div class="tl-dot"></div>
<div class="tl-date">2024</div>
<h3>Replace with milestone title</h3>
<p>Replace with description.</p>
</div>
<div class="tl-item">
<div class="tl-dot"></div>
<div class="tl-date">2023</div>
<h3>The beginning</h3>
<p>Replace with your origin story.</p>
</div>
</div>
</div>
</section>
<!-- @@/VARIANT:TIMELINE -->
<!-- @@VARIANT:A -->
<style>
.footer-a {
background: var(--bg-surface);
border-top: 1px solid var(--line);
padding: 3rem 5vw;
font-family: var(--font-body);
color: var(--ink-muted);
}
.footer-a .fa-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-a .fa-brand {
font-family: var(--font-display);
font-size: 1.25rem;
color: var(--ink);
text-decoration: none;
}
.footer-a .fa-links {
display: flex;
gap: 2rem;
list-style: none;
}
.footer-a .fa-links a {
color: var(--ink-muted);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.3s;
}
.footer-a .fa-links a:hover { color: var(--accent); }
.footer-a .fa-copy {
font-size: 0.8rem;
width: 100%;
text-align: center;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
}
</style>
<footer class="footer-a">
<div class="fa-inner">
<a href="#" class="fa-brand">__BRAND__</a>
<ul class="fa-links">
<li><a href="#features">Features</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href="#">Privacy</a></li>
</ul>
<p class="fa-copy">© 2026 __BRAND__. All rights reserved.</p>
</div>
</footer>
<!-- @@/VARIANT:A -->
<!-- @@VARIANT:B -->
<style>
.footer-b {
background: var(--bg-surface);
border-top: 1px solid var(--line);
padding: 4rem 5vw 2rem;
font-family: var(--font-body);
color: var(--ink-muted);
}
.footer-b .fb-grid {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 3rem;
}
@media (max-width: 768px) {
.footer-b .fb-grid { grid-template-columns: 1fr 1fr; gap: 2rem; }
}
.footer-b .fb-brand-col .fb-brand {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--ink);
display: block;
margin-bottom: 1rem;
}
.footer-b .fb-brand-col p {
font-size: 0.9rem;
line-height: 1.6;
max-width: 280px;
}
.footer-b .fb-col h4 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--ink);
margin-bottom: 1rem;
font-weight: 600;
}
.footer-b .fb-col ul { list-style: none; }
.footer-b .fb-col li { margin-bottom: 0.6rem; }
.footer-b .fb-col a {
color: var(--ink-muted);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.3s;
}
.footer-b .fb-col a:hover { color: var(--accent); }
.footer-b .fb-bottom {
max-width: 1400px;
margin: 3rem auto 0;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.8rem;
}
</style>
<footer class="footer-b">
<div class="fb-grid">
<div class="fb-brand-col">
<span class="fb-brand">__BRAND__</span>
<p>Replace with a one-line brand description that captures your product's value.</p>
</div>
<div class="fb-col">
<h4>Product</h4>
<ul>
<li><a href="#features">Features</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a href="#">Changelog</a></li>
</ul>
</div>
<div class="fb-col">
<h4>Company</h4>
<ul>
<li><a href="#">About</a></li>
<li><a href="#">Blog</a></li>
<li><a href="#">Careers</a></li>
</ul>
</div>
<div class="fb-col">
<h4>Legal</h4>
<ul>
<li><a href="#">Privacy</a></li>
<li><a href="#">Terms</a></li>
<li><a href="#">Contact</a></li>
</ul>
</div>
</div>
<div class="fb-bottom">
<span>© 2026 __BRAND__. All rights reserved.</span>
<a href="#" style="color: var(--ink-muted); text-decoration: none;">Back to top ↑</a>
</div>
</footer>
<!-- @@/VARIANT:B -->
<!-- @@VARIANT:C -->
<style>
.footer-c {
background: var(--bg-surface);
border-top: 1px solid var(--line);
padding: 5rem 5vw 2rem;
font-family: var(--font-body);
color: var(--ink-muted);
position: relative;
}
.footer-c .fc-inner {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: end;
}
@media (max-width: 768px) {
.footer-c .fc-inner { grid-template-columns: 1fr; gap: 2rem; }
}
.footer-c .fc-big-text {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3.5rem);
color: var(--ink);
line-height: 1.15;
font-weight: 400;
}
.footer-c .fc-big-text span { color: var(--accent); font-style: italic; }
.footer-c .fc-right {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.footer-c .fc-links {
display: flex;
gap: 2rem;
flex-wrap: wrap;
list-style: none;
}
.footer-c .fc-links a {
color: var(--ink-muted);
text-decoration: none;
font-size: 0.875rem;
border-bottom: 1px solid transparent;
padding-bottom: 2px;
transition: color 0.3s, border-color 0.3s;
}
.footer-c .fc-links a:hover { color: var(--accent); border-color: var(--accent); }
.footer-c .fc-bottom {
max-width: 1400px;
margin: 4rem auto 0;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
font-size: 0.8rem;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.footer-c .fc-watermark {
position: absolute;
bottom: 1rem;
right: 5vw;
font-family: var(--font-display);
font-size: clamp(6rem, 15vw, 14rem);
color: var(--ink);
opacity: 0.03;
pointer-events: none;
line-height: 1;
}
</style>
<footer class="footer-c">
<div class="fc-inner">
<div>
<h2 class="fc-big-text">Ready to<br><span>get started?</span></h2>
</div>
<div class="fc-right">
<ul class="fc-links">
<li><a href="#features">Features</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href="#">Privacy</a></li>
<li><a href="#">Terms</a></li>
</ul>
<p style="font-size: 0.9rem; line-height: 1.5;">Replace with brand tagline or contact email.</p>
</div>
</div>
<div class="fc-bottom">
<span>© 2026 __BRAND__. All rights reserved.</span>
<span>Crafted with intention.</span>
</div>
<div class="fc-watermark">__BRAND__</div>
</footer>
<!-- @@/VARIANT:C -->
<!-- @@VARIANT:NEWSLETTER -->
<style>
.form-newsletter {
padding: 5rem 5vw;
background: var(--bg-surface);
font-family: var(--font-body);
color: var(--ink);
position: relative;
overflow: hidden;
}
.fn-inner {
max-width: 640px;
margin: 0 auto;
text-align: center;
position: relative;
z-index: 2;
}
.fn-inner h2 {
font-family: var(--font-display);
font-size: clamp(1.8rem, 3.5vw, 2.8rem);
font-weight: 400;
margin-bottom: 0.75rem;
}
.fn-inner h2 span { color: var(--accent); font-style: italic; }
.fn-inner p {
color: var(--ink-muted);
font-size: 1.05rem;
line-height: 1.6;
margin-bottom: 2rem;
}
.fn-form {
display: flex;
gap: 0.75rem;
max-width: 480px;
margin: 0 auto;
}
@media (max-width: 520px) {
.fn-form { flex-direction: column; }
}
.fn-form input[type="email"] {
flex: 1;
padding: 0.9rem 1.2rem;
border: 1px solid var(--line);
border-radius: 100px;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 1rem;
min-height: 48px;
}
.fn-form input[type="email"]:focus {
outline: none;
border-color: var(--accent);
}
.fn-form button {
padding: 0.9rem 2rem;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 100px;
font-family: var(--font-body);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
min-height: 48px;
white-space: nowrap;
transition: background 0.3s, transform 0.2s;
}
.fn-form button:hover { background: var(--accent-hover, var(--accent)); }
.fn-form button:active { transform: scale(0.97); }
.fn-note {
font-size: 0.8rem;
color: var(--ink-muted);
margin-top: 1rem;
}
.fn-glow {
position: absolute;
top: 50%;
left: 50%;
width: 60vw;
height: 60vw;
background: radial-gradient(circle, var(--accent-soft) 0%, transparent 70%);
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 0;
}
</style>
<section class="form-newsletter">
<div class="fn-glow"></div>
<div class="fn-inner">
<h2>Stay in the <span>loop</span></h2>
<p>Replace with your newsletter value proposition. What will subscribers get? How often?</p>
<form class="fn-form" action="#" method="POST">
<label for="fn-email" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)">Email address</label>
<input type="email" id="fn-email" name="email" required placeholder="your@email.com" autocomplete="email">
<button type="submit">Subscribe</button>
</form>
<p class="fn-note">No spam. Unsubscribe anytime.</p>
</div>
</section>
<!-- @@/VARIANT:NEWSLETTER -->
<!-- @@VARIANT:WAITLIST -->
<style>
.form-waitlist {
padding: 6rem 5vw;
background: var(--bg);
font-family: var(--font-body);
color: var(--ink);
}
.fw-inner {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
}
@media (max-width: 768px) {
.fw-inner { grid-template-columns: 1fr; gap: 2rem; }
}
.fw-text h2 {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3.5rem);
font-weight: 400;
line-height: 1.1;
margin-bottom: 1rem;
}
.fw-text h2 span { color: var(--accent); }
.fw-text p {
color: var(--ink-muted);
font-size: 1.05rem;
line-height: 1.7;
margin-bottom: 1.5rem;
max-width: 480px;
}
.fw-stats {
display: flex;
gap: 2rem;
margin-bottom: 0;
}
.fw-stat-num {
font-family: var(--font-display);
font-size: clamp(1.5rem, 3vw, 2rem);
color: var(--ink);
display: block;
}
.fw-stat-label {
font-size: 0.8rem;
color: var(--ink-muted);
}
.fw-card {
background: var(--bg-surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 2.5rem;
}
.fw-card h3 {
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 400;
margin-bottom: 1.5rem;
}
.fw-card .fw-group { margin-bottom: 1.25rem; }
.fw-card label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
.fw-card input {
width: 100%;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 1rem;
min-height: 48px;
transition: border-color 0.3s;
}
.fw-card input:focus { outline: none; border-color: var(--accent); }
.fw-card button {
width: 100%;
padding: 1rem;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 100px;
font-family: var(--font-body);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
min-height: 48px;
transition: background 0.3s, transform 0.2s;
}
.fw-card button:hover { background: var(--accent-hover, var(--accent)); }
.fw-card button:active { transform: scale(0.97); }
</style>
<section class="form-waitlist">
<div class="fw-inner">
<div class="fw-text">
<h2>Join the <span>waitlist</span></h2>
<p>Replace with why people should join early. What exclusive access or benefit do they get?</p>
<div class="fw-stats">
<div>
<span class="fw-stat-num">2,400+</span>
<span class="fw-stat-label">Already signed up</span>
</div>
<div>
<span class="fw-stat-num">Q2 2026</span>
<span class="fw-stat-label">Expected launch</span>
</div>
</div>
</div>
<form class="fw-card" action="#" method="POST">
<h3>Get early access</h3>
<div class="fw-group">
<label for="fw-name">Name</label>
<input type="text" id="fw-name" name="name" required placeholder="Your name" autocomplete="name">
</div>
<div class="fw-group">
<label for="fw-email">Email</label>
<input type="email" id="fw-email" name="email" required placeholder="you@example.com" autocomplete="email">
</div>
<button type="submit">Join the waitlist</button>
</form>
</div>
</section>
<!-- @@/VARIANT:WAITLIST -->
<!-- @@VARIANT:CONTACT_INLINE -->
<style>
.form-contact-inline {
padding: 5rem 5vw;
background: var(--bg-surface);
font-family: var(--font-body);
color: var(--ink);
}
.fci-inner {
max-width: 720px;
margin: 0 auto;
}
.fci-inner h2 {
font-family: var(--font-display);
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-weight: 400;
text-align: center;
margin-bottom: 0.75rem;
}
.fci-inner > p {
text-align: center;
color: var(--ink-muted);
margin-bottom: 2.5rem;
font-size: 1.05rem;
}
.fci-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 520px) {
.fci-form { grid-template-columns: 1fr; }
}
.fci-form .fci-full { grid-column: 1 / -1; }
.fci-form label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
.fci-form input,
.fci-form textarea,
.fci-form select {
width: 100%;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 1rem;
min-height: 48px;
transition: border-color 0.3s;
}
.fci-form input:focus,
.fci-form textarea:focus,
.fci-form select:focus { outline: none; border-color: var(--accent); }
.fci-form textarea { min-height: 120px; resize: vertical; }
.fci-form button {
padding: 1rem 2.5rem;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 100px;
font-family: var(--font-body);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
min-height: 48px;
transition: background 0.3s, transform 0.2s;
}
.fci-form button:hover { background: var(--accent-hover, var(--accent)); }
.fci-form button:active { transform: scale(0.97); }
</style>
<section class="form-contact-inline">
<div class="fci-inner">
<h2>Send us a message</h2>
<p>Replace with what the user can expect after submitting. Response time? Next steps?</p>
<form class="fci-form" action="#" method="POST">
<div>
<label for="fci-first">First name</label>
<input type="text" id="fci-first" name="first_name" required placeholder="Jane" autocomplete="given-name">
</div>
<div>
<label for="fci-last">Last name</label>
<input type="text" id="fci-last" name="last_name" required placeholder="Doe" autocomplete="family-name">
</div>
<div class="fci-full">
<label for="fci-email">Email</label>
<input type="email" id="fci-email" name="email" required placeholder="jane@example.com" autocomplete="email">
</div>
<div class="fci-full">
<label for="fci-subject">Subject</label>
<select id="fci-subject" name="subject">
<option value="">Choose a topic...</option>
<option value="general">General inquiry</option>
<option value="support">Support</option>
<option value="partnership">Partnership</option>
</select>
</div>
<div class="fci-full">
<label for="fci-message">Message</label>
<textarea id="fci-message" name="message" required placeholder="Tell us more..."></textarea>
</div>
<div class="fci-full">
<button type="submit">Send message</button>
</div>
</form>
</div>
</section>
<!-- @@/VARIANT:CONTACT_INLINE -->
<!-- @@VARIANT:ABOUT -->
<style>
.page-about { background: var(--bg); color: var(--ink); font-family: var(--font-body); }
.about-hero {
min-height: 60vh;
display: flex;
align-items: center;
padding: 8rem 5vw 4rem;
position: relative;
}
.about-hero-inner {
max-width: 1400px;
margin: 0 auto;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
}
@media (max-width: 768px) {
.about-hero-inner { grid-template-columns: 1fr; gap: 2rem; }
}
.about-hero-text h1 {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4.5rem);
line-height: 1.1;
margin-bottom: 1.5rem;
font-weight: 400;
}
.about-hero-text h1 span { color: var(--accent); font-style: italic; }
.about-hero-text p {
font-size: clamp(1rem, 1.3vw, 1.2rem);
color: var(--ink-muted);
line-height: 1.7;
max-width: 520px;
}
.about-visual {
aspect-ratio: 4 / 3;
background: var(--bg-surface);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--line);
}
.about-visual svg { width: 60%; height: 60%; }
.about-story {
padding: 6rem 5vw;
max-width: 800px;
margin: 0 auto;
}
.about-story h2 {
font-family: var(--font-display);
font-size: clamp(1.8rem, 3vw, 2.5rem);
margin-bottom: 2rem;
font-weight: 400;
}
.about-story p {
font-size: 1.1rem;
line-height: 1.8;
color: var(--ink-muted);
margin-bottom: 1.5rem;
}
.about-values {
padding: 6rem 5vw;
background: var(--bg-surface);
}
.about-values-inner {
max-width: 1400px;
margin: 0 auto;
}
.about-values h2 {
font-family: var(--font-display);
font-size: clamp(1.8rem, 3vw, 2.5rem);
margin-bottom: 3rem;
font-weight: 400;
text-align: center;
}
.values-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.value-card {
padding: 2rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--bg);
}
.value-card h3 {
font-family: var(--font-display);
font-size: 1.25rem;
margin-bottom: 0.75rem;
font-weight: 400;
}
.value-card p {
font-size: 0.95rem;
color: var(--ink-muted);
line-height: 1.6;
}
.value-card:first-child {
border-color: var(--accent);
background: var(--bg);
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
}
</style>
<div class="page-about">
<section class="about-hero">
<div class="about-hero-inner">
<div class="about-hero-text">
<h1>Our <span>Story</span></h1>
<p>Replace with your brand origin story. What problem did you see? Why did you decide to build this? Keep it human and honest.</p>
</div>
<div class="about-visual">
<svg viewBox="0 0 200 200" fill="none">
<circle cx="100" cy="100" r="80" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="8 12"/>
<circle cx="100" cy="100" r="40" fill="var(--accent-soft)"/>
<circle cx="100" cy="100" r="8" fill="var(--accent)"/>
</svg>
</div>
</div>
</section>
<section class="about-story">
<h2>How it started</h2>
<p>Replace with paragraph 1 of your story. Talk about the early days, the frustration that led to the idea.</p>
<p>Replace with paragraph 2. What was the turning point? When did the product start to take shape?</p>
<p>Replace with paragraph 3. Where are you now, and where are you headed?</p>
</section>
<section class="about-values">
<div class="about-values-inner">
<h2>What we believe</h2>
<div class="values-grid">
<div class="value-card">
<h3>Value One</h3>
<p>Replace with your first core value. Make it specific, not generic.</p>
</div>
<div class="value-card">
<h3>Value Two</h3>
<p>Replace with your second core value.</p>
</div>
<div class="value-card">
<h3>Value Three</h3>
<p>Replace with your third core value.</p>
</div>
</div>
</div>
</section>
</div>
<!-- @@/VARIANT:ABOUT -->
<!-- @@VARIANT:CONTACT -->
<style>
.page-contact { background: var(--bg); color: var(--ink); font-family: var(--font-body); }
.contact-section {
padding: 8rem 5vw 6rem;
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: start;
}
@media (max-width: 768px) {
.contact-section { grid-template-columns: 1fr; gap: 2rem; padding-top: 6rem; }
}
.contact-info h1 {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4rem);
line-height: 1.1;
margin-bottom: 1.5rem;
font-weight: 400;
}
.contact-info h1 span { color: var(--accent); }
.contact-info p {
color: var(--ink-muted);
font-size: 1.1rem;
line-height: 1.7;
margin-bottom: 2rem;
max-width: 460px;
}
.contact-details {
list-style: none;
display: flex;
flex-direction: column;
gap: 1rem;
}
.contact-details li {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1rem;
}
.contact-details .cd-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-soft);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.contact-details .cd-icon svg {
width: 18px;
height: 18px;
stroke: var(--accent);
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.contact-form {
background: var(--bg-surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 2.5rem;
}
.contact-form .cf-group { margin-bottom: 1.5rem; }
.contact-form label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--ink);
}
.contact-form input,
.contact-form textarea {
width: 100%;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 1rem;
transition: border-color 0.3s;
}
.contact-form input:focus,
.contact-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.contact-form textarea { min-height: 140px; resize: vertical; }
.contact-form .cf-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1rem 2.5rem;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 100px;
font-family: var(--font-body);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
min-height: 48px;
transition: background 0.3s, transform 0.2s;
width: 100%;
}
.contact-form .cf-btn:hover { background: var(--accent-hover, var(--accent)); transform: translateY(-1px); }
.contact-form .cf-btn:active { transform: scale(0.98); }
</style>
<div class="page-contact">
<section class="contact-section">
<div class="contact-info">
<h1>Get in <span>touch</span></h1>
<p>Replace with a friendly invitation to reach out. What can people contact you about?</p>
<ul class="contact-details">
<li>
<span class="cd-icon"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 4l10 7 10-7"/></svg></span>
<span>hello@yourbrand.com</span>
</li>
<li>
<span class="cd-icon"><svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 1 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
<span>Replace with your address or "Remote-first"</span>
</li>
</ul>
</div>
<form class="contact-form" action="#" method="POST">
<div class="cf-group">
<label for="cf-name">Name</label>
<input type="text" id="cf-name" name="name" required placeholder="Your name">
</div>
<div class="cf-group">
<label for="cf-email">Email</label>
<input type="email" id="cf-email" name="email" required placeholder="you@example.com">
</div>
<div class="cf-group">
<label for="cf-message">Message</label>
<textarea id="cf-message" name="message" required placeholder="How can we help?"></textarea>
</div>
<button type="submit" class="cf-btn">Send message</button>
</form>
</section>
</div>
<!-- @@/VARIANT:CONTACT -->
<!-- @@VARIANT:BLOG -->
<style>
.page-blog { background: var(--bg); color: var(--ink); font-family: var(--font-body); }
.blog-header {
padding: 8rem 5vw 3rem;
max-width: 1400px;
margin: 0 auto;
}
.blog-header h1 {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 400;
margin-bottom: 0.75rem;
}
.blog-header p {
color: var(--ink-muted);
font-size: 1.1rem;
}
.blog-grid {
max-width: 1400px;
margin: 0 auto;
padding: 2rem 5vw 6rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
}
.blog-card {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
background: var(--bg-surface);
transition: box-shadow 0.3s, transform 0.3s;
display: flex;
flex-direction: column;
}
.blog-card:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
transform: translateY(-4px);
}
.blog-card:first-child {
grid-column: span 2;
}
@media (max-width: 768px) {
.blog-card:first-child { grid-column: span 1; }
}
.blog-card-img {
aspect-ratio: 16 / 9;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
}
.blog-card-img svg {
width: 40%;
height: 40%;
opacity: 0.15;
stroke: var(--ink);
fill: none;
stroke-width: 1;
}
.blog-card-body {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
}
.blog-card-meta {
font-size: 0.8rem;
color: var(--ink-muted);
margin-bottom: 0.75rem;
display: flex;
gap: 1rem;
}
.blog-card-meta .tag {
background: var(--accent-soft);
color: var(--accent);
padding: 0.2rem 0.6rem;
border-radius: 100px;
font-weight: 600;
font-size: 0.75rem;
}
.blog-card-body h2 {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 400;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.blog-card:first-child .blog-card-body h2 {
font-size: 1.5rem;
}
.blog-card-body p {
font-size: 0.9rem;
color: var(--ink-muted);
line-height: 1.6;
flex: 1;
}
.blog-card-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--accent);
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
margin-top: 1rem;
transition: gap 0.3s;
}
.blog-card-link:hover { gap: 0.8rem; }
</style>
<div class="page-blog">
<header class="blog-header">
<h1>Blog</h1>
<p>Thoughts, updates, and behind-the-scenes from the team.</p>
</header>
<div class="blog-grid">
<article class="blog-card">
<div class="blog-card-img">
<svg viewBox="0 0 100 100"><rect x="10" y="10" width="80" height="80" rx="4"/><line x1="25" y1="30" x2="75" y2="30"/><line x1="25" y1="45" x2="65" y2="45"/><line x1="25" y1="60" x2="55" y2="60"/></svg>
</div>
<div class="blog-card-body">
<div class="blog-card-meta">
<span class="tag">Featured</span>
<span>Jan 15, 2026</span>
</div>
<h2>Replace with your featured post title</h2>
<p>Replace with a 2-3 sentence excerpt that hooks the reader and makes them want to click through.</p>
<a href="#" class="blog-card-link">Read more <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></a>
</div>
</article>
<article class="blog-card">
<div class="blog-card-img">
<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="35" /><path d="M35 50 L45 60 L65 40"/></svg>
</div>
<div class="blog-card-body">
<div class="blog-card-meta">
<span class="tag">Product</span>
<span>Jan 8, 2026</span>
</div>
<h2>Replace with post title</h2>
<p>Replace with a short excerpt.</p>
<a href="#" class="blog-card-link">Read more <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></a>
</div>
</article>
<article class="blog-card">
<div class="blog-card-img">
<svg viewBox="0 0 100 100"><polygon points="50,15 90,85 10,85"/></svg>
</div>
<div class="blog-card-body">
<div class="blog-card-meta">
<span class="tag">Engineering</span>
<span>Dec 20, 2025</span>
</div>
<h2>Replace with post title</h2>
<p>Replace with a short excerpt.</p>
<a href="#" class="blog-card-link">Read more <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></a>
</div>
</article>
</div>
</div>
<!-- @@/VARIANT:BLOG -->
/* ============================================================
LANDING PAGE SKILL — TEXTURE LIBRARY
Copy the relevant class onto your <body> or section element.
Each texture is a CSS-only pattern at low opacity.
============================================================ */
/* KYOTO — washi paper grain + faint crosshatch */
.texture-kyoto {
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E"),
repeating-linear-gradient(45deg, transparent, transparent 18px, rgba(42,31,20,0.018) 18px, rgba(42,31,20,0.018) 19px),
repeating-linear-gradient(-45deg, transparent, transparent 18px, rgba(42,31,20,0.018) 18px, rgba(42,31,20,0.018) 19px);
}
/* PARIS — fine linen weave */
.texture-paris {
background-image:
repeating-linear-gradient(90deg, transparent, transparent 3px, rgba(26,22,18,0.012) 3px, rgba(26,22,18,0.012) 4px),
repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(26,22,18,0.012) 3px, rgba(26,22,18,0.012) 4px),
repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(26,22,18,0.008) 6px, rgba(26,22,18,0.008) 7px);
}
/* TOKYO NIGHT — precise grid lines */
.texture-tokyo {
background-image:
linear-gradient(rgba(240,236,255,0.022) 1px, transparent 1px),
linear-gradient(90deg, rgba(240,236,255,0.022) 1px, transparent 1px);
background-size: 48px 48px;
}
/* TOKYO NIGHT — scanlines variant (denser, more electric) */
.texture-tokyo-scan {
background-image:
repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(123,94,248,0.03) 3px, rgba(123,94,248,0.03) 4px),
linear-gradient(rgba(240,236,255,0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(240,236,255,0.015) 1px, transparent 1px);
background-size: auto, 64px 64px, 64px 64px;
}
/* NEW YORK — newsprint dot halftone */
.texture-newyork {
background-image: radial-gradient(circle, rgba(17,17,17,0.12) 1px, transparent 1px);
background-size: 8px 8px;
}
/* SEOUL — soft gradient mesh (apply on the element, not body) */
.texture-seoul {
background-image:
radial-gradient(ellipse at 15% 85%, rgba(61,92,255,0.07) 0%, transparent 55%),
radial-gradient(ellipse at 85% 15%, rgba(255,107,53,0.055) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(61,92,255,0.03) 0%, transparent 70%);
}
/* NOISE OVERLAY — add on top of any style for extra grain depth */
.texture-noise-overlay::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='turbulence' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
z-index: 0;
}
Changelog
本项目的所有重要变更记录。格式基于 Keep a Changelog。
[2.0.0] - 2026-04-15
新增
- 日文版项目说明文档(README.ja.md)
- 评估用例扩展至 9 条,新增多页面站点、日文触发、极简输入容错三类边界场景
- 中英日三语 README 导航互链
优化
- CI 断言更新为 9 条 evals 基线
[1.9.0] - 2026-04-15
优化
- 重新设计中英文 README,结构更简洁清晰
- 新增 "Why Landing Craft?" 差异化价值说明
- 功能列表从扁平罗列重组为三个分组(设计系统/页面板块/工作流)
- 添加 Stars 徽章和居中标题布局
- 项目结构收入折叠面板减少首屏长度
- 全部 section 标题添加语义化 emoji 增强扫读体验
- 中英文 README 结构完全对齐
[1.8.0] - 2026-04-14
新增
- 中文版项目说明文档
- 演示站点增强(GSAP 动画 + clip-path 分隔线 + 城市风格滚动预览 + 计数动画)
- 英文 README 和中文 README 互链
[1.7.0] - 2026-04-14
新增
- 在线演示站点部署到 GitHub Pages
- README 新增 Release 和 License 徽章和快速导航链接
[1.6.0] - 2026-04-14
新增
- 6 种扩展板块变体(团队成员/数据统计/品牌滚动墙/作品集/技术集成/时间线)
- GitHub Actions CI 自动化测试(Python 3.9 + 3.12 双版本验证)
- CI 徽章添加到 README
修复
- 推荐页面序列补充 Footer 和 Form 板块
- 新增 Startup pre-launch 推荐序列
[1.5.0] - 2026-04-14
新增
- 暗色模式运行时切换功能(localStorage 持久化 + 系统偏好检测)
- 部署支持扩展至 Netlify、Vercel、GitHub Pages 三个平台
修复
- 修复 Footer、Form、Page 变体未接入工作流的问题
- Step 1 新增子页面需求询问环节
- Step 3 补充 footer/form/pages 字段选择说明
[1.4.0] - 2026-04-14
新增
- 3 种表单板块变体(邮件订阅/等待列表/内嵌联系表单)
- 全部表单组件符合无障碍标准和触控目标规范
[1.3.0] - 2026-04-14
新增
- 一键部署到 Netlify 的可选步骤(先预览后发布两步式部署)
[1.2.0] - 2026-04-14
新增
- 多页面站点生成支持(关于我们/联系方式/博客列表三种子页面模板)
- 子页面共享同一设计系统、导航和页脚
- SKILL.md 工作流和资产表同步更新
[1.1.0] - 2026-04-14
新增
- 全部 8 个 GSAP 动画函数支持 prefers-reduced-motion 无障碍降级
- Design Laws 新增响应式布局、SEO 基线、Footer 必须、无障碍基线四条规则
- 3 种 Footer 板块变体(极简单行/多列链接/杂志编辑型)
- Step 5 生成完成后自动在浏览器中打开预览
- 预览页面语言根据用户对话语言自动检测
[1.0.0] - 2026-04-13
新增
- 57 种城市风格设计系统
- 浏览器交互式风格预览(style-preview)和选项选择(options-preview)
- 7 种 Hero 板块变体(A-G)
- 6 种 Features 板块变体(A-F)
- 6 种 Testimonials 板块变体(A-F)
- 6 种 Conversion 板块(定价/对比定价/品牌墙/CTA/FAQ×2)
- 4 种导航风格(全屏爆开/磁性胶囊/侧边垂直/拆分居中)
- 3 种色调变体(原色/暗黑/提亮)
- GSAP ScrollTrigger 动画库(8 个预制函数)
- CSS 纹理库(6 种)和 clip-path 分隔线库(8 种)
- 跨平台预览启动器(Python + PowerShell)
- 端口占用自动 fallback 机制
- 日文城市名查询支持(CJK 别名映射)
- 非城市描述的设计推导路径(imagery-derivation)
- 6 个评估用例覆盖所有主要路径