web: add another color theme (#1478)

This commit is contained in:
Simon Klee
2026-09-04 20:33:12 +02:00
committed by GitHub
parent aef16edbac
commit 87271aff19
11 changed files with 972 additions and 50 deletions
+46
View File
@@ -75,6 +75,51 @@ const codeBlue = grayscaleTheme({
comment: "#7783c5",
})
const codeCobalt = {
name: "opentui-cobalt",
type: "light",
colors: {
"editor.foreground": "#200f1a",
"editor.background": "#fffdf8",
},
settings: [
{ settings: { foreground: "#200f1a", background: "#fffdf8" } },
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#71676c", fontStyle: "italic" },
},
{
scope: ["keyword", "storage"],
settings: { foreground: "#c32f18", fontStyle: "bold" },
},
{
scope: ["keyword.operator"],
settings: { foreground: "#200f1a", fontStyle: "" },
},
{
scope: ["string", "constant", "support.constant", "markup.inline.raw"],
settings: { foreground: "#2046e8" },
},
{
scope: [
"entity.name.function",
"entity.name.type",
"entity.name.class",
"entity.name.tag",
"support.function",
"support.type",
"support.class",
"variable.function",
],
settings: { foreground: "#202b81" },
},
{
scope: ["constant.numeric", "constant.language"],
settings: { foreground: "#946400" },
},
],
}
export default defineConfig({
integrations: [
mdx(),
@@ -102,6 +147,7 @@ export default defineConfig({
light: codeLight,
dark: codeDark,
blue: codeBlue,
cobalt: codeCobalt,
},
transformers: [copyButtonTransformer],
},
+258
View File
@@ -0,0 +1,258 @@
import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { runInNewContext } from "node:vm"
import { expect, test } from "bun:test"
import config from "../astro.config.mjs"
const source = await readFile(new URL("../src/scripts/theme.js", import.meta.url), "utf8")
function page({
saved = null as string | null,
dark = false,
blocked = false,
control = true,
ready = true,
url = "https://opentui.test/docs",
} = {}) {
const root = { dataset: {} as Record<string, string> }
const metas = [{ content: "#ffffff" }, { content: "#000000" }]
const toggle = Object.assign(new EventTarget(), { ariaLabel: "Use blue tint", title: "Use blue tint" })
const system = Object.assign(new EventTarget(), { matches: dark })
const storage = new Map(saved ? [["theme", saved]] : [])
const location = { href: url }
const document = Object.assign(new EventTarget(), {
documentElement: root,
querySelector: () => (control ? toggle : null),
querySelectorAll: () => metas,
})
runInNewContext(source, {
document,
location,
URL,
history: {
replaceState: (_state: unknown, _title: string, url: URL) => {
location.href = String(url)
},
},
matchMedia: () => system,
localStorage: {
getItem(key: string) {
if (blocked) throw new Error("Storage blocked")
return storage.get(key) ?? null
},
setItem(key: string, value: string) {
if (blocked) throw new Error("Storage blocked")
storage.set(key, value)
},
},
})
if (ready) document.dispatchEvent(new Event("DOMContentLoaded"))
return {
root,
metas,
toggle,
storage,
location,
click: () => toggle.dispatchEvent(new Event("click")),
systemChange(matches: boolean) {
system.matches = matches
system.dispatchEvent(new Event("change"))
},
}
}
test.each([
["light", "#ffffff"],
["blue", "#ffffff"],
["cobalt", "#fffdf8"],
["dark", "#000000"],
])("restores %s and its browser chrome before the body loads", (saved, color) => {
const { root, metas } = page({ saved, dark: true, ready: false })
expect(root.dataset.theme).toBe(saved)
expect(metas.map((meta) => meta.content)).toEqual([color, color])
})
test("cycles through all themes, persists the choice, and names the next action", () => {
const view = page()
const cycle = [
["blue", "#ffffff", "Use cobalt colors"],
["cobalt", "#fffdf8", "Use dark mode"],
["dark", "#000000", "Use light mode"],
["light", "#ffffff", "Use blue tint"],
]
expect(view.toggle.ariaLabel).toBe("Use blue tint")
for (const [theme, color, label] of cycle) {
view.click()
expect(view.root.dataset.theme).toBe(theme)
expect(view.storage.get("theme")).toBe(theme)
expect(view.metas.map((meta) => meta.content)).toEqual([color!, color!])
expect(view.toggle.ariaLabel).toBe(label)
expect(view.toggle.title).toBe(label)
}
})
test.each([null, "sepia", "__proto__"])("uses system appearance when the stored theme is %s", (saved) => {
const view = page({ saved, dark: true })
expect(view.root.dataset.theme).toBeUndefined()
expect(view.toggle.ariaLabel).toBe("Use light mode")
view.systemChange(false)
expect(view.toggle.ariaLabel).toBe("Use blue tint")
view.click()
expect(view.root.dataset.theme).toBe("blue")
})
test("old Martens selections migrate to cobalt before the body loads", () => {
const view = page({ saved: "martens", ready: false })
expect(view.root.dataset.theme).toBe("cobalt")
expect(view.storage.get("theme")).toBe("cobalt")
expect(view.metas.map((meta) => meta.content)).toEqual(["#fffdf8", "#fffdf8"])
})
test("system changes do not replace a migrated theme", () => {
const view = page({ saved: "martens" })
view.systemChange(true)
expect(view.root.dataset.theme).toBe("cobalt")
expect(view.toggle.ariaLabel).toBe("Use dark mode")
})
test("theme switching still works when storage is unavailable", () => {
const view = page({ blocked: true })
view.click()
view.click()
expect(view.root.dataset.theme).toBe("cobalt")
expect(view.toggle.ariaLabel).toBe("Use dark mode")
expect(view.metas.map((meta) => meta.content)).toEqual(["#fffdf8", "#fffdf8"])
})
test("pages without a theme control still restore the saved theme", () => {
const view = page({ saved: "martens", control: false })
expect(view.root.dataset.theme).toBe("cobalt")
})
test("a theme preview link selects and remembers cobalt before the body loads", () => {
const view = page({ saved: "dark", ready: false, url: "https://opentui.test/docs?path=core&theme=cobalt#code" })
expect(view.root.dataset.theme).toBe("cobalt")
expect(view.storage.get("theme")).toBe("cobalt")
expect(view.metas.map((meta) => meta.content)).toEqual(["#fffdf8", "#fffdf8"])
expect(view.location.href).toBe("https://opentui.test/docs?path=core#code")
})
test.each([false, true])("old Martens links select cobalt when storage is blocked: %s", (blocked) => {
const view = page({ saved: "light", blocked, url: "https://opentui.test/docs?theme=martens#code" })
expect(view.root.dataset.theme).toBe("cobalt")
expect(view.toggle.ariaLabel).toBe("Use dark mode")
expect(view.location.href).toBe("https://opentui.test/docs#code")
if (!blocked) expect(view.storage.get("theme")).toBe("cobalt")
})
test("consumed preview links do not override a later theme choice on reload", () => {
const view = page({ url: "https://opentui.test/?theme=cobalt" })
view.click()
expect(view.root.dataset.theme).toBe("dark")
const reloaded = page({ saved: view.storage.get("theme"), url: view.location.href })
expect(reloaded.root.dataset.theme).toBe("dark")
})
test("preview links work without storage, and unknown theme names are ignored", () => {
expect(page({ blocked: true, url: "https://opentui.test/?theme=cobalt" }).root.dataset.theme).toBe("cobalt")
const view = page({ saved: "dark", url: "https://opentui.test/?theme=__proto__" })
expect(view.root.dataset.theme).toBe("dark")
expect(view.storage.get("theme")).toBe("dark")
})
test("cobalt distinguishes syntax roles with readable colors", async () => {
const require = createRequire(import.meta.resolve("astro/config"))
const { createHighlighter } = await import(require.resolve("shiki"))
expect(Object.keys(config.markdown!.shikiConfig!.themes!).sort()).toEqual(["blue", "cobalt", "dark", "light"])
const highlighter = await createHighlighter({
themes: [config.markdown!.shikiConfig!.themes!.cobalt],
langs: ["tsx"],
})
try {
const { tokens, bg, fg } = highlighter.codeToTokens(
`// A counter
const count: number = 42
const ready = true
function greet() { return "hello" }
const renderer = createCliRenderer()
const label = new TextRenderable(renderer, {})
const view = <text>{count}</text>`,
{ lang: "tsx", theme: "opentui-cobalt" },
)
const segments = tokens.flat() as Array<{ content: string; color: string; fontStyle: number }>
const token = (text: string) => segments.find((segment) => segment.content.includes(text))
expect(fg).toBe("#200f1a")
expect(token("const")).toMatchObject({ color: "#C32F18", fontStyle: 2 })
expect(token('"hello"')).toMatchObject({ color: "#2046E8" })
expect(token("greet")).toMatchObject({ color: "#202B81" })
expect(token("createCliRenderer")).toMatchObject({ color: "#202B81" })
expect(token("TextRenderable")).toMatchObject({ color: "#202B81" })
expect(token("number")).toMatchObject({ color: "#202B81" })
expect(token("text")).toMatchObject({ color: "#202B81" })
expect(token("42")).toMatchObject({ color: "#946400" })
expect(token("true")).toMatchObject({ color: "#946400" })
expect(token("// A counter")).toMatchObject({ color: "#71676C", fontStyle: 1 })
expect(bg).toBe("#fffdf8")
function luminance(hex: string) {
const channels = hex
.slice(1)
.match(/../g)!
.map((channel) => {
const value = parseInt(channel, 16) / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722
}
for (const segment of segments) {
expect((luminance(bg) + 0.05) / (luminance(segment.color) + 0.05)).toBeGreaterThanOrEqual(4.5)
}
} finally {
highlighter.dispose()
}
})
test("wordmark ink plates preserve the letters and overlap only at the p and t joins", async () => {
const svg = await readFile(new URL("../src/components/OpenTUILogo.astro", import.meta.url), "utf8")
const plates = [...svg.matchAll(/data-ink="(cyan|magenta|yellow)"\s+d="([^"]+)"/g)]
expect(plates.map((plate) => plate[1])).toEqual(["cyan", "magenta", "yellow"])
const layers = plates.map(([, , path]) => {
const cells = new Set<string>()
const rectangles = [...path!.matchAll(/M(\d+) (\d+)h(\d+)v(\d+)h-(\d+)z/g)]
expect(rectangles.map((rectangle) => rectangle[0]).join("")).toBe(path!)
for (const rectangle of rectangles) {
const [, x, y, width, height, returnWidth] = rectangle.map(Number)
expect(width).toBe(returnWidth!)
expect(Math.min(width!, height!)).toBe(1)
expect(Math.max(width!, height!)).toBeGreaterThan(1)
for (let dy = 0; dy < height!; dy++) {
for (let dx = 0; dx < width!; dx++) cells.add(`${x! + dx},${y! + dy}`)
}
}
return cells
})
const [cyan, magenta, yellow] = layers
expect([...cyan!].filter((cell) => magenta!.has(cell))).toEqual(["5,4"])
expect([...magenta!].filter((cell) => yellow!.has(cell))).toEqual(["17,1"])
expect([...cyan!].filter((cell) => yellow!.has(cell))).toEqual([])
const cells = new Set(layers.flatMap((layer) => [...layer]))
expect(cells.size).toBe(61)
expect(
Array.from({ length: 6 }, (_, y) =>
Array.from({ length: 25 }, (_, x) => (cells.has(`${x + 1},${y}`) ? "#" : ".")).join(""),
),
).toEqual([
"................#........",
"###.###.###.##..###.#.#.#",
"#.#.#.#.#.#.#.#.#...#.#.#",
"#.#.#.#.#...#.#.#.#.#.#.#",
"###.###.###.#.#.###.###.#",
"....#....................",
])
})
+12 -4
View File
@@ -17,6 +17,7 @@ const spectrum = variant === "spectrum"
aria-label={title}
aria-hidden={title ? undefined : "true"}
focusable="false"
data-wordmark={!spectrum || undefined}
>
{
spectrum && (
@@ -40,8 +41,15 @@ const spectrum = variant === "spectrum"
</defs>
)
}
<path
d="M17 0h1v1h-1zM1 1h3v1h-3zM5 1h3v1h-3zM9 1h3v1h-3zM13 1h2v1h-2zM17 1h3v1h-3zM21 1h1v1h-1zM23 1h1v1h-1zM25 1h1v1h-1zM1 2h1v1h-1zM3 2h1v1h-1zM5 2h1v1h-1zM7 2h1v1h-1zM9 2h1v1h-1zM11 2h1v1h-1zM13 2h1v1h-1zM15 2h1v1h-1zM17 2h1v1h-1zM21 2h1v1h-1zM23 2h1v1h-1zM25 2h1v1h-1zM1 3h1v1h-1zM3 3h1v1h-1zM5 3h1v1h-1zM7 3h1v1h-1zM9 3h1v1h-1zM13 3h1v1h-1zM15 3h1v1h-1zM17 3h1v1h-1zM19 3h1v1h-1zM21 3h1v1h-1zM23 3h1v1h-1zM25 3h1v1h-1zM1 4h3v1h-3zM5 4h3v1h-3zM9 4h3v1h-3zM13 4h1v1h-1zM15 4h1v1h-1zM17 4h3v1h-3zM21 4h3v1h-3zM25 4h1v1h-1zM5 5h1v1h-1z"
fill={spectrum ? "url(#opentui-logo-spectrum)" : "currentColor"}
/>
<g fill={spectrum ? "url(#opentui-logo-spectrum)" : "currentColor"}>
<path
data-ink="cyan"
d="M1 1h3v1h-3zM1 4h3v1h-3zM1 1h1v4h-1zM3 1h1v4h-1zM5 1h3v1h-3zM5 4h3v1h-3zM5 1h1v4h-1zM7 1h1v4h-1zM9 1h3v1h-3zM9 4h3v1h-3zM9 1h1v4h-1zM11 1h1v2h-1zM21 1h1v4h-1zM23 1h1v4h-1zM21 4h3v1h-3z"
/>
<path
data-ink="magenta"
d="M5 4h1v2h-1zM13 1h1v4h-1zM17 0h1v5h-1zM25 1h1v4h-1zM13 1h2v1h-2zM15 2h1v3h-1zM17 4h3v1h-3zM19 3h1v2h-1z"
/>
<path data-ink="yellow" d="M17 1h3v1h-3z" />
</g>
</svg>
@@ -5,44 +5,6 @@
</svg>
</button>
<script>
const toggle = document.querySelector<HTMLButtonElement>("[data-theme-toggle]")!
const systemTheme = matchMedia("(prefers-color-scheme: dark)")
const themes = ["light", "blue", "dark"] as const
type Theme = (typeof themes)[number]
const colors: Record<Theme, string> = { light: "#ffffff", blue: "#ffffff", dark: "#000000" }
const labels: Record<Theme, string> = { light: "Use light mode", blue: "Use blue tint", dark: "Use dark mode" }
function current(): Theme {
const theme = document.documentElement.dataset.theme as Theme | undefined
if (theme && themes.includes(theme)) return theme
return systemTheme.matches ? "dark" : "light"
}
function next(): Theme {
return themes[(themes.indexOf(current()) + 1) % themes.length]
}
function updateLabel() {
const label = labels[next()]
toggle.ariaLabel = label
toggle.title = label
}
toggle.addEventListener("click", () => {
const theme = next()
document.documentElement.dataset.theme = theme
localStorage.setItem("theme", theme)
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')) {
meta.content = colors[theme]
}
updateLabel()
})
systemTheme.addEventListener("change", updateLabel)
updateLabel()
</script>
<style>
button {
display: inline-flex;
@@ -0,0 +1,54 @@
---
import type { WordmarkStudy } from "../data/wordmark-studies"
interface Props {
study: WordmarkStudy
width: number
}
const { study, width } = Astro.props
const number = (value: number) => Number(value.toFixed(3))
---
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 6"
width={width}
height={(width * 6) / 25}
role="img"
aria-label={`OpenTUI — ${study.id}, ${study.name}, ${width}px wide`}
focusable="false"
shape-rendering="crispEdges"
>
{
study.layers.map((layer, index) => (
<g fill={layer.ink} class:list={{ overprint: index > 0 }}>
{"rectangles" in layer ? (
<path
d={layer.rectangles
.map(([x, y, w, h]) => `M${number(x)} ${number(y)}h${number(w)}v${number(h)}h${number(-w)}z`)
.join("")}
/>
) : (
layer.circles.map(([x, y, radius]) => (
<circle cx={x} cy={y} r={radius} shape-rendering="geometricPrecision" />
))
)}
</g>
))
}
</svg>
<style>
svg {
display: block;
flex: none;
max-width: 100%;
height: auto;
isolation: isolate;
}
.overprint {
mix-blend-mode: multiply;
}
</style>
+251
View File
@@ -0,0 +1,251 @@
type Rect = readonly [x: number, y: number, width: number, height: number]
type Circle = readonly [x: number, y: number, radius: number]
type InkLayer = { ink: string } & ({ rectangles: Rect[] } | { circles: Circle[] })
export interface WordmarkStudy {
id: string
name: string
group: string
note: string
layers: InkLayer[]
}
const cyan = "#0084d6"
const magenta = "#ff1c94"
const yellow = "#fff400"
const indigo = "#171080"
export const baseRows = [
"................#........",
"###.###.###.##..###.#.#.#",
"#.#.#.#.#.#.#.#.#...#.#.#",
"#.#.#.#.#...#.#.#.#.#.#.#",
"###.###.###.#.#.###.###.#",
"....#....................",
]
function bitmap(rows: string[]): Rect[] {
return rows.flatMap((row, y) => [...row].flatMap((cell, x): Rect[] => (cell === "#" ? [[x, y, 1, 1]] : [])))
}
const cells = bitmap(baseRows)
const has = (x: number, y: number) => baseRows[y]?.[x] === "#"
function runs(vertical: boolean): Rect[] {
const dx = vertical ? 0 : 1
const dy = vertical ? 1 : 0
return cells.flatMap(([x, y]): Rect[] => {
if (has(x - dx, y - dy)) return []
let length = 1
while (has(x + length * dx, y + length * dy)) length++
if (length < 2) return []
return [[x, y, vertical ? 1 : length, vertical ? length : 1]]
})
}
const horizontal = runs(false)
const vertical = runs(true)
const layer = (ink: string, rectangles: Rect[]): InkLayer => ({ ink, rectangles })
const covers = ([x, y, width, height]: Rect, [cx, cy]: Rect) => cx >= x && cx < x + width && cy >= y && cy < y + height
const connected: Rect[] = [
[4, 4, 1, 2],
[12, 1, 1, 4],
[12, 1, 2, 1],
[14, 2, 1, 3],
[16, 0, 1, 5],
[16, 4, 3, 1],
[18, 3, 1, 2],
[24, 1, 1, 4],
]
const crossbar: Rect[] = [[16, 1, 3, 1]]
function complementary(plate: Rect[], joins: string[]) {
return cells.filter((cell) => !plate.some((rect) => covers(rect, cell)) || joins.includes(`${cell[0]},${cell[1]}`))
}
const body = complementary(connected, ["4,4", "16,1"])
const bowls: Rect[] = [
[4, 4, 1, 2],
[12, 1, 1, 4],
[16, 0, 1, 5],
[24, 1, 1, 4],
]
function shapes(id: string, name: string, group: string, note: string, layers: InkLayer[]): WordmarkStudy {
return { id, name, group, note, layers }
}
function inks(id: string, name: string, first: string, second: string): WordmarkStudy {
return shapes(id, name, "Ink combinations", "The connected construction, with a different pair of inks.", [
layer(first, body),
layer(second, connected),
])
}
export const wordmarkStudies: WordmarkStudy[] = [
shapes("00", "Starting point", "Reference", "The three-ink mark on the site when this comparison was made.", [
layer(
cyan,
body.filter((cell) => !crossbar.some((rect) => covers(rect, cell))),
),
layer(magenta, connected),
layer(yellow, crossbar),
]),
shapes("01", "One ink", "Structure", "The existing letterforms, without color divisions.", [layer(indigo, cells)]),
shapes("02", "Alternating letters", "Structure", "A regular blue / pink cadence, like the postage-stamp book.", [
layer(
cyan,
cells.filter(([x]) => Math.floor(x / 4) % 2 === 0),
),
layer(
magenta,
cells.filter(([x]) => Math.floor(x / 4) % 2 === 1),
),
]),
shapes("03", "Open / TUI", "Structure", "Color separates the two parts of the name instead of individual strokes.", [
layer(
cyan,
cells.filter(([x]) => x < 16),
),
layer(
magenta,
cells.filter(([x]) => x >= 16),
),
]),
shapes("04", "All crossings", "Structure", "Blue crossbars and pink stems. Every intersection prints dark.", [
layer(cyan, horizontal),
layer(magenta, vertical),
]),
shapes("05", "Main stems", "Structure", "Only the main stems print pink; the returning strokes stay blue.", [
layer(cyan, [...horizontal, ...vertical.filter(([x]) => x % 4 !== 0)]),
layer(
magenta,
vertical.filter(([x]) => x % 4 === 0),
),
]),
shapes("06", "Intact bowls", "Structure", "Blue letter bodies with separate pink stems and descender.", [
layer(cyan, complementary(bowls, ["4,4", "12,1", "16,1", "16,4"])),
layer(magenta, bowls),
]),
shapes("07", "Connected forms", "Structure", "The n shoulder and t foot print as connected pink parts. No yellow.", [
layer(cyan, body),
layer(magenta, connected),
]),
shapes("08", "Extending stems", "Structure", "Pink appears only on the p and t, which extend beyond the x-height.", [
layer(cyan, [...horizontal, ...vertical.filter(([x]) => x !== 4 && x !== 16)]),
layer(
magenta,
vertical.filter(([x]) => x === 4 || x === 16),
),
]),
inks("09", "Cobalt / vermilion", "#2046e8", "#ff381d"),
inks("10", "Ultramarine / pink", "#2010bf", "#ff178b"),
inks("11", "Pink / cyan", magenta, cyan),
inks("12", "Black / vermilion", "#171717", "#f43820"),
inks("13", "Cyan / indigo", cyan, indigo),
shapes(
"14",
"Yellow underprint",
"Ink combinations",
"Yellow under both passes mixes green, orange and dark joins.",
[layer(yellow, cells), layer(cyan, horizontal), layer(magenta, vertical)],
),
shapes("15", "Split inks", "Overprints", "Two inks overlap inside each existing cell; the outline stays solid.", [
layer(
cyan,
cells.map(([x, y]): Rect => [x, y, 0.65, 1]),
),
layer(
magenta,
cells.map(([x, y]): Rect => [x + 0.35, y, 0.65, 1]),
),
]),
shapes("16", "Dot overprint", "Overprints", "Magenta dots print over a solid cyan wordmark. No cells are removed.", [
layer(cyan, cells),
{ ink: magenta, circles: cells.map(([x, y]): Circle => [x + 0.5, y + 0.5, 0.36]) },
]),
shapes(
"17",
"Alternating density",
"Overprints",
"A second pass darkens alternate cells, without changing their shape.",
[
layer(cyan, cells),
layer(
magenta,
cells.filter(([x, y]) => (x + y) % 2 === 0),
),
],
),
shapes("18", "Cross overprint", "Overprints", "Cross-shaped ink marks sit inside the solid original letters.", [
layer(cyan, cells),
layer(
magenta,
cells.flatMap(([x, y]): Rect[] => [
[x, y + 0.33, 1, 0.34],
[x + 0.33, y, 0.34, 1],
]),
),
]),
shapes(
"19",
"Reversed crossings",
"Ink placement",
"Pink crossbars and cyan stems, reversing the first stroke study.",
[layer(magenta, horizontal), layer(cyan, vertical)],
),
shapes("20", "Lower strokes", "Ink placement", "Pink occupies the lower strokes, with one shared row of overprint.", [
layer(
cyan,
cells.filter(([, y]) => y <= 3),
),
layer(
magenta,
cells.filter(([, y]) => y >= 3),
),
]),
shapes("21", "Upper strokes", "Ink placement", "Pink occupies the upper strokes; the lower parts stay cyan.", [
layer(
cyan,
cells.filter(([, y]) => y >= 2),
),
layer(
magenta,
cells.filter(([, y]) => y <= 2),
),
]),
shapes("22", "Return strokes", "Ink placement", "Pink moves to the returning stems, rather than the main stems.", [
layer(cyan, [...horizontal, ...vertical.filter(([x]) => x % 4 === 0)]),
layer(
magenta,
vertical.filter(([x]) => x % 4 !== 0),
),
]),
shapes(
"23",
"Three-ink stems",
"Ink placement",
"Yellow over the main stems adds red-orange and deeper intersections.",
[
layer(cyan, horizontal),
layer(magenta, vertical),
layer(
yellow,
vertical.filter(([x]) => x % 4 === 0),
),
],
),
shapes("24", "Overprinted letters", "Ink placement", "Whole letters cycle through cyan, magenta and their overlap.", [
layer(
cyan,
cells.filter(([x]) => Math.floor(x / 4) % 3 !== 1),
),
layer(
magenta,
cells.filter(([x]) => Math.floor(x / 4) % 3 !== 0),
),
]),
]
export const studyGroups = ["Structure", "Ink combinations", "Overprints", "Ink placement"]
+3 -8
View File
@@ -1,5 +1,7 @@
---
import SocialMeta from "../components/SocialMeta.astro"
import themeScript from "../scripts/theme.js?raw"
import "../styles/cobalt.css"
interface Props {
title: string
@@ -27,14 +29,7 @@ const { title, description, wide = false, robots } = Astro.props
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preload" href="/fonts/OpenTUIMono-Regular.woff2" as="font" type="font/woff2" crossorigin />
<script is:inline>
const theme = localStorage.getItem("theme")
if (theme === "light" || theme === "blue" || theme === "dark") {
document.documentElement.dataset.theme = theme
const color = theme === "dark" ? "#000000" : "#ffffff"
for (const meta of document.querySelectorAll('meta[name="theme-color"]')) meta.content = color
}
</script>
<script is:inline set:html={themeScript} />
</head>
<body>
<slot name="nav" />
+234
View File
@@ -0,0 +1,234 @@
---
import WordmarkStudy from "../../components/WordmarkStudy.astro"
import { studyGroups, wordmarkStudies } from "../../data/wordmark-studies"
import Minimal from "../../layouts/Minimal.astro"
const title = "Wordmark studies | OpenTUI"
const description = "Compare color and overprint treatments of the original OpenTUI wordmark."
const groups = ["Reference", ...studyGroups]
const groupId = (group: string) => group.toLowerCase().replaceAll(" ", "-")
---
<Minimal {title} {description} wide robots="noindex">
<main class="wordmark-lab">
<header>
<a href="/">&larr; Website</a>
<h1>Wordmark studies</h1>
<p>24 color and overprint variations, at 250px and 125px wide.</p>
<p>The glyphs, spacing, and proportions are identical. No alternate fonts or redrawn letters.</p>
<p>Shortlist any you like, then send me their numbers. Your selection stays in this pages URL.</p>
<nav aria-label="Study groups">
{studyGroups.map((group) => <a href={`#${groupId(group)}`}>{group}</a>)}
</nav>
</header>
<div class="controls">
<label><input id="shortlist-only" type="checkbox" /> Show shortlist only</label>
<output aria-live="polite" data-shortlist-count>0 shortlisted</output>
</div>
<p class="empty" hidden>No studies shortlisted. Uncheck “Show shortlist only” to see all the options.</p>
{
groups.map((group) => (
<section class="study-group" id={groupId(group)} aria-labelledby={`${groupId(group)}-heading`}>
<h2 id={`${groupId(group)}-heading`}>{group === "Reference" ? "Starting point" : group}</h2>
{wordmarkStudies.filter((study) => study.group === group).map((study) => (
<article class="candidate" id={`mark-${study.id}`} aria-labelledby={`name-${study.id}`}>
<div class="candidate-heading">
<h3 id={`name-${study.id}`}><a href={`#mark-${study.id}`}>{study.id}</a> {study.name}</h3>
<label>
<input type="checkbox" data-pick={study.id} aria-label={`Shortlist ${study.id}: ${study.name}`} />
Shortlist
</label>
</div>
<div class="specimens">
<WordmarkStudy {study} width={250} />
<div class="header-specimen">
<WordmarkStudy {study} width={125} />
<span aria-hidden="true">Docs</span><span aria-hidden="true">Packages</span>
</div>
</div>
<p class="note">{study.note}</p>
{study.id === "09" && (
<p class="theme-preview">
<a href="/?theme=cobalt">Try cobalt across the site</a>
<a href="/docs/getting-started/quickstart?theme=cobalt#create-the-app">Preview code</a>
</p>
)}
</article>
))}
</section>
))
}
</main>
</Minimal>
<script>
const picks = [...document.querySelectorAll<HTMLInputElement>("[data-pick]")]
const only = document.querySelector<HTMLInputElement>("#shortlist-only")!
const count = document.querySelector<HTMLOutputElement>("[data-shortlist-count]")!
const empty = document.querySelector<HTMLElement>(".empty")!
const groups = [...document.querySelectorAll<HTMLElement>(".study-group")]
function update(save = false) {
const selected = picks.filter((input) => input.checked).map((input) => input.dataset.pick!)
count.value = `${selected.length} shortlisted`
for (const input of picks) input.closest<HTMLElement>(".candidate")!.hidden = only.checked && !input.checked
for (const group of groups) {
group.hidden = !group.querySelector(".candidate:not([hidden])")
document.querySelector(`nav a[href="#${group.id}"]`)?.toggleAttribute("hidden", group.hidden)
}
empty.hidden = !only.checked || selected.length > 0
if (!save) return
const url = new URL(location.href)
if (selected.length) url.searchParams.set("pick", selected.join(","))
else url.searchParams.delete("pick")
if (only.checked) url.searchParams.set("only", "1")
else url.searchParams.delete("only")
history.replaceState(null, "", url)
}
function restore() {
const params = new URLSearchParams(location.search)
const selected = new Set(params.get("pick")?.slice(0, 100).split(",") ?? [])
for (const input of picks) input.checked = selected.has(input.dataset.pick!)
only.checked = params.get("only") === "1"
update()
}
for (const input of picks) input.addEventListener("change", () => {
if (only.checked && !input.checked) only.focus()
update(true)
})
only.addEventListener("change", () => update(true))
window.addEventListener("popstate", restore)
restore()
</script>
<style>
:global(:root:has(body .wordmark-lab)) {
color-scheme: light;
--page-background: #fffdf8;
--page-color: #1c1614;
--link-color: #1d709c;
--muted-color: #716c64;
}
h1 {
margin-block: 1.5rem;
}
header p {
max-width: var(--content-width);
margin-block: 0 1rem;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-block: 1.5rem;
}
.controls {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
padding-block: 0.75rem;
background: var(--page-background);
}
@supports (backdrop-filter: blur(1px)) {
.controls {
background: color-mix(in srgb, var(--page-background) var(--chrome-veil), transparent);
backdrop-filter: blur(16px);
}
}
label {
display: inline-flex;
align-items: center;
gap: 0.5ch;
cursor: pointer;
}
input {
accent-color: var(--link-color);
}
output {
color: var(--muted-color);
}
.study-group {
margin-block-start: 3rem;
scroll-margin-top: 5rem;
}
h2 {
font-size: 1.25rem;
margin-block: 0 2rem;
}
h3 {
font-size: 1rem;
margin: 0;
}
.candidate {
margin-block: 0 3.5rem;
scroll-margin-top: 5rem;
}
.candidate-heading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem 1.5rem;
margin-block-end: 1.25rem;
}
.candidate-heading label {
font-size: var(--text-small);
}
.specimens {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: center;
gap: 1.5rem;
}
.header-specimen {
display: flex;
align-items: center;
gap: 1rem;
color: var(--link-color);
font-weight: 700;
}
.note {
max-width: var(--content-width);
color: var(--muted-color);
font-size: var(--text-small);
margin-block: 1rem 0;
}
.theme-preview {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-block: 0.75rem 0;
}
@media (min-width: 48rem) {
.specimens {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 3rem;
}
}
</style>
+70
View File
@@ -0,0 +1,70 @@
;(() => {
const themes = [
{ id: "light", color: "#ffffff", label: "Use light mode" },
{ id: "blue", color: "#ffffff", label: "Use blue tint" },
{ id: "cobalt", color: "#fffdf8", label: "Use cobalt colors" },
{ id: "dark", color: "#000000", label: "Use dark mode" },
]
const root = document.documentElement
const systemTheme = matchMedia("(prefers-color-scheme: dark)")
function resolveTheme(id) {
return themes.find((theme) => theme.id === (id === "martens" ? "cobalt" : id))
}
function apply(theme, remember = false) {
root.dataset.theme = theme.id
for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
meta.content = theme.color
}
if (remember) {
try {
localStorage.setItem("theme", theme.id)
} catch {}
}
}
const url = new URL(location.href)
const requested = resolveTheme(url.searchParams.get("theme"))
if (requested) {
apply(requested, true)
url.searchParams.delete("theme")
try {
history.replaceState(null, "", url)
} catch {}
} else {
try {
const id = localStorage.getItem("theme")
const saved = resolveTheme(id)
if (saved) apply(saved, saved.id !== id)
} catch {}
}
document.addEventListener(
"DOMContentLoaded",
() => {
const toggle = document.querySelector("[data-theme-toggle]")
if (!toggle) return
function next() {
const current = root.dataset.theme || (systemTheme.matches ? "dark" : "light")
return themes[(themes.findIndex((theme) => theme.id === current) + 1) % themes.length]
}
function updateLabel() {
toggle.ariaLabel = next().label
toggle.title = toggle.ariaLabel
}
toggle.addEventListener("click", () => {
const theme = next()
apply(theme, true)
updateLabel()
})
systemTheme.addEventListener("change", updateLabel)
updateLabel()
},
{ once: true },
)
})()
+39
View File
@@ -0,0 +1,39 @@
:root[data-theme="cobalt"] {
color-scheme: light;
--page-background: #fffdf8;
--page-color: #200f1a;
--link-color: #2046e8;
--muted-color: #71676c;
--accent-color: #ff381d;
}
:root[data-theme="cobalt"] [data-wordmark] {
isolation: isolate;
}
:root[data-theme="cobalt"] [data-wordmark] :is([data-ink="cyan"], [data-ink="yellow"]) {
fill: var(--link-color);
}
:root[data-theme="cobalt"] [data-wordmark] [data-ink="magenta"] {
fill: var(--accent-color);
}
:root[data-theme="cobalt"] [data-wordmark] :is([data-ink="magenta"], [data-ink="yellow"]) {
mix-blend-mode: multiply;
}
:root[data-theme="cobalt"] ::selection {
background: var(--accent-color);
color: var(--page-color);
}
:root[data-theme="cobalt"] :focus-visible {
outline: 2px solid var(--link-color);
outline-offset: 3px;
}
:root[data-theme="cobalt"] button[data-video][aria-expanded="true"] {
color: var(--link-color);
text-decoration: underline;
}
+5
View File
@@ -271,6 +271,11 @@
color: var(--shiki-blue) !important;
}
:root[data-theme="cobalt"] .prose .astro-code,
:root[data-theme="cobalt"] .prose .astro-code span {
color: var(--shiki-cobalt) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .prose .astro-code,
:root:not([data-theme]) .prose .astro-code span {