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
Binary file added public/icon-192x192.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 added public/icon-512x512.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 added public/icon-maskable-192x192.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 added public/icon-maskable-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions public/icon-maskable.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "codeg",
"short_name": "codeg",
"description": "AI Coding Agent Conversation Manager",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#09090b",
"theme_color": "#09090b",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
33 changes: 32 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { resolveRequestLocale } from "@/i18n/resolve-request-locale"
import { ThemeProvider } from "@/components/theme-provider"
import { toIntlLocale } from "@/lib/i18n"
import { APPEARANCE_INIT_SCRIPT } from "@/lib/appearance-script"
import {
THEME_COLOR_DARK,
THEME_COLOR_LIGHT,
THEME_COLOR_MEDIA_DARK,
THEME_COLOR_MEDIA_LIGHT,
} from "@/lib/theme-color"
import { AppearanceProvider } from "@/components/appearance-provider"
import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init"
import { ClipboardFallbackInit } from "@/components/clipboard-fallback-init"
Expand All @@ -19,6 +25,13 @@ export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
colorScheme: "dark light",
// In-app light/dark overrides of these are applied by the pre-paint script
// and ThemeProvider, see `@/lib/theme-color`.
themeColor: [
{ media: THEME_COLOR_MEDIA_LIGHT, color: THEME_COLOR_LIGHT },
{ media: THEME_COLOR_MEDIA_DARK, color: THEME_COLOR_DARK },
],
}

