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
1 change: 1 addition & 0 deletions docs/docs.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const sidebar = [
{ label: "Built-in Authentication", slug: "auth", icon: "lock" },
{ label: "Images", slug: "images", icon: "image" },
{ label: "Fonts", slug: "fonts", icon: "file" },
{ label: "Themes", slug: "themes", icon: "sparkles" },
{ label: "Environment Functions", slug: "environment-functions", icon: "code" },
{ label: "Middleware", slug: "middleware", icon: "shield" },
],
Expand Down
4 changes: 4 additions & 0 deletions docs/src/app/docs/configuration/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export default defineConfig({
mdx: {
components: "./src/markdown-components.tsx",
},
theme: {
default: "system",
},
});
```

Expand Down Expand Up @@ -87,6 +90,7 @@ mount, and shortcut together.
| srcDir | Changing the app source folder from the default src. |
| integrations | Registering built-in or custom integrations. |
| auth | Enabling Farm's built-in email/password auth, sessions, helpers, and hooks. |
| theme | Enabling light, dark, and system modes with client and server APIs. |
| storage | Configuring KV drivers/mounts and, in the current beta, an integration DB client. |
| migrations | Running one-shot schema/provider commands with `farm migrate`. |
| cron | Mapping portable UTC schedules to ordinary GET API routes. |
Expand Down
1 change: 1 addition & 0 deletions docs/src/app/docs/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Routes, rendering, layouts, and request flow.

- [Routing](/docs/routing): Farm uses an app directory routing model with static routes, dynamic segments, catch-all routes, and typed navigation.
- [Fonts](/docs/fonts): Compile local or remote fonts into self-hosted, hashed assets with generated CSS and no loader runtime.
- [Themes](/docs/themes): Configure light, dark, and system color modes with a pre-paint selector, Tailwind variants, and typed client and server APIs.
- [Layouts and Route Boundaries](/docs/layouts): Wrap routes with root and nested layouts, then use loading, error, and not-found files for route-level UX.
- [Rendering Model](/docs/server-rendering): Choose dynamic rendering, static rendering, ISR, or PPR with route-level exports and config.
- [Built-in Authentication](/docs/auth): Enable email/password auth, sessions, server helpers, and React APIs with the top-level `auth` framework config.
Expand Down
2 changes: 2 additions & 0 deletions docs/src/app/docs/reference/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ A compact map of the main package exports and where to learn more.
| Package | Exports |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `@farm.js/core` | `defineIntegration`, `integrationRoute`, `defineIntegrationSchema`, `definePlugin`, `defineConfig`. |
| `@farm.js/core/theme/client` | `useTheme`, `getTheme`, `setTheme`, and `toggleTheme` for browser color-mode state. |
| `@farm.js/core/theme/server` | `getTheme` and `getThemeSnapshot` for cookie-backed server rendering. |
| `@farm.js/core/cron` | `cronRoute`, cron config types, schedule manifests, and deployment adapter helpers. |
| `@farm.js/core/workflows` | Legacy workflow-module APIs kept for compatibility. New schedules should use `cron` config and API routes. |
| `@farm.js/core/client` | `createIntegrations`, `createIntegrationClient`, `createIntegrationServerClient`, `endpoint`. |
Expand Down
154 changes: 154 additions & 0 deletions docs/src/app/docs/themes/page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
---
title: "Themes"
description: "Configure light, dark, and system color modes with a pre-paint selector, Tailwind variants, and typed client and server APIs."
section: "Core"
---

# Themes

FARMJS can manage a visitor's light, dark, or system preference without owning your colors. The
framework applies the active mode before styles load, persists the preference, and exposes typed
client and server APIs. Tailwind utilities, CSS variables, or ordinary selectors still define how
the application looks.

## Enable themes

Add `theme` to the application config:

**farm.config.ts**

```ts
import { defineConfig } from "@farm.js/core";

