mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
web: add another color theme (#1478)
This commit is contained in:
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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([
|
||||
"................#........",
|
||||
"###.###.###.##..###.#.#.#",
|
||||
"#.#.#.#.#.#.#.#.#...#.#.#",
|
||||
"#.#.#.#.#...#.#.#.#.#.#.#",
|
||||
"###.###.###.#.#.###.###.#",
|
||||
"....#....................",
|
||||
])
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
@@ -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" />
|
||||
|
||||
@@ -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="/">← 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 page’s 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>
|
||||
@@ -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 },
|
||||
)
|
||||
})()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user