Skip to content

Commit 9fde292

Browse files
committed
feat: add brand page with logos, colors, typography, and usage guidelines
Adds a /brand page for press and media use, featuring: - Logo section with dark/light previews and SVG/PNG downloads - Customizable logo preview with accent color picker and background toggle - Core brand color palette with click-to-copy hex and OKLch values - Typography specimens for Geist Sans and Geist Mono - Usage guidelines with do's and don'ts - Right-click context menu on header logo (copy SVG, browse brand kit) - Full i18n support - Navigation links in footer and mobile menu
1 parent 7f2fc1a commit 9fde292

File tree

9 files changed

+778
-11
lines changed

9 files changed

+778
-11
lines changed

app/components/AppFooter.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const closeModal = () => modalRef.value?.close?.()
3838
<LinkBase :to="{ name: 'translation-status' }">
3939
{{ $t('translation_status.title') }}
4040
</LinkBase>
41+
<LinkBase :to="{ name: 'brand' }">
42+
{{ $t('footer.brand') }}
43+
</LinkBase>
4144
<button
4245
type="button"
4346
class="cursor-pointer group inline-flex gap-x-1 items-center justify-center underline-offset-[0.2rem] underline decoration-1 decoration-fg/30 font-mono text-fg hover:(decoration-accent text-accent) focus-visible:(decoration-accent text-accent) transition-colors duration-200"

