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
33 changes: 19 additions & 14 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2246,20 +2246,25 @@
- id: fork-neutral-dark-theme
intent: >
Appearance offers Neutral Dark alongside System, Light, Dark, and Westworld.
Neutral Dark is a true-neutral gray palette from Figma t3-fork node
264:11816 — no cool undertone. Selecting it must not replace or alter
default Dark (#191919 / #1e1e1e) or Westworld (#26272c).
Neutral Dark is a warm-gray palette keyed off the sidebar #34322f (it began
as the true-neutral Figma t3-fork node 264:11816). Selecting it must not
replace or alter default Dark (#191919 / #1e1e1e) or Westworld (#26272c).

Neutral Dark reuses the fork palette overlay in custom/forkTheme.ts
(t3code:fork-theme = neutral-dark, upstream theme = dark,
data-fork-theme="neutral-dark"). Stage #1f1f1f, sidebar panel #2a2a2a
(slightly darker than Figma's #2e2e2e), composer vessel #323232 (a step
above the panel), prompt surface #3d3d3d (frosted), context chips #414141,
row hover/selected as opaque white-8% lifts (#383838 / #3a3a3a), borders
#3f3f3f. Accents stay monochrome (white primary/ring) with neutral muted
data-fork-theme="neutral-dark"). Sidebar panel #34322f, stage #2c2a27 (a
darker shade of the sidebar), composer vessel #3c3a37 (a step above the
panel), prompt surface #474542 (frosted), context chips #4b4946, row
hover/selected as opaque lifts (#42403d / #44423f), borders #494744.
Accents stay monochrome (white primary/ring) with neutral muted
text. theme.custom.palettes.css uses the same two-block stage + v2 panel
pattern as Westworld. index.html pre-paints #1f1f1f so the load flash matches.
pattern as Westworld. index.html pre-paints #2c2a27 so the load flash matches.
The sidebar stays opaque solid — wallpaper glass belongs to Neutral Darker.
Popups open flat on --popover #403e3b (a step above --card #383633) by
overriding the shared frost's tokens (--fork-popup-fill / -border /
-filter); the frost (fork-popup-surface) reads as glass, which belongs to
Glass. Every unchecked checkbox in the palette is a flat white-16% hairline
with no fill, highlight or shadow.
tier: 2
files:
- apps/web/src/custom/forkTheme.ts
Expand All @@ -2274,11 +2279,11 @@

