Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9fde292
feat: add brand page with logos, colors, typography, and usage guidel…
Adebesin-Cell Mar 22, 2026
eeef5e5
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 22, 2026
fb287bf
fix: prevent LogoContextMenu wrapper from breaking mobile nav layout
Adebesin-Cell Mar 22, 2026
c58e7e7
fix: resolve failing unit and i18n tests
Adebesin-Cell Mar 22, 2026
c956570
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 22, 2026
b4b5630
fix: regenerate i18n schema for brand page keys
Adebesin-Cell Mar 22, 2026
32900dc
fix: address CodeRabbit review comments
Adebesin-Cell Mar 22, 2026
284f70a
fix: replace remaining bare buttons with ButtonBase in Brand/Customize
Adebesin-Cell Mar 22, 2026
35f9f07
fix: await toBlob before clearing loading state in custom PNG download
Adebesin-Cell Mar 22, 2026
3639ee2
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 22, 2026
f22cec2
refactor: simplify brand page — remove colors, app icon, and strict g…
Adebesin-Cell Mar 22, 2026
ba79696
refactor: per-variant downloads, better spacing, friendlier guidelines
Adebesin-Cell Mar 22, 2026
33410bf
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 22, 2026
508ecca
fix: align logo cards with flex-col and consistent min-height
Adebesin-Cell Mar 22, 2026
9edd0ee
fix: increase spacing between logo cards
Adebesin-Cell Mar 22, 2026
e5ab1bb
fix: use space-y-16 for logo card spacing
Adebesin-Cell Mar 22, 2026
8c71173
fix: use mt-12 on figures instead of space-y for logo spacing
Adebesin-Cell Mar 22, 2026
8cf0210
fix: replace space-y-16 with flex col gap-16 for section spacing
Adebesin-Cell Mar 22, 2026
ab9d274
fix: reduce context menu size — remove min-w, use sm buttons
Adebesin-Cell Mar 22, 2026
a848281
fix: remove w-full from context menu buttons to shrink width
Adebesin-Cell Mar 22, 2026
22c3bb1
fix: add flex-col to context menu so items stack vertically
Adebesin-Cell Mar 22, 2026
7f07fcb
fix: use contrast-appropriate accent colors based on preview background
Adebesin-Cell Mar 22, 2026
f4c6916
fix: sync color picker swatches with preview background palette
Adebesin-Cell Mar 22, 2026
78df9f3
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 22, 2026
d1cf5be
fix: delay context menu close after copy so confirmation is readable
Adebesin-Cell Mar 22, 2026
d63a73b
style: Update setTimeout duration in copySvg function
Adebesin-Cell Mar 22, 2026
7abe2cd
Merge branch 'main' into feat/brand-page
Adebesin-Cell Mar 23, 2026
07e9928
chore: use generics
ghostdevv Mar 23, 2026
a97cf16
chore: ignore some lint issues
ghostdevv Mar 23, 2026
c3671ab
refactor: create downloadFile and downloadFileLink utils
ghostdevv Mar 23, 2026
7c04e99
refactor: remove composable
ghostdevv Mar 23, 2026
540ed93
fix: address PR review feedback for brand page
Adebesin-Cell Mar 23, 2026
7d1025a
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 23, 2026
1e0788f
fix: use correct light-mode colors for logo mark and add PNG spinners
Adebesin-Cell Mar 23, 2026
dc799b9
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const closeModal = () => modalRef.value?.close?.()
<LinkBase :to="{ name: 'translation-status' }">
{{ $t('translation_status.title') }}
</LinkBase>
<LinkBase :to="{ name: 'brand' }">
{{ $t('footer.brand') }}
</LinkBase>
<button
type="button"
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"
Expand Down
29 changes: 19 additions & 10 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ const mobileLinks = computed<NavigationConfigWithGroups>(() => [
external: false,
iconClass: 'i-lucide:languages',
},
{
name: 'Brand',
label: $t('footer.brand'),
to: { name: 'brand' },
type: 'link',
external: false,
iconClass: 'i-lucide:palette',
},
],
},
{
Expand Down Expand Up @@ -218,17 +226,18 @@ onKeyStroke(
class="relative container min-h-14 flex items-center gap-2 z-1 justify-end"
>
<!-- Mobile: Logo (navigates home) -->
<NuxtLink
v-if="!isSearchExpanded && !isOnHomePage"
to="/"
:aria-label="$t('header.home')"
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"
>
<AppMark class="w-6 h-auto" />
</NuxtLink>
<LogoContextMenu v-if="!isSearchExpanded && !isOnHomePage" class="sm:hidden flex-shrink-0">
<NuxtLink
to="/"
:aria-label="$t('header.home')"
class="font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring me-4"
>
<AppMark class="w-6 h-auto" />
</NuxtLink>
</LogoContextMenu>

<!-- Desktop: Logo (navigates home) -->
<div v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center">
<LogoContextMenu v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center">
<NuxtLink
:to="{ name: 'index' }"
:aria-label="$t('header.home')"
Expand All @@ -243,7 +252,7 @@ onKeyStroke(
{{ env === 'release' ? 'alpha' : env }}
</span>
</NuxtLink>
</div>
</LogoContextMenu>

<NuxtLink
v-if="showLogo && !isSearchExpanded && prNumber"
Expand Down
228 changes: 228 additions & 0 deletions app/components/Brand/Customize.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'

const { selectedAccentColor } = useAccentColor()
const { t } = useI18n()

const customAccent = ref<string | null>(null)
const customBgDark = ref(true)
const customLogoRef = useTemplateRef('customLogoRef')

const activeAccentId = computed(() => customAccent.value ?? selectedAccentColor.value ?? 'sky')

// Use the palette matching the preview background, not the site theme
const previewPalette = computed(() =>
customBgDark.value ? ACCENT_COLORS.dark : ACCENT_COLORS.light,
)

const activeAccentColor = computed(() => {
return previewPalette.value[activeAccentId.value as AccentColorId] ?? previewPalette.value.sky
})

const accentColorLabels = computed<Record<AccentColorId, string>>(() => ({
sky: t('settings.accent_colors.sky'),
coral: t('settings.accent_colors.coral'),
amber: t('settings.accent_colors.amber'),
emerald: t('settings.accent_colors.emerald'),
violet: t('settings.accent_colors.violet'),
magenta: t('settings.accent_colors.magenta'),
neutral: t('settings.clear_accent'),
}))

// Color swatches match the preview background palette so the circles reflect what the logo will render
const pickerColors = computed(() =>
Object.entries(previewPalette.value).map(([id, value]) => ({
id: id as AccentColorId,
label: accentColorLabels.value[id as AccentColorId],
value,
})),
)

function getCustomSvgString(): string {
const el = customLogoRef.value?.$el as SVGElement | undefined
if (!el) return ''
const clone = el.cloneNode(true) as SVGElement
clone.querySelectorAll<SVGElement>('[fill="currentColor"]').forEach(path => {
path.setAttribute('fill', customBgDark.value ? '#fafafa' : '#0a0a0a')
})
clone.querySelectorAll<SVGElement>('[fill="var(--accent)"]').forEach(path => {
const style = getComputedStyle(path as SVGElement)
path.setAttribute('fill', style.fill || activeAccentColor.value)
})
clone.removeAttribute('aria-hidden')
clone.removeAttribute('class')
return new XMLSerializer().serializeToString(clone)
}

function downloadCustomSvg() {
const svg = getCustomSvgString()
if (!svg) return

const blob = new Blob([svg], { type: 'image/svg+xml' })
downloadFile(blob, `npmx-logo-${activeAccentId.value}.svg`)
}

const pngLoading = ref(false)

async function downloadCustomPng() {
const svg = getCustomSvgString()
if (!svg) return
pngLoading.value = true

const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)

try {
await document.fonts.ready

const img = new Image()
const loaded = new Promise<void>((resolve, reject) => {
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
img.onload = () => resolve()
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
img.onerror = () => reject(new Error('Failed to load custom SVG'))
})
img.src = url
await loaded

const scale = 2
const canvas = document.createElement('canvas')
canvas.width = 602 * scale
canvas.height = 170 * scale
const ctx = canvas.getContext('2d')!
ctx.fillStyle = customBgDark.value ? '#0a0a0a' : '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, 602, 170)

await new Promise<void>(resolve => {
canvas.toBlob(pngBlob => {
if (pngBlob) downloadFile(pngBlob, `npmx-logo-${activeAccentId.value}.png`)
resolve()
}, 'image/png')
})
} finally {
URL.revokeObjectURL(url)
pngLoading.value = false
}
}
</script>

<template>
<section aria-labelledby="brand-customize-heading">
<h2 id="brand-customize-heading" class="text-lg text-fg uppercase tracking-wider mb-4">
{{ $t('brand.customize.title') }}
</h2>
<p class="text-fg-muted leading-relaxed mb-8">
{{ $t('brand.customize.description') }}
</p>

<div class="border border-border rounded-lg overflow-hidden">
<!-- Live preview -->
<div
class="flex items-center justify-center p-10 sm:p-16 transition-colors duration-300 motion-reduce:transition-none"
:class="customBgDark ? 'bg-[#0a0a0a]' : 'bg-white'"
>
<AppLogo
ref="customLogoRef"
class="h-10 sm:h-14 w-auto max-w-full transition-colors duration-300 motion-reduce:transition-none"
:class="customBgDark ? 'text-[#fafafa]' : 'text-[#0a0a0a]'"
:style="{ '--accent': activeAccentColor }"
/>
</div>

<!-- Controls -->
<div
class="border-t border-border p-4 sm:p-6 flex flex-col sm:flex-row sm:items-center gap-4"
>
<!-- Accent color picker -->
<fieldset class="flex items-center gap-3 flex-1 border-none p-0 m-0">
<legend class="sr-only">{{ $t('brand.customize.accent_label') }}</legend>
<span class="text-xs font-mono text-fg-muted shrink-0">{{
$t('brand.customize.accent_label')
}}</span>
<div class="flex items-center gap-1.5" role="radiogroup">
<ButtonBase
v-for="color in pickerColors"
:key="color.id"
role="radio"
:aria-checked="activeAccentId === color.id"
:aria-label="color.label"
class="!w-6 !h-6 !rounded-full !border-2 !p-0 !min-w-0 transition-all duration-150 motion-reduce:transition-none"
:class="
activeAccentId === color.id
? '!border-fg scale-110'
: color.id === 'neutral'
? '!border-border hover:!border-border-hover'
: '!border-transparent hover:!border-border-hover'
"
:style="{ backgroundColor: color.value }"
@click="customAccent = color.id"
/>
</div>
</fieldset>

<!-- Background toggle -->
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-fg-muted">{{ $t('brand.customize.bg_label') }}</span>
<div
class="flex items-center border border-border rounded-md overflow-hidden"
role="radiogroup"
>
<ButtonBase
size="sm"
role="radio"
:aria-checked="customBgDark"
:aria-label="$t('brand.logos.on_dark')"
class="!border-none !rounded-none motion-reduce:transition-none"
:class="
customBgDark ? 'bg-bg-muted text-fg' : 'bg-transparent text-fg-muted hover:text-fg'
"
@click="customBgDark = true"
>
{{ $t('brand.logos.on_dark') }}
</ButtonBase>
<ButtonBase
size="sm"
role="radio"
:aria-checked="!customBgDark"
:aria-label="$t('brand.logos.on_light')"
class="!border-none !rounded-none border-is border-is-border motion-reduce:transition-none"
:class="
!customBgDark ? 'bg-bg-muted text-fg' : 'bg-transparent text-fg-muted hover:text-fg'
"
@click="customBgDark = false"
>
{{ $t('brand.logos.on_light') }}
</ButtonBase>
Comment on lines +172 to +197
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be cool if it had a little spinner while it's downloading (e.g.), but it's kinda extra so don't do it if you don't want to xD

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the mode toggles?

</div>
</div>

<!-- Download buttons -->
<div class="flex items-center gap-2">
<ButtonBase
size="sm"
classicon="i-lucide:download"
:aria-label="$t('brand.customize.download_svg_aria')"
@click="downloadCustomSvg"
>
{{ $t('brand.logos.download_svg') }}
</ButtonBase>
<ButtonBase
size="sm"
:aria-label="$t('brand.customize.download_png_aria')"
:disabled="pngLoading"
@click="downloadCustomPng"
>
<span
class="size-[1em]"
aria-hidden="true"
:class="pngLoading ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:download'"
/>
{{ $t('brand.logos.download_png') }}
</ButtonBase>
</div>
</div>
</div>
</section>
</template>
Loading
Loading