export default defineConfig({
theme: {
default: "system",
storageKey: "farm-theme",
},
});
```

`default` accepts `"light"`, `"dark"`, or `"system"`. Theme support is opt-in; omit the property or
set it to `false` when the application manages color mode itself.

FARMJS stores the preference in a same-site cookie so server rendering can read it. It mirrors
changes to local storage for cross-tab updates. The cookie path follows the configured `basePath`.

## Tailwind dark variants

When the built-in Tailwind integration processes a stylesheet containing `@import "tailwindcss"`,
FARMJS connects the `dark:` variant to its `data-theme` selector automatically:

```tsx
export function Panel() {
return (
<section className="bg-white text-neutral-950 dark:bg-black dark:text-white">
Theme-aware content
</section>
);
}
```

The active document is either `<html data-theme="light">` or `<html data-theme="dark">`. If the
stylesheet already defines `@custom-variant dark`, FARMJS preserves that definition instead of
overriding it.

## Plain CSS and design tokens

Tailwind is optional. Use the same selector with CSS variables or ordinary styles:

```css
:root {
--background: #ffffff;
--foreground: #0a0a0a;
}

[data-theme="dark"] {
--background: #000000;
--foreground: #f5f5f5;
}

body {
background: var(--background);
color: var(--foreground);
}
```

FARMJS also sets `color-scheme` for the resolved mode so native form controls and browser surfaces
match the page.

## Read and change the theme

Use `useTheme` in a client component:

```tsx
"use client";

import { useTheme } from "@farm.js/core/theme/client";

export function ThemePicker() {
const { theme, resolvedTheme, mounted, setTheme } = useTheme();

return (
<div role="group" aria-label="Color theme">
{(["light", "dark", "system"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={theme === option}
onClick={() => setTheme(option)}
>
{option}
</button>
))}
<span aria-live="polite">
{mounted && resolvedTheme ? `Using ${resolvedTheme} mode` : "Resolving theme"}
</span>
</div>
);
}
```

The returned values have different jobs:

- `theme` is the saved `"light"`, `"dark"`, or `"system"` preference.
- `resolvedTheme` is the active `"light"` or `"dark"` browser mode. It is undefined during server
rendering when the saved preference is `"system"`.
- `mounted` becomes true when the browser runtime is active.
- `setTheme(theme)` saves and applies a preference.
- `toggleTheme()` switches between the resolved light and dark modes.

The client module also exports non-hook `getTheme`, `setTheme`, and `toggleTheme` functions for
event handlers or stores outside React components.

## Read the preference on the server

Server-rendered pages and helpers can read the cookie-backed preference:

```tsx
import { getTheme } from "@farm.js/core/theme/server";

