Compare commits

...
1 Commits
Author SHA1 Message Date
David Hill b436244729 feat(app): add about settings page 2026-08-27 17:56:03 -06:00
9 changed files with 385 additions and 0 deletions
+17
View File
@@ -892,6 +892,23 @@ export const dict = {
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.tab.about": "About",
"settings.about.version": "Version {{version}}",
"settings.about.devVersion": "development",
"settings.about.license": "Released under the MIT License",
"settings.about.writtenBy": "Written by",
"settings.about.illustratedBy": "Illustrated by",
"settings.about.and": "and",
"settings.about.otherContributor.one": "{{count}} other",
"settings.about.otherContributor.other": "{{count}} others",
"settings.about.firstPublished": "First published in Missouri, USA",
"settings.about.firstIllustrated": "First illustrated in London, England",
"settings.about.website": "www.opencode.ai",
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.tagline": "AI cant build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
+123
View File
@@ -0,0 +1,123 @@
import { For, createResource, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ExternalLink } from "@/runtime/platform/external-link"
import legal from "./legal.svg"
import anomalyBrush from "./anomaly-brush.svg"
import { AnimatedWordmark } from "./animated-wordmark"
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
const writers = [
"thdxr",
"adamdotdev",
"rekram1-node",
"kitlangton",
"iamdavidhill",
"jayair",
"fwang",
"brendonovich",
"nexxeln",
"hona",
"kommander",
"jlongster",
"vimtor",
"r44vcorp",
"simonklee",
"arvsrn",
] as const
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
export function SettingsAbout(props: { active: boolean }) {
const language = useLanguage()
const platform = usePlatform()
const [otherContributors] = createResource(
() => props.active || undefined,
() => loadOtherContributorCount(platform.fetch ?? fetch),
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
)
return (
<div class="settings-about-content">
<div class="settings-about-intro">
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
<p>{language.t("settings.about.license")}</p>
</div>
<AnimatedWordmark active={props.active} />
<div class="settings-about-credits">
<CreditLine
label={language.t("settings.about.writtenBy")}
names={writers}
and={language.t("settings.about.and")}
tail={
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
{language.plural("settings.about.otherContributor", otherContributors(), {
count: otherContributors(),
})}
</ExternalLink>
}
/>
<CreditLine
label={language.t("settings.about.illustratedBy")}
names={illustrators}
and={language.t("settings.about.and")}
/>
</div>
<div class="settings-about-publication">
<p>{language.t("settings.about.firstPublished")}</p>
<p>{language.t("settings.about.firstIllustrated")}</p>
</div>
<p class="settings-about-faint">
<ExternalLink href="https://opencode.ai">
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
</ExternalLink>
</p>
<div class="settings-about-details">
<p>{language.t("settings.about.description")}</p>
<p>{language.t("settings.about.trademark")}</p>
<p>{language.t("settings.about.typeset")}</p>
</div>
<p>{language.t("settings.about.tagline")}</p>
<img class="settings-about-legal" src={legal} alt="" />
<div class="settings-about-copyright">
<p>{language.t("settings.about.copyright")}</p>
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
</div>
</div>
)
}
function CreditLine(props: {
label: string
names: readonly string[]
and: string
tail?: JSX.Element
}) {
return (
<p>
{props.label}{" "}
<For each={props.names}>
{(name, index) => (
<>
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
<bdi dir="ltr">
<ExternalLink href={profile(name)}>{name}</ExternalLink>
</bdi>
</>
)}
</For>
{props.tail ? <>, {props.and} {props.tail}</> : null}
</p>
)
}
function profile(name: string) {
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
return `https://github.com/${name}`
}
@@ -0,0 +1,80 @@
import { createEffect, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
const choices = ["o", "p", "e", "n", "c", "d"] as const
export function AnimatedWordmark(props: { active: boolean }) {
const [state, setState] = createStore({ letters: [...target] })
const timers = new Set<ReturnType<typeof setTimeout>>()
createEffect(
on(
() => props.active,
(active) => {
timers.forEach(clearTimeout)
timers.clear()
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
setState("letters", [...target])
return
}
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
const last = Math.max(...settles)
setState("letters", starts)
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
const timer = setTimeout(() => {
setState(
"letters",
target.map((letter, index) =>
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
),
)
timers.delete(timer)
}, tick * 75)
timers.add(timer)
})
},
),
)
onCleanup(() => timers.forEach(clearTimeout))
return (
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
<defs>
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
</symbol>
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
</symbol>
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
</symbol>
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
</symbol>
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
</symbol>
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
</symbol>
</defs>
<For each={state.letters}>
{(letter, index) => (
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
)}
</For>
</svg>
)
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
describe("otherContributorCount", () => {
test("subtracts the contributors named in the colophon", () => {
expect(
otherContributorCount(
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
),
).toBe(988)
})
test("falls back when pagination metadata is unavailable", () => {
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
})
})
@@ -0,0 +1,23 @@
const CREDITED_CONTRIBUTORS = 16
let request: Promise<number> | undefined
export const FALLBACK_OTHER_CONTRIBUTORS = 935
export function otherContributorCount(link: string | null) {
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
return total - CREDITED_CONTRIBUTORS
}
export function loadOtherContributorCount(fetcher: typeof fetch) {
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}).then(
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
() => FALLBACK_OTHER_CONTRIBUTORS,
)
return request
}
@@ -0,0 +1,9 @@
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity=".5" fill="#808080">
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+96
View File
@@ -74,6 +74,102 @@
user-select: none;
}
.settings-about {
align-items: center;
padding-inline: 24px;
}
.settings-about-content {
display: flex;
width: 100%;
max-width: 560px;
min-height: 462px;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 24px;
padding-block: 80px 32px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
text-align: center;
}
.settings-about-content p {
margin: 0;
}
.settings-about-intro,
.settings-about-credits,
.settings-about-publication,
.settings-about-details {
display: flex;
flex-direction: column;
}
.settings-about-intro {
gap: 14px;
}
.settings-about-credits,
.settings-about-publication,
.settings-about-details {
gap: 16px;
}
.settings-about-wordmark {
width: 178px;
height: 32px;
flex-shrink: 0;
opacity: 0.2;
color: var(--v2-text-text-muted);
fill: currentColor;
}
.settings-about-letter-shadow {
opacity: 0.3;
}
.settings-about-faint,
.settings-about-credits {
color: var(--v2-text-text-faint);
}
.settings-about-legal {
width: 109.2px;
height: 19.2px;
}
.settings-about-copyright {
display: flex;
width: 100%;
flex-shrink: 0;
flex-direction: column;
align-items: center;
gap: 12px;
}
.settings-about-anomaly-brush {
width: 126.72px;
height: 48.384px;
flex-shrink: 0;
pointer-events: none;
}
.settings-about-content a {
color: inherit;
cursor: pointer;
text-decoration: none;
}
.settings-about-content a:hover,
.settings-about-content a:focus-visible {
color: var(--v2-text-text-base);
text-decoration: underline;
}
.settings-back {
display: flex;
height: 28px;
+19
View File
@@ -13,6 +13,7 @@ import { SettingsServers } from "./servers/servers"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { SettingsProjects } from "./workspaces/projects"
import { SettingsExtensions } from "./providers/extensions"
import { SettingsAbout } from "./about/about"
import { SettingsServerScope } from "./server-scope"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/shell/state/layout"
@@ -161,6 +162,21 @@ export const SettingsScreen: Component<{
{language.t("settings.tab.extensions")}
</Tabs.Trigger>
</div>
{/* Group 4: About */}
<div class="flex flex-col gap-1 w-full">
<Tabs.Trigger value="about">
<svg data-slot="icon-svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<g transform="translate(2 2)">
<path
d="M12 12H0V0H12V12ZM1 1V11H11V1H1ZM6.5791 4.81641V9.37207H5.5791V5.81641H4.46777V4.81641H6.5791ZM6.85645 2.62891V3.62891H5.30078V2.62891H6.85645Z"
fill="currentColor"
/>
</g>
</svg>
{language.t("settings.tab.about")}
</Tabs.Trigger>
</div>
</div>
</div>
<div class="settings-nav-footer">
@@ -203,6 +219,9 @@ export const SettingsScreen: Component<{
<SettingsExtensions />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={tab() === "about"} />
</Tabs.Content>
</Tabs>
</div>
)