Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,6 @@ public/styles.css
# GitHub Pages deployment
deployment-issues/
.quokka

# Local Claude Code state (permission allowlist, session lockfiles)
.claude/
439 changes: 439 additions & 0 deletions DESIGN.json

Large diffs are not rendered by default.

336 changes: 336 additions & 0 deletions DESIGN.md

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Product

## Register

brand

This site is a split surface: the home page (`/`) is brand-led marketing, and `/docs/*` is utility-led product. PRODUCT.md carries `brand` as the default because the home is the high-stakes first impression and the docs reading experience benefits from brand-level craft (typography, atmosphere, editorial pacing). For deep utility surfaces inside `/docs/*` (tables, schema reference, troubleshooting), override per task with `product`.

## Users

Two audiences in one site:

- **Decision makers — Platform / DevOps engineers.** They land on the home page or "What is Lifecycle?" while evaluating tools. They are skeptical, scan-first, terminal-adjacent, and reading on a 27-inch monitor in a dim office or a laptop on a flight. Their job: decide in under five minutes whether Lifecycle is worth a deeper look, then forward a link to a teammate.
- **Day-to-day consumers — App developers.** They land in `/docs/*` from a Google search or an internal Slack link, mid-task, with a specific question. Their job: find the exact recipe / flag / config / troubleshooting note, copy it, and get back to their PR. They do not want narrative; they want the answer.

The site must serve both without compromising either: the home convinces, the docs deliver.

## Product Purpose

Documentation and marketing site for **Lifecycle** — an open-source ephemeral environments orchestrator that turns every GitHub pull request into a fully-functional preview environment. Lifecycle is licensed Apache 2.0 and maintained by GoodRx OSS.

The site exists to:

1. Convince a new visitor — in under a minute — that Lifecycle replaces brittle shared dev/staging environments with per-PR isolation that cleans up after itself.
2. Get an evaluator from "what is this?" → "I have it running on a sample repo" with the fewest dead-ends possible.
3. Be the canonical answer-source for existing operators and consumers (config, schema, troubleshooting, recipes).
4. Carry the OSS community: visible paths to GitHub (stars, contribution) and Discord (community), without making the home feel like a star-farming page.

Success looks like: a DevOps lead reads the home, opens the demo iframe, skims one feature page, and either installs or sends the link to their platform team. A developer searching "lifecycle environment variable templating" lands directly on the relevant docs page and copies the exact snippet they need within seconds.

## Brand Personality

**Sharp, dev-native, slightly irreverent.**

- **Voice:** Direct. Technical without being dry. Explains hard things without dumbing them down. Earns trust by being precise about what Lifecycle does and does not do.
- **Tone:** Confident but not corporate. Comfortable with code, monospace, and shell snippets as first-class content. Allowed to wink — emoji in a `## A Developer's Story` heading is on-brand; corporate marketing copy is not.
- **Emotional goals:** A reader should feel "this was built by engineers who care", not "this was wrapped by a marketing team". The site itself should feel like a small piece of evidence that the product is well-made.

## Anti-references

- **Old-school Jekyll / MkDocs / read-the-docs default theme.** Sidebar + content + zero design care. The OSS docs we are not.
- **Crypto / web3 neon-on-black.** No high-saturation glow, no glassmorphism cards, no animated gradient text, no "matrix" hero.
- **Consumer GoodRx healthcare aesthetic (goodrx.com).** Do not borrow the parent brand's consumer color palette, rounded warmth, or photography style. Lifecycle is OSS infra, not a healthcare app.
- **Generic enterprise SaaS** (implied — guard against drift). No stock isometric illustrations, no gradient blobs, no "hero metric + 3 supporting stats" template, no identical icon-card grids.

## Design Principles

1. **Practice what you preach.** Lifecycle is a tool for engineers who care about craft. The docs themselves are evidence — pixel-aligned, fast, well-typeset. Sloppy docs would undercut the pitch.
2. **Two doors, one welcome.** The home funnels visitors to two endings — _install_ (read → run) and _community_ (GitHub star + Discord). Both must be visible from the home without competing for the same pixel or feeling like a star-farming bar.
3. **Density rewards the scanner.** DevOps readers scan before they read. Reward fast eyes with information-dense layouts, real code, real diagrams. Avoid hero-paragraph filler and stock-illustration negative space.
4. **Show the system, don't describe it.** The codebase already ships React Flow diagrams and CodeHike code walks — lean into them. A live diagram of "PR → environment → cleanup" beats a paragraph every time.
5. **Sharpness over softness.** Personality is sharp/dev-native, so the interface is too: crisp edges where appropriate, monospace as a first-class voice, code blocks treated as content not as ornament. Avoid rounded "consumer warmth".

## Accessibility & Inclusion