app/components/AppHeader.vue

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ const mobileLinks = computed<NavigationConfigWithGroups>(() => [
9292
external: false,
9393
iconClass: 'i-lucide:languages',
9494
},
95+
{
96+
name: 'Brand',
97+
label: $t('footer.brand'),
98+
to: { name: 'brand' },
99+
type: 'link',
100+
external: false,
101+
iconClass: 'i-lucide:palette',
102+
},
95103
],
96104
},
97105
{
@@ -218,17 +226,18 @@ onKeyStroke(
218226
class="relative container min-h-14 flex items-center gap-2 z-1 justify-end"
219227
>
220228
<!-- Mobile: Logo (navigates home) -->
221-
<NuxtLink
222-
v-if="!isSearchExpanded && !isOnHomePage"
223-
to="/"
224-
:aria-label="$t('header.home')"
225-
class="sm:hidden flex-shrink-0 font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring me-4"
226-
>
227-
<AppMark class="w-6 h-auto" />
228-
</NuxtLink>
229+
<LogoContextMenu v-if="!isSearchExpanded && !isOnHomePage" class="sm:hidden">
230+
<NuxtLink
231+
to="/"
232+
:aria-label="$t('header.home')"
233+
class="flex-shrink-0 font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring me-4"
234+
>
235+
<AppMark class="w-6 h-auto" />
236+
</NuxtLink>
237+
</LogoContextMenu>
229238

230239
<!-- Desktop: Logo (navigates home) -->
231-
<div v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center">
240+
<LogoContextMenu v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center">
232241
<NuxtLink
233242
:to="{ name: 'index' }"
234243
:aria-label="$t('header.home')"
@@ -243,7 +252,7 @@ onKeyStroke(
243252
{{ env === 'release' ? 'alpha' : env }}
244253
</span>
245254
</NuxtLink>
246-
</div>
255+
</LogoContextMenu>
247256

248257
<NuxtLink
249258
v-if="showLogo && !isSearchExpanded && prNumber"

app/components/Brand/Customize.vue

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<script setup lang="ts">
2+
const { accentColors, selectedAccentColor } = useAccentColor()
3+
const { convert: _convert, download: downloadBlob } = useSvgToPng()
4+
5+
const customAccent = ref<string | null>(null)
6+
const customBgDark = ref(true)
7+
const customLogoRef = useTemplateRef('customLogoRef')
8+
9+
const activeAccentId = computed(() => customAccent.value ?? selectedAccentColor.value ?? 'sky')
10+
const activeAccentColor = computed(() => {
11+
const match = accentColors.value.find(c => c.id === activeAccentId.value)
12+
return match?.value ?? accentColors.value[0]!.value
13+
})
14+
15+
function getCustomSvgString(): string {
16+
const el = customLogoRef.value?.$el as SVGElement | undefined
17+
if (!el) return ''
18+
const clone = el.cloneNode(true) as SVGElement
19+
clone.querySelectorAll('[fill="currentColor"]').forEach((path) => {
20+
;(path as SVGElement).setAttribute('fill', customBgDark.value ? '#fafafa' : '#0a0a0a')
21+
})
22+
clone.querySelectorAll('[fill="var(--accent)"]').forEach((path) => {
23+
const style = getComputedStyle(path as SVGElement)
24+
;(path as SVGElement).setAttribute('fill', style.fill || activeAccentColor.value)
25+
})
26+
clone.removeAttribute('aria-hidden')
27+
clone.removeAttribute('class')
28+
return new XMLSerializer().serializeToString(clone)
29+
}
30+
31+
function downloadCustomSvg() {
32+
const svg = getCustomSvgString()
33+
if (!svg) return
34+
const blob = new Blob([svg], { type: 'image/svg+xml' })
35+
const url = URL.createObjectURL(blob)
36+
const a = document.createElement('a')
37+
a.href = url
38+
a.download = `npmx-logo-${activeAccentId.value}.svg`
39+
a.click()
40+
URL.revokeObjectURL(url)
41+
}
42+
43+
const pngLoading = ref(false)
44+
45+
async function downloadCustomPng() {
46+
const svg = getCustomSvgString()
47+
if (!svg) return
48+
pngLoading.value = true
49+
try {
50+
await document.fonts.ready
51+
const blob = new Blob([svg], { type: 'image/svg+xml' })
52+
const url = URL.createObjectURL(blob)
53+
54+
const img = new Image()
55+
const loaded = new Promise<void>((resolve, reject) => {
56+
img.onload = () => resolve()
57+
img.onerror = () => reject(new Error('Failed to load custom SVG'))
58+
})
59+
img.src = url
60+
await loaded
61+
62+
const scale = 2
63+
const canvas = document.createElement('canvas')
64+
canvas.width = 602 * scale
65+
canvas.height = 170 * scale
66+
const ctx = canvas.getContext('2d')!
67+
ctx.fillStyle = customBgDark.value ? '#0a0a0a' : '#ffffff'
68+
ctx.fillRect(0, 0, canvas.width, canvas.height)
69+
ctx.scale(scale, scale)
70+
ctx.drawImage(img, 0, 0, 602, 170)
71+
72+
canvas.toBlob((pngBlob) => {
73+
if (pngBlob) downloadBlob(pngBlob, `npmx-logo-${activeAccentId.value}.png`)
74+
URL.revokeObjectURL(url)
75+
}, 'image/png')
76+
}
77+
finally {
78+
pngLoading.value = false
79+
}
80+
}
81+
</script>
82+
83+
<template>
84+
<section aria-labelledby="brand-customize-heading">
85+
<h2 id="brand-customize-heading" class="text-lg text-fg uppercase tracking-wider mb-4">
86+
{{ $t('brand.customize.title') }}
87+
</h2>
88+
<p class="text-fg-muted leading-relaxed mb-8">
89+
{{ $t('brand.customize.description') }}
90+
</p>
91+
92+
<div class="border border-border rounded-lg overflow-hidden">
93+
<!-- Live preview -->
94+
<div
95+
class="flex items-center justify-center p-10 sm:p-16 transition-colors duration-300 motion-reduce:transition-none"
96+
:class="customBgDark ? 'bg-[#0a0a0a]' : 'bg-white'"
97+
>
98+
<AppLogo
99+
ref="customLogoRef"
100+
class="h-10 sm:h-14 w-auto max-w-full transition-colors duration-300 motion-reduce:transition-none"
101+
:class="customBgDark ? 'text-[#fafafa]' : 'text-[#0a0a0a]'"
102+
:style="{ '--accent': activeAccentColor }"
103+
/>
104+
</div>
105+
106+
<!-- Controls -->
107+
<div class="border-t border-border p-4 sm:p-6 flex flex-col sm:flex-row sm:items-center gap-4">
108+
<!-- Accent color picker -->
109+
<fieldset class="flex items-center gap-3 flex-1 border-none p-0 m-0">
110+
<legend class="sr-only">{{ $t('brand.customize.accent_label') }}</legend>
111+
<span class="text-xs font-mono text-fg-muted shrink-0">{{ $t('brand.customize.accent_label') }}</span>
112+
<div class="flex items-center gap-1.5" role="radiogroup">
113+
<button
114+
v-for="color in accentColors"
115+
:key="color.id"
116+
type="button"
117+
role="radio"
118+
:aria-checked="activeAccentId === color.id"
119+
:aria-label="color.label"
120+
class="w-6 h-6 rounded-full border-2 cursor-pointer transition-all duration-150 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg motion-reduce:transition-none"
121+
:class="activeAccentId === color.id ? 'border-fg scale-110' : 'border-transparent hover:border-border-hover'"
122+
:style="{ backgroundColor: color.value }"
123+
@click="customAccent = color.id"
124+
/>
125+
</div>
126+
</fieldset>
127+
128+
<!-- Background toggle -->
129+
<div class="flex items-center gap-3">
130+
<span class="text-xs font-mono text-fg-muted">{{ $t('brand.customize.bg_label') }}</span>
131+
<div class="flex items-center border border-border rounded-md overflow-hidden" role="radiogroup">
132+
<button
133+
type="button"
134+
role="radio"
135+
:aria-checked="customBgDark"
136+
:aria-label="$t('brand.logos.on_dark')"
137+
class="px-2.5 py-1 text-xs font-mono cursor-pointer border-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent motion-reduce:transition-none"
138+
:class="customBgDark ? 'bg-bg-muted text-fg' : 'bg-transparent text-fg-muted hover:text-fg'"
139+
@click="customBgDark = true"
140+
>
141+
{{ $t('brand.logos.on_dark') }}
142+
</button>
143+
<button
144+
type="button"
145+
role="radio"
146+
:aria-checked="!customBgDark"
147+
:aria-label="$t('brand.logos.on_light')"
148+
class="px-2.5 py-1 text-xs font-mono cursor-pointer border-none border-is border-is-border transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent motion-reduce:transition-none"
149+
:class="!customBgDark ? 'bg-bg-muted text-fg' : 'bg-transparent text-fg-muted hover:text-fg'"
150+
@click="customBgDark = false"
151+
>
152+
{{ $t('brand.logos.on_light') }}
153+
</button>
154+
</div>
155+
</div>
156+
157+
<!-- Download buttons -->
158+
<div class="flex items-center gap-2">
159+
<ButtonBase
160+
size="sm"
161+
classicon="i-lucide:download"
162+
:aria-label="$t('brand.customize.download_svg_aria')"
163+
@click="downloadCustomSvg"
164+
>
165+
{{ $t('brand.logos.download_svg') }}
166+
</ButtonBase>
167+
<ButtonBase
168+
size="sm"
169+
classicon="i-lucide:download"
170+
:aria-label="$t('brand.customize.download_png_aria')"
171+
:disabled="pngLoading"
172+
@click="downloadCustomPng"
173+
>
174+
{{ $t('brand.logos.download_png') }}
175+
</ButtonBase>
176+
</div>
177+
</div>
178+
</div>
179+
</section>
180+
</template>

app/components/LogoContextMenu.vue

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<script setup lang="ts">
2+
const show = shallowRef(false)
3+
const x = shallowRef(0)
4+
const y = shallowRef(0)
5+
const menuRef = useTemplateRef('menuRef')
6+
7+
function onContextMenu(e: MouseEvent) {
8+
e.preventDefault()
9+
x.value = e.clientX
10+
y.value = e.clientY
11+
show.value = true
12+
nextTick(() => {
13+
menuRef.value?.focus()
14+
clampPosition()
15+
})
16+
}
17+
18+
function clampPosition() {
19+
const el = menuRef.value
20+
if (!el) return
21+
const rect = el.getBoundingClientRect()
22+
if (rect.right > window.innerWidth) {
23+
x.value -= rect.right - window.innerWidth + 8
24+
}
25+
if (rect.bottom > window.innerHeight) {
26+
y.value -= rect.bottom - window.innerHeight + 8
27+
}
28+
}
29+
30+
function close() {
31+
show.value = false
32+
}
33+
34+
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
35+
36+
async function copySvg() {
37+
try {
38+
const res = await fetch('/logo.svg')
39+
const svg = await res.text()
40+
await copy(svg)
41+
}
42+
finally {
43+
close()
44+
}
45+
}
46+
47+
function goToBrand() {
48+
close()
49+
navigateTo({ name: 'brand' })
50+
}
51+
52+
onClickOutside(menuRef, close)
53+
54+
onKeyStroke('Escape', () => {
55+
if (show.value) close()
56+
})
57+
</script>
58+
59+
<template>
60+
<div @contextmenu="onContextMenu">
61+
<slot />
62+
63+
<Teleport to="body">
64+
<Transition
65+
enter-active-class="transition duration-100 ease-out"
66+
enter-from-class="opacity-0 scale-95"
67+
enter-to-class="opacity-100 scale-100"
68+
leave-active-class="transition duration-75 ease-in"
69+
leave-from-class="opacity-100 scale-100"
70+
leave-to-class="opacity-0 scale-95"
71+
>
72+
<div
73+
v-if="show"
74+
ref="menuRef"
75+
role="menu"
76+
tabindex="-1"
77+
class="fixed z-[999] min-w-48 bg-bg-elevated border border-border rounded-lg shadow-lg py-1 origin-top-left focus:outline-none motion-reduce:transition-none"
78+
:style="{ left: `${x}px`, top: `${y}px` }"
79+
@keydown.escape="close"
80+
>
81+
<ButtonBase
82+
role="menuitem"
83+
class="w-full text-start gap-x-3 border-none"
84+
:classicon="copied ? 'i-lucide:check text-badge-green' : 'i-lucide:copy'"
85+
@click="copySvg"
86+
>
87+
{{ copied ? $t('brand.colors.copied') : $t('logo_menu.copy_svg') }}
88+
</ButtonBase>
89+
<ButtonBase
90+
role="menuitem"
91+
class="w-full text-start gap-x-3 border-none"
92+
classicon="i-lucide:palette"
93+
@click="goToBrand"
94+
>
95+
{{ $t('logo_menu.browse_brand') }}
96+
</ButtonBase>
97+
</div>
98+
</Transition>
99+
</Teleport>
100+
</div>
101+
</template>

app/composables/useSvgToPng.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export function useSvgToPng() {
2+
async function convert(svgUrl: string, width: number, height: number, scale = 2): Promise<Blob> {
3+
await document.fonts.ready
4+
5+
const img = new Image()
6+
img.crossOrigin = 'anonymous'
7+
8+
const loaded = new Promise<void>((resolve, reject) => {
9+
img.onload = () => resolve()
10+
img.onerror = () => reject(new Error(`Failed to load SVG: ${svgUrl}`))
11+
})
12+
13+
img.src = svgUrl
14+
await loaded
15+
16+
const canvas = document.createElement('canvas')
17+
canvas.width = width * scale
18+
canvas.height = height * scale
19+
20+
const ctx = canvas.getContext('2d')!
21+
ctx.scale(scale, scale)
22+
ctx.drawImage(img, 0, 0, width, height)
23+
24+
return new Promise<Blob>((resolve, reject) => {
25+
canvas.toBlob(
26+
(blob) => {
27+
if (blob) resolve(blob)
28+
else reject(new Error('Canvas toBlob failed'))
29+
},
30+
'image/png',
31+
)
32+
})
33+
}
34+
35+
function download(blob: Blob, filename: string) {
36+
const url = URL.createObjectURL(blob)
37+
const a = document.createElement('a')
38+
a.href = url
39+
a.download = filename
40+
a.click()
41+
URL.revokeObjectURL(url)
42+
}
43+
44+
return { convert, download }
45+
}

0 commit comments

Comments
 (0)