Skip to content

Commit 1dd1be9

Browse files
Adebesin-Cellautofix-ci[bot]claudeghostdevv
authored
feat: add brand page (#2197)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent 54ca7bd commit 1dd1be9

File tree

16 files changed

+1021
-17
lines changed

16 files changed

+1021
-17
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 flex-shrink-0">
230+
<NuxtLink
231+
to="/"
232+
:aria-label="$t('header.home')"
233+
class="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: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<script setup lang="ts">
2+
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
3+
4+
const { selectedAccentColor } = useAccentColor()
5+
const { t } = useI18n()
6+
7+
const customAccent = ref<string | null>(null)
8+
const customBgDark = ref(true)
9+
const customLogoRef = useTemplateRef('customLogoRef')
10+
11+
const activeAccentId = computed(() => customAccent.value ?? selectedAccentColor.value ?? 'sky')
12+
13+
// Use the palette matching the preview background, not the site theme
14+
const previewPalette = computed(() =>
15+
customBgDark.value ? ACCENT_COLORS.dark : ACCENT_COLORS.light,
16+
)
17+
18+
const activeAccentColor = computed(() => {
19+
return previewPalette.value[activeAccentId.value as AccentColorId] ?? previewPalette.value.sky
20+
})
21+
22+
const accentColorLabels = computed<Record<AccentColorId, string>>(() => ({
23+
sky: t('settings.accent_colors.sky'),
24+
coral: t('settings.accent_colors.coral'),
25+
amber: t('settings.accent_colors.amber'),
26+
emerald: t('settings.accent_colors.emerald'),
27+
violet: t('settings.accent_colors.violet'),
28+
magenta: t('settings.accent_colors.magenta'),
29+
neutral: t('settings.clear_accent'),
30+
}))
31+
32+
// Color swatches match the preview background palette so the circles reflect what the logo will render
33+
const pickerColors = computed(() =>
34+
Object.entries(previewPalette.value).map(([id, value]) => ({
35+
id: id as AccentColorId,
36+
label: accentColorLabels.value[id as AccentColorId],
37+
value,
38+
})),
39+
)
40+
41+
function getCustomSvgString(): string {
42+
const el = customLogoRef.value?.$el as SVGElement | undefined
43+
if (!el) return ''
44+
const clone = el.cloneNode(true) as SVGElement
45+
clone.querySelectorAll<SVGElement>('[fill="currentColor"]').forEach(path => {
46+
path.setAttribute('fill', customBgDark.value ? '#fafafa' : '#0a0a0a')
47+
})
48+
clone.querySelectorAll<SVGElement>('[fill="var(--accent)"]').forEach(path => {
49+
const style = getComputedStyle(path as SVGElement)
50+
path.setAttribute('fill', style.fill || activeAccentColor.value)
51+
})
52+
clone.removeAttribute('aria-hidden')
53+
clone.removeAttribute('class')
54+
return new XMLSerializer().serializeToString(clone)
55+
}
56+
57+
const svgLoading = ref(false)
58+
59+
function downloadCustomSvg() {
60+
const svg = getCustomSvgString()
61+
if (!svg) return
62+
svgLoading.value = true
63+
try {
64+
const blob = new Blob([svg], { type: 'image/svg+xml' })
65+
downloadFile(blob, `npmx-logo-${activeAccentId.value}.svg`)
66+
} finally {
67+
svgLoading.value = false
68+
}
69+
}
70+
71+
const pngLoading = ref(false)
72+
73+
async function downloadCustomPng() {
74+
const svg = getCustomSvgString()
75+
if (!svg) return
76+
pngLoading.value = true
77+
78+
const blob = new Blob([svg], { type: 'image/svg+xml' })
79+
const url = URL.createObjectURL(blob)
80+
81+
try {
82+
await document.fonts.ready
83+
84+
const img = new Image()
85+
const loaded = new Promise<void>((resolve, reject) => {
86+
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
87+
img.onload = () => resolve()
88+
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
89+
img.onerror = () => reject(new Error('Failed to load custom SVG'))
90+
})
91+
img.src = url
92+
await loaded
93+
94+
const scale = 2
95+
const canvas = document.createElement('canvas')
96+
canvas.width = 602 * scale
97+
canvas.height = 170 * scale
98+
const ctx = canvas.getContext('2d')!
99+
ctx.fillStyle = customBgDark.value ? '#0a0a0a' : '#ffffff'
100+
ctx.fillRect(0, 0, canvas.width, canvas.height)
101+
ctx.scale(scale, scale)
102+
ctx.drawImage(img, 0, 0, 602, 170)
103+
104+
await new Promise<void>(resolve => {
105+
canvas.toBlob(pngBlob => {
106+
if (pngBlob) downloadFile(pngBlob, `npmx-logo-${activeAccentId.value}.png`)
107+
resolve()
108+
}, 'image/png')
109+
})
110+
} finally {
111+
URL.revokeObjectURL(url)
112+
pngLoading.value = false
113+
}
114+
}
115+
</script>
116+
117+
<template>
118+
<section aria-labelledby="brand-customize-heading">
119+
<h2 id="brand-customize-heading" class="text-lg text-fg uppercase tracking-wider mb-4">
120+
{{ $t('brand.customize.title') }}
121+
</h2>
122+
<p class="text-fg-muted leading-relaxed mb-8">
123+
{{ $t('brand.customize.description') }}
124+
</p>
125+
126+
<div class="border border-border rounded-lg overflow-hidden">
127+
<!-- Live preview -->
128+
<div
129+
class="flex items-center justify-center p-10 sm:p-16 transition-colors duration-300 motion-reduce:transition-none"
130+
:class="customBgDark ? 'bg-[#0a0a0a]' : 'bg-white'"
131+
>
132+
<AppLogo
133+
ref="customLogoRef"
134+
class="h-10 sm:h-14 w-auto max-w-full transition-colors duration-300 motion-reduce:transition-none"
135+
:class="customBgDark ? 'text-[#fafafa]' : 'text-[#0a0a0a]'"
136+
:style="{ '--accent': activeAccentColor }"
137+
/>
138+
</div>
139+
140+
<!-- Controls -->
141+
<div class="border-t border-border p-4 sm:p-6 flex flex-col gap-4">
142+
<!-- Row 1: Accent color picker -->
143+
<fieldset class="flex items-center gap-3 border-none p-0 m-0">
144+
<legend class="sr-only">{{ $t('brand.customize.accent_label') }}</legend>
145+
<span class="text-sm font-mono text-fg-muted shrink-0">{{
146+
$t('brand.customize.accent_label')
147+
}}</span>
148+
<div class="flex items-center gap-1.5" role="radiogroup">
149+
<ButtonBase
150+
v-for="color in pickerColors"
151+
:key="color.id"
152+
role="radio"
153+
:aria-checked="activeAccentId === color.id"
154+
:aria-label="color.label"
155+
class="!w-6 !h-6 !rounded-full !border-2 !p-0 !min-w-0 transition-all duration-150 motion-reduce:transition-none"
156+
:class="
157+
activeAccentId === color.id
158+
? '!border-fg scale-110'
159+
: color.id === 'neutral'
160+
? '!border-border hover:!border-border-hover'
161+
: '!border-transparent hover:!border-border-hover'
162+
"
163+
:style="{ backgroundColor: color.value }"
164+
@click="customAccent = color.id"
165+
/>
166+
</div>
167+
</fieldset>
168+
169+
<!-- Row 2: Background toggle + Download buttons -->
170+
<div class="flex items-center gap-4 flex-wrap">
171+
<div class="flex items-center gap-3">
172+
<span class="text-sm font-mono text-fg-muted">{{
173+
$t('brand.customize.bg_label')
174+
}}</span>
175+
<div
176+
class="flex items-center border border-border rounded-md overflow-hidden"
177+
role="radiogroup"
178+
>
179+
<ButtonBase
180+
size="md"
181+
role="radio"
182+
:aria-checked="customBgDark"
183+
:aria-label="$t('brand.logos.on_dark')"
184+
class="!border-none !rounded-none motion-reduce:transition-none"
185+
:class="
186+
customBgDark
187+
? 'bg-bg-muted text-fg'
188+
: 'bg-transparent text-fg-muted hover:text-fg'
189+
"
190+
@click="customBgDark = true"
191+
>
192+
{{ $t('brand.logos.on_dark') }}
193+
</ButtonBase>
194+
<ButtonBase
195+
size="md"
196+
role="radio"
197+
:aria-checked="!customBgDark"
198+
:aria-label="$t('brand.logos.on_light')"
199+
class="!border-none !rounded-none border-is border-is-border motion-reduce:transition-none"
200+
:class="
201+
!customBgDark
202+
? 'bg-bg-muted text-fg'
203+
: 'bg-transparent text-fg-muted hover:text-fg'
204+
"
205+
@click="customBgDark = false"
206+
>
207+
{{ $t('brand.logos.on_light') }}
208+
</ButtonBase>
209+
</div>
210+
</div>
211+
212+
<div class="flex items-center gap-2">
213+
<ButtonBase
214+
size="md"
215+
:aria-label="$t('brand.customize.download_svg_aria')"
216+
:disabled="svgLoading"
217+
@click="downloadCustomSvg"
218+
>
219+
<span
220+
class="size-[1em]"
221+
aria-hidden="true"
222+
:class="svgLoading ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:download'"
223+
/>
224+
{{ $t('brand.logos.download_svg') }}
225+
</ButtonBase>
226+
<ButtonBase
227+
size="md"
228+
:aria-label="$t('brand.customize.download_png_aria')"
229+
:disabled="pngLoading"
230+
@click="downloadCustomPng"
231+
>
232+
<span
233+
class="size-[1em]"
234+
aria-hidden="true"
235+
:class="pngLoading ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:download'"
236+
/>
237+
{{ $t('brand.logos.download_png') }}
238+
</ButtonBase>
239+
</div>
240+
</div>
241+
</div>
242+
</div>
243+
</section>
244+
</template>

0 commit comments

Comments
 (0)