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
2 changes: 1 addition & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"recommendations": [
"yzhang.markdown-all-in-one",
"prettier.prettier-vscode",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"github.vscode-github-actions"
]
Expand Down
43 changes: 41 additions & 2 deletions apps/frontend/e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ test("load initial page correctly", async ({ page }) => {
// Logo exists
await expect(page.locator('img[alt="logo"]')).toBeVisible();

// Star on GitHub button
const starButton = page.getByRole("button", { name: /star on/i });
// Star on GitHub link
const starButton = page.getByRole("link", { name: /star on/i });
await expect(starButton).toBeVisible();

const githubLink = page.locator(
Expand All @@ -39,6 +39,45 @@ test("load initial page correctly", async ({ page }) => {
await expect(guestBtn).toBeEnabled();
});

test("theme picker switches and persists the theme", async ({ page }) => {
await page.goto("");

const html = page.locator("html");
const trigger = page.getByRole("button", { name: /choose theme/i });
const options = page.getByRole("menuitemradio");

// The menu is closed initially.
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute("aria-expanded", "false");
await expect(options).toHaveCount(0);

// Opening it reveals the options (portaled to body, above the stepper).
await trigger.click();
await expect(trigger).toHaveAttribute("aria-expanded", "true");

// Read the themes off the options instead of hardcoding their names.
const firstTheme = await options.first().getAttribute("data-theme-value");
const lastTheme = await options.last().getAttribute("data-theme-value");

// Selecting an option applies the theme, persists it, and closes the menu.
await options.last().click();
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
await expect(options).toHaveCount(0);
await expect(
page.evaluate(() => localStorage.getItem("theme")),
).resolves.toBe(lastTheme);

// The choice survives a reload and is marked as selected on reopen.
await page.reload();
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
await trigger.click();
await expect(options.last()).toHaveAttribute("aria-checked", "true");

// Switching to another option works too.
await options.first().click();
await expect(html).toHaveAttribute("data-theme", firstTheme ?? "");
});

test("navigates between steps", async ({ page }) => {
await page.goto("");

Expand Down
18 changes: 18 additions & 0 deletions apps/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@
<title>GitHub Stats Extended</title>
</head>
<body>
<script>
// Apply the persisted theme from localStorage before first paint. This is
// what makes a saved theme survive a reload when it differs from the OS
// preference (DaisyUI's `--prefersdark` only covers the OS default, and
// nothing in React sets `data-theme` on mount).
// Also suppress color transitions until after the first paint so elements
// don't animate from their unstyled state on load.
(function () {
try {
var stored = localStorage.getItem("theme");
const matchesDarkTheme = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
var theme = stored || (matchesDarkTheme ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch (e) {}
})();
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
Expand Down
45 changes: 40 additions & 5 deletions apps/frontend/src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { clsx } from "clsx";
import type { JSX } from "react";
import type { CSSProperties, JSX } from "react";

import { CardImage } from "./CardImage";
import { LIGHT_CARD_BG } from "./themeBackdrop";

interface CardProps {
title: string;
Expand All @@ -11,6 +12,10 @@ interface CardProps {
selected?: boolean;
compact?: boolean;
fixedSize?: boolean;
/** CSS background for the card surface (e.g. a theme's own background). */
backgroundColor?: string;
/** Title color, used when the card sits on a custom background. */
titleColor?: string;
}

export const Card = ({
Expand All @@ -21,16 +26,46 @@ export const Card = ({
selected = false,
compact = false,
fixedSize = false,
backgroundColor,
titleColor,
}: CardProps): JSX.Element => {
const customBackground = backgroundColor
? selected
? `color-mix(in oklab, var(--color-primary) ${backgroundColor === LIGHT_CARD_BG ? "10%" : "25%"}, ${backgroundColor})`
: backgroundColor
: undefined;

const cardStyle = customBackground
? ({ "--card-bg": customBackground } as CSSProperties)
: undefined;

return (
<div
className={clsx("p-6 rounded border-2", {
className={clsx("p-6 rounded border-4", {
"h-[370px] w-[510px]": fixedSize,
"border-blue-500 bg-blue-50": selected,
"border-gray-200 bg-white hover:bg-gray-50": !selected,
"border-primary": selected,
"border-base-300": !selected,
"bg-[var(--card-bg)]": !!customBackground,
// Token backgrounds only apply when no custom background is given.
"bg-primary/10": selected && !customBackground,
"bg-base-100 hover:bg-base-200": !selected && !customBackground,
"hover:bg-base-300": !selected && !!customBackground,
})}
style={cardStyle}
data-theme={
stage == 3
? backgroundColor === LIGHT_CARD_BG || !backgroundColor
? "light"
: "dark"
: undefined
}
>
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
<h2
className="text-xl font-medium title-font text-base-content"
style={titleColor ? { color: titleColor } : undefined}
>
{title}
</h2>
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
<CardImage
imageSrc={imageSrc}
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/Card/SvgInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
FETCH_MULTI_PAGE_STARS: "10",
PAT_1: userToken as string, // even if it's null, core's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-call

loadConfigFromEnv(config);

setLoaded(false);
Expand Down
90 changes: 90 additions & 0 deletions apps/frontend/src/components/Card/themeBackdrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { themes } from "@stats-organization/github-readme-stats-core";

// We use the same background colors here which GitHub uses.
export const LIGHT_CARD_BG = "#ffffff";
const DARK_CARD_BG = "#0d1117";

// Themes that look good in either mode (their text/icon colors read on both a
// light and a dark backdrop), so their backdrop follows the app theme. Note we
// only list themes that are genuinely mode-agnostic here: other transparent
// themes (e.g. shadow_*) have dark text and must stay on a light backdrop.
const ADAPTIVE_THEMES = ["transparent", "ambient_gradient"];

/** Expand a shorthand `#abc` hex to `#aabbcc`. */
function expandHex(hex: string): string {
if (hex.length === 3) {
return hex
.split("")
.map((char) => char + char)
.join("");
}
return hex;
}

/** Whether a hex color (3-, 6- or 8-digit) is dark by perceived luminance. */
function isDarkHex(hex: string): boolean {
const normalized = expandHex(hex);
if (normalized.length < 6) {
return false;
}
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance < 0.5;
}

/** Whether a theme's `bg_color` (hex or `angle,c1,c2,…` gradient) is dark. */
function isDarkBg(bgColor: string): boolean {
const parts = bgColor.split(",");
// For gradients use the first color stop (index 0 is the angle).
return isDarkHex((parts.length > 1 ? parts[1] : parts[0]) ?? "");
}

/** A theme whose backdrop should follow the app mode rather than its own bg. */
function isAdaptiveTheme(themeName: string): boolean {
return ADAPTIVE_THEMES.includes(themeName);
}

/**
* Whether the backdrop a card actually gets is dark, for the given app mode.
* Adaptive themes follow the app mode; the rest follow their own background.
*/
function isCardBackdropDark(themeName: string, appIsDark: boolean): boolean {
const theme = themes[themeName as keyof typeof themes];
if (isAdaptiveTheme(themeName)) {
return appIsDark;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!theme) {
return appIsDark;
}
return isDarkBg(theme.bg_color);
}

/**
* Sort rank for the theme grid: light themes first (0), adaptive themes in the
* middle (1), dark themes last (2). Adaptive cards sit between the groups so
* they blend with whichever side matches their backdrop in the current mode.
*/
export function getThemeSortRank(themeName: string): number {
if (isAdaptiveTheme(themeName)) {
return 1;
}
const theme = themes[themeName as keyof typeof themes];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!theme) {
return 0;
}
return isDarkBg(theme.bg_color) ? 2 : 0;
}

/** The backdrop color a card rendered with the given theme should sit on. */
export function getCardThemeBackdrop(
themeName: string,
appIsDark: boolean,
): string {
return isCardBackdropDark(themeName, appIsDark)
? DARK_CARD_BG
: LIGHT_CARD_BG;
}
20 changes: 17 additions & 3 deletions apps/frontend/src/components/Generic/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
import { clsx } from "clsx";
import type { HTMLProps, JSX, ReactNode } from "react";

interface ButtonProps extends HTMLProps<HTMLButtonElement> {
type ButtonVariant = "primary" | "soft" | "error";
type ButtonSize = "sm" | "md" | "lg";

interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, "size"> {
children: ReactNode;
/** DaisyUI color variant. Omit for the default neutral button. */
variant?: ButtonVariant | undefined;
size?: ButtonSize;
}

export function Button(props: ButtonProps): JSX.Element {
const { className, children, ...rest } = props;
const { className, children, variant, size = "md", ...rest } = props;

return (
<button
{...rest}
type="button"
className={clsx(
"border-0 py-2 px-6 inline-flex focus:outline-none rounded-[0.25rem] text-lg",
"btn text-lg",
{
"btn-primary": variant === "primary",
"btn-soft": variant === "soft",
"btn-error": variant === "error",
"btn-sm": size === "sm",
"btn-lg": size === "lg",
},
className,
)}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/Generic/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function Checkbox({
type="checkbox"
disabled={disabled}
checked={checked}
className="checkbox bg-white text-[#18181b] mr-2"
className="checkbox checkbox-primary mr-2"
onChange={() => {
onCheckedChange(!checked);
}}
Expand Down
7 changes: 2 additions & 5 deletions apps/frontend/src/components/Generic/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ export function Select({
}: SelectProps): JSX.Element {
return (
<select
className={clsx(
"text-gray-700 text-base bg-white select select-sm w-40 rounded-sm mt-4",
className,
)}
className={clsx("text-base select select-sm w-40 mt-4", className)}
value={selectedOption.value}
onChange={(e) => {
onOptionChange(options[e.target.selectedIndex] as SelectOption);
Expand All @@ -43,7 +40,7 @@ export function Select({
value={option.value}
disabled={option.disabled}
className={clsx({
"bg-blue-200": option.value === selectedOption.value,
"bg-primary/20": option.value === selectedOption.value,
})}
>
{option.label}
Expand Down
Loading
Loading