S3.1: pixel design language: palette with day-part and season rotation, Fusion Pixel font on whole screen pixels, dither ground, 9-slice dialogs, pixel buttons, menu arrow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 12:07:31 -04:00
1 parent d422b2ce69
commit 86dbb4efa1
24 files changed
+777 -145

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ export const organizationContracts = {
goalTitle: z.string(), goalDescription: z.string(), providerKey: z.string(), providerSecret: z.string(), providerModel: z.string(),
}) satisfies z.ZodType<C.BootstrapOnboardingInput>,
setActiveOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType<C.SetActiveOrganizationInput>,
setUiPreferences: z.strictObject({ theme: z.enum(["system", "light", "dark"]).optional(), language: z.enum(["en", "zh"]).optional() }) satisfies z.ZodType<C.SetUiPreferencesInput>,
setUiPreferences: z.strictObject({ theme: z.enum(["system", "light", "dark", "clock"]).optional(), language: z.enum(["en", "zh"]).optional() }) satisfies z.ZodType<C.SetUiPreferencesInput>,
deleteOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType<C.DeleteOrganizationInput>,
createOrganization: z.strictObject({
organizationName: z.string(), description: z.string().optional(), mission: z.string(), goal: z.string(), employeeName: z.string(),
+1 -1
View File
@@ -10,7 +10,7 @@ export function setAutonomousPaused(db: DatabaseSync, paused: boolean): void {
}
/** The app-wide theme and language, kept for every company. */
export function updateUiPreferences(db: DatabaseSync, input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void {
export function updateUiPreferences(db: DatabaseSync, input: { theme?: "system" | "light" | "dark" | "clock"; language?: "en" | "zh" }): void {
const current = db.prepare("select theme, language from app_state where id = 1")
.get() as { theme: string; language: string } | undefined;
const theme = input.theme ?? current?.theme ?? "system";
+1 -1
View File
@@ -67,7 +67,7 @@ export class OrganizationService {
setAutonomousPaused(this.db, paused);
}
updateUiPreferences(input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void {
updateUiPreferences(input: { theme?: "system" | "light" | "dark" | "clock"; language?: "en" | "zh" }): void {
updateUiPreferences(this.db, input);
}
+5 -13
View File
@@ -1,3 +1,5 @@
import { usePaletteClock } from "../shared/palette-clock";
import { usePixelScale } from "../shared/pixel-scale";
import { Modal } from "../shared/Modal";
import { StaleBanner } from "./StaleBanner";
import { oneOf, readViewState, seedViewState, writeViewState } from "../shared/view-state";
@@ -8,7 +10,7 @@ import { hasPageUnsaved } from "../shared/unsaved";
import { ErrorBoundary } from "../shared/ErrorBoundary";
import { StartupScreen } from "./StartupScreen";
import type { Focus } from "../pages/types";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Shell } from "../shared/Shell";
import { ROUTE_IDS, type RouteId } from "../shared/routes";
import { AvatarDirectoryContext, type AvatarLook } from "../shared/ui";
@@ -67,18 +69,8 @@ export function App() {
}, [snapshot?.agents, snapshot?.employees, snapshot?.activeOrgId]);
const [creatingOrg, setCreatingOrg] = useState(false);
const theme = snapshot?.uiPreferences.theme ?? "system";
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const resolved = theme === "system" ? (media.matches ? "dark" : "light") : theme;
document.documentElement.dataset.theme = resolved;
};
apply();
if (theme !== "system") return;
media.addEventListener("change", apply);
return () => media.removeEventListener("change", apply);
}, [theme]);
usePaletteClock(snapshot?.uiPreferences.theme ?? "system");
usePixelScale();
if (!snapshot) {
// A failed manual retry is already recorded in syncFailure, which the screen shows.
Binary file not shown.
@@ -0,0 +1,96 @@
Fusion Pixel Font
https://github.com/TakWolf/fusion-pixel-font
Copyright (c) 2022, TakWolf (https://takwolf.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,96 @@
Ark Pixel Font
https://github.com/TakWolf/ark-pixel-font
Copyright (c) 2021, TakWolf (https://takwolf.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,108 @@
[Cubic 11]
These fonts are free software.
Unlimited permission is granted to use, copy, and distribute them, with or without modification, either commercially or noncommercially.
THESE FONTS ARE PROVIDED "AS IS" WITHOUT WARRANTY.
此字型是免費的。
無論您是否進行對本字型進行商業或非商業性修改,均可無限制地使用,複製和分發它們。
本字型的衍生品之授權必須與此字型相同,且不作任何擔保。
[JF Dot M+H 12]
Copyright(c) 2005 M+ FONTS PROJECT
[M+ BITMAP FONTS]
Copyright (C) 2002-2004 COZ
These fonts are free software.
Unlimited permission is granted to use, copy, and distribute it, with or without modification, either commercially and noncommercially.
THESE FONTS ARE PROVIDED "AS IS" WITHOUT WARRANTY.
これらのフォントはフリー(自由な)ソフトウエアです。
あらゆる改変の有無に関わらず、また商業的な利用であっても、自由にご利用、複製、再配布することができますが、全て無保証とさせていただきます。
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright (c) 2019–2025 Lee Minseo (quiple@quiple.dev)
This font software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1
View File
@@ -601,6 +601,7 @@ export const zh: Record<string, string> = {
"Version (semver)": "版本(semver)",
"Visibility": "可见性",
"Needs you": "需要你处理",
"Follow the clock": "跟随时间(夜间变暗)",
"Next in line": "下一项工作",
"Stopped: {reason}": "已停下:{reason}",
"{n} more after these": "之后还有 {n} 项",
+1
View File
@@ -7,6 +7,7 @@ import "./styles/fonts.css";
import "./styles.css";
import "./styles/components.css";
import "./overlays.css";
import "./styles/pixel.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
+2 -6
View File
@@ -12,10 +12,6 @@
width: min(520px, 100%);
max-height: min(760px, 100%);
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--r-xl);
background: var(--panel);
box-shadow: 0 20px 60px rgba(24, 24, 24, 0.18);
padding: var(--sp-5);
animation: overlay-enter var(--motion-base) var(--ease-out-expo);
}
@@ -27,7 +23,7 @@
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.modal-title { margin: 0 0 var(--sp-4); font-size: var(--fs-title); font-weight: 600; letter-spacing: -0.01em; }
.modal-title { margin: 0 0 var(--sp-4); font-size: var(--fs-title); font-weight: 600; letter-spacing: 0; }
.modal-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-5); }
.onboarding-window {
@@ -146,7 +142,7 @@
padding: var(--sp-2) var(--sp-2) var(--sp-1);
font-size: var(--fs-caption);
font-weight: 540;
letter-spacing: 0.01em;
letter-spacing: 0;
color: var(--text-3);
}
.menu-row {
@@ -100,6 +100,7 @@ export function WorkspacePanel({
<option value="system">{t("System")}</option>
<option value="light">{t("Light")}</option>
<option value="dark">{t("Dark")}</option>
<option value="clock">{t("Follow the clock")}</option>
</select>
</Field>
<LanguageField uiPreferences={uiPreferences} actions={actions} />
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import type { UiTheme } from "../../../shared/snapshot-domain";
export type DayPart = "dawn" | "day" | "dusk" | "night";
export type Season = "spring" | "summer" | "autumn" | "winter";
/** Local-hour bands the palette rotates through. */
export function dayPart(now: Date): DayPart {
const hour = now.getHours();
if (hour >= 5 && hour < 8) return "dawn";
if (hour >= 8 && hour < 17) return "day";
if (hour >= 17 && hour < 20) return "dusk";
return "night";
}
/** Meteorological seasons by month, northern hemisphere. */
export function season(now: Date): Season {
const month = now.getMonth();
if (month >= 2 && month <= 4) return "spring";
if (month >= 5 && month <= 7) return "summer";
if (month >= 8 && month <= 10) return "autumn";
return "winter";
}
export function resolveTheme(theme: UiTheme, prefersDark: boolean, now: Date): "light" | "dark" {
if (theme === "system") return prefersDark ? "dark" : "light";
if (theme === "clock") return dayPart(now) === "night" ? "dark" : "light";
return theme;
}
const TICK_MS = 60_000;
/** Applies the theme, day part and season to the document root, rechecked every minute and whenever the OS scheme changes. */
export function usePaletteClock(theme: UiTheme): void {
const [now, setNow] = useState(() => new Date());
const [prefersDark, setPrefersDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches);
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), TICK_MS);
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onScheme = () => setPrefersDark(media.matches);
media.addEventListener("change", onScheme);
return () => {
window.clearInterval(timer);
media.removeEventListener("change", onScheme);
};
}, []);
const resolved = resolveTheme(theme, prefersDark, now);
const part = dayPart(now);
const time = season(now);
useEffect(() => {
Object.assign(document.documentElement.dataset, { theme: resolved, daypart: part, season: time });
}, [resolved, part, time]);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
/** CSS px per font pixel so each one lands on a whole number of screen pixels: the device ratio rounded to the nearest integer (at least 1), over the ratio. */
export function pixelScale(devicePixelRatio: number): number {
return Math.max(1, Math.round(devicePixelRatio)) / devicePixelRatio;
}
/** Keeps --px on the root in step with the display, including when the window moves to another screen. */
export function usePixelScale(): void {
const [ratio, setRatio] = useState(() => window.devicePixelRatio);
useEffect(() => {
const media = window.matchMedia(`(resolution: ${ratio}dppx)`);
const onChange = () => setRatio(window.devicePixelRatio);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [ratio]);
useEffect(() => {
document.documentElement.style.setProperty("--px", String(pixelScale(ratio)));
}, [ratio]);
}
+90 -94
View File
@@ -1,38 +1,34 @@
/* Pixel design language: a Sweetie 16 based palette (GrafxKid), square corners, hard 2px shadows, and type at the pixel font's native 12px or 2x. */
:root {
/* Surfaces — pure-neutral gray ramp anchored on #181818 */
--bg: #fafafa;
--panel: #ffffff;
--panel-2: #f6f6f6;
--panel-3: #efefef;
--hover: #f4f4f4;
--border: #eaeaea;
--border-strong: #e0e0e0;
--border-soft: #f0f0f0;
--bg: #dfe6ee;
--panel: #f4f4f4;
--panel-2: #e9eef3;
--panel-3: #d5dde6;
--hover: #e3e9ef;
--border: #94b0c2;
--border-strong: #333c57;
--border-soft: #c9d5df;
/* Text — Primary #181818, Secondary #6A6A6A (+ derived muted/faint) */
--text-1: #181818;
--text-2: #6a6a6a;
--text-3: #9a9a9a;
--text-4: #c4c4c4;
--text-1: #1a1c2c;
--text-2: #333c57;
--text-3: #566c86;
--text-4: #94b0c2;
/* Accent + status */
--accent: #5e6ad2;
--accent-soft: #eef0ff;
--green: #2f9e58;
--green-soft: #e8f5ec;
--amber: #c87d12;
--amber-soft: #fbf3e2;
--red: #d23b3b;
--accent: #3b5dc9;
--accent-soft: #c8d4f5;
--green: #257a45;
--green-soft: #d4f2dc;
--amber: #a85a1d;
--amber-soft: #fbe6c4;
--red: #b13e53;
/* Radius — 8 → 40 scale */
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-xl: 20px;
--r-2xl: 28px;
--r-full: 999px;
--r-sm: 0;
--r-md: 0;
--r-lg: 0;
--r-xl: 0;
--r-2xl: 0;
--r-full: 0;
/* Spacing — strict 4px grid */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
@@ -43,63 +39,62 @@
--sp-8: 32px;
--sp-10: 40px;
/* Type scale — Geist; body 14/16, title 18, display 24 */
--fs-caption: 12px;
--fs-body: 14px;
--fs-body-lg: 16px;
--fs-title: 18px;
--fs-display: 24px;
/* The pixel font is drawn on a 12px grid: text is 1x, headings 2x; --px (shared/pixel-scale.ts) keeps each font pixel on whole screen pixels. */
--px: 1;
--fs-caption: calc(12px * var(--px));
--fs-body: calc(12px * var(--px));
--fs-body-lg: calc(12px * var(--px));
--fs-title: calc(12px * var(--px));
--fs-display: calc(24px * var(--px));
/* Icons — 18px glyph, 1.5 stroke, bound in a 24px box */
--icon-box: 24px;
--shadow-sm: 0 1px 0 rgba(24, 24, 24, 0.03), 0 1px 2px rgba(24, 24, 24, 0.05);
--shadow-md: 0 1px 3px rgba(24, 24, 24, 0.06), 0 6px 24px rgba(24, 24, 24, 0.05);
--font-sans: "Geist", -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
--shadow-sm: 2px 2px 0 0 var(--border-soft);
--shadow-md: 4px 4px 0 0 rgba(26, 28, 44, 0.35);
--font-sans: "Fusion Pixel", monospace;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
--font-display: "Geist", -apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif;
--font-display: "Fusion Pixel", monospace;
--motion-fast: 120ms;
--motion-base: 200ms;
--motion-slow: 280ms;
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out: steps(4, end);
--ease-out-expo: steps(6, end);
/* Primary action — inverts against the theme */
--btn-primary-bg: var(--text-1);
--btn-primary-fg: #ffffff;
--btn-primary-hover: #2c2c2c;
--btn-primary-bg: var(--accent);
--btn-primary-fg: #f4f4f4;
--btn-primary-hover: #29366f;
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #131313;
--panel: #1b1b1b;
--panel-2: #212121;
--panel-3: #2a2a2a;
--hover: #232323;
--border: #2c2c2c;
--border-strong: #3a3a3a;
--border-soft: #242424;
--bg: #1a1c2c;
--panel: #262b44;
--panel-2: #2c3352;
--panel-3: #333c57;
--hover: #2f3756;
--border: #566c86;
--border-strong: #94b0c2;
--border-soft: #333c57;
--text-1: #ededed;
--text-2: #a5a5a5;
--text-3: #7c7c7c;
--text-4: #4f4f4f;
--text-1: #f4f4f4;
--text-2: #c2d0dc;
--text-3: #94b0c2;
--text-4: #566c86;
--accent: #7c88e8;
--accent-soft: #262a4a;
--green: #45b06d;
--green-soft: #16301f;
--amber: #d99a3d;
--amber-soft: #33270f;
--red: #e05252;
--accent: #41a6f6;
--accent-soft: #29366f;
--green: #a7f070;
--green-soft: #1f3a2a;
--amber: #ffcd75;
--amber-soft: #3d3220;
--red: #ef7d57;
--shadow-sm: 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 1px 3px rgba(0, 0, 0, 0.4), 0 6px 24px rgba(0, 0, 0, 0.35);
--shadow-sm: 2px 2px 0 0 #10111c;
--shadow-md: 4px 4px 0 0 rgba(0, 0, 0, 0.5);
--btn-primary-bg: #ededed;
--btn-primary-fg: #131313;
--btn-primary-hover: #ffffff;
--btn-primary-bg: var(--accent);
--btn-primary-fg: #1a1c2c;
--btn-primary-hover: #73eff7;
color-scheme: dark;
}
@@ -107,14 +102,15 @@
html, body, #root { width: 100%; height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
font-feature-settings: "cv01", "ss03";
color: var(--text-1);
background: var(--bg);
font-size: var(--fs-body);
line-height: 1.45;
letter-spacing: -0.006em;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
line-height: 1.5;
letter-spacing: 0;
font-synthesis: none;
-webkit-font-smoothing: none;
text-rendering: optimizeSpeed;
image-rendering: pixelated;
}
.icon-box {
width: var(--icon-box);
@@ -176,14 +172,14 @@ a { color: inherit; }
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 6px;
border-radius: 0;
display: grid;
place-items: center;
color: #fff;
background: linear-gradient(135deg, #1a1a1d 0%, #3a3a40 100%);
font-size: 11px;
font-size: var(--fs-body);
font-weight: 600;
letter-spacing: -0.02em;
letter-spacing: 0;
}
.brand-name {
flex: 1;
@@ -222,7 +218,7 @@ a { color: inherit; }
color: var(--text-3);
padding: var(--sp-3) var(--sp-2) var(--sp-2);
font-weight: 540;
letter-spacing: 0.01em;
letter-spacing: 0;
}
.nav-item {
display: flex;
@@ -279,7 +275,7 @@ a { color: inherit; }
color: var(--accent);
background: var(--accent-soft);
padding: 1px 6px;
border-radius: 999px;
border-radius: 0;
flex-shrink: 0;
}
@@ -328,7 +324,7 @@ a { color: inherit; }
font-size: var(--fs-caption);
color: var(--text-3);
font-weight: 540;
letter-spacing: 0.01em;
letter-spacing: 0;
padding: var(--sp-1) var(--sp-2);
}
.org-menu-row {
@@ -353,7 +349,7 @@ a { color: inherit; }
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
border-radius: 0;
cursor: pointer;
opacity: 0;
transition: opacity var(--motion-fast) var(--ease-out), background var(--motion-fast) var(--ease-out);
@@ -428,8 +424,8 @@ a { color: inherit; }
.brand-mark.sm {
width: 20px;
height: 20px;
font-size: 11px;
border-radius: 6px;
font-size: var(--fs-body);
border-radius: 0;
}
.sidebar-spacer { flex: 1; }
@@ -524,7 +520,7 @@ a { color: inherit; }
bottom: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
border-radius: 0;
border: 1.5px solid var(--bg);
}
.page { max-width: 1100px; }
@@ -555,7 +551,7 @@ a { color: inherit; }
font-family: var(--font-display);
font-size: var(--fs-title);
font-weight: 600;
letter-spacing: -0.014em;
letter-spacing: 0;
}
.page-sub {
margin: 0 0 var(--sp-6);
@@ -567,7 +563,7 @@ a { color: inherit; }
color: var(--text-3);
font-size: var(--fs-caption);
font-weight: 540;
letter-spacing: 0.01em;
letter-spacing: 0;
}
.row { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.col { display: flex; flex-direction: column; min-width: 0; }
@@ -606,10 +602,10 @@ a { color: inherit; }
width: 26px;
height: 24px;
border: 0;
border-radius: 4px;
border-radius: 0;
background: transparent;
color: var(--text-2);
font-size: 15px;
font-size: var(--fs-body);
line-height: 1;
transition: background var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out);
}
@@ -619,10 +615,10 @@ a { color: inherit; }
min-width: 46px;
height: 24px;
border: 0;
border-radius: 4px;
border-radius: 0;
background: transparent;
color: var(--text-2);
font-size: 12px;
font-size: var(--fs-body);
font-variant-numeric: tabular-nums;
}
.org-zoom-label:hover { background: var(--hover); color: var(--text-1); }
@@ -650,7 +646,7 @@ a { color: inherit; }
.org-node-name {
font-size: var(--fs-body);
font-weight: 560;
letter-spacing: -0.01em;
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -675,4 +671,4 @@ a { color: inherit; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* The object a link or search hit opened. */
[data-revealed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
[data-revealed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 0; }
+22 -21
View File
@@ -8,7 +8,7 @@
color: var(--text-3);
font-size: var(--fs-caption);
font-weight: 540;
letter-spacing: 0.01em;
letter-spacing: 0;
}
.button {
@@ -18,11 +18,11 @@
gap: var(--sp-2);
padding: 0 var(--sp-3);
border-radius: var(--r-sm);
border: 1px solid var(--border-strong);
border: 2px solid var(--border-strong);
box-shadow: 0 2px 0 0 var(--border-strong);
background: var(--panel);
color: var(--text-1);
font-size: var(--fs-body);
font-weight: 500;
white-space: nowrap;
transition:
background var(--motion-fast) var(--ease-out),
@@ -32,10 +32,11 @@
opacity var(--motion-fast) var(--ease-out);
}
.button:hover { background: var(--panel-2); }
.button:active:not(:disabled) { transform: scale(0.97); }
/* Pixel press: the 2px drop collapses. */
.button:active:not(:disabled) { transform: translateY(2px); box-shadow: none; }
.button.primary { background: var(--btn-primary-bg); color: var(--btn-primary-fg); border-color: var(--btn-primary-bg); }
.button.primary:hover { background: var(--btn-primary-hover); }
.button.ghost { border-color: transparent; background: transparent; color: var(--text-2); }
.button.ghost { border-color: transparent; box-shadow: none; background: transparent; color: var(--text-2); }
.button.ghost:hover { background: var(--hover); color: var(--text-1); }
.button.small { height: 28px; padding: 0 var(--sp-2); font-size: var(--fs-caption); }
.button:disabled { opacity: 0.45; cursor: default; }
@@ -105,7 +106,7 @@
transition: background var(--motion-base) var(--ease-out), color var(--motion-base) var(--ease-out), border-color var(--motion-base) var(--ease-out);
}
.pill .pill-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; transition: background var(--motion-base) var(--ease-out); }
.pill .dot { width: 6px; height: 6px; border-radius: 0; background: var(--text-3); flex-shrink: 0; transition: background var(--motion-base) var(--ease-out); }
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
.pill.amber .dot { background: var(--amber); }
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
@@ -121,21 +122,21 @@
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 50%;
border-radius: 0;
color: #fff;
font-size: var(--fs-caption);
font-weight: 600;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.avatar.bot { border-radius: var(--r-sm); }
.avatar.sm { width: 24px; height: 24px; font-size: 11px; }
.avatar.sm { width: 24px; height: 24px; font-size: var(--fs-body); }
.avatar.lg { width: 36px; height: 36px; font-size: var(--fs-body); }
.avatar.xl { width: 48px; height: 48px; font-size: var(--fs-body-lg); }
.avatar-stack { display: inline-flex; }
.avatar-stack > .avatar-ring { margin-left: -6px; border: 2px solid var(--panel); border-radius: 50%; }
.avatar-stack > .avatar-ring { margin-left: -6px; border: 2px solid var(--panel); border-radius: 0; }
.avatar-stack > .avatar-ring:first-child { margin-left: 0; }
.num { font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.num { font-variant-numeric: tabular-nums; letter-spacing: 0; }
.mono { font-family: var(--font-mono); }
.input {
@@ -183,7 +184,7 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
font-size: var(--fs-caption);
color: var(--text-3);
font-weight: 540;
letter-spacing: 0.01em;
letter-spacing: 0;
background: var(--panel-2);
border-bottom: 1px solid var(--border-soft);
}
@@ -236,7 +237,7 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border-radius: 0;
border: 2px solid var(--border-strong);
border-top-color: var(--text-2);
animation: spinner-rotate 0.7s linear infinite;
@@ -248,7 +249,7 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.avatar-editor { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.avatar-emoji-input { width: 72px; text-align: center; }
.avatar-swatches { display: flex; gap: 6px; align-items: center; }
.avatar-swatch { width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; }
.avatar-swatch { width: 18px; height: 18px; border-radius: 0; border: 2px solid transparent; cursor: pointer; padding: 0; }
.avatar-swatch.selected { border-color: var(--accent); }
.avatar-swatch.auto { background: conic-gradient(#5e6ad2, #e8b339, #26b69a, #5e6ad2); }
.sidebar-foot-button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: none; padding: 0; cursor: pointer; color: inherit; font: inherit; text-align: left; border-radius: var(--r-sm); }
@@ -266,10 +267,10 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
padding: 6px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
border-radius: 0;
box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18);
}
.office-legend { align-self: center; padding: 0 6px; font-size: 12px; color: var(--text-2); }
.office-legend { align-self: center; padding: 0 6px; font-size: var(--fs-body); color: var(--text-2); }
/* Floating "jump to latest" pill above the chat composer */
.jump-latest {
@@ -281,12 +282,12 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border-radius: 0;
border: 1px solid var(--border-strong);
background: var(--panel);
color: var(--text-1);
font: inherit;
font-size: 12px;
font-size: var(--fs-body);
font-weight: 540;
cursor: pointer;
box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18);
@@ -321,10 +322,10 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.md-body p { margin: 0 0 6px; white-space: pre-wrap; }
.md-body ul, .md-body ol { margin: 0 0 6px; padding-left: 22px; }
.md-body li > ul, .md-body li > ol { margin: 2px 0; }
.md-body h1, .md-body h2, .md-body h3 { font-size: 15px; font-weight: 600; margin: 8px 0 4px; }
.md-body h1, .md-body h2, .md-body h3 { font-size: var(--fs-body); font-weight: 600; margin: 8px 0 4px; }
.md-body blockquote { margin: 0 0 6px; padding-left: 10px; border-left: 3px solid var(--border-strong); color: var(--text-2); }
.md-body code { font-family: var(--font-mono); background: var(--panel-3); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
.md-body pre { margin: 0 0 6px; padding: 10px 12px; background: var(--panel-3); border-radius: 8px; overflow-x: auto; }
.md-body code { font-family: var(--font-mono); background: var(--panel-3); padding: 1px 5px; border-radius: 0; font-size: 0.92em; }
.md-body pre { margin: 0 0 6px; padding: 10px 12px; background: var(--panel-3); border-radius: 0; overflow-x: auto; }
.md-body pre code { background: transparent; padding: 0; white-space: pre; }
.md-body table { border-collapse: collapse; margin: 0 0 6px; display: block; overflow-x: auto; max-width: 100%; }
.md-body th, .md-body td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
+5 -7
View File
@@ -1,12 +1,10 @@
/* Geist is vendored locally (variable woff2) so the desktop build stays
offline and CSP-clean (font-src 'self'). One file per family covers every
weight via the variable axis. */
/* Fonts are vendored so the build stays offline and CSP-clean (font-src 'self'). Fusion Pixel 12px (OFL, see fonts/fusion-pixel-licenses) covers Latin and Simplified Chinese. */
@font-face {
font-family: "Geist";
font-family: "Fusion Pixel";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/Geist-Variable.woff2") format("woff2");
font-weight: 400;
font-display: block;
src: url("../fonts/FusionPixel-12px-zh_hans.woff2") format("woff2");
}
@font-face {
font-family: "Geist Mono";
+58
View File
@@ -0,0 +1,58 @@
/* Palette rotation: the day part tints the ground, the season sets the accent (shared/palette-clock.ts sets both on the root). */
:root[data-theme="light"][data-daypart="dawn"] { --bg: #ebe1ea; --panel-2: #f1e9ef; }
:root[data-theme="light"][data-daypart="dusk"] { --bg: #f0e0cf; --panel-2: #f6ebdf; }
:root[data-theme="light"][data-daypart="night"] { --bg: #cfd7e4; --panel-2: #e1e7f0; }
:root[data-theme="dark"][data-daypart="dawn"] { --bg: #221c30; --panel: #2c2540; }
:root[data-theme="dark"][data-daypart="day"] { --bg: #1c2236; --panel: #27304a; }
:root[data-theme="dark"][data-daypart="dusk"] { --bg: #2a1c2a; --panel: #34243a; }
:root[data-theme="light"][data-season="spring"] { --accent: #b03a6e; --accent-soft: #f6d3e3; --btn-primary-hover: #7a2450; }
:root[data-theme="light"][data-season="autumn"] { --accent: #b4501d; --accent-soft: #f7d9c4; --btn-primary-hover: #7d3713; }
:root[data-theme="light"][data-season="winter"] { --accent: #257179; --accent-soft: #c7e8ea; --btn-primary-hover: #174a50; }
:root[data-theme="dark"][data-season="spring"] { --accent: #f49ac1; --accent-soft: #4a2238; --btn-primary-hover: #ffc4dd; }
:root[data-theme="dark"][data-season="autumn"] { --accent: #ef7d57; --accent-soft: #4a2a1c; --btn-primary-hover: #ffcd75; }
:root[data-theme="dark"][data-season="winter"] { --accent: #73eff7; --accent-soft: #1d4048; --btn-primary-hover: #c9fbff; }
/* 9-slice frame: an 8px pixel tile (outline, top-left light, bottom-right shade, fill) scaled 2x; corners are notched. */
:root {
--px-frame: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' shape-rendering='crispEdges'%3E%3Cpath fill='%23333c57' d='M1 0h6v1H1zM1 7h6v1H1zM0 1h1v6H0zM7 1h1v6H7z'/%3E%3Cpath fill='%23ffffff' d='M1 1h6v1H1zM1 2h1v5H1z'/%3E%3Cpath fill='%2394b0c2' d='M2 6h5v1H2zM6 2h1v4H6z'/%3E%3Cpath fill='%23f4f4f4' d='M2 2h4v4H2z'/%3E%3C/svg%3E");
--px-dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2' shape-rendering='crispEdges'%3E%3Cpath fill='%231a1c2c' fill-opacity='0.07' d='M0 0h1v1H0zM1 1h1v1H1z'/%3E%3C/svg%3E");
}
:root[data-theme="dark"] {
--px-frame: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' shape-rendering='crispEdges'%3E%3Cpath fill='%2394b0c2' d='M1 0h6v1H1zM1 7h6v1H1zM0 1h1v6H0zM7 1h1v6H7z'/%3E%3Cpath fill='%23566c86' d='M1 1h6v1H1zM1 2h1v5H1z'/%3E%3Cpath fill='%2310111c' d='M2 6h5v1H2zM6 2h1v4H6z'/%3E%3Cpath fill='%23262b44' d='M2 2h4v4H2z'/%3E%3C/svg%3E");
--px-dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2' shape-rendering='crispEdges'%3E%3Cpath fill='%23f4f4f4' fill-opacity='0.05' d='M0 0h1v1H0zM1 1h1v1H1z'/%3E%3C/svg%3E");
}
/* Ground: a faint 2x2 checker scaled to 4px cells, on every layer that paints the ground colour. */
.app-shell, .sidebar, .main {
background-image: var(--px-dither);
background-size: 4px 4px;
}
/* Dialogs are framed by the 9-slice tile at 2x. */
.modal {
border: 6px solid transparent;
border-image: var(--px-frame) 3 fill / 6px stretch;
background: none;
box-shadow: var(--shadow-md);
}
/* Menu arrow: the chosen or pointed-at nav row shows a blinking right-pointing pixel arrow. */
.nav > .nav-item { position: relative; padding-left: 18px; }
.nav > .nav-item::before {
content: "";
position: absolute;
left: 4px;
top: 50%;
width: 6px;
height: 10px;
margin-top: -5px;
background: currentColor;
clip-path: polygon(0 0, 34% 0, 34% 20%, 67% 20%, 67% 40%, 100% 40%, 100% 60%, 67% 60%, 67% 80%, 34% 80%, 34% 100%, 0 100%);
visibility: hidden;
}
.nav > .nav-item:is(:hover, :focus-visible, [aria-current], .active)::before {
visibility: visible;
animation: px-blink 0.8s steps(1, end) infinite;
}
@keyframes px-blink { 50% { visibility: hidden; } }
+2 -1
View File
@@ -54,7 +54,8 @@ import type {
SocialAuditRecord,
} from "./social-domain";
export type UiTheme = "system" | "light" | "dark";
/** "clock" turns dark at night and light by day. */
export type UiTheme = "system" | "light" | "dark" | "clock";
export type UiLanguage = "en" | "zh";
export interface UiPreferences {
+72
View File
@@ -0,0 +1,72 @@
import { _electron as electron, expect, test } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
import { closeApp, onboard } from "./onboard";
test("text uses the bundled pixel font, and the palette follows the day part, season and clock theme", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-pixel-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-pixel-builtin-${randomUUID()}`));
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir },
});
try {
const page = await app.firstWindow();
await onboard(page, "Pixel Co");
// The font file itself loaded, not a fallback.
expect(await page.evaluate(async () => {
await document.fonts.load('12px "Fusion Pixel"', "像素 Pixel");
return [...document.fonts].some((face) => face.family.replace(/"/g, "") === "Fusion Pixel" && face.status === "loaded");
})).toBe(true);
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toMatch(/^"?Fusion Pixel/);
const expected = await page.evaluate(() => {
const now = new Date();
const hour = now.getHours();
const month = now.getMonth();
const part = hour >= 5 && hour < 8 ? "dawn" : hour >= 8 && hour < 17 ? "day" : hour >= 17 && hour < 20 ? "dusk" : "night";
const season = month >= 2 && month <= 4 ? "spring" : month >= 5 && month <= 7 ? "summer" : month >= 8 && month <= 10 ? "autumn" : "winter";
return { part, season };
});
const root = page.locator("html");
await expect(root).toHaveAttribute("data-daypart", expected.part);
await expect(root).toHaveAttribute("data-season", expected.season);
// The ground carries the dither; the chosen nav row blinks the pixel menu arrow, other rows show none; dialogs sit in the 9-slice frame.
expect(await page.evaluate(() => getComputedStyle(document.querySelector(".main")!).backgroundImage)).toContain("data:image/svg+xml");
expect(await page.evaluate(() => {
const rows = [...document.querySelectorAll(".nav > .nav-item")];
const active = rows.find((row) => row.matches(".active, [aria-current]"))!;
const idle = rows.find((row) => !row.matches(".active, [aria-current]"))!;
return [getComputedStyle(active, "::before").animationName, getComputedStyle(idle, "::before").animationName, getComputedStyle(idle, "::before").visibility];
})).toEqual(["px-blink", "none", "hidden"]);
await page.getByRole("button", { name: "New task" }).click();
const dialog = page.getByRole("dialog", { name: "New task" });
await expect(dialog).toBeVisible();
expect(await dialog.evaluate((node) => {
const style = getComputedStyle(node.closest(".modal") ?? node.querySelector(".modal")!);
return [style.borderImageSource.startsWith('url("data:image/svg+xml'), style.borderImageSlice, style.borderTopWidth];
})).toEqual([true, "3 fill", "6px"]);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("combobox", { name: "Theme" }).selectOption("dark");
await expect(root).toHaveAttribute("data-theme", "dark");
await page.getByRole("combobox", { name: "Theme" }).selectOption({ label: "Follow the clock" });
await expect(root).toHaveAttribute("data-theme", expected.part === "night" ? "dark" : "light");
// Each font pixel covers a whole number of screen pixels at this display's scale.
expect(await page.evaluate(() => {
const fontPx = parseFloat(getComputedStyle(document.body).fontSize) * window.devicePixelRatio;
return Math.abs(fontPx - Math.round(fontPx)) < 1e-6 && Math.round(fontPx) % 12 === 0;
})).toBe(true);
} finally {
await closeApp(app);
}
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "vitest";
import { dayPart, resolveTheme, season } from "../../src/renderer/src/shared/palette-clock";
const at = (month: number, hour: number, minute = 0) => new Date(2026, month - 1, 15, hour, minute);
test("the day turns dawn, day, dusk and night at fixed local hours", () => {
expect(dayPart(at(6, 4, 59))).toBe("night");
expect(dayPart(at(6, 5))).toBe("dawn");
expect(dayPart(at(6, 7, 59))).toBe("dawn");
expect(dayPart(at(6, 8))).toBe("day");
expect(dayPart(at(6, 16, 59))).toBe("day");
expect(dayPart(at(6, 17))).toBe("dusk");
expect(dayPart(at(6, 19, 59))).toBe("dusk");
expect(dayPart(at(6, 20))).toBe("night");
expect(dayPart(at(6, 0))).toBe("night");
});
test("seasons follow the months", () => {
expect([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((month) => season(at(month, 12)))).toEqual([
"winter", "winter", "spring", "spring", "spring", "summer", "summer", "summer", "autumn", "autumn", "autumn", "winter",
]);
});
test("light and dark are kept as chosen; system follows the OS; the clock goes dark only at night", () => {
expect(resolveTheme("light", true, at(1, 23))).toBe("light");
expect(resolveTheme("dark", false, at(1, 12))).toBe("dark");
expect(resolveTheme("system", true, at(1, 12))).toBe("dark");
expect(resolveTheme("system", false, at(1, 23))).toBe("light");
expect(resolveTheme("clock", true, at(1, 12))).toBe("light");
expect(resolveTheme("clock", false, at(1, 18))).toBe("light");
expect(resolveTheme("clock", false, at(1, 21))).toBe("dark");
expect(resolveTheme("clock", true, at(1, 4))).toBe("dark");
});
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from "vitest";
import { pixelScale } from "../../src/renderer/src/shared/pixel-scale";
test("one font pixel always covers a whole number of screen pixels, never fewer than one", () => {
expect(pixelScale(1)).toBe(1);
expect(pixelScale(2)).toBe(1);
expect(pixelScale(1.5)).toBeCloseTo(4 / 3);
expect(pixelScale(1.25)).toBeCloseTo(0.8);
expect(pixelScale(1.75)).toBeCloseTo(8 / 7);
expect(pixelScale(0.9)).toBeCloseTo(1 / 0.9);
for (const ratio of [1, 1.25, 1.5, 1.75, 2, 2.5, 3]) {
const device = 12 * pixelScale(ratio) * ratio;
expect(Math.abs(device - Math.round(device))).toBeLessThan(1e-9);
expect(Math.round(device) % 12).toBe(0);
}
});