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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Marketing site for Focus, built with Astro and Tailwind CSS v4. The current site is Astro-first, with React available when a page genuinely needs client-side state or interactivity.

## Publishing Blog Posts

Blog posts live at `src/blog/<slug>/index.mdx`. Put images used by a post in the same folder, import them into the MDX file, and render them with Astro's `Image` component so the build can optimize them.

A post is included in production only when `published` is `true`. Publishing is therefore a normal pull request: add or edit
the content, get it reviewed, and merge it to `main`.

## Stack

- Astro 6
Expand All @@ -27,10 +34,13 @@ All commands run from the repo root:
/
├── public/
├── src/
│ ├── blog/
│ ├── components/
│ │ └── page-sections/
│ │ └── shared/
│ ├── data/
│ ├── layouts/
│ ├── lib/
│ ├── pages/
│ │ └── index.astro
│ └── styles/
Expand Down
6 changes: 4 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import tailwindcss from "@tailwindcss/vite";

import react from "@astrojs/react";

import mdx from "@astrojs/mdx";

import sitemap from "@astrojs/sitemap";

// https://astro.build/config
Expand All @@ -12,9 +14,9 @@ export default defineConfig({
plugins: [tailwindcss()],
},
site: "https://focusconsulting.io",
integrations: [react(), sitemap()],
integrations: [react(), mdx(), sitemap()],
redirects: {
'/about': '/about-focus',
'/services': '/capabilities',
},
});
});
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "5.0.6",
"@astrojs/react": "5.0.2",
"@astrojs/rss": "4.0.19",
"@astrojs/sitemap": "^3.7.2",
"@tailwindcss/vite": "4.2.2",
"@types/react": "19.2.14",
Expand All @@ -21,6 +23,7 @@
"astro-seo": "^1.1.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"sharp": "0.34.5",
"tailwindcss": "4.2.2"
},
"pnpm": {
Expand Down
665 changes: 655 additions & 10 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions src/blog/building-services-that-endure/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
title: "Building services that endure"
description: "A practical way to make early delivery, policy learning, and long-term stewardship reinforce one another."
publishDate: 2026-06-18
author: Bryan Bassett
published: false
---

import { Image } from "astro:assets";
import serviceMap from "./service-map.png";

The first release of a public service is not the finish line. It is the first
reliable opportunity to learn how policy, operations, and technology behave
together in the real world.

<Image
src={serviceMap}
alt="Computer screen, folder with files and gears, and government building on separate plinths with dashed lines connecting them"
widths={[640, 960, 1280]}
sizes="(max-width: 720px) 100vw, 720px"
/>

## Start with a complete path

A small release should still let someone complete a meaningful task. That may
mean serving one program, one user group, or one carefully chosen scenario
before broadening the service. The boundary is narrow; the outcome is whole.

This approach gives a team evidence that planning alone cannot provide:

- where people hesitate or abandon the process;
- which policy questions need an operational answer;
- what staff need in order to support the service; and
- which technical choices will be expensive to reverse.

## Treat learning as delivery

The goal of an early release is not simply to ship sooner. It is to shorten the
distance between a decision and the evidence needed to improve it. Teams can
then make smaller, clearer decisions while change is still affordable.

Enduring services grow from that rhythm: deliver a useful slice, observe the
whole system, and make the next commitment with better information.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/components/shared/Eyebrow.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="mb-4 text-sm font-semibold uppercase text-[var(--color-brand-accent)]">
<slot />
</div>
8 changes: 3 additions & 5 deletions src/components/shared/PageHero.astro
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
---
import Eyebrow from './Eyebrow.astro';

interface Props {
eyebrow?: string;
headline: string;
Expand Down Expand Up @@ -47,11 +49,7 @@ const { eyebrow, headline, description, fullWidth = false, ctaText, ctaHref, cta

<div class="page-shell relative z-1">
<div class="text-left">
{eyebrow && (
<div class="mb-4 text-sm font-semibold uppercase text-[var(--color-brand-accent)]">
{eyebrow}
</div>
)}
{eyebrow && (<Eyebrow>{eyebrow}</Eyebrow>)}

<h1
class="display-2"
Expand Down
20 changes: 20 additions & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineCollection, reference } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
loader: glob({
base: "./src/blog",
pattern: "**/index.mdx",
generateId: ({ entry }) => entry.replace(/\/index\.mdx$/, ""),
}),
schema: z.object({
title: z.string().min(1),
description: z.string().min(1),
publishDate: z.coerce.date(),
author: z.string().min(1),
published: z.coerce.boolean().default(false),
}),
});

export const collections = { blog };
19 changes: 14 additions & 5 deletions src/layouts/Footer.astro
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
---
import { getBlogPostsSortedByPubDate } from "../lib/blog";

let exploreGroupListItems = [
{ label: "Home", href: "/" },
{ label: "Capabilities", href: "/capabilities" },
{ label: "Our Work", href: "/our-work" },
];

const posts = await getBlogPostsSortedByPubDate();
if (posts && posts.length > 0) {
exploreGroupListItems.push({ label: "Blog", href: "/blog" });
}

const footerGroups = [
{
label: "Explore",
items: [
{ label: "Home", href: "/" },
{ label: "Capabilities", href: "/capabilities" },
{ label: "Our Work", href: "/our-work" },
],
items: exploreGroupListItems,
},
{
label: "About Focus",
Expand Down
16 changes: 16 additions & 0 deletions src/lib/blog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getCollection } from "astro:content";

export async function getBlogPostsSortedByPubDate() {
return (await getCollection('blog')).filter(
(x) => !!x.data.published,
).sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(),
);
}

