Skip to content

Commit 9cf716c

Browse files
authored
bring latest changes to github-stats-extended.vercel.app (#316)
no GitHub release planned for this merge
2 parents 9f4ad92 + ef3f207 commit 9cf716c

77 files changed

Lines changed: 1202 additions & 557 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/extensions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"recommendations": [
33
"yzhang.markdown-all-in-one",
4-
"prettier.prettier-vscode",
4+
"esbenp.prettier-vscode",
55
"dbaeumer.vscode-eslint",
66
"github.vscode-github-actions"
77
]

apps/frontend/e2e/app.spec.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ test("load initial page correctly", async ({ page }) => {
1212
// Logo exists
1313
await expect(page.locator('img[alt="logo"]')).toBeVisible();
1414

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

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

42+
test("theme picker switches and persists the theme", async ({ page }) => {
43+
await page.goto("");
44+
45+
const html = page.locator("html");
46+
const trigger = page.getByRole("button", { name: /choose theme/i });
47+
const options = page.getByRole("menuitemradio");
48+
49+
// The menu is closed initially.
50+
await expect(trigger).toBeVisible();
51+
await expect(trigger).toHaveAttribute("aria-expanded", "false");
52+
await expect(options).toHaveCount(0);
53+
54+
// Opening it reveals the options (portaled to body, above the stepper).
55+
await trigger.click();
56+
await expect(trigger).toHaveAttribute("aria-expanded", "true");
57+
58+
// Read the themes off the options instead of hardcoding their names.
59+
const firstTheme = await options.first().getAttribute("data-theme-value");
60+
const lastTheme = await options.last().getAttribute("data-theme-value");
61+
62+
// Selecting an option applies the theme, persists it, and closes the menu.
63+
await options.last().click();
64+
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
65+
await expect(options).toHaveCount(0);
66+
await expect(
67+
page.evaluate(() => localStorage.getItem("theme")),
68+
).resolves.toBe(lastTheme);
69+
70+
// The choice survives a reload and is marked as selected on reopen.
71+
await page.reload();
72+
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
73+
await trigger.click();
74+
await expect(options.last()).toHaveAttribute("aria-checked", "true");
75+
76+
// Switching to another option works too.
77+
await options.first().click();
78+
await expect(html).toHaveAttribute("data-theme", firstTheme ?? "");
79+
});
80+
4281
test("navigates between steps", async ({ page }) => {
4382
await page.goto("");
4483

apps/frontend/index.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@
3737
<title>GitHub Stats Extended</title>
3838
</head>
3939
<body>
40+
<script>
41+
// Apply the persisted theme from localStorage before first paint. This is
42+
// what makes a saved theme survive a reload when it differs from the OS
43+
// preference (DaisyUI's `--prefersdark` only covers the OS default, and
44+
// nothing in React sets `data-theme` on mount).
45+
// Also suppress color transitions until after the first paint so elements
46+
// don't animate from their unstyled state on load.
47+
(function () {
48+
try {
49+
var stored = localStorage.getItem("theme");
50+
const matchesDarkTheme = window.matchMedia(
51+
"(prefers-color-scheme: dark)",
52+
).matches;
53+
var theme = stored || (matchesDarkTheme ? "dark" : "light");
54+
document.documentElement.setAttribute("data-theme", theme);
55+
} catch (e) {}
56+
})();
57+
</script>
4058
<noscript>You need to enable JavaScript to run this app.</noscript>
4159
<div id="root"></div>
4260
<script type="module" src="/src/index.tsx"></script>

apps/frontend/src/components/Card/Card.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { clsx } from "clsx";
2-
import type { JSX } from "react";
2+
import type { CSSProperties, JSX } from "react";
33

44
import { CardImage } from "./CardImage";
5+
import { LIGHT_CARD_BG } from "./themeBackdrop";
56

67
interface CardProps {
78
title: string;
@@ -11,6 +12,10 @@ interface CardProps {
1112
selected?: boolean;
1213
compact?: boolean;
1314
fixedSize?: boolean;
15+
/** CSS background for the card surface (e.g. a theme's own background). */
16+
backgroundColor?: string;
17+
/** Title color, used when the card sits on a custom background. */
18+
titleColor?: string;
1419
}
1520

1621
export const Card = ({
@@ -21,16 +26,46 @@ export const Card = ({
2126
selected = false,
2227
compact = false,
2328
fixedSize = false,
29+
backgroundColor,
30+
titleColor,
2431
}: CardProps): JSX.Element => {
32+
const customBackground = backgroundColor
33+
? selected
34+
? `color-mix(in oklab, var(--color-primary) ${backgroundColor === LIGHT_CARD_BG ? "10%" : "25%"}, ${backgroundColor})`
35+
: backgroundColor
36+
: undefined;
37+
38+
const cardStyle = customBackground
39+
? ({ "--card-bg": customBackground } as CSSProperties)
40+
: undefined;
41+
2542
return (
2643
<div
27-
className={clsx("p-6 rounded border-2", {
44+
className={clsx("p-6 rounded border-4", {
2845
"h-[370px] w-[510px]": fixedSize,
29-
"border-blue-500 bg-blue-50": selected,
30-
"border-gray-200 bg-white hover:bg-gray-50": !selected,
46+
"border-primary": selected,
47+
"border-base-300": !selected,
48+
"bg-[var(--card-bg)]": !!customBackground,
49+
// Token backgrounds only apply when no custom background is given.
50+
"bg-primary/10": selected && !customBackground,
51+
"bg-base-100 hover:bg-base-200": !selected && !customBackground,
52+
"hover:bg-base-300": !selected && !!customBackground,
3153
})}
54+
style={cardStyle}
55+
data-theme={
56+
stage == 3
57+
? backgroundColor === LIGHT_CARD_BG || !backgroundColor
58+
? "light"
59+
: "dark"
60+
: undefined
61+
}
3262
>
33-
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
63+
<h2
64+
className="text-xl font-medium title-font text-base-content"
65+
style={titleColor ? { color: titleColor } : undefined}
66+
>
67+
{title}
68+
</h2>
3469
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
3570
<CardImage
3671
imageSrc={imageSrc}

apps/frontend/src/components/Card/SvgInline.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
5050
FETCH_MULTI_PAGE_STARS: "10",
5151
PAT_1: userToken as string, // even if it's null, core's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
5252
};
53-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
53+
5454
loadConfigFromEnv(config);
5555

5656
setLoaded(false);
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { themes } from "@stats-organization/github-readme-stats-core";
2+
3+
// We use the same background colors here which GitHub uses.
4+
export const LIGHT_CARD_BG = "#ffffff";
5+
const DARK_CARD_BG = "#0d1117";
6+
7+
// Themes that look good in either mode (their text/icon colors read on both a
8+
// light and a dark backdrop), so their backdrop follows the app theme. Note we
9+
// only list themes that are genuinely mode-agnostic here: other transparent
10+
// themes (e.g. shadow_*) have dark text and must stay on a light backdrop.
11+
const ADAPTIVE_THEMES = ["transparent", "ambient_gradient"];
12+
13+
/** Expand a shorthand `#abc` hex to `#aabbcc`. */
14+
function expandHex(hex: string): string {
15+
if (hex.length === 3) {
16+
return hex
17+
.split("")
18+
.map((char) => char + char)
19+
.join("");
20+
}
21+
return hex;
22+
}
23+
24+
/** Whether a hex color (3-, 6- or 8-digit) is dark by perceived luminance. */
25+
function isDarkHex(hex: string): boolean {
26+
const normalized = expandHex(hex);
27+
if (normalized.length < 6) {
28+
return false;
29+
}
30+
const r = parseInt(normalized.slice(0, 2), 16);
31+
const g = parseInt(normalized.slice(2, 4), 16);
32+
const b = parseInt(normalized.slice(4, 6), 16);
33+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
34+
return luminance < 0.5;
35+
}
36+
37+
/** Whether a theme's `bg_color` (hex or `angle,c1,c2,…` gradient) is dark. */
38+
function isDarkBg(bgColor: string): boolean {
39+
const parts = bgColor.split(",");
40+
// For gradients use the first color stop (index 0 is the angle).
41+
return isDarkHex((parts.length > 1 ? parts[1] : parts[0]) ?? "");
42+
}
43+
44+
/** A theme whose backdrop should follow the app mode rather than its own bg. */
45+
function isAdaptiveTheme(themeName: string): boolean {
46+
return ADAPTIVE_THEMES.includes(themeName);
47+
}
48+
49+
/**
50+
* Whether the backdrop a card actually gets is dark, for the given app mode.
51+
* Adaptive themes follow the app mode; the rest follow their own background.
52+
*/
53+
function isCardBackdropDark(themeName: string, appIsDark: boolean): boolean {
54+
const theme = themes[themeName as keyof typeof themes];
55+
if (isAdaptiveTheme(themeName)) {
56+
return appIsDark;
57+
}
58+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
59+
if (!theme) {
60+
return appIsDark;
61+
}
62+
return isDarkBg(theme.bg_color);
63+
}
64+
65+
/**
66+
* Sort rank for the theme grid: light themes first (0), adaptive themes in the
67+
* middle (1), dark themes last (2). Adaptive cards sit between the groups so
68+
* they blend with whichever side matches their backdrop in the current mode.
69+
*/
70+
export function getThemeSortRank(themeName: string): number {
71+
if (isAdaptiveTheme(themeName)) {
72+
return 1;
73+
}
74+
const theme = themes[themeName as keyof typeof themes];
75+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
76+
if (!theme) {
77+
return 0;
78+
}
79+
return isDarkBg(theme.bg_color) ? 2 : 0;
80+
}
81+
82+
/** The backdrop color a card rendered with the given theme should sit on. */
83+
export function getCardThemeBackdrop(
84+
themeName: string,
85+
appIsDark: boolean,
86+
): string {
87+
return isCardBackdropDark(themeName, appIsDark)
88+
? DARK_CARD_BG
89+
: LIGHT_CARD_BG;
90+
}

apps/frontend/src/components/Generic/Button.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
11
import { clsx } from "clsx";
22
import type { HTMLProps, JSX, ReactNode } from "react";
33

4-
interface ButtonProps extends HTMLProps<HTMLButtonElement> {
4+
type ButtonVariant = "primary" | "soft" | "error";
5+
type ButtonSize = "sm" | "md" | "lg";
6+
7+
interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, "size"> {
58
children: ReactNode;
9+
/** DaisyUI color variant. Omit for the default neutral button. */
10+
variant?: ButtonVariant | undefined;
11+
size?: ButtonSize;
612
}
713

814
export function Button(props: ButtonProps): JSX.Element {
9-
const { className, children, ...rest } = props;
15+
const { className, children, variant, size = "md", ...rest } = props;
16+
1017
return (
1118
<button
1219
{...rest}
1320
type="button"
1421
className={clsx(
15-
"border-0 py-2 px-6 inline-flex focus:outline-none rounded-[0.25rem] text-lg",
22+
"btn text-lg",
23+
{
24+
"btn-primary": variant === "primary",
25+
"btn-soft": variant === "soft",
26+
"btn-error": variant === "error",
27+
"btn-sm": size === "sm",
28+
"btn-lg": size === "lg",
29+
},
1630
className,
1731
)}
1832
>

apps/frontend/src/components/Generic/Checkbox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function Checkbox({
1919
type="checkbox"
2020
disabled={disabled}
2121
checked={checked}
22-
className="checkbox bg-white text-[#18181b] mr-2"
22+
className="checkbox checkbox-primary mr-2"
2323
onChange={() => {
2424
onCheckedChange(!checked);
2525
}}

apps/frontend/src/components/Generic/Select.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,7 @@ export function Select({
2727
}: SelectProps): JSX.Element {
2828
return (
2929
<select
30-
className={clsx(
31-
"text-gray-700 text-base bg-white select select-sm w-40 rounded-sm mt-4",
32-
className,
33-
)}
30+
className={clsx("text-base select select-sm w-40 mt-4", className)}
3431
value={selectedOption.value}
3532
onChange={(e) => {
3633
onOptionChange(options[e.target.selectedIndex] as SelectOption);
@@ -43,7 +40,7 @@ export function Select({
4340
value={option.value}
4441
disabled={option.disabled}
4542
className={clsx({
46-
"bg-blue-200": option.value === selectedOption.value,
43+
"bg-primary/20": option.value === selectedOption.value,
4744
})}
4845
>
4946
{option.label}

0 commit comments

Comments
 (0)