diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index 8b3ac25f..0ac28326 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -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, setActiveOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType, - setUiPreferences: z.strictObject({ theme: z.enum(["system", "light", "dark"]).optional(), language: z.enum(["en", "zh"]).optional() }) satisfies z.ZodType, + setUiPreferences: z.strictObject({ theme: z.enum(["system", "light", "dark", "clock"]).optional(), language: z.enum(["en", "zh"]).optional() }) satisfies z.ZodType, deleteOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType, createOrganization: z.strictObject({ organizationName: z.string(), description: z.string().optional(), mission: z.string(), goal: z.string(), employeeName: z.string(), diff --git a/src/main/services/app-state.ts b/src/main/services/app-state.ts index b25d76e4..aab1418e 100644 --- a/src/main/services/app-state.ts +++ b/src/main/services/app-state.ts @@ -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"; diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 356beee6..6c0d26ae 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -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); } diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index a48450c7..1b7e5b9e 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -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. diff --git a/src/renderer/src/fonts/FusionPixel-12px-zh_hans.woff2 b/src/renderer/src/fonts/FusionPixel-12px-zh_hans.woff2 new file mode 100644 index 00000000..103102d7 Binary files /dev/null and b/src/renderer/src/fonts/FusionPixel-12px-zh_hans.woff2 differ diff --git a/src/renderer/src/fonts/Geist-Variable.woff2 b/src/renderer/src/fonts/Geist-Variable.woff2 deleted file mode 100644 index 9983e92a..00000000 Binary files a/src/renderer/src/fonts/Geist-Variable.woff2 and /dev/null differ diff --git a/src/renderer/src/fonts/fusion-pixel-licenses/OFL.txt b/src/renderer/src/fonts/fusion-pixel-licenses/OFL.txt new file mode 100644 index 00000000..0427d1db --- /dev/null +++ b/src/renderer/src/fonts/fusion-pixel-licenses/OFL.txt @@ -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. diff --git a/src/renderer/src/fonts/fusion-pixel-licenses/ark-pixel/OFL.txt b/src/renderer/src/fonts/fusion-pixel-licenses/ark-pixel/OFL.txt new file mode 100644 index 00000000..264873a2 --- /dev/null +++ b/src/renderer/src/fonts/fusion-pixel-licenses/ark-pixel/OFL.txt @@ -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. diff --git a/src/renderer/src/fonts/fusion-pixel-licenses/cubic-11/OFL.txt b/src/renderer/src/fonts/fusion-pixel-licenses/cubic-11/OFL.txt new file mode 100644 index 00000000..4e863313 --- /dev/null +++ b/src/renderer/src/fonts/fusion-pixel-licenses/cubic-11/OFL.txt @@ -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. diff --git a/src/renderer/src/fonts/fusion-pixel-licenses/galmuri/LICENSE.txt b/src/renderer/src/fonts/fusion-pixel-licenses/galmuri/LICENSE.txt new file mode 100644 index 00000000..ae34182f --- /dev/null +++ b/src/renderer/src/fonts/fusion-pixel-licenses/galmuri/LICENSE.txt @@ -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. diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 3032fb2c..d70069bb 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -601,6 +601,7 @@ export const zh: Record = { "Version (semver)": "版本(semver)", "Visibility": "可见性", "Needs you": "需要你处理", + "Follow the clock": "跟随时间(夜间变暗)", "Next in line": "下一项工作", "Stopped: {reason}": "已停下:{reason}", "{n} more after these": "之后还有 {n} 项", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index f6e0a60e..91de1c39 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( diff --git a/src/renderer/src/overlays.css b/src/renderer/src/overlays.css index c97ae21b..699a3246 100644 --- a/src/renderer/src/overlays.css +++ b/src/renderer/src/overlays.css @@ -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 { diff --git a/src/renderer/src/pages/settings/WorkspacePanel.tsx b/src/renderer/src/pages/settings/WorkspacePanel.tsx index 61d427de..860e3c04 100644 --- a/src/renderer/src/pages/settings/WorkspacePanel.tsx +++ b/src/renderer/src/pages/settings/WorkspacePanel.tsx @@ -100,6 +100,7 @@ export function WorkspacePanel({ + diff --git a/src/renderer/src/shared/palette-clock.ts b/src/renderer/src/shared/palette-clock.ts new file mode 100644 index 00000000..3ab0d3ad --- /dev/null +++ b/src/renderer/src/shared/palette-clock.ts @@ -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]); +} diff --git a/src/renderer/src/shared/pixel-scale.ts b/src/renderer/src/shared/pixel-scale.ts new file mode 100644 index 00000000..5cbc32bd --- /dev/null +++ b/src/renderer/src/shared/pixel-scale.ts @@ -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]); +} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index c0329647..e2418206 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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; } diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index 00beea1b..72df7bc4 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -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; } diff --git a/src/renderer/src/styles/fonts.css b/src/renderer/src/styles/fonts.css index f6bc8800..af12ed0b 100644 --- a/src/renderer/src/styles/fonts.css +++ b/src/renderer/src/styles/fonts.css @@ -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"; diff --git a/src/renderer/src/styles/pixel.css b/src/renderer/src/styles/pixel.css new file mode 100644 index 00000000..1f7303ab --- /dev/null +++ b/src/renderer/src/styles/pixel.css @@ -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; } } diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index 6447cf99..73344613 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -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 { diff --git a/tests/e2e/pixel-style.spec.ts b/tests/e2e/pixel-style.spec.ts new file mode 100644 index 00000000..56e1795e --- /dev/null +++ b/tests/e2e/pixel-style.spec.ts @@ -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); + } +}); diff --git a/tests/unit/palette-clock.test.ts b/tests/unit/palette-clock.test.ts new file mode 100644 index 00000000..b29a123f --- /dev/null +++ b/tests/unit/palette-clock.test.ts @@ -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"); +}); diff --git a/tests/unit/pixel-scale.test.ts b/tests/unit/pixel-scale.test.ts new file mode 100644 index 00000000..aba3aded --- /dev/null +++ b/tests/unit/pixel-scale.test.ts @@ -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); + } +});