From 6bae1ad12bbbf7eb44c8dd05429f1338b1a8e323 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 00:13:13 -0700 Subject: [PATCH] feat(app): text rendering controls in the debug bar (#57326) --- packages/gui-extensions/src/debug/bar.tsx | 9 +++ packages/gui-extensions/src/debug/i18n/en.ts | 3 + .../gui-extensions/src/debug/renderer.tsx | 13 ++++ .../gui-extensions/src/debug/type-panel.tsx | 59 +++++++++++++++ packages/gui-extensions/src/debug/type.ts | 75 +++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 packages/gui-extensions/src/debug/type-panel.tsx create mode 100644 packages/gui-extensions/src/debug/type.ts diff --git a/packages/gui-extensions/src/debug/bar.tsx b/packages/gui-extensions/src/debug/bar.tsx index 4893b159ab4..5dde517967e 100644 --- a/packages/gui-extensions/src/debug/bar.tsx +++ b/packages/gui-extensions/src/debug/bar.tsx @@ -196,6 +196,7 @@ export default function DebugBar(props: { diagnostics?: boolean inline?: boolean motion?: { open: boolean; toggle: () => void } + type?: { open: boolean; toggle: () => void } }) { const ctx = useExtension() const router = ctx.router @@ -758,6 +759,14 @@ export default function DebugBar(props: { value={ctx.t(globals().slow ? "focus.on" : "focus.off")} onClick={() => motionTuning.set(motionGlobals.slow, !globals().slow)} /> + props.type?.toggle()} + /> = (ctx) => { // builds, prod included, drop the import and never contain knobs. const motionTools = import.meta.env.DEV || import.meta.env.VITE_OPENCODE_CHANNEL === "beta" const Motion = motionTools ? lazy(() => import("./motion")) : undefined + const TypePanel = motionTools ? lazy(() => import("./type-panel")) : undefined + + if (motionTools) void import("./type").then((type) => type.applyStored()) // Window-local; every window starts with the bar hidden. const [visible, setVisible] = createSignal(false) @@ -17,6 +20,8 @@ const setup: Setup = (ctx) => { setVisible((value) => !value) } + const [typing, setTyping] = createSignal(false) + const toggleMotion = () => { setMotion((value) => !value) } @@ -41,6 +46,7 @@ const setup: Setup = (ctx) => { setTyping((value) => !value) } : undefined} inline /> @@ -53,6 +59,13 @@ const setup: Setup = (ctx) => { )} + {TypePanel && ( + + + + + + )} ), }) diff --git a/packages/gui-extensions/src/debug/type-panel.tsx b/packages/gui-extensions/src/debug/type-panel.tsx new file mode 100644 index 00000000000..ec2d9adb352 --- /dev/null +++ b/packages/gui-extensions/src/debug/type-panel.tsx @@ -0,0 +1,59 @@ +import { createSignal, For } from "solid-js" +import { Portal } from "solid-js/web" +import { apply, load, options, save, type Type } from "./type" + +const groups = [ + ["smoothing", "Smoothing"], + ["family", "Family"], + ["weight", "Weight"], + ["tracking", "Tracking"], + ["opsz", "Inter opsz"], +] as const + +// The Type workspace: live text-rendering overrides, docked above the debug bar's left end. Choices persist on this device. +export default function TypePanel(props: { title: string }) { + const [type, setType] = createSignal(load()) + + const update = (next: Type) => { + setType(next) + apply(next) + save(next) + } + + return ( + +
+
+ {props.title} + +
+ + {([name, label]) => ( +
+ {label} +
+ + {(value) => ( + + )} + +
+
+ )} +
+
+
+ ) +} diff --git a/packages/gui-extensions/src/debug/type.ts b/packages/gui-extensions/src/debug/type.ts new file mode 100644 index 00000000000..693e1ece4fd --- /dev/null +++ b/packages/gui-extensions/src/debug/type.ts @@ -0,0 +1,75 @@ +const key = "opencode.type.panel" + +export const options = { + smoothing: ["auto", "antialiased"], + family: ["inter", "system"], + weight: ["400", "440", "450", "500"], + tracking: ["0", "-0.04", "-0.1"], + opsz: ["auto", "none"], +} as const + +export type Type = { [K in keyof typeof options]?: (typeof options)[K][number] } + +const css = ` +:root[data-type-smoothing], :root[data-type-smoothing] * { -webkit-font-smoothing: var(--type-smoothing) !important; } +:root[data-type-weight] body :not(b, strong, h1, h2, h3, h4, h5, h6, th, [class*="font-bold"], [class*="font-semibold"], [class*="font-medium"], [class*="font-["]) { font-weight: var(--type-weight) !important; } +:root[data-type-tracking] body * { letter-spacing: var(--type-tracking) !important; } +:root[data-type-opsz] body * { font-optical-sizing: none !important; } +` + +const system = '-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif' + +// Overrides are global and sit on the root, so no component changes are needed. An unset group leaves the app as shipped. +export function apply(type: Type) { + const root = document.documentElement + + const set = (name: string, value: string | undefined, css?: string) => { + if (value === undefined) { + root.removeAttribute(`data-type-${name}`) + root.style.removeProperty(`--type-${name}`) + + return + } + + root.setAttribute(`data-type-${name}`, value) + root.style.setProperty(`--type-${name}`, css ?? value) + } + + + if (!document.getElementById("type-tuning")) { + const style = document.createElement("style") + style.id = "type-tuning" + style.textContent = css + document.head.append(style) + } + + + set("smoothing", type.smoothing) + set("weight", type.weight) + set("tracking", type.tracking, type.tracking && `${type.tracking}px`) + set("opsz", type.opsz === "none" ? "none" : undefined) + + + for (const name of ["--font-family-sans", "--v2-font-family-sans"]) { + if (type.family === "system") root.style.setProperty(name, system) + else root.style.removeProperty(name) + } +} + +export function load(): Type { + try { + return JSON.parse(localStorage.getItem(key) ?? "{}") + } catch { + return {} + } +} + +export function save(type: Type) { + try { + localStorage.setItem(key, JSON.stringify(type)) + } catch {} +} + +export function applyStored() { + apply(load()) +}