export const metadata: Metadata = {
Expand All @@ -27,9 +40,15 @@ export const metadata: Metadata = {
icons: {
icon: [
{ url: "/icon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ url: "/icon-512x512.png", sizes: "512x512", type: "image/png" },
{ url: "/icon.svg", type: "image/svg+xml" },
],
apple: { url: "/icon-128x128.png", sizes: "128x128", type: "image/png" },
apple: [
{ url: "/icon-128x128.png", sizes: "128x128", type: "image/png" },
{ url: "/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ url: "/icon-512x512.png", sizes: "512x512", type: "image/png" },
],
},
}

Expand All @@ -44,6 +63,18 @@ export default async function RootLayout({

return (
<html lang={initialLocale} suppressHydrationWarning>
<head>
{/* Written by hand rather than via `metadata.manifest`: Next only adds
`crossOrigin="use-credentials"` there on Vercel preview builds.
Without it the browser fetches the manifest without cookies, and
an auth proxy in front of codeg-server answers with its login
redirect instead of the manifest. */}
<link
rel="manifest"
href="/manifest.json"
crossOrigin="use-credentials"
/>
</head>
<body>
{/* CSS-only dark background: applies before JS executes, preventing white flash in dark mode */}
<style
Expand Down
146 changes: 146 additions & 0 deletions src/components/theme-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { useTheme } from "next-themes"
import { ThemeProvider } from "./theme-provider"
import {
THEME_COLOR_DARK,
THEME_COLOR_LIGHT,
THEME_COLOR_MEDIA_DARK,
THEME_COLOR_MEDIA_LIGHT,
} from "@/lib/theme-color"

/** The two tags the root layout's `viewport.themeColor` renders. */
function addSchemeTags() {
for (const [media, color] of [
[THEME_COLOR_MEDIA_LIGHT, THEME_COLOR_LIGHT],
[THEME_COLOR_MEDIA_DARK, THEME_COLOR_DARK],
]) {
const tag = document.createElement("meta")
tag.setAttribute("name", "theme-color")
tag.setAttribute("media", media)
tag.setAttribute("content", color)
document.head.appendChild(tag)
}
}

function removeThemeColorTags() {
document
.querySelectorAll('meta[name="theme-color"]')
.forEach((tag) => tag.remove())
}

function contents() {
return Array.from(
document.querySelectorAll('meta[name="theme-color"]'),
(tag) => tag.getAttribute("content")
)
}

function ThemeButtons() {
const { setTheme } = useTheme()
return (
<>
<button onClick={() => setTheme("dark")}>dark</button>
<button onClick={() => setTheme("light")}>light</button>
<button onClick={() => setTheme("system")}>system</button>
</>
)
}

function renderProvider() {
// Same props as the root layout.
return render(
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeButtons />
</ThemeProvider>
)
}

beforeEach(() => {
localStorage.clear()
removeThemeColorTags()
})

afterEach(() => {
cleanup()
removeThemeColorTags()
localStorage.clear()
})

describe("ThemeProvider — theme-color", () => {
it("gives every tag the color of an explicitly chosen mode", () => {
addSchemeTags()
localStorage.setItem("theme", "dark")

renderProvider()

expect(contents()).toEqual([THEME_COLOR_DARK, THEME_COLOR_DARK])
})

it("follows a switch made after load", () => {
addSchemeTags()
renderProvider()
expect(contents()).toEqual([THEME_COLOR_LIGHT, THEME_COLOR_DARK])

fireEvent.click(screen.getByText("light"))

expect(contents()).toEqual([THEME_COLOR_LIGHT, THEME_COLOR_LIGHT])
})

it("restores each tag from its media on the way back to system", () => {
addSchemeTags()
localStorage.setItem("theme", "dark")
renderProvider()

// From dark, only the light-scheme tag has to come back…
fireEvent.click(screen.getByText("system"))
expect(contents()).toEqual([THEME_COLOR_LIGHT, THEME_COLOR_DARK])

// …from light, only the dark-scheme one.
fireEvent.click(screen.getByText("light"))
expect(contents()).toEqual([THEME_COLOR_LIGHT, THEME_COLOR_LIGHT])
fireEvent.click(screen.getByText("system"))
expect(contents()).toEqual([THEME_COLOR_LIGHT, THEME_COLOR_DARK])
})

it("re-applies the choice when a navigation recreates the tags", async () => {
addSchemeTags()
localStorage.setItem("theme", "dark")
renderProvider()

// Next remounts its head on every client-side navigation (the `/` →
// `/workspace` launch redirect included): the old tags go, fresh ones
// carrying the per-scheme defaults take their place.
removeThemeColorTags()
addSchemeTags()

await waitFor(() =>
expect(contents()).toEqual([THEME_COLOR_DARK, THEME_COLOR_DARK])
)
})

it("stops watching the head once unmounted", async () => {
addSchemeTags()
localStorage.setItem("theme", "dark")
const { unmount } = renderProvider()
unmount()

removeThemeColorTags()
addSchemeTags()
// Observer callbacks are microtasks; let any stray one run.
await new Promise((resolve) => setTimeout(resolve, 0))

expect(contents()).toEqual([THEME_COLOR_LIGHT, THEME_COLOR_DARK])
})
})
71 changes: 69 additions & 2 deletions src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,75 @@
"use client"

import { useEffect } from "react"
import type { ThemeProviderProps } from "next-themes"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
import {
THEME_COLOR_DARK,
THEME_COLOR_LIGHT,
THEME_COLOR_MEDIA_DARK,
THEME_COLOR_MEDIA_LIGHT,
} from "@/lib/theme-color"

function colorForMedia(media: string | null): string | null {
if (media === THEME_COLOR_MEDIA_DARK) return THEME_COLOR_DARK
if (media === THEME_COLOR_MEDIA_LIGHT) return THEME_COLOR_LIGHT
return null
}

/**
* Keeps `<meta name="theme-color">` in step with the in-app light/dark choice.
*
* The tags come from `viewport.themeColor` in the root layout, one per
* `prefers-color-scheme`, so "system" only has to restore them. An explicit
* choice must beat the OS preference, so every tag gets that mode's color.
*
* Next owns those elements and recreates them from props whenever its head
* remounts: on every client-side navigation, including the `/` → `/workspace`
* redirect an installed app launches through. A one-shot write would revert
* to the OS colors there, so the write is repeated whenever `<head>` gains or
* loses children. Observer callbacks run as microtasks, before the next paint.
*
* Every theme-color tag is written, not only the ones React tracks. React
* matches hoisted `<meta>` tags to the server HTML by `content`, which the
* pre-paint script has already rewritten, so hydration can leave an extra,
* untracked tag behind.
*/
function ThemeColorMetaSync() {
const { theme } = useTheme()

useEffect(() => {
const explicit =
theme === "dark"
? THEME_COLOR_DARK
: theme === "light"
? THEME_COLOR_LIGHT
: null
const apply = () => {
document
.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
.forEach((tag) => {
const color = explicit ?? colorForMedia(tag.getAttribute("media"))
if (color !== null && tag.getAttribute("content") !== color) {
tag.setAttribute("content", color)
}
})
}
apply()
// Attribute writes are not childList mutations, so `apply` cannot retrigger
// itself.
const observer = new MutationObserver(apply)
observer.observe(document.head, { childList: true })
return () => observer.disconnect()
}, [theme])

return null
}

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
return (
<NextThemesProvider {...props}>
<ThemeColorMetaSync />
{children}
</NextThemesProvider>
)
}
Loading
Loading