- id: fork-neutral-darker-theme
intent: >
Appearance offers Neutral Darker as a deeper sibling of Neutral Dark.
True-neutral grays only. Chat stage #1a1a1a (slightly darker than Neutral
Dark's #1f1f1f); sidebar #212121 (much darker than Neutral Dark's #2a2a2a,
still a step lighter than the stage). Stage --card/--popover match the
sidebar (#212121) so menus open at the same lift. Composer vessel sits a
Appearance offers Neutral Darker as a true-neutral counterpart to the warm
Neutral Dark, pushed down. True-neutral grays only. Chat stage #1a1a1a
(well below Neutral Dark's #2c2a27); sidebar #212121 (much darker than
Neutral Dark's #34322f, still a step lighter than the stage). Stage
--card/--popover match the sidebar (#212121) so menus open at the same lift. Composer vessel sits a
step above the sidebar at #252525 so the control row contrasts against the
stage; prompt/chips lift from that vessel. Must not alter Neutral Dark,
Westworld, or default Dark fills.
Expand Down
4 changes: 2 additions & 2 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
const forkPaletteBackgrounds = { "cool-dark": "#26272c" };
/* fork:end fork-cool-dark-theme */
/* fork:begin fork-neutral-dark-theme — see .fork/customizations.yaml#fork-neutral-dark-theme */
forkPaletteBackgrounds["neutral-dark"] = "#1f1f1f";
forkPaletteBackgrounds["neutral-dark"] = "#2c2a27";
/* fork:end fork-neutral-dark-theme */
/* fork:begin fork-neutral-darker-theme — see .fork/customizations.yaml#fork-neutral-darker-theme */
forkPaletteBackgrounds["neutral-darker"] = "#1a1a1a";
Expand Down Expand Up @@ -489,7 +489,7 @@

/* fork:begin fork-neutral-dark-theme — see .fork/customizations.yaml#fork-neutral-dark-theme */
html.dark[data-fork-theme="neutral-dark"] body {
background: #1f1f1f;
background: #2c2a27;
color: #e8e8e8;
}
/* fork:end fork-neutral-dark-theme */
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/__fork_guards__/forkLocalDictation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ describe("fork local dictation", () => {
const frost = cssRules(theme).find(
(rule) =>
rule.selector.includes('[data-slot="tooltip-popup"]') &&
/backdrop-filter: blur\(\d+px\)/u.test(rule.body),
rule.body.includes("backdrop-filter: var(--fork-popup-filter)"),
);
expect(frost?.selector).toContain(".dropdown-glass");
expect(frost?.selector).not.toContain("data-fork-glass-tooltip");
Expand Down
89 changes: 62 additions & 27 deletions apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Fork guard — see `.fork/README.md` §4b and
* `.fork/customizations.yaml#fork-neutral-dark-theme`.
*
* Neutral Dark is a selectable true-neutral dark palette. Losing the
* Neutral Dark is a selectable warm-gray dark palette. Losing the
* preference wiring leaves the Appearance option as a dead label; losing the
* CSS attribute selectors silently paints Neutral Dark with default Dark fills.
*/
Expand Down Expand Up @@ -95,12 +95,12 @@ describe("fork guard: fork-neutral-dark-theme", () => {
const stage = ruleBodyFor(themeRules, NEUTRAL_STAGE);
const panel = ruleBodyFor(themeRules, NEUTRAL_PANEL);
expect(declarationHex(stage, "--background")).toBe(NEUTRAL_DARK_BACKGROUND);
expect(declarationHex(panel, "--background")).toBe("#2a2a2a");
expect(panel).toContain("--sidebar: #2a2a2a");
expect(panel).toContain("--sidebar-stage-fade: #2a2a2a");
expect(stage).toContain("--fork-composer-vessel-bg: #323232");
expect(stage).toContain("--fork-composer-bg: rgb(61 61 61 / 80%)");
expect(stage).toContain("--fork-context-chip-bg: rgb(65 65 65)");
expect(declarationHex(panel, "--background")).toBe("#34322f");
expect(panel).toContain("--sidebar: #34322f");
expect(panel).toContain("--sidebar-stage-fade: #34322f");
expect(stage).toContain("--fork-composer-vessel-bg: #3c3a37");
expect(stage).toContain("--fork-composer-bg: rgb(71 69 66 / 80%)");
expect(stage).toContain("--fork-context-chip-bg: rgb(75 73 70)");
});

it("keeps Neutral Dark lighter than the default Dark stage and distinct from Westworld", () => {
Expand All @@ -112,30 +112,33 @@ describe("fork guard: fork-neutral-dark-theme", () => {
expect(neutralStage).toBe(NEUTRAL_DARK_BACKGROUND);
});

it("keeps Neutral Dark truly neutral (R = G = B on stage)", () => {
const [r, g, b] = parseHex(
declarationHex(ruleBodyFor(themeRules, NEUTRAL_STAGE), "--background"),
);
expect(r).toBe(g);
expect(g).toBe(b);
it("paints the stage as a darker shade of the warm sidebar", () => {
const stage = parseHex(declarationHex(ruleBodyFor(themeRules, NEUTRAL_STAGE), "--background"));
const panel = parseHex(declarationHex(ruleBodyFor(themeRules, NEUTRAL_PANEL), "--background"));
const [r, g, b] = stage;
expect(r).toBeLessThan(panel[0]);
expect(g).toBeLessThan(panel[1]);
expect(b).toBeLessThan(panel[2]);
expect(r).toBeGreaterThan(g);
expect(g).toBeGreaterThan(b);
});

it("states Neutral Dark row fills as opaque values", () => {
const panel = ruleBodyFor(themeRules, NEUTRAL_PANEL);
expect(panel).toContain("--sidebar-row-hover: #383838");
expect(panel).toContain("--sidebar-row-active: #3a3a3a");
expect(panel).toContain("--sidebar-row-selected: #3a3a3a");
expect(panel).toContain("--sidebar-row-hover: #42403d");
expect(panel).toContain("--sidebar-row-active: #44423f");
expect(panel).toContain("--sidebar-row-selected: #44423f");
expect(panel).not.toMatch(
/--sidebar-row-(?:hover|active|selected):[^;]*(?:color-mix|--alpha)/u,
);
});

it("keeps context chips on an opaque match of the Figma chip fill", () => {
it("keeps context chips on an opaque fill", () => {
const contextRules = cssRules(theme).filter((rule) =>
rule.body.includes("--fork-context-chip-bg:"),
);
const neutralDark = contextRules.find((rule) => rule.selector === NEUTRAL_STAGE[0]);
expect(neutralDark?.body).toContain("--fork-context-chip-bg: rgb(65 65 65)");
expect(neutralDark?.body).toContain("--fork-context-chip-bg: rgb(75 73 70)");
expect(neutralDark?.body).not.toMatch(/--fork-context-chip-bg:[^;]*\//u);
});

Expand All @@ -150,21 +153,53 @@ describe("fork guard: fork-neutral-dark-theme", () => {
expect(indexHtml).toContain('setAttribute("data-fork-theme", activeForkPalette)');
expect(indexHtml).toContain(`html.dark[${FORK_THEME_ATTRIBUTE}="neutral-dark"] body`);
expect(indexHtml).toMatch(
/html\.dark\[data-fork-theme="neutral-dark"\] body\s*\{[^}]*background:\s*#1f1f1f/u,
/html\.dark\[data-fork-theme="neutral-dark"\] body\s*\{[^}]*background:\s*#2c2a27/u,
);
expect(indexHtml).toContain("fork:begin fork-neutral-dark-theme");
});

it("opens popups flat by overriding the shared frost's tokens", () => {
const stage = ruleBodyFor(themeRules, NEUTRAL_STAGE);
expect(stage).toContain("--fork-popup-fill: var(--popover)");
expect(stage).toContain("--fork-popup-border: var(--border)");
expect(stage).toContain("--fork-popup-filter: none");
// Restating the frost rule's arms would let a new arm stay frosted here.
const restated = themeRules.filter(
(rule) =>
rule.selector.includes(`[${FORK_THEME_ATTRIBUTE}="${NEUTRAL_DARK_THEME}"]`) &&
rule.selector.includes(".dropdown-glass"),
);
expect(restated).toHaveLength(0);
});

it("draws unchecked checkboxes as a flat hairline", () => {
const checkbox = ruleBodyFor(themeRules, [
...NEUTRAL_STAGE,
'[data-slot="checkbox"][data-unchecked]:not([aria-invalid="true"])',
]);
expect(checkbox).toContain("border-color: rgb(255 255 255 / 16%)");
expect(checkbox).toContain("background: transparent");
expect(checkbox).toContain("box-shadow: none");
const highlight = ruleBodyFor(themeRules, [
...NEUTRAL_STAGE,
'[data-slot="checkbox"][data-unchecked]:not([aria-invalid="true"])::before',
]);
expect(highlight).toContain("box-shadow: none");
});

it("does not leak Neutral Dark fills into light mode", () => {
const neutralHexes = [
"#1f1f1f",
"#2a2a2a",
"#2e2e2e",
"#383838",
"#3a3a3a",
"#3d3d3d",
"#3f3f3f",
"#414141",
"#2c2a27",
"#34322f",
"#383633",
"#403e3b",
"#3c3a37",
"#42403d",
"#44423f",
"#494744",
"#4b4946",
"#54524e",
"#5c5a57",
];
const lightRules = cssRules(theme).filter(
(rule) => rule.selector.includes(MARKER) && !rule.selector.includes(".dark"),
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/__fork_guards__/forkPopupSurface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe("fork guard: fork-popup-surface", () => {
const frosts = cssRules(theme).filter(
(candidate) =>
candidate.selector.includes(".dropdown-glass") &&
/backdrop-filter: blur\(\d+px\)/u.test(candidate.body),
candidate.body.includes("backdrop-filter: var(--fork-popup-filter)"),
);
const frost = frosts[0];

Expand All @@ -109,10 +109,11 @@ describe("fork guard: fork-popup-surface", () => {
expect(flat(fill?.body ?? "")).toContain(
`--fork-popup-fill: linear-gradient( ${WASH}, ${WASH} ), color-mix(in srgb, var(--popover) 45%, transparent);`,
);
expect(fill?.body).toContain(`--fork-popup-border: ${HAIRLINE};`);
expect(fill?.body).toContain("--fork-popup-filter: blur(28px) saturate(1.4);");
expect(frost?.body).toMatch(/background:\s*var\(--fork-popup-fill\);/u);
expect(frost?.body).toContain(`border-color: ${HAIRLINE};`);
expect(frost?.body).toContain("backdrop-filter: blur(28px) saturate(1.4);");
expect(frost?.body).not.toContain("backdrop-filter: none");
expect(frost?.body).toContain("border-color: var(--fork-popup-border);");
expect(frost?.body).toContain("-webkit-backdrop-filter: var(--fork-popup-filter);");
// No fork rule paints the popups with the opaque composer-stack fill.
// Only Glass may use the vessel token, where it is a 6% wash.
for (const sheet of [theme, palettes]) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/__fork_guards__/forkUsagePopover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe("fork guard: fork-usage-popover", () => {
const frost = cssRules(theme).find(
(rule) =>
rule.selector.includes('[data-slot="dialog-popup"]') &&
/backdrop-filter: blur\(\d+px\)/u.test(rule.body),
rule.body.includes("backdrop-filter: var(--fork-popup-filter)"),
);
expect(frost?.selector).toContain('[data-slot="popover-popup"]');
expect(frost?.selector).toContain(".dropdown-glass");
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/custom/forkTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const COOL_DARKER_BACKGROUND = "#141618";
export const NEUTRAL_DARK_THEME = "neutral-dark" as const;
export const NEUTRAL_DARK_LABEL = "Neutral Dark";
/** Pre-paint / overscroll colour for Neutral Dark — matches stage `--background`. */
export const NEUTRAL_DARK_BACKGROUND = "#1f1f1f";
export const NEUTRAL_DARK_BACKGROUND = "#2c2a27";

export const NEUTRAL_DARKER_THEME = "neutral-darker" as const;
export const NEUTRAL_DARKER_LABEL = "Neutral Darker";
Expand Down
19 changes: 12 additions & 7 deletions apps/web/src/theme.custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -2855,16 +2855,21 @@
paints --popover with !important where backdrop-filter is unsupported,
which outranks anything declared here.

The fill is named once, --fork-popup-fill, so a surface that should read
as the popup material without the frost (the Questions card above the
composer, fork-pending-user-input) wears the same recipe. */
The fill, hairline and filter are named once (--fork-popup-fill,
--fork-popup-border, --fork-popup-filter), so a palette restyles every
arm by overriding the tokens rather than restating the selector, and a
surface that should read as the popup material without the frost (the
Questions card above the composer, fork-pending-user-input) wears the
same fill. */
:root[data-fork="noahhendrickson-t3code"].dark {
--fork-popup-fill:
linear-gradient(
color-mix(in srgb, var(--contrast-foreground) 8%, transparent),
color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
),
color-mix(in srgb, var(--popover) 45%, transparent);
--fork-popup-border: color-mix(in srgb, var(--contrast-foreground) 12%, transparent);
--fork-popup-filter: blur(28px) saturate(1.4);
}

:root[data-fork="noahhendrickson-t3code"].dark
Expand All @@ -2881,9 +2886,9 @@
[data-thread-details-card].dropdown-glass
) {
background: var(--fork-popup-fill);
border-color: color-mix(in srgb, var(--contrast-foreground) 12%, transparent);
-webkit-backdrop-filter: blur(28px) saturate(1.4);
backdrop-filter: blur(28px) saturate(1.4);
border-color: var(--fork-popup-border);
-webkit-backdrop-filter: var(--fork-popup-filter);
backdrop-filter: var(--fork-popup-filter);
}

/* ── Thread details panel (fork-popup-surface) ──────────────────────────────
Expand Down Expand Up @@ -3267,7 +3272,7 @@

/* The user's sent bubble takes the same fill as the card above it. Upstream
paints `bg-message` → --message-surface → --accent; on every fork palette
that is the secondary/hover step (cool #353a3d, neutral #383838), one rung
that is the secondary/hover step (cool #353a3d, neutral dark #42403d), one rung
brighter than the changed-files card, so the two surfaces in the same
column disagreed.

Expand Down
Loading
Loading