- **Target:** WCAG 2.1 AA. Treat as a hard floor, not a ceiling.
- **Color:** All text and code blocks must meet AA contrast in both light and dark themes. Brand yellow is decorative — never the carrier of meaning.
- **Keyboard:** Every interactive element (theme toggle, sidebar, code-tabs, accordions, React Flow controls, copy-code buttons) must be reachable and operable via keyboard with a visible focus ring.
- **Motion:** Respect `prefers-reduced-motion`. Hero/feature animations must have a non-animated fallback. The existing custom keyframes (`fade-up`, `slide-in-*`, `draw-line`, logo `shake`) all need motion-safe variants.
- **Content:** Code samples must be selectable and copyable, not images of code. Diagrams (React Flow) must have a textual description nearby for screen-reader users.
6 changes: 6 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,11 @@ export default [
version: "detect",
},
},
rules: {
"react/no-unknown-property": [
"error",
{ ignore: ["jsx", "global"] },
],
},
},
];
38 changes: 13 additions & 25 deletions src/components/home/bg/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,22 @@
*/

import { GridPattern } from "@/components/home/bg/grid";
import { GridPulses } from "@/components/home/bg/pulses";

export const Bg = () => {
return (
<div className="absolute inset-0 -z-10 mx-0 max-w-none overflow-hidden">
<div className="absolute left-1/2 top-0 ml-[-38rem] h-[25rem] w-[81.25rem] dark:[mask-image:linear-gradient(white,transparent)]">
<div className="absolute inset-0 bg-gradient-to-r from-primary-brand-default to-primary-brand-gold opacity-40 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-primary-brand-default/30 dark:to-primary-brand-default/30 dark:opacity-100">
<GridPattern
width={72}
height={56}
x="-12"
y="4"
squares={[
[4, 3],
[2, 1],
[7, 3],
[10, 6],
]}
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
/>
</div>
<svg
viewBox="0 0 1113 440"
aria-hidden="true"
className="absolute left-1/2 top-0 ml-[-19rem] w-[69.5625rem] fill-white blur-[26px] dark:hidden"
>
<path d="M.016 439.5s-9.5-300 434-300S882.516 20 882.516 20V0h230.004v439.5H.016Z" />
</svg>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 overflow-hidden"
>
<GridPattern
width={56}
height={56}
x="-1"
y="-1"
className="absolute inset-0 h-full w-full stroke-foreground/[0.05] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,black_30%,transparent_75%)] dark:stroke-foreground/[0.07]"
/>
<GridPulses />
</div>
);
};
Expand Down
140 changes: 140 additions & 0 deletions src/components/home/bg/pulses.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Copyright 2025 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

"use client";

import { motion } from "framer-motion";

const easeOutQuart = [0.25, 1, 0.5, 1] as const;
const cell = 56;

type Pulse = {
axis: "x" | "y";
lane: number;
length: number;
duration: number;
delay: number;
repeatDelay: number;
reverse?: boolean;
};

const pulses: Pulse[] = [
{
axis: "x",
lane: 4,
length: 168,
duration: 5.6,
delay: 0.0,
repeatDelay: 4.4,
},
{
axis: "x",
lane: 8,
length: 112,
duration: 4.0,
delay: 2.4,
repeatDelay: 5.6,
reverse: true,
},
{
axis: "x",
lane: 11,
length: 96,
duration: 3.6,
delay: 1.4,
repeatDelay: 5.0,
},
{
axis: "y",
lane: 6,
length: 140,
duration: 5.4,
delay: 0.6,
repeatDelay: 4.8,
},
{
axis: "y",
lane: 14,
length: 112,
duration: 4.2,
delay: 3.0,
repeatDelay: 5.8,
},
{
axis: "y",
lane: 19,
length: 84,
duration: 3.8,
delay: 4.0,
repeatDelay: 6.0,
reverse: true,
},
];

const gradientFor = (axis: "x" | "y", reverse: boolean) => {
const angle =
axis === "x" ? (reverse ? "270deg" : "90deg") : reverse ? "0deg" : "180deg";
return `linear-gradient(${angle}, transparent 0%, hsl(var(--primary) / 0.05) 30%, hsl(var(--primary) / 0.55) 75%, hsl(var(--primary)) 96%, transparent 100%)`;
};

export function GridPulses() {
return (
<div
aria-hidden="true"
className="absolute inset-0 [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,black_30%,transparent_75%)]"
>
{pulses.map((p, i) => {
const isX = p.axis === "x";
const reverse = p.reverse ?? false;
const lineOffset = `${p.lane * cell + 55.5}px`;

const start = isX
? { x: reverse ? "120vw" : "-25vw" }
: { y: reverse ? "120vh" : "-25vh" };
const animate = isX
? { x: reverse ? ["120vw", "-25vw"] : ["-25vw", "120vw"] }
: { y: reverse ? ["120vh", "-25vh"] : ["-25vh", "120vh"] };

return (
<motion.span
key={i}
initial={start}
animate={animate}
transition={{
duration: p.duration,
ease: easeOutQuart,
repeat: Infinity,
repeatDelay: p.repeatDelay,
delay: p.delay,
}}
style={{
position: "absolute",
[isX ? "top" : "left"]: lineOffset,
[isX ? "left" : "top"]: 0,
[isX ? "width" : "height"]: `${p.length}px`,
[isX ? "height" : "width"]: "1px",
background: gradientFor(p.axis, reverse),
boxShadow: "0 0 6px hsl(var(--primary) / 0.3)",
willChange: "transform",
}}
/>
);
})}
</div>
);
}

export default GridPulses;
Loading
Loading