+
+
-
- What you are left with
-
+
What you are left with
{service.outcome}
-
- Typical engagement
-
+
Typical engagement
{service.typical}
-
+
>
)
}
diff --git a/packages/web/src/app/services/page.tsx b/packages/web/src/app/services/page.tsx
index 12e5638..04f9625 100644
--- a/packages/web/src/app/services/page.tsx
+++ b/packages/web/src/app/services/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next"
import Link from "next/link"
+import { Eyebrow, Lede, Section, Title } from "@/components/ui/section"
import { servicesInGroup } from "@/lib/services"
export const metadata: Metadata = {
@@ -25,39 +26,32 @@ const groups = [
const ServicesPage = () => (
<>
-
- Services
-
- What we do
-
-
+
+ Services
+ What we do
+
One page for each, because the problems are not interchangeable and neither are the
people who have them.
-
-
+
+
{groups.map((group) => (
-
-
- {group.label}
-
- {group.note}
-
+
+ {group.label}
+ {group.note}
+
{servicesInGroup(group.key).map((service) => (
- -
-
+
-
+
{service.name}
- {service.summary}
+ {service.summary}
))}
-
+
))}
>
)
diff --git a/packages/web/src/components/ui/section.tsx b/packages/web/src/components/ui/section.tsx
new file mode 100644
index 0000000..2cffe22
--- /dev/null
+++ b/packages/web/src/components/ui/section.tsx
@@ -0,0 +1,48 @@
+import type { ComponentProps } from "react"
+
+import { cn } from "@/lib/cn"
+
+/**
+ * One container, so page padding and rhythm are decided once. Mobile first:
+ * 16px of padding on a phone, growing at the breakpoints.
+ */
+export const Section = ({ className, ...props }: ComponentProps<"section">) => (
+
+)
+
+export const Eyebrow = ({ className, ...props }: ComponentProps<"p">) => (
+
+)
+
+export const Title = ({ className, ...props }: ComponentProps<"h1">) => (
+
+)
+
+export const Heading = ({ className, ...props }: ComponentProps<"h2">) => (
+
+)
+
+export const Lede = ({ className, ...props }: ComponentProps<"p">) => (
+
+)
diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css
index 0f643bb..3c3ac1d 100644
--- a/packages/web/src/styles/globals.css
+++ b/packages/web/src/styles/globals.css
@@ -51,8 +51,10 @@
--font-body: "Hanken Grotesk", ui-sans-serif, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
- /* An 8 point grid. Nothing between these steps. */
- --spacing: 8px;
+ /* Tailwind counts in 4px. Do not redefine it: overriding --spacing doubles
+ every utility on every element at once, which blows the layout out on a
+ narrow screen. Stay on the 8 point grid by using even steps instead, so
+ px-4 is 16px and py-6 is 24px. */
/* Square corners. A rounded corner reads consumer grade and this is not a
consumer product. */
diff --git a/packages/web/test/pages.test.ts b/packages/web/test/pages.test.ts
index 25e02ad..a0df04c 100644
--- a/packages/web/test/pages.test.ts
+++ b/packages/web/test/pages.test.ts
@@ -175,3 +175,35 @@ describe("the service pages", () => {
}
})
})
+
+describe("mobile layout", () => {
+ // The header needed about 438px before this: a wordmark, two text links and
+ // a button, all unconditional. The narrowest phone in common use is 320.
+ it("hides the text navigation below the small breakpoint", async () => {
+ const { body } = await get("/")
+ const header = body.match(/
/)?.[0] ?? ""
+ expect(header).toContain(">Services<")
+ expect(header).toMatch(/class="[^"]*hidden[^"]*sm:inline[^"]*"[^>]*>Services)
+ })
+
+ it("keeps the call to action visible at every width", async () => {
+ const { body } = await get("/")
+ const header = body.match(//)?.[0] ?? ""
+ expect(header).toContain("Book a call")
+ expect(header).not.toMatch(/class="[^"]*hidden[^"]*"[^>]*>Book a call)
+ })
+
+ // Overriding --spacing scales every utility on every element at once.
+ it("does not redefine the Tailwind spacing unit", async () => {
+ const stylesheet = (await get("/")).body.match(/href="(\/_next\/static\/[^"]+\.css)"/)?.[1]
+ const css = await (await fetch(`${baseUrl}${stylesheet}`)).text()
+ expect(css).not.toMatch(/--spacing:\s*8px/)
+ })
+
+ it("has breakpoints beyond the single one it shipped with", async () => {
+ const stylesheet = (await get("/")).body.match(/href="(\/_next\/static\/[^"]+\.css)"/)?.[1]
+ const css = await (await fetch(`${baseUrl}${stylesheet}`)).text()
+ const widths = new Set([...css.matchAll(/min-width:\s*([\d.]+rem)/g)].map((m) => m[1]))
+ expect(widths.size).toBeGreaterThan(2)
+ })
+})