export default function SettingsPage() {
const theme = getTheme();
return <p>Saved preference: {theme}</p>;
}
```

`getTheme()` returns the preference, not an invented server-side resolution for `"system"`; only
the browser knows the visitor's operating-system color mode. Prefer CSS and `useTheme` for visual
styling. When server-rendered content depends on the cookie, treat that route as request-specific
rather than shared static output.

## No-flash behavior

FARMJS places a small bootstrap script and color-scheme style at the start of the document head.
They resolve the cookie and operating-system preference before application CSS loads. The runtime
also follows operating-system changes while `theme === "system"` and preserves `data-theme` during
SPA document navigation.

## Configuration reference

| Option | Type | Default | Purpose |
| ------------ | ------------------------------- | -------------- | --------------------------------------------- |
| `default` | `"light" \| "dark" \| "system"` | `"system"` | Preference used before a visitor chooses one. |
| `storageKey` | `string` | `"farm-theme"` | Cookie and cross-tab storage key. |
11 changes: 8 additions & 3 deletions docs/src/farm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export type RoutePath =
| "/docs/server-queries"
| "/docs/server-rendering"
| "/docs/storage"
| "/docs/testing";
| "/docs/testing"
| "/docs/themes";
export type RoutePattern =
| "/"
| "/docs"
Expand Down Expand Up @@ -142,7 +143,8 @@ export type RoutePattern =
| "/docs/server-queries"
| "/docs/server-rendering"
| "/docs/storage"
| "/docs/testing";
| "/docs/testing"
| "/docs/themes";
export type RouteModulePattern =
| "/"
| "/docs"
Expand Down Expand Up @@ -208,7 +210,8 @@ export type RouteModulePattern =
| "/docs/server-queries"
| "/docs/server-rendering"
| "/docs/storage"
| "/docs/testing";
| "/docs/testing"
| "/docs/themes";
declare module "@farm.js/core/client" {
interface LinkDefaultRoute {
_: import("./farm").RoutePath;
Expand Down Expand Up @@ -266,3 +269,5 @@ declare module "@farm.js/core" {
public: FarmResolvedEnv["public"];
}
}

export {};
3 changes: 3 additions & 0 deletions packages/create-farm-app/templates/auth/farm.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { defineConfig } from "@farm.js/core";

export default defineConfig({
auth: true,
theme: {
default: "dark",
},
experimental: {
serverComponents: true,
},
Expand Down
3 changes: 3 additions & 0 deletions packages/create-farm-app/templates/basic/farm.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { defineConfig } from "@farm.js/core";

export default defineConfig({
theme: {
default: "dark",
},
deploy: {
target: "vercel",
},
Expand Down
3 changes: 3 additions & 0 deletions packages/create-farm-app/templates/better-auth/farm.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { auth } from "./src/lib/auth.ts";

export default defineConfig({
srcDir: "src",
theme: {
default: "dark",
},
experimental: {
serverComponents: true,
},
Expand Down
3 changes: 3 additions & 0 deletions packages/create-farm-app/test/create-app.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ test("generates a buildable starter application", async () => {
"utf8",
);
assert.doesNotMatch(generatedConfig, /srcDir/);
assert.match(generatedConfig, /theme:\s*\{\s*default: "dark"/s);

const generatedTsconfig = JSON.parse(
await readFile(path.join(tempDir, "generated-app/tsconfig.json"), "utf8"),
Expand Down Expand Up @@ -333,6 +334,8 @@ for (const template of [

const generatedGitignore = await readFile(path.join(generatedDir, ".gitignore"), "utf8");
assert.match(generatedGitignore, /^\.env\.local$/m);
const generatedConfig = await readFile(path.join(generatedDir, "farm.config.ts"), "utf8");
assert.match(generatedConfig, /theme:\s*\{\s*default: "dark"/s);
await readFile(path.join(generatedDir, ".env.example"), "utf8");
const generatedReadme = await readFile(path.join(generatedDir, "README.md"), "utf8");
assert.match(generatedReadme, /^# FARMJS /);
Expand Down
24 changes: 24 additions & 0 deletions packages/farm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@
"headers": [
"./dist/headers.d.ts"
],
"theme": [
"./dist/theme/index.d.ts"
],
"theme/client": [
"./dist/theme/client.d.ts"
],
"theme/server": [
"./dist/theme/server.d.ts"
],
"docs": [
"./dist/docs.d.ts"
],
Expand Down Expand Up @@ -247,6 +256,21 @@
"import": "./dist/headers.mjs",
"require": "./dist/headers.cjs"
},
"./theme": {
"types": "./dist/theme/index.d.ts",
"import": "./dist/theme/index.mjs",
"require": "./dist/theme/index.cjs"
},
"./theme/client": {
"types": "./dist/theme/client.d.ts",
"import": "./dist/theme/client.mjs",
"require": "./dist/theme/client.cjs"
},
"./theme/server": {
"types": "./dist/theme/server.d.ts",
"import": "./dist/theme/server.mjs",
"require": "./dist/theme/server.cjs"
},
"./docs": {
"types": "./dist/docs.d.ts",
"import": "./dist/docs.mjs",
Expand Down
4 changes: 4 additions & 0 deletions packages/farm/src/__tests__/production-ssg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { getCurrentRequest } from "@farm.js/core/request";
export default {
srcDir: "src",
images: { provider: "none" },
theme: { default: "dark" },
security: {
csp: ${JSON.stringify(testCspPolicy)},
},
Expand Down Expand Up @@ -407,6 +408,9 @@ describe("production SSG output", () => {
"utf8",
);
expect(staticArtifact).toContain("pure-static-page");
expect(staticArtifact).toContain('data-theme="dark"');
expect(staticArtifact).toContain('id="farm-theme-script"');
expect(staticArtifact).not.toContain("__name");
expect(dynamicArtifact).toContain("blog-");
expect(dynamicArtifact).toContain("built");
await expect(
Expand Down
Loading
Loading