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
21 changes: 20 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import stylex from "@stylexjs/unplugin";
import autoprefixer from "autoprefixer";
import path from "node:path";
import { themes } from "storybook/theming";
import svgr from "vite-plugin-svgr";

// Relative path: Node loads this file, so @/ aliases do not work.
import { stylexOptions } from "../babel.config.js";
Expand Down Expand Up @@ -46,7 +47,19 @@ const config: StorybookConfig = {
"msw-storybook-addon",
"storybook-next-intl",
],
framework: "@storybook/nextjs-vite",
framework: {
name: "@storybook/nextjs-vite",
options: {
/**
* Next turns every `.svg` import into a component.
* This plugin would turn the same import into
* `{ src, width, height }`, which React cannot render.
*/
image: {
excludeFiles: ["**/*.svg"],
},
},
},
managerHead: appendShellFirstPaint,
staticDirs: ["../public"],
stories: [
Expand Down Expand Up @@ -101,6 +114,12 @@ const config: StorybookConfig = {
"process.env.STORYBOOK_URL": JSON.stringify(storybookUrl),
},
plugins: [
/**
* The Next image plugin skips `.svg` above, so this
* plugin turns each import into a component, matching Next.
* The query is optional: Vite sometimes appends one.
*/
svgr({ include: /\.svg(?:\?|$)/ }),
stylexConstsPreloadPlugin(),
stylex.vite({
...stylexOptions,
Expand Down
2 changes: 1 addition & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const preview: Preview = {
storySort: {
order: [
"App",
["Home", "OG images", "Global error"],
["Home", "Global error"],
"Features",
"UI",
"Tokens",
Expand Down
10 changes: 6 additions & 4 deletions BACKLOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
- 0.0.1 RELEASE. Tag, README “what this ships,” kill leftover stubs.

- HTTP client, thin. Base URL, JSON, error shape. Outcome: `lib/http` (or similar) + a short README note.
- OpenAPI codegen in the repo. One tool, tiny spec, generate types + request fns. Outcome: `pnpm` script, dummy `GET /health`.
- HTTP client
- query <-> msw bridge
- openapi
- scripts in package.json, smth kinda should be extracted
- breakpoints vs orientation
- form
- how to cook vignette?
Expand All @@ -26,6 +26,8 @@

DONE:

- readable favicon
- v.0.0.1 release
- i18n messages colocation
- viewport: defined named entries of exact W×H to inspect composition
- composition tweaked to be good through all the screens
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A personal Next.js starter:
- browser MSW
- pnpm
- ESLint / Prettier
- SVGR (Turbopack)
- SVGR (Turbopack in Next, Vite in Storybook)

## Create an app from this template

Expand All @@ -41,7 +41,7 @@ pnpm dev # http://localhost:3000
pnpm storybook # http://localhost:6006
```

No `.env*` file is required. `STORYBOOK_URL` is optional (see `.env.example`). When it is set, the home page links that address. When it is unset, the same sentence is plain text. `pnpm storybook` starts Storybook and reuses a Next.js server already responding on port 3000, or starts one if needed. The `App/OG images` story shows the generated image routes for all four locales in one pane.
No `.env*` file is required. `STORYBOOK_URL` is optional (see `.env.example`). When it is set, the home page links that address. When it is unset, the same sentence is plain text. `pnpm storybook` starts Storybook and reuses a Next.js server already responding on port 3000, or starts one if needed. The `App/Home` entry includes an `OG images` story with the generated image for every locale.

The Next.js app and Storybook are separate Vercel projects. In the app project, set `STORYBOOK_URL` to the Storybook production origin and `SITE_URL` to the app's production origin. `SITE_URL` feeds Next.js `metadataBase`; preview deployments use their Vercel deployment URL, and local builds default to `http://localhost:3000`. In the Storybook project, set `STORYBOOK_OG_BASE_URL` to the stable production app origin so the deployed Storybook story loads its images from the deployed app. The story reads that value at build time; without it, image URLs default to `http://localhost:3000`.

Expand Down
70 changes: 69 additions & 1 deletion app/[locale]/page.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";

import Image from "next/image";

import { routing } from "@/i18n/routing";
import { captionForLocale } from "@/lib/storybook/locale-captions";
import { SyncHtmlTheme } from "@/lib/storybook/sync-html-theme";
import {
fullscreenViewportMatrix,
Expand All @@ -8,6 +12,10 @@ import {

import HomePage from "./page";

const openGraphBaseUrl = (
process.env.STORYBOOK_OG_BASE_URL || "http://localhost:3000"
).replace(/\/$/, "");

const meta = {
component: HomePage,
decorators: [
Expand All @@ -27,9 +35,10 @@ under that field, and theme as a row on the bottom-right with the
cut on the sheet edge. Landscape keeps the wide column-and-version
composition.

Each story is one named viewport. Tiles inside it are
Each viewport story is one named size. Tiles inside it are
English on the left and one other locale on the right, then
scroll — the pane is not locked to the token pixel size.
OG images shows the generated image for every locale.
`,
},
},
Expand Down Expand Up @@ -76,3 +85,62 @@ export const FourK: Story = {
...viewportLocaleStory(HomePage, "four-k"),
name: "4K · 3840 × 2160",
};

export const OpenGraphImages: Story = {
name: "OG images",
render: () => (
<main
style={{
boxSizing: "border-box",
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridTemplateRows: "repeat(2, minmax(0, 1fr))",
height: "100dvh",
padding: 12,
width: "100%",
}}
>
{routing.locales.map((locale) => (
<figure
key={locale}
style={{
display: "flex",
flexDirection: "column",
margin: 0,
minHeight: 0,
minWidth: 0,
}}
>
<figcaption
style={{
color: "#64748b",
fontFamily: "sans-serif",
fontSize: 12,
marginBottom: 6,
}}
>
{captionForLocale(locale)}
</figcaption>
<Image
alt={`${captionForLocale(locale)} Open Graph image`}
height={630}
src={`${openGraphBaseUrl}/${locale}/opengraph-image`}
style={{
background: "#07161d",
border: "1px solid #334155",
display: "block",
flex: "1 1 auto",
height: "100%",
minHeight: 0,
objectFit: "contain",
width: "100%",
}}
unoptimized
width={1200}
/>
</figure>
))}
</main>
),
};
Binary file modified app/apple-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/favicon.ico
Binary file not shown.
17 changes: 17 additions & 0 deletions app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 0 additions & 12 deletions app/icon0.svg

This file was deleted.

Binary file removed app/icon1.png
Binary file not shown.
79 changes: 0 additions & 79 deletions app/opengraph-images.stories.tsx

This file was deleted.

88 changes: 88 additions & 0 deletions features/error-widget/error-title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { type ReactNode, useEffect, useState } from "react";

import { Glitch } from "@/ui/glitch";

/**
* Quiet time before the next color split.
* Each wait is a new pick from 8 seconds through 32 seconds.
*/
const quietMinMs = 8_000;
const quietMaxMs = 32_000;

/**
* How long one color split stays visible.
* Each run is a new pick from 1 second through 3 seconds.
*/
const playMinMs = 1_000;
const playMaxMs = 3_000;

function millisecondsBetween(min: number, max: number) {
return min + Math.random() * (max - min);
}

export function ErrorTitle({ children }: { children: ReactNode }) {
const [playing, setPlaying] = useState(false);
// Hover holds the glitch on so the timed run can be checked by hand.
const [hovered, setHovered] = useState(false);
const visible = playing || hovered;

useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}

let timer = 0;
let stopped = false;

function play() {
if (stopped) {
return;
}

setPlaying(true);
timer = window.setTimeout(
rest,
millisecondsBetween(playMinMs, playMaxMs),
);
}

function rest() {
if (stopped) {
return;
}

setPlaying(false);
timer = window.setTimeout(
play,
millisecondsBetween(quietMinMs, quietMaxMs),
);
}

timer = window.setTimeout(
play,
millisecondsBetween(quietMinMs, quietMaxMs),
);

return () => {
stopped = true;
window.clearTimeout(timer);
};
}, []);

return (
<span
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
>
<Glitch active={visible} colorSplit>
{children}
</Glitch>
</span>
);
}
Loading
Loading