export function formatPostDate(date: Date) {
return new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
timeZone: "UTC",
}).format(date);
}
56 changes: 56 additions & 0 deletions src/pages/blog/[slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
import { render } from "astro:content";
import Eyebrow from "../../components/shared/Eyebrow.astro";
import Layout from "../../layouts/Layout.astro";
import {
formatPostDate,
getBlogPostsSortedByPubDate,
} from "../../lib/blog";

export async function getStaticPaths() {
return (await getBlogPostsSortedByPubDate()).map((post) => ({
params: { slug: post.id },
props: { post },
}));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<Layout
title={post.data.title}
description={post.data.description}
ogType="article"
>
<article class="page-shell pb-20 pt-20 sm:pb-28">
<header class="layout-grid border-b border-[var(--color-brand-border)] pb-6">
<div class="col-span-12">
<Eyebrow><a href="/blog">Blog</a></Eyebrow>
<h1 class="display-2 mt-5">{post.data.title}</h1>
</div>
<div class="col-span-12 lg:col-span-8">
<p class="body-copy text-[1.2rem]">{post.data.description}</p>
<div class="mt-6 flex flex-wrap gap-x-3 text-[0.95rem] text-[var(--color-brand-soft)]">
<time dateTime={post.data.publishDate}>{formatPostDate(post.data.publishDate)}</time>
<span aria-hidden="true">•</span>
<span>{post.data.author}</span>
</div>
</div>
</header>

<div class="layout-grid pt-10">
<div class="blog-prose col-span-12 lg:col-span-8">
<Content />
</div>
</div>

<footer class="layout-grid mt-14 border-t border-[var(--color-brand-border)] pt-8">
<div class="col-span-12 lg:col-span-8">
<a class="inline-flex font-medium text-[var(--color-brand-accent)] underline underline-offset-4" href="/blog/">
Back to all blog posts
</a>
</div>
</footer>
</article>
</Layout>
50 changes: 50 additions & 0 deletions src/pages/blog/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
import Layout from "../../layouts/Layout.astro";
import Eyebrow from "../../components/shared/Eyebrow.astro";
import {
formatPostDate,
getBlogPostsSortedByPubDate,
} from "../../lib/blog";

const posts = await getBlogPostsSortedByPubDate();
---

<Layout
title="Blog"
description="Ideas and field notes from Focus about public digital services, delivery, and building systems that endure."
>
<section class="page-shell pt-6 pb-12 sm:pt-20 sm:pb-18">
<Eyebrow>Blog</Eyebrow>

<div>
<ol>
{posts.map((post) => (
<li key={post.id} class="border-b border-[var(--color-brand-border)]">
<article class="grid gap-7 py-10 md:grid-cols-[minmax(0,1fr)_13rem] md:items-start md:py-12">
<div>
<h2 class="section-heading">
<a
class="no-underline hover:text-[var(--color-brand-accent)]"
href={`/blog/${post.id}/`}
>
{post.data.title}
</a>
</h2>
<p class="body-copy mt-4">{post.data.description}</p>
<div class="mt-4 flex flex-wrap gap-x-3 text-[0.86rem] text-[var(--color-brand-soft)]">
<time dateTime={post.data.publishDate}>{formatPostDate(post.data.publishDate)}</time>
<span aria-hidden="true">•</span>
<span>{post.data.author}</span>
</div>
</div>
</article>
</li>
))}
</ol>
</div>

<a class="mt-8 inline-flex text-[0.92rem] font-medium text-[var(--color-brand-accent)] underline underline-offset-4" href="/rss.xml">
Subscribe via RSS
</a>
</section>
</Layout>
20 changes: 20 additions & 0 deletions src/pages/rss.xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import rss from "@astrojs/rss";
import { getBlogPostsSortedByPubDate } from "../lib/blog";

export async function GET(context: { site: URL }) {
const posts = await getBlogPostsSortedByPubDate();

return rss({
title: "Focus blog",
description:
"Ideas and field notes from Focus about public digital services, delivery, and building systems that endure.",
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
publishDate: post.data.publishDate,
link: `/blog/${post.id}/`,
author: post.data.author,
})),
});
}
64 changes: 64 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,68 @@
color: var(--color-brand-soft);
@apply no-underline hover:underline;
}

.blog-prose {
color: var(--color-brand-muted);
@apply text-[1.08rem] leading-[1.8];
}

.blog-prose > * + * {
@apply mt-6;
}

.blog-prose h2,
.blog-prose h3 {
font-family: var(--font-serif);
color: var(--color-brand-ink);
@apply font-normal leading-[1.2] tracking-[0.01em];
}

.blog-prose h2 {
@apply mt-12 text-[2rem] sm:text-[2.35rem];
}

.blog-prose h3 {
@apply mt-10 text-[1.55rem] sm:text-[1.8rem];
}

.blog-prose ul,
.blog-prose ol {
@apply ml-6 space-y-2;
}

.blog-prose ul {
@apply list-disc;
}

.blog-prose ol {
@apply list-decimal;
}

.blog-prose a {
color: var(--color-brand-accent);
@apply underline underline-offset-4;
}

.blog-prose blockquote {
border-color: var(--color-brand-accent);
@apply border-l-3 pl-6 text-[1.2rem] italic;
}

.blog-prose img {
border-radius: var(--radius-card);
@apply my-10 h-auto w-full;
}

.blog-prose code {
@apply rounded bg-[var(--color-brand-surface)] px-1.5 py-0.5 text-[0.92em];
}

.blog-prose pre {
@apply overflow-x-auto rounded-[var(--radius-card)] p-5 text-[0.9rem];
}

.blog-prose pre code {
@apply bg-transparent p-0;
}
}